From 0f93124c7ec8e03f33ee08e37d0055eae7e237d3 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 17 Sep 2026 12:29:17 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20canonical=20world-scale=20renderer=20?= =?UTF-8?q?=E2=80=94=20sizes=20become=20world=20units=20+=20baked=20tuning?= =?UTF-8?q?=20+=20nice-number=20ladders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/graph-canvas.ts | 168 ++++++++++++++-------- components/graph-view.tsx | 219 +++++++++++++++++----------- src/core/layout.ts | 2 +- src/core/render-settings.ts | 200 ++++++++++++++++++-------- tests/graph-canvas.test.ts | 59 ++++++-- tests/render-settings.test.ts | 263 +++++++++++++++++++++++----------- 6 files changed, 615 insertions(+), 296 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index c0f8b08..17292e0 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -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) => 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. { diff --git a/components/graph-view.tsx b/components/graph-view.tsx index b99d8d0..43619e8 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -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 && (
- {/* 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). */} - changeSettings((cur) => ({ ...cur, nodeSize: fromSliderPosition(p, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max) }))} + changeSettings((cur) => ({ ...cur, nodeSize: fromLadderPosition(p, NODE_SIZE_LADDER) }))} /> - changeSettings((cur) => ({ ...cur, edgeWidth: fromSliderPosition(p, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max) }))} + changeSettings((cur) => ({ ...cur, edgeWidth: fromLadderPosition(p, EDGE_WIDTH_LADDER) }))} /> - changeSettings((cur) => ({ ...cur, arrowLength: fromSliderPosition(p, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max) }))} + changeSettings((cur) => ({ ...cur, arrowLength: fromLadderPosition(p, ARROW_LENGTH_LADDER) }))} /> - 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. */} - 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) }, }))} /> - + + changeSettings((cur) => ({ + ...cur, + regions: { ...cur.regions, blur: fromLadderPosition(p, REGION_BLUR_LADDER) }, + })) + } + /> 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) }, }))} />
@@ -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 | 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]); diff --git a/src/core/layout.ts b/src/core/layout.ts index df388db..f244623 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -196,7 +196,7 @@ export interface LayoutEdge { } export const LINK_DISTANCE = 40; -export const CHARGE_STRENGTH = -120; +export const CHARGE_STRENGTH = -240; // 2026-09-17: −120 → −240 (user: repulsion default too weak); the default is shared with render-settings (gm-centered in CHARGE_RANGE). /** Per-tick anchor re-blend: fraction of an anchored node's displacement * from its previous position that SURVIVES each tick. 0.85 = gentle * evolution where forces are strong, stillness where they are not — diff --git a/src/core/render-settings.ts b/src/core/render-settings.ts index 02becac..7fa968a 100644 --- a/src/core/render-settings.ts +++ b/src/core/render-settings.ts @@ -10,10 +10,26 @@ * is over. Whatever arrives from disk or the network is passed through * sanitizeRenderSettings before use — never trusted raw. * + * SIZE SEMANTICS (2026-09-15 pivot): nodeSize, labelSize, arrowLength and + * regions.dilation are WORLD UNITS — the same law as the physics spacings. + * The world scale is canonical: identical settings render identically at + * any graph size, at any zoom. (They were "screen px at fit zoom" before, + * which made node sizes a function of graph extent — the small-graph bug.) + * * The labelZoom semantics: the sigma-compatible "camera ratio" threshold * (1 = fit zoom, SMALLER = zoomed in). Node labels show at ratio <= labelZoom - * and fade over LABELS_FADE_SPAN; cluster region labels fade in inversely. - * Persisted values keep these units across the renderer swap. + * and cross-fade with the cluster labels across a span proportional to the + * gate (see LABELS_FADE_RATIO in the renderer). Persisted values keep these + * units across the renderer swap. + * + * SLIDER LADDERS (2026-09-17, user-directed): every NON-physics knob moves on + * a hand-authored 11-stop ladder — the classic two-per-octave nice-number + * series (x1.5 / x1.33 alternation: ...1, 1.5, 2, 3, 4, 6, 8, 12...) — with + * the DEFAULT dead-center at position 50 (slider step 10, one stop per + * detent). No more log-slider artifacts like 2.18137...977: a knob's possible + * values ARE the ladder, the sanitizer snaps any incoming value to the + * nearest entry, and out-of-range snaps to the end. Physics keeps the + * fine-grained log sliders (user: "very fine tunable... good as they are"). */ import { @@ -25,12 +41,18 @@ import { } from "./layout.ts"; export interface GraphRenderSettings { + /** Semantic stamp for the size-unit pivot (2026-09-15): "world" = + * nodeSize/labelSize/arrowLength/regions.dilation are WORLD units; + * absent = the old "screen px at fit zoom" semantics — the + * sanitizer resets those four fields (their meaning changed, not + * their magnitude) and keeps everything else. */ + sizeUnits: "world"; nodeSize: number; edgeWidth: number; arrowLength: number; /** Camera-ratio threshold for node labels (see module doc). */ labelZoom: number; - /** Node + hover label font size, px at fit zoom (scales with camera). */ + /** Node + hover label font size, WORLD units (scales with camera). */ labelSize: number; /** Toggle for the cluster region labels (hulls always draw). */ clusterLabels: boolean; @@ -56,27 +78,36 @@ const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min) */ export function sanitizeRenderSettings(raw: unknown): GraphRenderSettings { const r = (raw ?? {}) as Partial; - const num = (v: unknown, fallback: number, min: number, max: number) => { - const n = typeof v === "number" && Number.isFinite(v) ? v : fallback; - return clamp(n, min, max); - }; + // Ladder knob: any finite value snaps to the nearest ladder entry (ties + // break low); non-numbers fall back to the default (dead-center anyway). + const ladd = (v: unknown, ladder: readonly number[], dflt: number) => + typeof v === "number" && Number.isFinite(v) ? snapToLadder(v, ladder) : dflt; + // The size-unit pivot (2026-09-15): pre-"world" files stored sizes in + // "screen px at fit zoom" — a unit whose meaning depended on the + // graph's own extent (THE small-graph bug). Those values cannot be + // carried into world units honestly, so they reset to defaults; the + // reference default IS the old tuned look expressed in world units. + const legacySizes = r.sizeUnits !== "world"; return { - nodeSize: num(r.nodeSize, DEFAULT_RENDER_SETTINGS.nodeSize, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max), - edgeWidth: num(r.edgeWidth, DEFAULT_RENDER_SETTINGS.edgeWidth, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max), - arrowLength: num(r.arrowLength, DEFAULT_RENDER_SETTINGS.arrowLength, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max), - // Range matches the log slider's span [LABEL_ZOOM_MIN, LABEL_ZOOM_MAX]. - labelZoom: num(r.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX), - labelSize: num(r.labelSize, DEFAULT_RENDER_SETTINGS.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max), + sizeUnits: "world", + nodeSize: legacySizes ? DEFAULT_RENDER_SETTINGS.nodeSize : ladd(r.nodeSize, NODE_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.nodeSize), + edgeWidth: ladd(r.edgeWidth, EDGE_WIDTH_LADDER, DEFAULT_RENDER_SETTINGS.edgeWidth), + arrowLength: legacySizes ? DEFAULT_RENDER_SETTINGS.arrowLength : ladd(r.arrowLength, ARROW_LENGTH_LADDER, DEFAULT_RENDER_SETTINGS.arrowLength), + labelZoom: ladd(r.labelZoom, LABEL_ZOOM_LADDER, DEFAULT_RENDER_SETTINGS.labelZoom), + labelSize: legacySizes ? DEFAULT_RENDER_SETTINGS.labelSize : ladd(r.labelSize, LABEL_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.labelSize), clusterLabels: typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels, - clusterLabelSize: num( + clusterLabelSize: ladd( r.clusterLabelSize, + CLUSTER_LABEL_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.clusterLabelSize, - CLUSTER_LABEL_SIZE_RANGE.min, - CLUSTER_LABEL_SIZE_RANGE.max, ), // Nested sections (2026-09-13): old files lack them → defaults. - regions: sanitizeRegionTuning(r.regions), + // Legacy files also reset regions.dilation (its units changed with + // the pivot); the rest of the section's semantics are untouched. + regions: legacySizes + ? { ...sanitizeRegionTuning(r.regions), dilation: DEFAULT_REGION_TUNING.dilation } + : sanitizeRegionTuning(r.regions), physics: sanitizePhysicsTuning(r.physics), }; } @@ -91,16 +122,22 @@ export function sanitizeRenderSettings(raw: unknown): GraphRenderSettings { // both range ends AND all four range corners settles cleanly — no NaN, no // explosion (linkStrength ≥ 8 explodes the layout, 5 wobbles; cap 4), // no non-settle (velocityDecay 0 never damps; floor 0.1), no 3x sprawl -// (gravity 0 / charge -1000 excluded). +// (gravity 0 / charge -1000 excluded). 2026-09-17: CHARGE_RANGE +// narrowed to [120, 480] around the new −240 default — strictly inside +// the validated [30, 480], so every corner stays sweep-safe. /** Region rendering knobs (the blurred cluster tints). */ export interface RegionTuning { - /** Dilation half-width of the round-cap edge strokes, screen px at - * fit zoom (the expansion of the cluster skeleton). */ + /** Dilation half-width of the round-cap edge strokes, WORLD units + * (the expansion of the cluster skeleton). */ dilation: number; - /** Box-blur radius in bitmap px (3 passes ≈ Gaussian). Fixed set: - * see REGION_BLUR_OPTIONS (dropdown in the panel — non-integer - * radii broke the rendering, so the control is integer-only). */ + /** Region softness: the blur's reach, WORLD units (3 box-blur + * passes ≈ Gaussian). The 2026-09-15 world-unit pivot ended the + * bitmap-px era — the old reach scaled with the graph's span + * (blur × span / bitmap), so the same value read 6× softer on a + * 338-node graph than on a 13-node one. Now the reach is canonical + * like dilation; the renderer rasterizes it (round to bitmap px, + * floor 1px when the graph out-resolves the blur). */ blur: number; /** Max bitmap dimension (cell = worldSpan / this). Fixed set: * see REGION_BITMAP_OPTIONS (dropdown in the panel). */ @@ -118,13 +155,11 @@ export interface RegionTuning { } export const REGION_BITMAP_OPTIONS = [128, 192, 256, 384, 512] as const; -export const REGION_BLUR_OPTIONS = [2, 4, 6, 8, 10] as const; - export const DEFAULT_REGION_TUNING: RegionTuning = { - dilation: 28, - blur: 6, - bitmap: 384, - edgeAlpha: 0.35, + dilation: 20, + blur: 20, + bitmap: 256, + edgeAlpha: 0.2, enabled: true, }; @@ -133,18 +168,20 @@ function snapToOption(value: number, options: readonly number[]): number { return options.reduce((best, o) => (Math.abs(o - value) < Math.abs(best - value) ? o : best), options[0] as number); } -/** Clamp/validate a region tuning. `bitmap` and `blur` snap to the nearest - * allowed option (they're fixed sets — dropdowns, not free numbers). */ +/** Clamp/validate a region tuning. `bitmap` snaps to the nearest allowed + * option (a fixed set — dropdown, not a free number). */ export function sanitizeRegionTuning(input: Partial | null | undefined): RegionTuning { const d = DEFAULT_REGION_TUNING; const num = (v: unknown, fallback: number): number => typeof v === "number" && Number.isFinite(v) ? v : fallback; const bitmap = num(input?.bitmap, d.bitmap); + const ladd = (v: unknown, ladder: readonly number[], dflt: number) => + typeof v === "number" && Number.isFinite(v) ? snapToLadder(v, ladder) : dflt; return { - dilation: Math.min(Math.max(num(input?.dilation, d.dilation), REGION_DILATION_RANGE.min), REGION_DILATION_RANGE.max), - blur: snapToOption(num(input?.blur, d.blur), REGION_BLUR_OPTIONS), + dilation: ladd(input?.dilation, REGION_DILATION_LADDER, d.dilation), + blur: ladd(input?.blur, REGION_BLUR_LADDER, d.blur), bitmap: snapToOption(bitmap, REGION_BITMAP_OPTIONS), - edgeAlpha: Math.min(Math.max(num(input?.edgeAlpha, d.edgeAlpha), REGION_EDGE_ALPHA_RANGE.min), 1), + edgeAlpha: ladd(input?.edgeAlpha, REGION_EDGE_ALPHA_LADDER, d.edgeAlpha), // Old persisted files predate the switch: missing → on (the look // users tuned); only an explicit false turns regions off. enabled: typeof input?.enabled === "boolean" ? input.enabled : true, @@ -212,39 +249,48 @@ export function sanitizePhysicsTuning(input: Partial | null | und }; } -// Sizes are screen px AT FIT ZOOM (the world-space renderer scales them -// with the camera, so a label reads at labelSize x k/kFit on screen). -// Defaults were re-tuned for world-scaling on 2026-09-13: the old values -// were calibrated in the screen-fixed era, so everything rendered ~2x -// too big once sizes started scaling with the graph. +// Sizes are WORLD UNITS (the 2026-09-15 pivot — see the ranges block +// below for the full story): the world scale is canonical, identical +// settings render identically at any graph size. The values are the +// 2026-09-13 re-tune (2px at fit on the reference graph) ÷ its kFit 0.69. +// The 2026-09-17 bake: the user's live-tuned values (tuned against the +// real graph after the world-unit pivot), each dead-center on its ladder. export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = { + sizeUnits: "world", nodeSize: 2, edgeWidth: 1, arrowLength: 2, - labelZoom: 0.5, - labelSize: 2, + labelZoom: 4, + labelSize: 3, clusterLabels: true, clusterLabelSize: 14, regions: DEFAULT_REGION_TUNING, physics: DEFAULT_PHYSICS_TUNING, }; -// ─── The slider mapping ──────────────────────────────────────────────────────── +// ─── The slider ladders (2026-09-17) ───────────────────────────────────────── // -// EVERY slider is log-scaled over its setting's range with the DEFAULT at -// position 50 (dead center), per the user's spec: centered defaults, coarse -// steps (the UI steps by 5 → 21 stops), no fine-grained linear tracks. -// Ranges are chosen so each default is the geometric mean of its ends. +// Non-physics knobs move on hand-authored 11-stop ladders — the classic +// two-per-octave nice-number series (x1.5 / x1.33 alternation: quarter and +// half steps at the low end, integers above) — with the DEFAULT dead-center +// at index 5 / slider position 50. The slider steps by 10: one stop per +// detent, 21 positions → 11 values, all integers or clean fractions. The +// sanitizer snaps ANY incoming value to the nearest entry, so stored files +// can never hold (or render) 2.18137...977-style artifacts. -export const NODE_SIZE_RANGE = { min: 0.5, max: 8 } as const; -export const EDGE_WIDTH_RANGE = { min: 0.25, max: 4 } as const; -export const ARROW_LENGTH_RANGE = { min: 0.5, max: 8 } as const; -export const LABEL_SIZE_RANGE = { min: 0.5, max: 8 } as const; -// Screen-px at ANY zoom (drawn at size / k). 14 = geometric mean — old -// REGION_LABEL_SIZE default, so the panel slider starts dead-center. -export const CLUSTER_LABEL_SIZE_RANGE = { min: 7, max: 28 } as const; -export const LABEL_ZOOM_MIN = 0.0625; // slider far left: labels only very deep in -export const LABEL_ZOOM_MAX = 4; // slider far right: labels visible at fit zoom +// Each ladder is authored around its DEFAULT at index 5: five stops down, +// five up, following the x1.5 / x4-3 alternation where it lands clean +// (powers of two at the floors where the series would go fractional). +export const NODE_SIZE_LADDER = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8, 12] as const; +export const EDGE_WIDTH_LADDER = [0.1, 0.2, 0.4, 0.6, 0.8, 1, 1.5, 2, 3, 4, 6] as const; +export const ARROW_LENGTH_LADDER = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8, 12] as const; +export const LABEL_SIZE_LADDER = [0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8, 12, 16] as const; +// Screen px at any zoom (drawn at size / k). 14 = the old REGION_LABEL_SIZE. +export const CLUSTER_LABEL_SIZE_LADDER = [7, 8, 9, 10, 12, 14, 16, 18, 20, 24, 28] as const; +// Camera-ratio gates; the DISPLAY ladder reads 1.5×, 2×, 3×, 4×, 6×, 8×, +// 12×, 16×, 24×, 32×, 48× (labelZoomDisplay) with the user's tuned 8× +// (ratio 4) dead-center — it used to sit at the very end of the axis. +export const LABEL_ZOOM_LADDER = [0.75, 1, 1.5, 2, 3, 4, 6, 8, 12, 16, 24] as const; // ─── Region + physics slider ranges ────────────────────────────────────────── // @@ -256,10 +302,11 @@ export const LABEL_ZOOM_MAX = 4; // slider far right: labels visible at fit zoom // toOffSliderPosition), the log range starts one step in. Blur used to // have one too; it's a dropdown since 2026-09-13 (non-integers broke). -export const REGION_DILATION_RANGE = { min: 7, max: 112 } as const; // gm = 28 -export const REGION_EDGE_ALPHA_RANGE = { min: 0.12, max: 1 } as const; // gm ≈ 0.35 +export const REGION_DILATION_LADDER = [4, 5, 7.5, 10, 15, 20, 30, 40, 60, 80, 120] as const; // world units +export const REGION_BLUR_LADDER = [4, 5, 7.5, 10, 15, 20, 30, 40, 60, 80, 120] as const; // world units +export const REGION_EDGE_ALPHA_LADDER = [0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.4, 0.6, 0.8, 1] as const; -export const CHARGE_RANGE = { min: -480, max: -30 } as const; // |gm| = 120 +export const CHARGE_RANGE = { min: -480, max: -120 } as const; // |gm| = 240 (2026-09-17: default −240, dead-center; a subrange of the sweep-validated [30, 480]) export const LINK_STRENGTH_RANGE = { min: 0.25, max: 4 } as const; // gm = 1 export const LINK_DISTANCE_RANGE = { min: 10, max: 160 } as const; // gm = 40 export const GRAVITY_RANGE = { min: 0.03, max: 0.48 } as const; // gm = 0.12 @@ -269,6 +316,36 @@ export const DEPTH_STRENGTH_RANGE = { min: 0.2, max: 3.2 } as const; // gm = 0.8 export const DEPTH_SPACING_RANGE = { min: 15, max: 240 } as const; // gm = 60 export const DEPTH_BASE_RADIUS_RANGE = { min: 55, max: 880 } as const; // gm = 220 +/** Nearest ladder entry (ties break LOW — deterministic). */ +export function snapToLadder(value: number, ladder: readonly number[]): number { + return ladder.reduce((best, v) => (Math.abs(v - value) < Math.abs(best - value) ? v : best), ladder[0] as number); +} + +/** Ladder index of a value (nearest; ties break low) — the slider position + * is index × 10 (step-10 track, 11 stops, default at 50). */ +export function ladderIndex(value: number, ladder: readonly number[]): number { + let best = 0; + for (let i = 1; i < ladder.length; i++) + if (Math.abs(ladder[i] as number - value) < Math.abs(ladder[best] as number - value)) best = i; + return best; +} + +/** Setting value → ladder slider position [0, 100]. */ +export function toLadderPosition(value: number, ladder: readonly number[]): number { + return ladderIndex(clampToLadder(value, ladder), ladder) * 10; +} + +/** Ladder slider position [0, 100] → setting value (the ladder entry). */ +export function fromLadderPosition(position: number, ladder: readonly number[]): number { + const i = Math.min(Math.max(Math.round(position / 10), 0), ladder.length - 1); + return ladder[i] as number; +} + +/** Clamp a value into the ladder's span (no snapping — bounds only). */ +function clampToLadder(value: number, ladder: readonly number[]): number { + return clamp(value, ladder[0] as number, ladder[ladder.length - 1] as number); +} + /** Off-aware slider mapping (position 0 → value 0 = OFF; the log range * starts one step in). Inverse of fromOffSliderPosition. */ export function toOffSliderPosition(value: number, min: number, max: number): number { @@ -296,8 +373,11 @@ export function fromSliderPosition(position: number, min: number, max: number): return min * Math.exp(p * logSpan); } -/** Display units for labelZoom: normalized so the default (0.5) reads as 1. */ +/** Display units for labelZoom: ABSOLUTE since 2026-09-17 (ratio / 0.5 — + * anchored to the historical pre-bake default, so "8×" keeps its meaning + * now that 4 IS the default; the old default-relative form would have + * relabeled the user's tuned 8× as "1×"). */ export function labelZoomDisplay(labelZoom: number): string { - const v = Math.round((labelZoom / DEFAULT_RENDER_SETTINGS.labelZoom) * 100) / 100; + const v = Math.round((labelZoom / 0.5) * 100) / 100; return `${v}×`; } diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index 011d3a2..2306363 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -18,7 +18,7 @@ import { labelAlpha, pickNearest, zoomRatioOf, - LABELS_FADE_SPAN, + LABELS_FADE_RATIO, } from "../components/graph-canvas.ts"; import { sanitizePhysicsTuning, @@ -26,6 +26,7 @@ import { DEFAULT_PHYSICS_TUNING, DEFAULT_REGION_TUNING, REGION_BITMAP_OPTIONS, + REGION_BLUR_LADDER, } from "../src/core/render-settings.ts"; describe("zoomRatioOf (the sigma-compatible camera ratio)", () => { @@ -38,26 +39,60 @@ describe("zoomRatioOf (the sigma-compatible camera ratio)", () => { describe("labelAlpha (the single zoom gate)", () => { test("fully visible at/below the threshold, faded out after the span", () => { + // Proportional span: at gate 0.5 the span is 0.5 × 0.5 = 0.25. assert.equal(labelAlpha(0.5, 0.5), 1); // at the threshold assert.equal(labelAlpha(0.25, 0.5), 1); // zoomed further in: stays 1 - assert.equal(labelAlpha(0.75, 0.5), 0.5); // mid-fade - assert.equal(labelAlpha(1.0, 0.5), 0); // one span out: gone + assert.equal(labelAlpha(0.625, 0.5), 0.5); // mid-fade (span midpoint) + assert.equal(labelAlpha(0.75, 0.5), 0); // one proportional span out: gone assert.equal(labelAlpha(2.0, 0.5), 0); // far out: clamped }); + test("the span is PROPORTIONAL to the gate (2026-09-17), not absolute", () => { + // The old absolute 0.5-ratio span read as a 100% relative change at + // the old 0.5 gate but a bare 12% sliver at the tuned 8× gate (ratio + // 4). Proportional = one consistent handoff feel at any gate. + assert.equal(LABELS_FADE_RATIO, 0.5); + // At the tuned default (gate 4): fades across ratio 4 → 6 (8× → 12× + // on screen), one full ladder stop. + assert.equal(labelAlpha(4, 4), 1); // at the gate + assert.equal(labelAlpha(5, 4), 0.5); // mid-fade + assert.equal(labelAlpha(6, 4), 0); // one proportional span out: gone + // The relative shape is identical at the old gate: 0.5 → 0.75. + assert.equal(labelAlpha(5, 4), 0.5); // mid-fade at the tuned gate, same relative shape + }); + test("node labels and cluster labels cross-fade over the exact same span", () => { // The renderer draws cluster labels at 1 - labelAlpha; the two must // always sum to 1 within the fade and never coexist at full opacity. - for (const ratio of [0, 0.25, 0.5, 0.6, 0.75, 0.9, 1, 2, 4]) { - const node = labelAlpha(ratio, 0.5); - const cluster = 1 - node; - assert.ok(node + cluster > 0.999 && node + cluster < 1.001, `sum at ratio ${ratio}`); - assert.ok(node <= 1 && cluster <= 1, `clamped at ratio ${ratio}`); + for (const gate of [0.5, 4]) { + for (let ratio = 0; ratio <= 8; ratio += gate / 8) { + const node = labelAlpha(ratio, gate); + const cluster = 1 - node; + assert.ok(node + cluster > 0.999 && node + cluster < 1.001, `sum at gate ${gate} ratio ${ratio}`); + assert.ok(node <= 1 && cluster <= 1, `clamped at gate ${gate} ratio ${ratio}`); + } } }); +}); - test("the span constant is unchanged from the sigma era (stored settings depend on it)", () => { - assert.equal(LABELS_FADE_SPAN, 0.5); +describe("d3 camera composition (the persisted-camera restore path)", () => { + // The renderer restores a persisted camera via + // zoomIdentity.translate(x, y).scale(k) — the same composition fit() + // uses. d3's Transform.translate() shifts in SCALED space while + // .scale() keeps the translation: identity.translate(X, Y).scale(K) + // is exactly {k: K, x: X, y: Y}. This pins that law (the persisted + // {k, x, y} shape round-trips through the composition) so a d3 + // upgrade that changes it fails here, not as a wrong camera. + test("zoomIdentity.translate(X, Y).scale(K) === {k: K, x: X, y: Y}", async () => { + const { zoomIdentity } = await import("d3-zoom"); + const t = zoomIdentity.translate(120, -40).scale(3.5); + assert.equal(t.k, 3.5); + assert.equal(t.x, 120); + assert.equal(t.y, -40); + // And the inverse direction: a stored camera re-applies to the + // same screen position for the same world point. + const p = t.apply([7, 9]); + assert.deepEqual(p, [7 * 3.5 + 120, 9 * 3.5 - 40]); }); }); @@ -152,14 +187,14 @@ describe("sanitizeRegionTuning (dev number inputs)", () => { const t = sanitizeRegionTuning(undefined); assert.deepEqual(t, DEFAULT_REGION_TUNING); const half = sanitizeRegionTuning({ blur: 7.2 }); - assert.equal(half.blur, 8); // snaps into the fixed set + assert.equal(half.blur, 7.5); // snapped to the ladder (2026-09-17) assert.equal(half.dilation, DEFAULT_REGION_TUNING.dilation); }); test("garbage clamps: NaN falls back, out-of-range clamps", () => { const t = sanitizeRegionTuning({ dilation: Number.NaN, blur: -5, bitmap: 99999, edgeAlpha: 42 }); assert.equal(t.dilation, DEFAULT_REGION_TUNING.dilation); // NaN -> default - assert.equal(t.blur, 2); // snaps into the fixed set + assert.equal(t.blur, REGION_BLUR_LADDER[0]); // snapped to the ladder floor assert.equal(t.bitmap, REGION_BITMAP_OPTIONS[REGION_BITMAP_OPTIONS.length - 1]); // 99999 snaps to the largest option assert.equal(t.edgeAlpha, 1); }); diff --git a/tests/render-settings.test.ts b/tests/render-settings.test.ts index 7602bea..af02080 100644 --- a/tests/render-settings.test.ts +++ b/tests/render-settings.test.ts @@ -1,40 +1,43 @@ /** * Tests for src/core/render-settings.ts — the shared settings contract - * (shape, defaults, sanitizer, label-zoom slider mapping). Pure module, - * pure tests: whatever JSON arrives from disk or the network goes through + * (shape, defaults, sanitizer, ladder/slider mapping). Pure module, pure + * tests: whatever JSON arrives from disk or the network goes through * sanitizeRenderSettings and must come out valid, never throw. */ import assert from "node:assert/strict"; import { describe, it as test } from "node:test"; import { - ARROW_LENGTH_RANGE, + ARROW_LENGTH_LADDER, CHARGE_RANGE, - CLUSTER_LABEL_SIZE_RANGE, + CLUSTER_LABEL_SIZE_LADDER, CLUSTER_PULL_RANGE, DEFAULT_RENDER_SETTINGS, DEPTH_BASE_RADIUS_RANGE, DEPTH_SPACING_RANGE, DEPTH_STRENGTH_RANGE, - 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, - REGION_BLUR_OPTIONS, + NODE_SIZE_LADDER, + REGION_BLUR_LADDER, sanitizeRegionTuning, - REGION_DILATION_RANGE, - REGION_EDGE_ALPHA_RANGE, + REGION_DILATION_LADDER, + REGION_EDGE_ALPHA_LADDER, VELOCITY_DECAY_RANGE, DEFAULT_PHYSICS_TUNING, DEFAULT_REGION_TUNING, + fromLadderPosition, fromOffSliderPosition, fromSliderPosition, labelZoomDisplay, + ladderIndex, sanitizeRenderSettings, + snapToLadder, + toLadderPosition, toOffSliderPosition, toSliderPosition, } from "../src/core/render-settings.ts"; @@ -48,7 +51,7 @@ describe("sanitizeRenderSettings", () => { }); test("partial input → defaults fill the gaps", () => { - const s = sanitizeRenderSettings({ nodeSize: 6 }); + const s = sanitizeRenderSettings({ sizeUnits: "world", nodeSize: 6 }); assert.equal(s.nodeSize, 6); assert.equal(s.edgeWidth, DEFAULT_RENDER_SETTINGS.edgeWidth); assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); @@ -57,8 +60,9 @@ describe("sanitizeRenderSettings", () => { assert.equal(s.clusterLabelSize, DEFAULT_RENDER_SETTINGS.clusterLabelSize); // old files → 14 }); - test("out-of-range values clamp; wrong types reset", () => { + test("out-of-range values snap to ladder ends; wrong types reset", () => { const s = sanitizeRenderSettings({ + sizeUnits: "world", nodeSize: 999, edgeWidth: 0, arrowLength: -5, @@ -67,69 +71,134 @@ describe("sanitizeRenderSettings", () => { clusterLabels: "yes" as unknown as boolean, clusterLabelSize: "huge" as unknown as number, }); - assert.equal(s.nodeSize, NODE_SIZE_RANGE.max); // clamped to max - assert.equal(s.edgeWidth, EDGE_WIDTH_RANGE.min); // clamped to min - assert.equal(s.arrowLength, ARROW_LENGTH_RANGE.min); - assert.equal(s.labelZoom, LABEL_ZOOM_MAX); + assert.equal(s.nodeSize, NODE_SIZE_LADDER[NODE_SIZE_LADDER.length - 1]); // snapped to the ladder top + assert.equal(s.edgeWidth, EDGE_WIDTH_LADDER[0]); // snapped to the ladder floor + assert.equal(s.arrowLength, ARROW_LENGTH_LADDER[0]); + assert.equal(s.labelZoom, LABEL_ZOOM_LADDER[LABEL_ZOOM_LADDER.length - 1]); assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); // NaN-safe reset assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels); assert.equal(s.clusterLabelSize, DEFAULT_RENDER_SETTINGS.clusterLabelSize); // NaN-safe reset }); - test("clusterLabelSize clamps to its own range (slider law)", () => { - // Default 14 is the geometric mean of [7, 28] → dead-center slider. - assert.equal( - Math.sqrt(CLUSTER_LABEL_SIZE_RANGE.min * CLUSTER_LABEL_SIZE_RANGE.max), - DEFAULT_RENDER_SETTINGS.clusterLabelSize, - ); - const s = sanitizeRenderSettings({ clusterLabelSize: 999 }); - assert.equal(s.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.max); - const s2 = sanitizeRenderSettings({ clusterLabelSize: 1 }); - assert.equal(s2.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.min); + test("the ladder law: any finite value snaps to the nearest stop", () => { + // Stored artifacts like 2.18137...977 (px-at-fit survivors) become + // nice numbers; nothing non-finite ever enters a file. + assert.equal(snapToLadder(2.18, NODE_SIZE_LADDER), 2); + assert.equal(snapToLadder(3.9, NODE_SIZE_LADDER), 4); + assert.equal(snapToLadder(0.63, NODE_SIZE_LADDER), 0.75); // 0.63 is nearer 0.75 than 0.5 + // Ties break low (deterministic, and the lower stop is the + // conservative direction for every knob). + assert.equal(snapToLadder(1.25, NODE_SIZE_LADDER), 1); + // The display cluster-label ladder snaps too. + assert.equal(sanitizeRenderSettings({ sizeUnits: "world", clusterLabelSize: 21 }).clusterLabelSize, 20); }); - test("the low ends accept small sizes (log-slider floors)", () => { - const s = sanitizeRenderSettings({ nodeSize: 0.5, edgeWidth: 0.25, arrowLength: 0.5, labelSize: 0.5 }); + test("clusterLabelSize snaps to its own ladder", () => { + // Default 14 is a stop (index 5) → dead-center slider. + assert.equal(DEFAULT_RENDER_SETTINGS.clusterLabelSize, CLUSTER_LABEL_SIZE_LADDER[ladderIndex(14, CLUSTER_LABEL_SIZE_LADDER)]); + const s = sanitizeRenderSettings({ sizeUnits: "world", clusterLabelSize: 999 }); + assert.equal(s.clusterLabelSize, CLUSTER_LABEL_SIZE_LADDER[CLUSTER_LABEL_SIZE_LADDER.length - 1]); + const s2 = sanitizeRenderSettings({ sizeUnits: "world", clusterLabelSize: 1 }); + assert.equal(s2.clusterLabelSize, CLUSTER_LABEL_SIZE_LADDER[0]); + }); + + test("the low ends accept small sizes (ladder floors)", () => { + const s = sanitizeRenderSettings({ sizeUnits: "world", nodeSize: 0.3, edgeWidth: 0.1, arrowLength: 0.2, labelSize: 0.3 }); assert.deepEqual( { nodeSize: s.nodeSize, edgeWidth: s.edgeWidth, arrowLength: s.arrowLength, labelSize: s.labelSize }, - { nodeSize: 0.5, edgeWidth: 0.25, arrowLength: 0.5, labelSize: 0.5 }, + { nodeSize: 0.25, edgeWidth: 0.1, arrowLength: 0.25, labelSize: 0.5 }, // snapped to the ladder floors ); }); test("a fully valid object passes through unchanged", () => { const valid = { + sizeUnits: "world", nodeSize: 4, edgeWidth: 2, arrowLength: 3, - labelZoom: 0.25, + labelZoom: 2, labelSize: 4, clusterLabels: false, clusterLabelSize: 20, regions: DEFAULT_REGION_TUNING, physics: DEFAULT_PHYSICS_TUNING, }; - assert.deepEqual(sanitizeRenderSettings(valid), valid); + assert.deepEqual(sanitizeRenderSettings(valid), valid); // every value a ladder member }); test("the pre-2026-09-13 stored shape (flat, no regions/physics) still loads", () => { // data/render-settings.json on disk predates the nested sections — // those loads must get defaults, not crash, not shift anything. + // It also predates sizeUnits — the sizes reset (below), the flat + // fields keep their stored values (snapped to the nearest stop). const s = sanitizeRenderSettings({ nodeSize: 4, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true }); assert.deepEqual(s.regions, DEFAULT_REGION_TUNING); assert.deepEqual(s.physics, DEFAULT_PHYSICS_TUNING); - assert.equal(s.nodeSize, 4); + assert.equal(s.nodeSize, DEFAULT_RENDER_SETTINGS.nodeSize); // legacy sizes reset + assert.equal(s.edgeWidth, 1); + assert.equal(s.labelZoom, 0.75); // 0.5 isn't a stop — snaps to 0.75 }); - test("nested sections sanitize: garbage clamps inside them without touching the rest", () => { + test("the 2026-09-15 world-unit pivot: legacy files reset ONLY the size fields", () => { + // Pre-pivot files stored sizes as "screen px at fit zoom" — a unit + // whose meaning depended on the graph's own extent. Those values + // cannot be carried into world units honestly → reset; everything + // whose semantics did NOT change keeps its stored value. const s = sanitizeRenderSettings({ + nodeSize: 2, labelSize: 2, arrowLength: 2, + edgeWidth: 3, labelZoom: 0.75, clusterLabelSize: 21, + regions: { dilation: 28, blur: 8.1, bitmap: 512, edgeAlpha: 0.6, enabled: false }, + physics: { linkStrength: 2 }, + }); + assert.equal(s.sizeUnits, "world"); // the sanitizer always stamps + assert.equal(s.nodeSize, DEFAULT_RENDER_SETTINGS.nodeSize); + assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); + assert.equal(s.arrowLength, DEFAULT_RENDER_SETTINGS.arrowLength); + assert.equal(s.regions.dilation, DEFAULT_REGION_TUNING.dilation); + // Unchanged semantics survive — snapped to the ladder: + assert.equal(s.edgeWidth, 3); + assert.equal(s.labelZoom, 0.75); + assert.equal(s.clusterLabelSize, 20); // 21 snaps to 20 + assert.equal(s.regions.blur, 7.5); // 8.1 snaps to 7.5 + assert.equal(s.regions.bitmap, 512); + assert.equal(s.regions.edgeAlpha, 0.6); + assert.equal(s.regions.enabled, false); + assert.equal(s.physics.linkStrength, 2); + }); + + test("a stamped world-unit file keeps its size values", () => { + const s = sanitizeRenderSettings({ sizeUnits: "world", nodeSize: 6, arrowLength: 8, labelSize: 3 }); + assert.equal(s.nodeSize, 6); + assert.equal(s.arrowLength, 8); + assert.equal(s.labelSize, 3); + }); + + test("the 2026-09-17 defaults are the user's live-tuned bake", () => { + // nodeSize 2, edgeWidth 1, arrowLength 2, labelSize 3, labelZoom 4 + // (displays 8×), dilation 20, blur 20, edgeAlpha 0.2, bitmap 256 — + // tuned live against the real graph, each dead-center on its ladder. + assert.equal(DEFAULT_RENDER_SETTINGS.nodeSize, 2); + assert.equal(DEFAULT_RENDER_SETTINGS.edgeWidth, 1); + assert.equal(DEFAULT_RENDER_SETTINGS.arrowLength, 2); + assert.equal(DEFAULT_RENDER_SETTINGS.labelSize, 3); + assert.equal(DEFAULT_RENDER_SETTINGS.labelZoom, 4); + assert.equal(DEFAULT_REGION_TUNING.dilation, 20); + assert.equal(DEFAULT_REGION_TUNING.blur, 20); + assert.equal(DEFAULT_REGION_TUNING.edgeAlpha, 0.2); + assert.equal(DEFAULT_REGION_TUNING.bitmap, 256); + }); + + test("nested sections sanitize: garbage snaps inside them without touching the rest", () => { + const s = sanitizeRenderSettings({ + sizeUnits: "world", nodeSize: 3, regions: { blur: -5, bitmap: 999, edgeAlpha: 9 }, physics: { linkStrength: 99, gravity: 7, depthMode: "sideways" }, }); assert.equal(s.nodeSize, 3); - assert.equal(s.regions.blur, 2); // snaps into the fixed set {2,4,6,8,10} + assert.equal(s.regions.blur, REGION_BLUR_LADDER[0]); // snapped to the ladder floor assert.equal(s.regions.bitmap, 512); // snaps to the nearest fixed option - assert.equal(s.regions.edgeAlpha, 1); + assert.equal(s.regions.edgeAlpha, 1); // snapped to the ladder top assert.equal(s.regions.dilation, DEFAULT_REGION_TUNING.dilation); assert.equal(s.physics.linkStrength, 4); // sweep-validated cap assert.equal(s.physics.gravity, 0.48); @@ -139,59 +208,83 @@ describe("sanitizeRenderSettings", () => { test("the pre-wave stored shape (no labelSize) still loads", () => { // localStorage-era records lacked labelSize — must not shift anything. - const s = sanitizeRenderSettings({ nodeSize: 4, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true }); + const s = sanitizeRenderSettings({ sizeUnits: "world", nodeSize: 4, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true }); assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); assert.equal(s.nodeSize, 4); + assert.equal(s.labelZoom, 0.75); // non-member snaps }); }); -describe("slider mapping (log scale, default-centered)", () => { - const RANGES: [string, number, number, number][] = [ - ["nodeSize", NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max, DEFAULT_RENDER_SETTINGS.nodeSize], - ["edgeWidth", EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max, DEFAULT_RENDER_SETTINGS.edgeWidth], - ["arrowLength", ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max, DEFAULT_RENDER_SETTINGS.arrowLength], - ["labelSize", LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max, DEFAULT_RENDER_SETTINGS.labelSize], - ["labelZoom", LABEL_ZOOM_MIN, LABEL_ZOOM_MAX, DEFAULT_RENDER_SETTINGS.labelZoom], +describe("ladder mapping (11 stops, default-centered)", () => { + const LADDERS: [string, readonly number[], number][] = [ + ["nodeSize", NODE_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.nodeSize], + ["edgeWidth", EDGE_WIDTH_LADDER, DEFAULT_RENDER_SETTINGS.edgeWidth], + ["arrowLength", ARROW_LENGTH_LADDER, DEFAULT_RENDER_SETTINGS.arrowLength], + ["labelSize", LABEL_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.labelSize], + ["labelZoom", LABEL_ZOOM_LADDER, DEFAULT_RENDER_SETTINGS.labelZoom], + ["clusterLabelSize", CLUSTER_LABEL_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.clusterLabelSize], + ["regions.dilation", REGION_DILATION_LADDER, DEFAULT_REGION_TUNING.dilation], + ["regions.blur", REGION_BLUR_LADDER, DEFAULT_REGION_TUNING.blur], + ["regions.edgeAlpha", REGION_EDGE_ALPHA_LADDER, DEFAULT_REGION_TUNING.edgeAlpha], ]; - test("every default sits dead-center at position 50", () => { - for (const [name, min, max, dflt] of RANGES) - assert.equal(Math.round(toSliderPosition(dflt, min, max)), 50, name); + test("every ladder has 11 stops; every default is stop #5 (dead-center)", () => { + for (const [name, ladder, dflt] of LADDERS) { + assert.equal(ladder.length, 11, name); + assert.equal(ladder[5], dflt, name); // default at index 5 → position 50 + } }); - test("round-trips both directions across the whole span", () => { - for (const [name, min, max] of RANGES) { - for (let p = 0; p <= 100; p += 5) { - const v = fromSliderPosition(p, min, max); - assert.ok(Math.abs(toSliderPosition(v, min, max) - p) < 1e-9, `${name} @ ${p}`); + test("every value is a nice number (clean decimal, no float artifacts)", () => { + // The worst-case granularity across all ladders is 1/40 (the + // edge-alpha floor 0.025); everything else is eighths, quarters, + // halves, or integers. Nothing like 2.18137...977 can ever persist. + for (const [name, ladder] of LADDERS) { + for (const v of ladder) { + assert.ok(Number.isInteger(v * 40), `${name} stop ${v} isn't a clean fortieth`); + assert.equal(String(v), `${+v.toFixed(3)}`, `${name} stop ${v} has float noise`); } } }); - test("slider extremes map to the range ends and clamp beyond", () => { - for (const [name, min, max] of RANGES) { - assert.equal(fromSliderPosition(0, min, max), min, name); - assert.ok(Math.abs(fromSliderPosition(100, min, max) - max) < 1e-9, name); // exp/log float noise - assert.equal(toSliderPosition(min * 0.1, min, max), 0, name); // clamps - assert.equal(toSliderPosition(max * 10, min, max), 100, name); + test("round-trips: positions 0-100 step 10 hit every stop and back", () => { + for (const [name, ladder] of LADDERS) { + for (let i = 0; i < ladder.length; i++) { + const p = i * 10; + const stop = ladder[i] as number; + assert.equal(fromLadderPosition(p, ladder), stop, `${name} @ ${p}`); + assert.equal(toLadderPosition(stop, ladder), p, `${name} stop ${i}`); + } } }); - test("display units normalize the default to 1", () => { - assert.equal(labelZoomDisplay(0.5), "1×"); - assert.equal(labelZoomDisplay(0.25), "0.5×"); - assert.equal(labelZoomDisplay(4), "8×"); - assert.equal(labelZoomDisplay(0.0625), "0.13×"); // rounded to 2 decimals + test("off-ladder positions snap to the nearest stop; extremes clamp", () => { + for (const [name, ladder] of LADDERS) { + assert.equal(fromLadderPosition(-20, ladder), ladder[0], name); + assert.equal(fromLadderPosition(120, ladder), ladder[ladder.length - 1], name); + // Round-half-up at the 55 midpoint (JS Math.round), so 54 → stop 5 + // and 58 → stop 6 — the slider readout never shows a non-member. + assert.equal(fromLadderPosition(54, ladder), ladder[5], name); + assert.equal(fromLadderPosition(58, ladder), ladder[6], name); + } }); + test("display units are absolute (ratio / 0.5): 8× is the tuned default", () => { + assert.equal(labelZoomDisplay(0.5), "1×"); + assert.equal(labelZoomDisplay(4), "8×"); // the default, not "1×" — pre-2026-09-17 displays divided by the default, which would have relabeled the bake + assert.equal(labelZoomDisplay(0.75), "1.5×"); + assert.equal(labelZoomDisplay(24), "48×"); + assert.equal(labelZoomDisplay(0.25), "0.5×"); + assert.equal(labelZoomDisplay(0.0625), "0.13×"); // rounded to 2 decimals + }); +}); + +describe("physics sliders (fine-grained log, unchanged 2026-09-17)", () => { test("every region/physics default sits dead-center at position 50", () => { - // The same law for the 2026-09-13 sliders: default = geometric mean - // of the range ends (repulsion maps |charge|; friction tolerates - // the sub-0.5° drift). + // Physics keeps the log-slider law: default = geometric mean of the + // range ends (repulsion maps |charge|; friction tolerates the + // sub-0.5° drift). const pairs: [number, number, number][] = [ - [REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max, DEFAULT_REGION_TUNING.dilation], - [REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max, DEFAULT_REGION_TUNING.edgeAlpha], - // |charge| in [30, 480] (the range stores negative ends). [-CHARGE_RANGE.max, -CHARGE_RANGE.min, -DEFAULT_PHYSICS_TUNING.chargeStrength], [LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max, DEFAULT_PHYSICS_TUNING.linkStrength], [LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max, DEFAULT_PHYSICS_TUNING.linkDistance], @@ -208,6 +301,13 @@ describe("slider mapping (log scale, default-centered)", () => { assert.ok(Math.abs(toOffSliderPosition(DEFAULT_PHYSICS_TUNING.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) - 50) < 0.6); }); + test("the 2026-09-17 charge bump: default −240, dead-center", () => { + // User: "repulsion default too weak". −240 is the gm of [120, 480] — + // the range stays inside the sweep-validated [30, 480]. + assert.equal(DEFAULT_PHYSICS_TUNING.chargeStrength, -240); + assert.ok(Math.abs(toSliderPosition(240, 120, 480) - 50) < 1e-9); + }); + test("off-slider mapping: position 0 ↔ value 0; the log part round-trips", () => { assert.equal(toOffSliderPosition(0, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max), 0); assert.equal(fromOffSliderPosition(0, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max), 0); @@ -220,16 +320,19 @@ describe("slider mapping (log scale, default-centered)", () => { assert.ok(Math.abs(fromOffSliderPosition(5, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) - stepIn) < 1e-9); }); - test("blur is a fixed set: the default is 6, non-members snap to the nearest", () => { - // Non-integer radii broke the rendering, so the control became a - // dropdown — the sanitizer enforces the set for ANY input. - assert.equal(DEFAULT_REGION_TUNING.blur, 6); - assert.equal(sanitizeRegionTuning({ blur: 3.7 }).blur, 4); - assert.equal(sanitizeRegionTuning({ blur: 7.2 }).blur, 8); - assert.equal(sanitizeRegionTuning({ blur: -5 }).blur, 2); - assert.equal(sanitizeRegionTuning({ blur: 99 }).blur, 10); - // Stored blur 0 (the old off zone) snaps back into the set. - assert.equal(sanitizeRegionTuning({ blur: 0 }).blur, 2); + test("blur is WORLD units (2026-09-15): free values, snapped to the ladder", () => { + // The bitmap-px era ended: the old reach scaled with the graph's + // span, so a value tuned on a big graph read far too sharp on a + // small one. It's a canonical world reach now — any stored value + // snaps to the nearest stop. + assert.equal(DEFAULT_REGION_TUNING.blur, 20); + assert.equal(sanitizeRegionTuning({ blur: 3.7 }).blur, 4); // nearest stop + assert.equal(sanitizeRegionTuning({ blur: 7.2 }).blur, 7.5); + assert.equal(sanitizeRegionTuning({ blur: -5 }).blur, REGION_BLUR_LADDER[0]); + assert.equal(sanitizeRegionTuning({ blur: 999 }).blur, REGION_BLUR_LADDER[REGION_BLUR_LADDER.length - 1]); + assert.equal(sanitizeRegionTuning({ blur: 99 }).blur, 80); // inside the span → nearest stop, not the top + // Stored blur 0 (the old off zone) snaps back into the ladder. + assert.equal(sanitizeRegionTuning({ blur: 0 }).blur, REGION_BLUR_LADDER[0]); }); test("enabled is the compute off switch: only an explicit false turns it off", () => {