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
+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}×`;
}