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:
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user