feat: canonical world-scale renderer — sizes become world units + baked tuning + nice-number ladders

The world-scale pivot (2026-09-17): node/label/arrowhead sizes were
calibrated as "screen px at fit zoom", a unit whose meaning depended on
the graph's own extent — a 13-node graph at kFit 3.65 rendered 5.3x
smaller nodes than a 338-node graph at kFit 0.69, with no slider values
able to reproduce the dense feel. Sizes are now canonical WORLD units
(graph-canvas.ts drops the ÷kFit division in geometry/regions/hit-test);
kFit survives only as the label-gate 1x reference. The renderer's module
doc and the SIZE SEMANTICS block in render-settings.ts carry the story.

Camera becomes pure persisted view state: {k, x, y} stored to localStorage
(lh.graph.camera.v1, 250ms debounce, flush on unmount), restored on mount
via the new initialCamera constructor arg; the hardcoded fit() runs only
on first-ever open. A pin test locks the d3 zoomIdentity composition.

Region blur becomes world units too (the last span-coupled quantity): the
old bitmap-px reach scaled with graph span, so the 2026-09-14
search-filter collapse fix is now structural (blurW = tuning.blur; the
cellRef anchor is gone). Charge default bumps to -240 (single source:
CHARGE_STRENGTH in layout.ts; CHARGE_RANGE re-anchored so -240 sits
dead-center).

The 2026-09-17 bake — the user's live-tuned defaults (nodeSize 2,
edgeWidth 1, arrowLength 2, labelSize 3, labelZoom 4 / "8x", dilation 20,
blur 20, edgeAlpha 0.2, bitmap 256) — plus the ladder pivot: every
non-physics knob moves on a hand-authored 11-stop ladder (x1.5/x1.33
series, quarter/half steps at the low end), default dead-center at slider
position 50, step 10 = one stop per detent. The sanitizer snaps ANY
incoming value to the nearest stop (ties break low), so stored files can
never hold 2.18137...977-style artifacts; pre-pivot files (sizeUnits !=
"world") reset only the four resemanticized size fields. Physics sliders
keep the fine-grained log mapping.

The label cross-fade becomes PROPORTIONAL to the gate (span = gate x
LABELS_FADE_RATIO): the old absolute 0.5-ratio span read as a 100%
relative change at the old 0.5 gate but a 12% sliver at the tuned 8x —
"lower zoom levels naturally produce a longer transitionary period".
At the 8x default the node→cluster handoff now fades across one full
ladder stop (8x -> 12x). labelZoomDisplay is absolute (ratio / 0.5) so
the tuned gate keeps reading "8x" now that 4 IS the default.

Gates: 495/495 tests, tsc/eslint/build clean.
This commit is contained in:
amosblomqvist
2026-09-17 12:29:17 +03:00
parent 5ce78d0754
commit 0f93124c7e
6 changed files with 615 additions and 296 deletions
+107 -61
View File
@@ -34,7 +34,8 @@
* Zoom-gated labels (the Obsidian text-fade feel): sigma's camera "ratio"
* convention is kept for the settings' persisted semantics — ratio 1 =
* fit zoom, SMALLER = zoomed in (ratio = kFit / k). Node labels are
* fully visible at ratio ≤ labelZoom and fade out across LABELS_FADE_SPAN
* fully visible at ratio ≤ labelZoom and fade out across a span
* proportional to the gate (LABELS_FADE_RATIO)
* of ratio; cluster labels fade in over the exact same span (sums to 1)
* so the two never coexist. All labels gate together — no per-node
* popping.
@@ -125,8 +126,9 @@ export interface RendererCallbacks {
onSelect: (nodeId: string, screenPos: Pt) => void;
/** The empty stage was clicked. */
onStageClick: () => void;
/** Any camera move (pan/zoom) — the caller dismisses anchored UI. */
onCameraMove: () => void;
/** Any camera move (pan/zoom) — the caller dismisses anchored UI and
* persists the camera (view state). */
onCameraMove: (transform: { k: number; x: number; y: number }) => void;
/** The live sim settled after a drag: commit the new rest state
* (the caller PUTs it to the positions ledger). */
onCommit: (positions: Record<string, Position>) => void;
@@ -134,13 +136,20 @@ export interface RendererCallbacks {
// ─── Constants (ported verbatim from the sigma view) ────────────────────────
const LABEL_SIZE = 12; // px — the default label size (settings.labelSize scales it)
const LABEL_SIZE = 12; // the reference label size the wrap-width ratio is taken against
const LABEL_WEIGHT = "500";
const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip
const LABEL_GAP = 2; // px between node edge and first label line (was 6 — user: way too far)
const LABEL_MAX_WIDTH = 170; // wrap width at the reference label size, world units
const LABEL_GAP = 2.9; // world units between node edge and first label line (the 2px-at-fit reference ÷ 0.69)
const LABEL_LINE_HEIGHT = 1.3;
/** Zoom span (camera-ratio units) over which labels fade together. */
export const LABELS_FADE_SPAN = 0.5;
/** The node→cluster label cross-fade spans gate × this (camera-ratio
* units) — PROPORTIONAL since 2026-09-17. The old absolute span (0.5
* ratio) read as a 100% relative change at the old 0.5 gate but a bare
* 12% sliver at the tuned 8x gate (ratio 4): "lower zoom levels naturally
* produce a longer transitionary period while higher ones are more
* abrupt" — the user's exact observation. Proportional keeps ONE
* transitionary feel at any gate; at the 8x default the handoff fades
* across a full stop (8x → 12x). */
export const LABELS_FADE_RATIO = 0.5;
const GLOW_SCALE = 1.7; // halo outer radius = node radius x this (proportional, like blurring a copy of the disc)
const GLOW_ALPHA = 0.3; // halo opacity at the disc edge, fading outward
@@ -241,10 +250,14 @@ export function boxBlurRound(
}
}
/** All "px" constants above are screen px AT FIT ZOOM. The renderer scales
* them into world units (÷ kFit) once per geometry build; camera zoom then
* scales nodes/labels/arrowheads with the graph (Obsidian-style), while
* edge widths and hull borders stay viewer-relative hairlines (÷ k). */
/** Geometry constants are WORLD UNITS (the 2026-09-15 pivot): node/
* label/arrowhead sizes come from the settings directly — the same law
* as linkDistance — so the world scale is canonical and never depends on
* the graph's extent or the viewport. Camera zoom scales them with the
* graph (Obsidian-style); edge widths and hull borders stay
* viewer-relative hairlines (÷ k); cluster labels stay screen-fixed
* (px at any zoom). kFit survives ONLY as the label-gate's "1×"
* reference (zoomRatioOf) — a pure LOD threshold, no geometry reads it. */
const clamp01 = (v: number) => Math.min(Math.max(v, 0), 1);
@@ -261,11 +274,15 @@ export function zoomRatioOf(kFit: number, k: number): number {
/**
* Node-label opacity at a given camera ratio: 1 at/below the labelZoom
* threshold, fading to 0 across LABELS_FADE_SPAN of ratio. Cluster labels
* use `1 - this` over the same span — the two cross-fade and never
* coexist at full opacity.
* threshold, fading to 0 across a span of labelZoom x LABELS_FADE_RATIO
* (see there). Cluster labels use `1 - this` over the same span — the
* two cross-fade and never coexist at full opacity.
*/
export function labelAlpha(ratio: number, labelZoom: number, span = LABELS_FADE_SPAN): number {
export function labelAlpha(
ratio: number,
labelZoom: number,
span = labelZoom * LABELS_FADE_RATIO,
): number {
return clamp01(1 - (ratio - labelZoom) / span);
}
@@ -499,7 +516,15 @@ export class GraphCanvasRenderer {
private worldLabelFont = "";
private world: WorldSizes = { nodeR: 0, glowOuter: 0, labelSize: 0, labelGap: 0 };
constructor(canvas: HTMLCanvasElement, callbacks: RendererCallbacks) {
constructor(
canvas: HTMLCanvasElement,
callbacks: RendererCallbacks,
/** The caller's persisted camera (view state — localStorage): when
* present it REPLACES the first-open fit; the graph opens exactly
* where the user left it (2026-09-15: a default zoom exists only
* on first open, ever). Invalid shapes fall back to fit. */
initialCamera?: { k: number; x: number; y: number } | null,
) {
this.canvas = canvas;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2d context unavailable");
@@ -513,7 +538,8 @@ export class GraphCanvasRenderer {
.on("zoom", ({ transform }) => {
this.transform = transform;
this.markDirty();
this.callbacks.onCameraMove();
// The transform doubles as the persistence payload (view state).
this.callbacks.onCameraMove(transform);
});
select(canvas)
.call(this.zoomBehavior)
@@ -529,19 +555,27 @@ export class GraphCanvasRenderer {
canvas.addEventListener("pointerup", this.onPointerUp);
canvas.addEventListener("pointerleave", this.onPointerLeave);
// The persisted camera: replace the default with wherever the user
// left the graph. Applied through the zoom behavior so d3's internal
// state and ours agree (the same path fit() uses); hasFit so neither
// the ResizeObserver nor setData re-fits over it.
if (
initialCamera &&
typeof initialCamera.k === "number" && Number.isFinite(initialCamera.k) && initialCamera.k > 0 &&
typeof initialCamera.x === "number" && Number.isFinite(initialCamera.x) &&
typeof initialCamera.y === "number" && Number.isFinite(initialCamera.y)
) {
this.hasFit = true;
select(canvas).call(
this.zoomBehavior.transform,
zoomIdentity.translate(initialCamera.x, initialCamera.y).scale(initialCamera.k),
);
}
this.resizeObserver = new ResizeObserver(() => {
this.syncSize();
if (!this.hasFit) this.fit(); // first real size → fit once
else if (this.worldBBox) {
// Keep the label-gate's "1×" honest: kFit is viewport-bound,
// so a resize changes it even though the camera persists.
const w = this.canvas.clientWidth;
const h = this.canvas.clientHeight;
if (w >= 10 && h >= 10) {
this.kFit = fitTransform(this.worldBBox, w, h, 40).k;
this.geoVersion++; // resize redefines the fit-zoom scale
}
}
else this.refreshFitZoom(); // the gate's "1×" is viewport-bound
// Redraw SYNCHRONOUSLY: syncSize may have reallocated the backing
// store (which clears the canvas), and during live window resizes
// rAF is starved by relayout — a deferred draw would leave the
@@ -569,6 +603,9 @@ export class GraphCanvasRenderer {
}
this.worldBBox = { minX, maxX, minY, maxY };
if (!this.hasFit && data.nodes.length > 0) this.fit();
// The camera may be user-restored (constructor initialCamera) — the
// gate's "1×" reference still needs the fresh bbox's fit zoom.
else this.refreshFitZoom();
// Rebuild the live sim around the fresh nodes. New data means the
// server spoke (merge or refetch); any pending commit is moot and
// the graph is at rest until the next drag.
@@ -633,7 +670,8 @@ export class GraphCanvasRenderer {
}
/** Frame the whole graph (padding included). Keeps the camera on later
* setData calls — call fit() only for an explicit re-frame. */
* setData calls — call fit() only for an explicit re-frame (the
* first-ever open; afterwards the caller-persisted camera wins). */
fit(): void {
if (!this.data || this.data.nodes.length === 0 || !this.worldBBox) return;
const w = this.canvas.clientWidth;
@@ -642,13 +680,22 @@ export class GraphCanvasRenderer {
const t = fitTransform(this.worldBBox, w, h, 40);
this.kFit = t.k;
this.hasFit = true;
this.geoVersion++; // kFit defines the world-unit scale
select(this.canvas).call(
this.zoomBehavior.transform,
zoomIdentity.translate(t.x, t.y).scale(t.k),
);
}
/** Refresh the label-gate's "1×" reference from the current bbox +
* viewport. kFit feeds ONLY zoomRatioOf now (the 2026-09-15 pivot
* removed it from geometry) — so this never touches geoVersion. */
private refreshFitZoom(): void {
if (!this.worldBBox) return;
const w = this.canvas.clientWidth;
const h = this.canvas.clientHeight;
if (w >= 10 && h >= 10) this.kFit = fitTransform(this.worldBBox, w, h, 40).k;
}
destroy(): void {
if (this.frameId !== null) cancelAnimationFrame(this.frameId);
if (this.commitTimer) clearTimeout(this.commitTimer);
@@ -798,7 +845,10 @@ export class GraphCanvasRenderer {
const { x: tx, y: ty, k } = this.transform;
// Discs scale with the camera now: their screen radius is
// nodeSize × k/kFit; the halo pad stays a constant screen pad.
const maxDist = this.settings.nodeSize * (k / this.kFit) + PICKING_PAD;
// nodeSize is world units now (2026-09-15): the on-screen hit radius
// is the disc's world radius × the camera zoom, plus the constant
// screen pad — the whole halo stays hit-testable.
const maxDist = this.settings.nodeSize * k + PICKING_PAD;
const projected: { id: string; x: number; y: number }[] = [];
for (const n of this.data.nodes) {
if (this.visible && !this.visible.has(n.id)) continue;
@@ -948,24 +998,24 @@ export class GraphCanvasRenderer {
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
/** Derive ALL world geometry from data + visibility + settings + kFit.
/** Derive ALL world geometry from data + visibility + settings.
* Runs only when geoVersion moves — never per frame, never per camera
* change. Zoom/pan pay zero geometry math, only rasterization. */
* change. Zoom/pan pay zero geometry math, only rasterization. Sizes
* are WORLD UNITS straight from the settings (the 2026-09-15 pivot —
* no ÷kFit: the world scale is canonical, viewport-independent). */
private buildGeometry(): void {
this.builtVersion = this.geoVersion;
const data = this.data;
const settings = this.settings;
if (!data || !settings) return;
// Screen px at fit zoom → world units.
const s = 1 / this.kFit;
const nodeR = settings.nodeSize * s;
this.worldLabelFont = `${LABEL_WEIGHT} ${settings.labelSize * s}px ${data.fontFamily}`;
const nodeR = settings.nodeSize;
this.worldLabelFont = `${LABEL_WEIGHT} ${settings.labelSize}px ${data.fontFamily}`;
this.world = {
nodeR,
glowOuter: nodeR * GLOW_SCALE,
labelSize: settings.labelSize * s,
labelGap: LABEL_GAP * s,
labelSize: settings.labelSize,
labelGap: LABEL_GAP,
};
const visible = this.visible;
const isVisible = (id: string) => !visible || visible.has(id);
@@ -983,7 +1033,7 @@ export class GraphCanvasRenderer {
// relation types draw the same arrow for now — the double-shaft
// glyph was rejected (2026-09-15); differentiation is pending a new
// approach. CanvasEdge.type is plumbed for whatever comes next.
const arrowLen = settings.arrowLength * s;
const arrowLen = settings.arrowLength;
const shafts = new Path2D();
const heads = new Path2D();
for (const e of data.edges) {
@@ -1014,7 +1064,7 @@ export class GraphCanvasRenderer {
const ctx = this.ctx;
const prevFont = ctx.font;
ctx.font = this.worldLabelFont;
const maxW = LABEL_MAX_WIDTH * (settings.labelSize / LABEL_SIZE) * s;
const maxW = LABEL_MAX_WIDTH * (settings.labelSize / LABEL_SIZE);
for (const n of data.nodes) {
if (isVisible(n.id)) this.labelLines.set(n.id, wrapLabel(ctx, n.label, maxW));
}
@@ -1072,7 +1122,8 @@ export class GraphCanvasRenderer {
const isVisible = (id: string): boolean => !visible || visible.has(id);
const tuning = this.regionTuning;
const s = 1 / this.kFit;
// Dilation is WORLD units (the 2026-09-15 pivot) — the region stroke
// half-width comes straight from the setting, no kFit.
// World rect over VISIBLE cluster members (regions cover members
// and their intra-cluster edges — nothing else).
@@ -1140,22 +1191,15 @@ export class GraphCanvasRenderer {
// is padded by the stroke half-width (dilation) plus the blur
// reach so nothing clips at the bitmap edge.
//
// SCREEN-ANCHORED RADII (user report 2026-09-14: a tight search
// collapsed the blur). The stroke width is anchored to kFit (screen
// px at fit zoom — the camera persists, searches don't refit), but
// the blur radius was anchored to `cell`, which shrinks with the
// FILTERED span: close-together matches → tiny cell → blur reach
// (blur·cell·kFit) collapsed on screen while the stroke stayed put.
// The blur now uses the stroke's anchoring: its world reach is
// blur · cellRef, cellRef being the FULL-graph cell (worldBBox —
// the same bbox kFit comes from). Span-independent, and with
// nothing filtered cell == cellRef, so the look is unchanged.
const dilationW = tuning.dilation * s;
const bbox = this.worldBBox;
const cellRef = bbox
? Math.max(bbox.maxX - bbox.minX, bbox.maxY - bbox.minY) / tuning.bitmap
: 0;
const blurW = tuning.blur * cellRef;
// The blur reach is WORLD units (the 2026-09-15 pivot's last step):
// tuning.blur IS the world reach, canonical like dilation — it no
// longer scales with the graph's span (the old blurW = blur ×
// span/bitmap read 6× softer on a 338-node graph than on a
// 13-node one). This also makes the 2026-09-14 search-collapse fix
// structural: a world-unit blur cannot be coupled to any cell, so
// the old `cellRef` anchor is gone.
const dilationW = tuning.dilation;
const blurW = tuning.blur;
const padWorld = dilationW + blurW * 2;
// Cell floor: a single visible node (span 0) zeroed the cell and
// blew the bitmap dims to Infinity (getImageData TypeError). The
@@ -1248,9 +1292,11 @@ export class GraphCanvasRenderer {
// THEN scale the whole field by edgeAlpha once. Bilinear
// upscaling alone is no blur at all (tent ramps — the JPEG look);
// this makes the field genuinely smooth before the blit. The
// radius is blurW in bitmap px (screen-anchored — see sizing
// above), an integer because boxBlurRound indexes with the loop
// counter. Radius 0 (blur off) still runs this path — boxBlurRound
// radius is the world reach ÷ cell, rounded to an integer
// (boxBlurRound indexes with the loop counter) with a 1px floor —
// a graph whose cell exceeds the blur reach still gets one pixel
// of softening rather than aliasing to zero. Radius 0 (blur off)
// still runs this path — boxBlurRound
// copies — so the end-alpha applies uniformly with or without
// blur.
{
+137 -82
View File
@@ -68,26 +68,28 @@ import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/cor
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
import { GraphCoverageControl } from "./graph-coverage.tsx";
import {
ARROW_LENGTH_RANGE,
ARROW_LENGTH_LADDER,
CHARGE_RANGE,
CLUSTER_LABEL_SIZE_RANGE,
CLUSTER_LABEL_SIZE_LADDER,
CLUSTER_PULL_RANGE,
DEPTH_BASE_RADIUS_RANGE,
DEPTH_SPACING_RANGE,
DEPTH_STRENGTH_RANGE,
DEFAULT_RENDER_SETTINGS,
EDGE_WIDTH_RANGE,
EDGE_WIDTH_LADDER,
GRAVITY_RANGE,
LABEL_SIZE_RANGE,
LABEL_ZOOM_MAX,
LABEL_ZOOM_MIN,
LABEL_SIZE_LADDER,
LABEL_ZOOM_LADDER,
LINK_DISTANCE_RANGE,
LINK_STRENGTH_RANGE,
NODE_SIZE_RANGE,
NODE_SIZE_LADDER,
REGION_BITMAP_OPTIONS,
REGION_BLUR_OPTIONS,
REGION_DILATION_RANGE,
REGION_EDGE_ALPHA_RANGE,
REGION_BLUR_LADDER,
fromLadderPosition,
snapToLadder,
toLadderPosition,
REGION_DILATION_LADDER,
REGION_EDGE_ALPHA_LADDER,
VELOCITY_DECAY_RANGE,
fromOffSliderPosition,
fromSliderPosition,
@@ -101,6 +103,10 @@ import {
* the accent color, until the state model lands. */
const NODE_COLOR_TOKEN = "--primary";
/** Per-device graph camera persistence (zoom+pan view state). v1 in the
* key: the stored shape is the renderer's d3 transform {k, x, y}. */
const CAMERA_STORAGE_KEY = "lh.graph.camera.v1";
// ---- Render settings (the settings-panel controls) ------------------------
//
// The shape, defaults, sanitizer, and slider mappings live in
@@ -233,58 +239,60 @@ function GraphSettingsControl({
{open && (
<div className="absolute right-0 top-full mt-2 w-64 rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
<div className="flex flex-col gap-2">
{/* All sliders: log-scaled 0-100, step 5, so every
DEFAULT sits at 50 (src/core/render-settings.ts). */}
{/* Size/zoom/region sliders: LADDER-mapped 0-100, step 10 (11
stops, every DEFAULT dead-center at 50, every value a nice
number — src/core/render-settings.ts). Physics keeps the
fine-grained log sliders (step 5). */}
<SettingSlider
label="Node size"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.nodeSize, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max)}
step={10}
value={toLadderPosition(settings.nodeSize, NODE_SIZE_LADDER)}
display={sizeDisplay(settings.nodeSize)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))}
changeSettings((cur) => ({ ...cur, nodeSize: fromLadderPosition(p, NODE_SIZE_LADDER) }))}
/>
<SettingSlider
label="Edge width"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.edgeWidth, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max)}
step={10}
value={toLadderPosition(settings.edgeWidth, EDGE_WIDTH_LADDER)}
display={sizeDisplay(settings.edgeWidth)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))}
changeSettings((cur) => ({ ...cur, edgeWidth: fromLadderPosition(p, EDGE_WIDTH_LADDER) }))}
/>
<SettingSlider
label="Arrowhead size"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.arrowLength, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max)}
step={10}
value={toLadderPosition(settings.arrowLength, ARROW_LENGTH_LADDER)}
display={sizeDisplay(settings.arrowLength)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))}
changeSettings((cur) => ({ ...cur, arrowLength: fromLadderPosition(p, ARROW_LENGTH_LADDER) }))}
/>
<SettingSlider
label="Label size"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max)}
step={10}
value={toLadderPosition(settings.labelSize, LABEL_SIZE_LADDER)}
display={sizeDisplay(settings.labelSize)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelSize: fromSliderPosition(p, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max) }))}
changeSettings((cur) => ({ ...cur, labelSize: fromLadderPosition(p, LABEL_SIZE_LADDER) }))}
/>
{/* Displayed units normalize the default (0.5) to "1×". */}
{/* Display is absolute (ratio / 0.5): "8×" is the tuned default, dead-center. */}
<SettingSlider
label="Labels until zoom"
min={0}
max={100}
step={5}
value={toSliderPosition(settings.labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX)}
step={10}
value={toLadderPosition(settings.labelZoom, LABEL_ZOOM_LADDER)}
display={labelZoomDisplay(settings.labelZoom)}
onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelZoom: fromSliderPosition(p, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX) }))}
changeSettings((cur) => ({ ...cur, labelZoom: fromLadderPosition(p, LABEL_ZOOM_LADDER) }))}
/>
{/* Regions: the blurred cluster tints. Sliders + a fixed-set
dropdown for the bitmap resolution (it's a discrete
@@ -315,49 +323,42 @@ function GraphSettingsControl({
disabled={!regionTuning.enabled}
min={0}
max={100}
step={5}
value={toSliderPosition(regionTuning.dilation, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max)}
step={10}
value={toLadderPosition(regionTuning.dilation, REGION_DILATION_LADDER)}
display={sizeDisplay(regionTuning.dilation)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) },
regions: { ...cur.regions, dilation: fromLadderPosition(p, REGION_DILATION_LADDER) },
}))}
/>
<label
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`}
>
<span>Blur</span>
<select
disabled={!regionTuning.enabled}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
value={regionTuning.blur}
onChange={(e) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, blur: Number(e.target.value) },
}))
}
>
{REGION_BLUR_OPTIONS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
<SettingSlider
label="Blur"
disabled={!regionTuning.enabled}
min={0}
max={100}
step={10}
value={toLadderPosition(regionTuning.blur, REGION_BLUR_LADDER)}
display={sizeDisplay(regionTuning.blur)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, blur: fromLadderPosition(p, REGION_BLUR_LADDER) },
}))
}
/>
<SettingSlider
label="Edge alpha"
disabled={!regionTuning.enabled}
min={0}
max={100}
step={5}
value={toSliderPosition(regionTuning.edgeAlpha, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max)}
step={10}
value={toLadderPosition(regionTuning.edgeAlpha, REGION_EDGE_ALPHA_LADDER)}
display={sizeDisplay(regionTuning.edgeAlpha)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
regions: { ...cur.regions, edgeAlpha: fromSliderPosition(p, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max) },
regions: { ...cur.regions, edgeAlpha: fromLadderPosition(p, REGION_EDGE_ALPHA_LADDER) },
}))}
/>
<label
@@ -401,13 +402,13 @@ function GraphSettingsControl({
disabled={!settings.clusterLabels}
min={0}
max={100}
step={5}
value={toSliderPosition(settings.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.min, CLUSTER_LABEL_SIZE_RANGE.max)}
step={10}
value={toLadderPosition(settings.clusterLabelSize, CLUSTER_LABEL_SIZE_LADDER)}
display={sizeDisplay(settings.clusterLabelSize)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
clusterLabelSize: fromSliderPosition(p, CLUSTER_LABEL_SIZE_RANGE.min, CLUSTER_LABEL_SIZE_RANGE.max),
clusterLabelSize: fromLadderPosition(p, CLUSTER_LABEL_SIZE_LADDER),
}))}
/>
</div>
@@ -766,33 +767,87 @@ export function GraphView() {
if (isEmpty) return;
const canvas = canvasRef.current;
if (!canvas) return;
const renderer = new GraphCanvasRenderer(canvas, {
onSelect: (nodeId, pos) => {
const snap = snapshotRef.current;
const node = snap?.nodes.find((n) => n.id === nodeId);
if (!snap || !node) return;
setSelected({
node,
sourceIds: snap.nodeSessions[node.id] ?? [],
anchor: pos,
});
// Persisted camera (2026-09-15): the graph reopens exactly where the
// user left it — zoom AND pan, in localStorage (per-device view
// state, deliberately NOT the server settings ledger). Absent or
// corrupt → undefined → the renderer fits on first open, ever.
let initialCamera: { k: number; x: number; y: number } | null = null;
try {
const stored = localStorage.getItem(CAMERA_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as { k?: unknown; x?: unknown; y?: unknown };
if (
typeof parsed.k === "number" && Number.isFinite(parsed.k) && parsed.k > 0 &&
typeof parsed.x === "number" && Number.isFinite(parsed.x) &&
typeof parsed.y === "number" && Number.isFinite(parsed.y)
)
initialCamera = { k: parsed.k, x: parsed.x, y: parsed.y };
}
} catch {
// Corrupt entry → treat as absent; the renderer fits.
}
// Debounced camera writes: a pan emits hundreds of transforms per
// second; one trailing write per quiet spell is plenty.
let cameraTimer: ReturnType<typeof setTimeout> | null = null;
let lastCamera: { k: number; x: number; y: number } | null = null;
const persistCamera = (t: { k: number; x: number; y: number }) => {
lastCamera = t;
if (cameraTimer) clearTimeout(cameraTimer);
cameraTimer = setTimeout(() => {
cameraTimer = null;
try {
localStorage.setItem(CAMERA_STORAGE_KEY, JSON.stringify(t));
} catch {
// Storage full/blocked: camera persistence is best-effort.
}
}, 250);
};
const renderer = new GraphCanvasRenderer(
canvas,
{
onSelect: (nodeId, pos) => {
const snap = snapshotRef.current;
const node = snap?.nodes.find((n) => n.id === nodeId);
if (!snap || !node) return;
setSelected({
node,
sourceIds: snap.nodeSessions[node.id] ?? [],
anchor: pos,
});
},
onStageClick: () => setSelected(null),
// Any camera move detaches the card from its node — close
// it, and (debounced) remember where the camera stands.
onCameraMove: (t) => {
setSelected(null);
persistCamera(t);
},
// Live physics settled: persist the new rest state. Fire-and-
// forget — the next snapshot fetch carries the ledger anyway.
onCommit: (positions) => {
void fetch("/api/graph/positions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(positions),
}).catch((err) => console.error("[graph] positions commit failed", err));
},
},
onStageClick: () => setSelected(null),
// Any camera move detaches the card from its node — close it.
onCameraMove: () => setSelected(null),
// Live physics settled: persist the new rest state. Fire-and-
// forget — the next snapshot fetch carries the ledger anyway.
onCommit: (positions) => {
void fetch("/api/graph/positions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(positions),
}).catch((err) => console.error("[graph] positions commit failed", err));
},
});
initialCamera,
);
rendererRef.current = renderer;
return () => {
rendererRef.current = null;
// Flush a pending camera write so an unmount <250ms after a
// move still persists the user's last standpoint.
if (cameraTimer && lastCamera) {
clearTimeout(cameraTimer);
cameraTimer = null;
try {
localStorage.setItem(CAMERA_STORAGE_KEY, JSON.stringify(lastCamera));
} catch {
// best-effort
}
}
renderer.destroy();
};
}, [isEmpty]);