diff --git a/components/graph-view.tsx b/components/graph-view.tsx index aed3fe7..a584456 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -53,8 +53,9 @@ * (pan/zoom) dismisses it. The `requires` edges render as arrows pointing * prerequisite → dependent. * - * Rendering settings (2026-09-13): a gear in the search panel reveals three - * sliders — node size, edge width, arrowhead length — persisted per browser + * Rendering settings (2026-09-13): a gear in the search panel reveals four + * sliders — node size, edge width, arrowhead length, and THE zoom-level + * control (label visibility threshold; 1× = fit zoom) — persisted per browser * (localStorage) and applied by REBUILDING ONLY THE RENDERER: the layout * effect (graph + forceAtlas2) is untouched by settings, and the camera * position carries across rebuilds, so tweaking a slider never re-fits or @@ -88,8 +89,17 @@ interface RenderSettings { nodeSize: number; edgeWidth: number; arrowLength: number; + /** + * The single zoom-level control (camera ratio at/below which node labels + * appear; 1 = fit zoom, higher = labels visible further zoomed out). It + * will ALSO drive cluster labels (vision §1, DECIDED 2026-09-13) — + * inversely: cluster/domain labels fade in over the same span as node + * labels fade out — so the two never coexist. Kept in the persisted + * settings for exactly that reuse. + */ + labelZoom: number; } -const DEFAULT_RENDER_SETTINGS: RenderSettings = { nodeSize: 6, edgeWidth: 1, arrowLength: 5 }; +const DEFAULT_RENDER_SETTINGS: RenderSettings = { nodeSize: 6, edgeWidth: 1, arrowLength: 5, labelZoom: 1 }; const RENDER_SETTINGS_KEY = "graph-render-settings"; const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); /** Defensively merge stored settings (stale/partial/corrupt → defaults). */ @@ -99,6 +109,7 @@ function sanitizeRenderSettings(raw: unknown): RenderSettings { nodeSize: clamp(Number(r?.nodeSize ?? DEFAULT_RENDER_SETTINGS.nodeSize), 2, 14), edgeWidth: clamp(Number(r?.edgeWidth ?? DEFAULT_RENDER_SETTINGS.edgeWidth), 0.5, 4), arrowLength: clamp(Number(r?.arrowLength ?? DEFAULT_RENDER_SETTINGS.arrowLength), 2, 15), + labelZoom: clamp(Number(r?.labelZoom ?? DEFAULT_RENDER_SETTINGS.labelZoom), 0.5, 4), }; } @@ -123,8 +134,6 @@ export function isNodeVisible(node: Pick, query const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip const LABEL_GAP = 6; // px between node edge and first label line const LABEL_LINE_HEIGHT = 1.3; -/** Camera ratio at/below which ALL labels appear at once (1 = fit zoom). */ -const LABELS_CAMERA_RATIO = 1; /** Zoom span (camera-ratio units) over which labels fade out together. */ const LABELS_FADE_SPAN = 0.5; @@ -371,6 +380,7 @@ function SettingSlider({ max, step, value, + display, onChange, }: { label: string; @@ -378,13 +388,15 @@ function SettingSlider({ max: number; step: number; value: number; + /** Value display override (e.g. "1.5×" for zoom) — defaults to the raw number. */ + display?: string; onChange: (v: number) => void; }) { return (