diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index b9bab57..dfabc38 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -58,6 +58,12 @@ import { CHARGE_STRENGTH, CLUSTER_PULL_STRENGTH, createGraphSimulation, + DEFAULT_PHYSICS_TUNING, + DEFAULT_REGION_TUNING, + sanitizePhysicsTuning, + sanitizeRegionTuning, + type PhysicsTuning, + type RegionTuning, INCREMENTAL_GRAVITY, LINK_DISTANCE, type DepthAnchorMode, @@ -160,109 +166,6 @@ const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest const REGION_LABEL_SIZE = 14; -/** Dev-tunable region rendering (2026-09-13, user request): plain number - * inputs in the settings panel — session-only, never persisted — so the - * look can be tuned live against the real graph. A cluster is its - * CONNECTED SKELETON: the region is the dilated line network ONLY (no - * per-node discs — they double-counted where nodes sit on edges and - * made hubs hot). Blur is REAL: a premultiplied 3-pass box blur on the - * bitmap (bilinear upscale alone was no blur at all — zoom-in showed - * tent-ramp chunkiness, the "low-quality JPEG" look). */ -export interface RegionTuning { - /** Dilation half-width of the round-cap edge strokes, screen px at - * fit zoom (the expansion of the cluster skeleton). */ - dilation: number; - /** Box-blur radius in bitmap px (3 passes ≈ Gaussian). 0 = off. */ - blur: number; - /** Max bitmap dimension (cell = worldSpan / this). */ - bitmap: number; - /** Per-stroke alpha; overlapping strokes stack toward the core. */ - edgeAlpha: number; -} - -export const DEFAULT_REGION_TUNING: RegionTuning = { - dilation: 28, - blur: 10, - bitmap: 384, - edgeAlpha: 0.35, -}; - -/** Clamp anything into a usable tuning (number inputs can be empty/NaN). */ -export function sanitizeRegionTuning(input: Partial | null | undefined): RegionTuning { - const d = DEFAULT_REGION_TUNING; - const num = (v: unknown, fallback: number): number => - typeof v === "number" && Number.isFinite(v) ? v : fallback; - return { - dilation: Math.min(Math.max(num(input?.dilation, d.dilation), 4), 250), - blur: Math.min(Math.max(num(input?.blur, d.blur), 0), 40), - bitmap: Math.round(Math.min(Math.max(num(input?.bitmap, d.bitmap), 128), 1024)), - edgeAlpha: Math.min(Math.max(num(input?.edgeAlpha, d.edgeAlpha), 0.02), 1), - }; -} - -/** User-facing live-sim physics controls (2026-09-13, user request): - * every value feeds createGraphSimulation — the SAME engine the server - * settle runs — so tuning the feel changes exactly what a drag relaxes - * toward. Session state, never persisted. The cluster pull is the - * "virtual node" realized as a centroid force (O(n)/tick, measured - * indistinguishable from off). */ -export interface PhysicsTuning { - /** Node repulsion (d3 charge, negative = push apart). */ - chargeStrength: number; - /** Edge attraction: link-spring stiffness multiplier (1 = d3 default). */ - linkStrength: number; - /** Link spring rest length (world units). */ - linkDistance: number; - /** Central pull toward the graph centroid. */ - gravity: number; - /** Per-cluster pull toward the cluster centroid. */ - clusterPull: number; - /** Per-tick velocity damping (d3 default 0.4). */ - velocityDecay: number; - /** Inference-depth anchoring mode (see DepthAnchor in core). */ - depthMode: DepthAnchorMode; - /** Depth-anchor pull strength. */ - depthStrength: number; - /** Spacing between inference levels (world units). */ - depthSpacing: number; - /** Inner ring radius, radial mode (world units). */ - depthBaseRadius: number; -} - -export const DEFAULT_PHYSICS_TUNING: PhysicsTuning = { - chargeStrength: CHARGE_STRENGTH, - linkStrength: 1, - linkDistance: LINK_DISTANCE, - gravity: INCREMENTAL_GRAVITY, - clusterPull: CLUSTER_PULL_STRENGTH, - velocityDecay: 0.4, - depthMode: "off", - // 0.8 by sweep on the real graph: 215/216 arrows read upward with - // organic sag (mean |dev| from level ~34u at spacing 60); 1.5 gives - // crisp rows (dev ~18) if the promoted default wants them. - depthStrength: 0.8, - depthSpacing: 60, - depthBaseRadius: 220, -}; - -/** Clamp anything into a usable tuning (number inputs can be empty/NaN). */ -export function sanitizePhysicsTuning(input: Partial | null | undefined): PhysicsTuning { - const d = DEFAULT_PHYSICS_TUNING; - const num = (v: unknown, fallback: number): number => - typeof v === "number" && Number.isFinite(v) ? v : fallback; - return { - chargeStrength: Math.min(Math.max(num(input?.chargeStrength, d.chargeStrength), -1000), -10), - linkStrength: Math.min(Math.max(num(input?.linkStrength, d.linkStrength), 0), 5), - linkDistance: Math.min(Math.max(num(input?.linkDistance, d.linkDistance), 1), 300), - gravity: Math.min(Math.max(num(input?.gravity, d.gravity), 0), 1), - clusterPull: Math.min(Math.max(num(input?.clusterPull, d.clusterPull), 0), 2), - velocityDecay: Math.min(Math.max(num(input?.velocityDecay, d.velocityDecay), 0), 0.99), - depthMode: input?.depthMode === "vertical" || input?.depthMode === "radial" ? input.depthMode : "off", - depthStrength: Math.min(Math.max(num(input?.depthStrength, d.depthStrength), 0), 2), - depthSpacing: Math.min(Math.max(num(input?.depthSpacing, d.depthSpacing), 1), 500), - depthBaseRadius: Math.min(Math.max(num(input?.depthBaseRadius, d.depthBaseRadius), 0), 2000), - }; -} /** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA * (Float32, stride 4, length w*h*4). Premultiplying first is what keeps diff --git a/components/graph-view.tsx b/components/graph-view.tsx index a139ddf..60c3ff3 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -65,25 +65,33 @@ import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState import Link from "next/link"; import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/core"; -import { - DEFAULT_PHYSICS_TUNING, - DEFAULT_REGION_TUNING, - GraphCanvasRenderer, - type GraphRenderSettings, - type PhysicsTuning, - type RegionTuning, -} from "./graph-canvas.ts"; +import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; import { ARROW_LENGTH_RANGE, + CHARGE_RANGE, + CLUSTER_PULL_RANGE, + DEPTH_BASE_RADIUS_RANGE, + DEPTH_SPACING_RANGE, + DEPTH_STRENGTH_RANGE, DEFAULT_RENDER_SETTINGS, EDGE_WIDTH_RANGE, + GRAVITY_RANGE, LABEL_SIZE_RANGE, LABEL_ZOOM_MAX, LABEL_ZOOM_MIN, + LINK_DISTANCE_RANGE, + LINK_STRENGTH_RANGE, NODE_SIZE_RANGE, + REGION_BITMAP_OPTIONS, + REGION_BLUR_RANGE, + REGION_DILATION_RANGE, + REGION_EDGE_ALPHA_RANGE, + VELOCITY_DECAY_RANGE, + fromOffSliderPosition, fromSliderPosition, labelZoomDisplay, sanitizeRenderSettings, + toOffSliderPosition, toSliderPosition, } from "@/src/core"; @@ -149,33 +157,6 @@ function sizeDisplay(v: number): string { return `${v >= 1 ? Math.round(v * 10) / 10 : Math.round(v * 100) / 100}`; } -/** Dev number input: raw value in, raw value out (NaN while empty → the - * renderer's sanitizer holds the last sane tuning). Development tool. */ -function NumberField({ - label, - value, - step, - onChange, -}: { - label: string; - value: number; - step: number; - onChange: (v: number) => void; -}) { - return ( - - ); -} - function SettingSlider({ label, min, @@ -227,19 +208,16 @@ export function GraphView() { // Search panel state: the query text + the settings section toggle. const [query, setQuery] = useState(""); const [settingsOpen, setSettingsOpen] = useState(false); - // Region tuning: DEV-ONLY, session state, never persisted (unlike the - // render settings, which live on the server). Number inputs so any - // value can be tried quickly. - const [regionTuning, setRegionTuning] = useState(DEFAULT_REGION_TUNING); - // Live-sim physics controls: session state, never persisted. Changes - // rebuild the renderer's sim from the current node positions — the - // same engine the server settle runs, so the feel IS the physics. - const [physicsTuning, setPhysicsTuning] = useState(DEFAULT_PHYSICS_TUNING); // Render settings: SERVER-persisted (data/render-settings.json via - // /api/graph/render-settings). Loaded once after mount; every user - // change saves back, debounced. Defaults render until the load - // resolves — a fraction of a second at worst. - const [settings, setSettings] = useState(DEFAULT_RENDER_SETTINGS); + // /api/graph/render-settings) — sizes AND the region/physics sections + // (user-facing since 2026-09-13; the dev-session era is over). Loaded + // once after mount; every user change saves back, debounced. Defaults + // render until the load resolves — a fraction of a second at worst. + const [settings, setSettings] = useState(DEFAULT_RENDER_SETTINGS); + // The two nested sections, unpacked for the renderer feed effects + // below (same names the old session state had). + const regionTuning = settings.regions; + const physicsTuning = settings.physics; const settingsRef = useRef(settings); settingsRef.current = settings; // True once the user has moved a control this mount: the server copy @@ -625,42 +603,82 @@ export function GraphView() { className="accent-primary" /> - {/* Regions (dev): quick-tuning number inputs. Session state - only - never persisted. Bad/empty values fall back to - the last sane tuning inside the renderer. */} + {/* Regions: the blurred cluster tints. Sliders + a fixed-set + dropdown for the bitmap resolution (it's a discrete + choice, not a free number). Server-persisted like the + sizes; every range end was sweep-validated on the real + graph. */}
- Regions (dev) + Regions - setRegionTuning((t) => ({ ...t, dilation: v }))} + value={toSliderPosition(regionTuning.dilation, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max)} + display={sizeDisplay(regionTuning.dilation)} + onChange={(p) => + changeSettings((cur) => ({ + ...cur, + regions: { ...cur.regions, dilation: fromSliderPosition(p, REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max) }, + }))} /> - setRegionTuning((t) => ({ ...t, blur: v }))} + + changeSettings((cur) => ({ + ...cur, + regions: { ...cur.regions, blur: fromOffSliderPosition(p, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max) }, + }))} /> - setRegionTuning((t) => ({ ...t, bitmap: v }))} - /> - setRegionTuning((t) => ({ ...t, edgeAlpha: v }))} + min={0} + max={100} + step={5} + value={toSliderPosition(regionTuning.edgeAlpha, REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max)} + 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) }, + }))} /> +
- {/* Physics (user-facing): the live sim's forces. Every value feeds - the SAME engine the server settle runs (createGraphSimulation); + {/* Physics: the live sim's forces. Every value feeds the SAME + engine the server settle runs (createGraphSimulation); changing one rebuilds the sim from the current positions — - drag mid-settle re-anneals, pins survive. Session-only. */} + drag mid-settle re-anneals, pins survive. Sliders over + sweep-validated ranges (src/core/render-settings.ts): + range ends and all four corners settle cleanly — no NaN, + no explosion, no non-settle. Repulsion is displayed as + the negative it is; cluster pull's far left = off. */}
Physics @@ -671,9 +689,9 @@ export function GraphView() { className="rounded border bg-background px-1 py-0.5 text-xs" value={physicsTuning.depthMode} onChange={(e) => - setPhysicsTuning((t) => ({ - ...t, - depthMode: e.target.value as PhysicsTuning["depthMode"], + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] }, })) } > @@ -682,59 +700,122 @@ export function GraphView() { - setPhysicsTuning((t) => ({ ...t, depthStrength: v }))} - /> - setPhysicsTuning((t) => ({ ...t, depthSpacing: v }))} - /> - setPhysicsTuning((t) => ({ ...t, depthBaseRadius: v }))} - /> - setPhysicsTuning((t) => ({ ...t, chargeStrength: v }))} - /> - setPhysicsTuning((t) => ({ ...t, linkStrength: v }))} - /> - setPhysicsTuning((t) => ({ ...t, linkDistance: v }))} + value={toSliderPosition(physicsTuning.depthStrength, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max)} + display={sizeDisplay(physicsTuning.depthStrength)} + onChange={(p) => + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, depthStrength: fromSliderPosition(p, DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max) }, + }))} /> - + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, chargeStrength: -fromSliderPosition(p, -CHARGE_RANGE.max, -CHARGE_RANGE.min) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, linkStrength: fromSliderPosition(p, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max) }, + }))} + /> + + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, linkDistance: fromSliderPosition(p, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max) }, + }))} + /> + setPhysicsTuning((t) => ({ ...t, gravity: v }))} + min={0} + max={100} + step={5} + value={toSliderPosition(physicsTuning.gravity, GRAVITY_RANGE.min, GRAVITY_RANGE.max)} + display={sizeDisplay(physicsTuning.gravity)} + onChange={(p) => + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, gravity: fromSliderPosition(p, GRAVITY_RANGE.min, GRAVITY_RANGE.max) }, + }))} /> - setPhysicsTuning((t) => ({ ...t, clusterPull: v }))} + min={0} + max={100} + step={5} + value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)} + display={physicsTuning.clusterPull <= 0 ? "off" : sizeDisplay(physicsTuning.clusterPull)} + onChange={(p) => + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, clusterPull: fromOffSliderPosition(p, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) }, + }))} /> - setPhysicsTuning((t) => ({ ...t, velocityDecay: v }))} + min={0} + max={100} + step={5} + value={toSliderPosition(physicsTuning.velocityDecay, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max)} + display={sizeDisplay(physicsTuning.velocityDecay)} + onChange={(p) => + changeSettings((cur) => ({ + ...cur, + physics: { ...cur.physics, velocityDecay: fromSliderPosition(p, VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max) }, + }))} />
diff --git a/src/core/render-settings.ts b/src/core/render-settings.ts index edfb1ca..4f653a9 100644 --- a/src/core/render-settings.ts +++ b/src/core/render-settings.ts @@ -16,6 +16,14 @@ * Persisted values keep these units across the renderer swap. */ +import { + CHARGE_STRENGTH, + CLUSTER_PULL_STRENGTH, + INCREMENTAL_GRAVITY, + LINK_DISTANCE, + type DepthAnchorMode, +} from "./layout.ts"; + export interface GraphRenderSettings { nodeSize: number; edgeWidth: number; @@ -26,21 +34,14 @@ export interface GraphRenderSettings { labelSize: number; /** Toggle for the cluster region labels (hulls always draw). */ clusterLabels: boolean; + /** Region rendering (the blurred cluster tints). Server-persisted + * since 2026-09-13 (user request) — was session-only dev state. */ + regions: RegionTuning; + /** Live-sim physics + inference-depth anchoring. Server-persisted + * since 2026-09-13 (user request) — was session-only dev state. */ + physics: PhysicsTuning; } -// 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. -export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = { - nodeSize: 2, - edgeWidth: 1, - arrowLength: 2, - labelZoom: 0.5, - labelSize: 2, - clusterLabels: true, -}; const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); @@ -64,9 +65,142 @@ export function sanitizeRenderSettings(raw: unknown): GraphRenderSettings { labelSize: num(r.labelSize, DEFAULT_RENDER_SETTINGS.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max), clusterLabels: typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels, + // Nested sections (2026-09-13): old files lack them → defaults. + regions: sanitizeRegionTuning(r.regions), + physics: sanitizePhysicsTuning(r.physics), }; } +// ─── Region + physics tuning (moved from the renderer, 2026-09-13) ───────────── +// +// The renderer's region/physics controls became USER-FACING persisted +// settings: one shape + one sanitizer in core, consumed by the settings +// store (this file's schema), the panel, and the renderer's +// setRegionTuning/setPhysicsTuning. Every clamp below is a SWEEP-VALIDATED +// safe range (2026-09-13, real graph, renderer's exact sim): each field at +// 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). + +/** 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: number; + /** Box-blur radius in bitmap px (3 passes ≈ Gaussian). 0 = off. */ + blur: number; + /** Max bitmap dimension (cell = worldSpan / this). Fixed set: + * see REGION_BITMAP_OPTIONS (dropdown in the panel). */ + bitmap: number; + /** Per-stroke alpha; overlapping strokes stack toward the core. */ + edgeAlpha: number; +} + +export const REGION_BITMAP_OPTIONS = [128, 192, 256, 384, 512] as const; + +export const DEFAULT_REGION_TUNING: RegionTuning = { + dilation: 28, + blur: 10, + bitmap: 384, + edgeAlpha: 0.35, +}; + +/** Clamp/validate a region tuning. `bitmap` snaps to the nearest allowed + * option (it's a fixed set — a dropdown, not a free number). */ +export function sanitizeRegionTuning(input: Partial | null | undefined): RegionTuning { + const d = DEFAULT_REGION_TUNING; + const num = (v: unknown, fallback: number): number => + typeof v === "number" && Number.isFinite(v) ? v : fallback; + const bitmap = num(input?.bitmap, d.bitmap); + return { + dilation: Math.min(Math.max(num(input?.dilation, d.dilation), REGION_DILATION_RANGE.min), REGION_DILATION_RANGE.max), + blur: Math.min(Math.max(num(input?.blur, d.blur), 0), REGION_BLUR_RANGE.max), + bitmap: REGION_BITMAP_OPTIONS.reduce( + (best, o) => (Math.abs(o - bitmap) < Math.abs(best - bitmap) ? o : best), + REGION_BITMAP_OPTIONS[0] as number, + ), + edgeAlpha: Math.min(Math.max(num(input?.edgeAlpha, d.edgeAlpha), REGION_EDGE_ALPHA_RANGE.min), 1), + }; +} + +/** Live-sim physics controls (user-facing since 2026-09-13): every value + * feeds createGraphSimulation — the SAME engine the server settle runs — + * so tuning the feel changes exactly what a drag relaxes toward. */ +export interface PhysicsTuning { + /** Node repulsion (d3 charge, negative = push apart). */ + chargeStrength: number; + /** Edge attraction: link-spring stiffness multiplier (1 = d3 default). */ + linkStrength: number; + /** Link spring rest length (world units). */ + linkDistance: number; + /** Central pull toward the graph centroid. */ + gravity: number; + /** Per-cluster pull toward the cluster centroid. 0 = off. */ + clusterPull: number; + /** Per-tick velocity damping (d3 default 0.4). */ + velocityDecay: number; + /** Inference-depth anchoring mode (see DepthAnchor in core). */ + depthMode: DepthAnchorMode; + /** Depth-anchor pull strength. */ + depthStrength: number; + /** Spacing between inference levels (world units). */ + depthSpacing: number; + /** Inner ring radius, radial mode (world units). */ + depthBaseRadius: number; +} + +export const DEFAULT_PHYSICS_TUNING: PhysicsTuning = { + chargeStrength: CHARGE_STRENGTH, + linkStrength: 1, + linkDistance: LINK_DISTANCE, + gravity: INCREMENTAL_GRAVITY, + clusterPull: CLUSTER_PULL_STRENGTH, + velocityDecay: 0.4, + depthMode: "off", + // 0.8 by sweep on the real graph: 215/216 arrows read upward with + // organic sag (mean |dev| from level ~34u at spacing 60); 1.5 gives + // crisp rows (dev ~18) if the promoted default wants them. + depthStrength: 0.8, + depthSpacing: 60, + depthBaseRadius: 220, +}; + +/** Clamp anything into a usable tuning (sliders/JSON can be empty/NaN). */ +export function sanitizePhysicsTuning(input: Partial | null | undefined): PhysicsTuning { + const d = DEFAULT_PHYSICS_TUNING; + const num = (v: unknown, fallback: number): number => + typeof v === "number" && Number.isFinite(v) ? v : fallback; + return { + chargeStrength: Math.min(Math.max(num(input?.chargeStrength, d.chargeStrength), CHARGE_RANGE.min), CHARGE_RANGE.max), + linkStrength: Math.min(Math.max(num(input?.linkStrength, d.linkStrength), LINK_STRENGTH_RANGE.min), LINK_STRENGTH_RANGE.max), + linkDistance: Math.min(Math.max(num(input?.linkDistance, d.linkDistance), LINK_DISTANCE_RANGE.min), LINK_DISTANCE_RANGE.max), + gravity: Math.min(Math.max(num(input?.gravity, d.gravity), GRAVITY_RANGE.min), GRAVITY_RANGE.max), + clusterPull: Math.min(Math.max(num(input?.clusterPull, d.clusterPull), 0), CLUSTER_PULL_RANGE.max), + velocityDecay: Math.min(Math.max(num(input?.velocityDecay, d.velocityDecay), VELOCITY_DECAY_RANGE.min), VELOCITY_DECAY_RANGE.max), + depthMode: input?.depthMode === "vertical" || input?.depthMode === "radial" ? input.depthMode : "off", + depthStrength: Math.min(Math.max(num(input?.depthStrength, d.depthStrength), DEPTH_STRENGTH_RANGE.min), DEPTH_STRENGTH_RANGE.max), + depthSpacing: Math.min(Math.max(num(input?.depthSpacing, d.depthSpacing), DEPTH_SPACING_RANGE.min), DEPTH_SPACING_RANGE.max), + depthBaseRadius: Math.min(Math.max(num(input?.depthBaseRadius, d.depthBaseRadius), DEPTH_BASE_RADIUS_RANGE.min), DEPTH_BASE_RADIUS_RANGE.max), + }; +} + +// 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. +export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = { + nodeSize: 2, + edgeWidth: 1, + arrowLength: 2, + labelZoom: 0.5, + labelSize: 2, + clusterLabels: true, + regions: DEFAULT_REGION_TUNING, + physics: DEFAULT_PHYSICS_TUNING, +}; + // ─── The slider mapping ──────────────────────────────────────────────────────── // // EVERY slider is log-scaled over its setting's range with the DEFAULT at @@ -81,6 +215,42 @@ export const LABEL_SIZE_RANGE = { min: 0.5, max: 8 } 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 +// ─── Region + physics slider ranges ────────────────────────────────────────── +// +// Same laws as the size sliders: log-scaled, DEFAULT AT 50 (each default is +// the geometric mean of its ends), coarse 5-step tracks. Signed/zeroed +// fields get special treatment: +// chargeStrength is NEGATIVE — the slider maps |charge| and displays "-x"; +// clusterPull and region blur have an OFF zone — position 0 is exactly 0 +// (see toOffSliderPosition), the log range starts one step in. + +export const REGION_DILATION_RANGE = { min: 7, max: 112 } as const; // gm = 28 +export const REGION_BLUR_RANGE = { min: 2.5, max: 40 } as const; // gm = 10; 0 = off +export const REGION_EDGE_ALPHA_RANGE = { min: 0.12, max: 1 } as const; // gm ≈ 0.35 + +export const CHARGE_RANGE = { min: -480, max: -30 } as const; // |gm| = 120 +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 +export const CLUSTER_PULL_RANGE = { min: 0.0225, max: 1 } as const; // gm = 0.15; 0 = off +export const VELOCITY_DECAY_RANGE = { min: 0.18, max: 0.9 } as const; // gm ≈ 0.40 +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 + +/** 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 { + if (value <= 0) return 0; + return toSliderPosition(value, min, max); +} + +/** Off-aware slider mapping: position 0 → 0 (off), else log-scaled. */ +export function fromOffSliderPosition(position: number, min: number, max: number): number { + if (position <= 0) return 0; + return fromSliderPosition(position, min, max); +} + /** Setting value → slider position [0, 100] (log scale, default at 50). */ export function toSliderPosition(value: number, min: number, max: number): number { const v = clamp(value, min, max); diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index 87aaa71..d472e2b 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -17,13 +17,16 @@ import { fitTransform, labelAlpha, pickNearest, - sanitizePhysicsTuning, - sanitizeRegionTuning, zoomRatioOf, - DEFAULT_PHYSICS_TUNING, - DEFAULT_REGION_TUNING, LABELS_FADE_SPAN, } from "../components/graph-canvas.ts"; +import { + sanitizePhysicsTuning, + sanitizeRegionTuning, + DEFAULT_PHYSICS_TUNING, + DEFAULT_REGION_TUNING, + REGION_BITMAP_OPTIONS, +} from "../src/core/render-settings.ts"; describe("zoomRatioOf (the sigma-compatible camera ratio)", () => { test("1 at fit zoom, smaller when zoomed in, larger when zoomed out", () => { @@ -156,7 +159,7 @@ describe("sanitizeRegionTuning (dev number inputs)", () => { 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, 0); - assert.equal(t.bitmap, 1024); + assert.equal(t.bitmap, REGION_BITMAP_OPTIONS[REGION_BITMAP_OPTIONS.length - 1]); // 99999 snaps to the largest option assert.equal(t.edgeAlpha, 1); }); @@ -215,10 +218,10 @@ describe("sanitizePhysicsTuning (physics controls)", () => { velocityDecay: 3, }); assert.equal(t.chargeStrength, DEFAULT_PHYSICS_TUNING.chargeStrength); - assert.equal(t.linkStrength, 5); - assert.equal(t.linkDistance, 1); - assert.equal(t.gravity, 1); + assert.equal(t.linkStrength, 4); // sweep-validated cap (8 explodes, 5 wobbles) + assert.equal(t.linkDistance, 10); + assert.equal(t.gravity, 0.48); assert.equal(t.clusterPull, 0); - assert.equal(t.velocityDecay, 0.99); + assert.equal(t.velocityDecay, 0.9); }); }); diff --git a/tests/render-settings.test.ts b/tests/render-settings.test.ts index 3fef0fe..38e45f1 100644 --- a/tests/render-settings.test.ts +++ b/tests/render-settings.test.ts @@ -9,15 +9,31 @@ import assert from "node:assert/strict"; import { describe, it as test } from "node:test"; import { ARROW_LENGTH_RANGE, + CHARGE_RANGE, + CLUSTER_PULL_RANGE, DEFAULT_RENDER_SETTINGS, + DEPTH_BASE_RADIUS_RANGE, + DEPTH_SPACING_RANGE, + DEPTH_STRENGTH_RANGE, EDGE_WIDTH_RANGE, + GRAVITY_RANGE, LABEL_SIZE_RANGE, LABEL_ZOOM_MAX, LABEL_ZOOM_MIN, + LINK_DISTANCE_RANGE, + LINK_STRENGTH_RANGE, NODE_SIZE_RANGE, + REGION_BLUR_RANGE, + REGION_DILATION_RANGE, + REGION_EDGE_ALPHA_RANGE, + VELOCITY_DECAY_RANGE, + DEFAULT_PHYSICS_TUNING, + DEFAULT_REGION_TUNING, + fromOffSliderPosition, fromSliderPosition, labelZoomDisplay, sanitizeRenderSettings, + toOffSliderPosition, toSliderPosition, } from "../src/core/render-settings.ts"; @@ -64,10 +80,45 @@ describe("sanitizeRenderSettings", () => { }); test("a fully valid object passes through unchanged", () => { - const valid = { nodeSize: 4, edgeWidth: 2, arrowLength: 3, labelZoom: 0.25, labelSize: 4, clusterLabels: false }; + const valid = { + nodeSize: 4, + edgeWidth: 2, + arrowLength: 3, + labelZoom: 0.25, + labelSize: 4, + clusterLabels: false, + regions: DEFAULT_REGION_TUNING, + physics: DEFAULT_PHYSICS_TUNING, + }; assert.deepEqual(sanitizeRenderSettings(valid), valid); }); + 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. + 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); + }); + + test("nested sections sanitize: garbage clamps inside them without touching the rest", () => { + const s = sanitizeRenderSettings({ + 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, 0); + assert.equal(s.regions.bitmap, 512); // snaps to the nearest fixed option + assert.equal(s.regions.edgeAlpha, 1); + 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); + assert.equal(s.physics.depthMode, "off"); // unknown mode → off + assert.equal(s.physics.chargeStrength, DEFAULT_PHYSICS_TUNING.chargeStrength); + }); + 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 }); @@ -114,4 +165,41 @@ describe("slider mapping (log scale, default-centered)", () => { assert.equal(labelZoomDisplay(4), "8×"); assert.equal(labelZoomDisplay(0.0625), "0.13×"); // rounded to 2 decimals }); + + 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). + 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], + [GRAVITY_RANGE.min, GRAVITY_RANGE.max, DEFAULT_PHYSICS_TUNING.gravity], + [VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max, DEFAULT_PHYSICS_TUNING.velocityDecay], + [DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max, DEFAULT_PHYSICS_TUNING.depthStrength], + [DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max, DEFAULT_PHYSICS_TUNING.depthSpacing], + [DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max, DEFAULT_PHYSICS_TUNING.depthBaseRadius], + ]; + for (const [min, max, d] of pairs) { + assert.ok(Math.abs(toSliderPosition(d, min, max) - 50) < 0.6, `default ${d} in [${min}, ${max}]`); + } + // Off-zone fields: the default sits at 50 INSIDE the on-part. + assert.ok(Math.abs(toOffSliderPosition(DEFAULT_PHYSICS_TUNING.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) - 50) < 0.6); + assert.ok(Math.abs(toOffSliderPosition(DEFAULT_REGION_TUNING.blur, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max) - 50) < 0.6); + }); + + test("off-slider mapping: position 0 ↔ value 0; the log part round-trips", () => { + assert.equal(toOffSliderPosition(0, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max), 0); + assert.equal(fromOffSliderPosition(0, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max), 0); + const v = fromOffSliderPosition(toOffSliderPosition(10, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max), REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max); + assert.ok(Math.abs(v - 10) < 1e-9); + // One step in (position 5) sits just above the log floor: + // min * (max/min)^(5/100), NOT the floor itself (that's position 0, + // the off zone). + const stepIn = REGION_BLUR_RANGE.min * (REGION_BLUR_RANGE.max / REGION_BLUR_RANGE.min) ** (5 / 100); + assert.ok(Math.abs(fromOffSliderPosition(5, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max) - stepIn) < 1e-9); + }); });