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:
+107
-61
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user