Graph view settings: THE zoom-level control (label threshold)

The label-visibility zoom threshold (was the LABELS_CAMERA_RATIO=1
constant) becomes the fourth persisted setting: labels appear when the
camera ratio is at/below it (1x = fit zoom; slider 0.5x-4x, shown with
an x suffix). Deliberately THE single zoom knob per the cluster design
(vision section 1): cluster labels will fade in over the same span as
node labels fade out, gated inversely on this same setting.

Live-verified: at a zoomed-out camera (labels previously hidden),
raising the threshold to 4x rebuilds and re-shows labels without any
camera movement; resetting restores the fade. 355/355 tests,
typecheck, lint clean.
This commit is contained in:
amosblomqvist
2026-09-13 03:15:09 +03:00
parent d6b229865a
commit 5144706fb9
2 changed files with 30 additions and 9 deletions
+29 -8
View File
@@ -53,8 +53,9 @@
* (pan/zoom) dismisses it. The `requires` edges render as arrows pointing * (pan/zoom) dismisses it. The `requires` edges render as arrows pointing
* prerequisite → dependent. * prerequisite → dependent.
* *
* Rendering settings (2026-09-13): a gear in the search panel reveals three * Rendering settings (2026-09-13): a gear in the search panel reveals four
* sliders — node size, edge width, arrowhead length — persisted per browser * 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 * (localStorage) and applied by REBUILDING ONLY THE RENDERER: the layout
* effect (graph + forceAtlas2) is untouched by settings, and the camera * effect (graph + forceAtlas2) is untouched by settings, and the camera
* position carries across rebuilds, so tweaking a slider never re-fits or * position carries across rebuilds, so tweaking a slider never re-fits or
@@ -88,8 +89,17 @@ interface RenderSettings {
nodeSize: number; nodeSize: number;
edgeWidth: number; edgeWidth: number;
arrowLength: 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 RENDER_SETTINGS_KEY = "graph-render-settings";
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
/** Defensively merge stored settings (stale/partial/corrupt → defaults). */ /** 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), nodeSize: clamp(Number(r?.nodeSize ?? DEFAULT_RENDER_SETTINGS.nodeSize), 2, 14),
edgeWidth: clamp(Number(r?.edgeWidth ?? DEFAULT_RENDER_SETTINGS.edgeWidth), 0.5, 4), edgeWidth: clamp(Number(r?.edgeWidth ?? DEFAULT_RENDER_SETTINGS.edgeWidth), 0.5, 4),
arrowLength: clamp(Number(r?.arrowLength ?? DEFAULT_RENDER_SETTINGS.arrowLength), 2, 15), 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<GraphNode, "name" | "statement">, query
const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip 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_GAP = 6; // px between node edge and first label line
const LABEL_LINE_HEIGHT = 1.3; 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. */ /** Zoom span (camera-ratio units) over which labels fade out together. */
const LABELS_FADE_SPAN = 0.5; const LABELS_FADE_SPAN = 0.5;
@@ -371,6 +380,7 @@ function SettingSlider({
max, max,
step, step,
value, value,
display,
onChange, onChange,
}: { }: {
label: string; label: string;
@@ -378,13 +388,15 @@ function SettingSlider({
max: number; max: number;
step: number; step: number;
value: number; value: number;
/** Value display override (e.g. "1.5×" for zoom) — defaults to the raw number. */
display?: string;
onChange: (v: number) => void; onChange: (v: number) => void;
}) { }) {
return ( return (
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[11px] text-muted-foreground"> <span className="flex items-center justify-between text-[11px] text-muted-foreground">
{label} {label}
<span className="tabular-nums">{value}</span> <span className="tabular-nums">{display ?? value}</span>
</span> </span>
<input <input
type="range" type="range"
@@ -666,8 +678,8 @@ export function GraphView() {
const labelsCanvas = renderer.getCanvases().labels; const labelsCanvas = renderer.getCanvases().labels;
let labelsRendering: boolean | null = null; let labelsRendering: boolean | null = null;
const syncRenderLabels = (ratio: number) => { const syncRenderLabels = (ratio: number) => {
// 0 at fit zoom → 1 at the end of the fade span // 0 at the labelZoom threshold → 1 at the end of the fade span
const fade = (ratio - LABELS_CAMERA_RATIO) / LABELS_FADE_SPAN; const fade = (ratio - s.labelZoom) / LABELS_FADE_SPAN;
const alpha = Math.min(Math.max(1 - fade, 0), 1); const alpha = Math.min(Math.max(1 - fade, 0), 1);
const shouldRender = alpha > 0; const shouldRender = alpha > 0;
if (shouldRender !== labelsRendering) { if (shouldRender !== labelsRendering) {
@@ -833,6 +845,15 @@ export function GraphView() {
value={settings.arrowLength} value={settings.arrowLength}
onChange={(v) => setSettings((cur) => ({ ...cur, arrowLength: v }))} onChange={(v) => setSettings((cur) => ({ ...cur, arrowLength: v }))}
/> />
<SettingSlider
label="Labels until zoom"
min={0.5}
max={4}
step={0.25}
value={settings.labelZoom}
display={`${settings.labelZoom}×`}
onChange={(v) => setSettings((cur) => ({ ...cur, labelZoom: v }))}
/>
</div> </div>
)} )}
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && ( {snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
+1 -1
View File
@@ -95,7 +95,7 @@ When the graph fills up, the canvas needs a readable at-a-glance structure: **cl
### Rendering (DECIDED — research report 2026-08-22) ### Rendering (DECIDED — research report 2026-08-22)
**Render settings (2026-09-13):** three basic controls in the graph view — node size, edge width, arrowhead length — persisted per browser. Settings changes rebuild only the renderer: layout positions and the camera never move. (Edge-length / spacing controls deliberately parked until a cluster-aware spacing design exists.) **Render settings (2026-09-13):** four basic controls in the graph view — node size, edge width, arrowhead length, and **the zoom-level control** (the label-visibility threshold; 1× = fit zoom) — persisted per browser. The zoom control is deliberately THE single zoom knob: cluster labels (below) will gate inversely on it over the same fade span. Settings changes rebuild only the renderer: layout positions and the camera never move. (Edge-length / spacing controls deliberately parked until a cluster-aware spacing design exists.)
- **Library: sigma.js v3 + graphology + graphology-layout-forceatlas2.** ~40 KB gzipped all-in — half of vis-network, a third of cytoscape, far below react-force-graph (~250 KB deps) and cosmos.gl (luma.gl stack). - **Library: sigma.js v3 + graphology + graphology-layout-forceatlas2.** ~40 KB gzipped all-in — half of vis-network, a third of cytoscape, far below react-force-graph (~250 KB deps) and cosmos.gl (luma.gl stack).
- **Layout: synchronous run-then-freeze.** `forceAtlas2.assign(graph, { iterations: 100, settings: inferSettings(graph) })` runs once, writes x/y, and no animation loop ever starts — near-zero idle CPU (sigma only redraws on camera/graph events). <150 ms at hundreds of nodes; at ~2k enable `barnesHutOptimize`, then the bundled Web Worker, then server-cached positions — in that order, only if latency bites. - **Layout: synchronous run-then-freeze.** `forceAtlas2.assign(graph, { iterations: 100, settings: inferSettings(graph) })` runs once, writes x/y, and no animation loop ever starts — near-zero idle CPU (sigma only redraws on camera/graph events). <150 ms at hundreds of nodes; at ~2k enable `barnesHutOptimize`, then the bundled Web Worker, then server-cached positions — in that order, only if latency bites.