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]);
+1 -1
View File
@@ -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 —
+140 -60
View File
@@ -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<GraphRenderSettings>;
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<RegionTuning> | 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<PhysicsTuning> | 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}×`;
}
+47 -12
View File
@@ -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);
});
+183 -80
View File
@@ -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", () => {