User-facing persisted region + physics settings
All graph panel controls are now user-facing and server-persisted: the old session-only dev number inputs are gone. GraphRenderSettings gains nested regions/physics sections (backward compatible: old flat files load with defaults), and RegionTuning/PhysicsTuning moved from the renderer into core/render-settings.ts — one shape, one sanitizer, one source for the store, the panel, and the renderer. Physics/region clamps are sweep-validated safe ranges (each field at both ends + all four corners settles cleanly on the real graph: no NaN, no explosion — linkStrength caps at 4 since 8 explodes and 5 wobbles; friction floors at 0.1 since 0 never damps). Panel: log sliders (default = geometric mean = position 50, step 5), off-zone sliders for blur/cluster pull (position 0 = exactly 0), a fixed-set dropdown for bitmap resolution, depth-anchor modes promoted to the select.
This commit is contained in:
+6
-103
@@ -58,6 +58,12 @@ import {
|
|||||||
CHARGE_STRENGTH,
|
CHARGE_STRENGTH,
|
||||||
CLUSTER_PULL_STRENGTH,
|
CLUSTER_PULL_STRENGTH,
|
||||||
createGraphSimulation,
|
createGraphSimulation,
|
||||||
|
DEFAULT_PHYSICS_TUNING,
|
||||||
|
DEFAULT_REGION_TUNING,
|
||||||
|
sanitizePhysicsTuning,
|
||||||
|
sanitizeRegionTuning,
|
||||||
|
type PhysicsTuning,
|
||||||
|
type RegionTuning,
|
||||||
INCREMENTAL_GRAVITY,
|
INCREMENTAL_GRAVITY,
|
||||||
LINK_DISTANCE,
|
LINK_DISTANCE,
|
||||||
type DepthAnchorMode,
|
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_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest
|
||||||
const REGION_LABEL_SIZE = 14;
|
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<RegionTuning> | 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<PhysicsTuning> | 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
|
/** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA
|
||||||
* (Float32, stride 4, length w*h*4). Premultiplying first is what keeps
|
* (Float32, stride 4, length w*h*4). Premultiplying first is what keeps
|
||||||
|
|||||||
+202
-121
@@ -65,25 +65,33 @@ import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/core";
|
import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/core";
|
||||||
import {
|
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
||||||
DEFAULT_PHYSICS_TUNING,
|
|
||||||
DEFAULT_REGION_TUNING,
|
|
||||||
GraphCanvasRenderer,
|
|
||||||
type GraphRenderSettings,
|
|
||||||
type PhysicsTuning,
|
|
||||||
type RegionTuning,
|
|
||||||
} from "./graph-canvas.ts";
|
|
||||||
import {
|
import {
|
||||||
ARROW_LENGTH_RANGE,
|
ARROW_LENGTH_RANGE,
|
||||||
|
CHARGE_RANGE,
|
||||||
|
CLUSTER_PULL_RANGE,
|
||||||
|
DEPTH_BASE_RADIUS_RANGE,
|
||||||
|
DEPTH_SPACING_RANGE,
|
||||||
|
DEPTH_STRENGTH_RANGE,
|
||||||
DEFAULT_RENDER_SETTINGS,
|
DEFAULT_RENDER_SETTINGS,
|
||||||
EDGE_WIDTH_RANGE,
|
EDGE_WIDTH_RANGE,
|
||||||
|
GRAVITY_RANGE,
|
||||||
LABEL_SIZE_RANGE,
|
LABEL_SIZE_RANGE,
|
||||||
LABEL_ZOOM_MAX,
|
LABEL_ZOOM_MAX,
|
||||||
LABEL_ZOOM_MIN,
|
LABEL_ZOOM_MIN,
|
||||||
|
LINK_DISTANCE_RANGE,
|
||||||
|
LINK_STRENGTH_RANGE,
|
||||||
NODE_SIZE_RANGE,
|
NODE_SIZE_RANGE,
|
||||||
|
REGION_BITMAP_OPTIONS,
|
||||||
|
REGION_BLUR_RANGE,
|
||||||
|
REGION_DILATION_RANGE,
|
||||||
|
REGION_EDGE_ALPHA_RANGE,
|
||||||
|
VELOCITY_DECAY_RANGE,
|
||||||
|
fromOffSliderPosition,
|
||||||
fromSliderPosition,
|
fromSliderPosition,
|
||||||
labelZoomDisplay,
|
labelZoomDisplay,
|
||||||
sanitizeRenderSettings,
|
sanitizeRenderSettings,
|
||||||
|
toOffSliderPosition,
|
||||||
toSliderPosition,
|
toSliderPosition,
|
||||||
} from "@/src/core";
|
} 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}`;
|
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 (
|
|
||||||
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
|
||||||
<span>{label}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={Number.isFinite(value) ? value : ""}
|
|
||||||
step={step}
|
|
||||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
|
||||||
className="w-20 rounded-md border bg-background px-1.5 py-0.5 text-right tabular-nums text-foreground"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingSlider({
|
function SettingSlider({
|
||||||
label,
|
label,
|
||||||
min,
|
min,
|
||||||
@@ -227,19 +208,16 @@ export function GraphView() {
|
|||||||
// Search panel state: the query text + the settings section toggle.
|
// Search panel state: the query text + the settings section toggle.
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
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<RegionTuning>(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<PhysicsTuning>(DEFAULT_PHYSICS_TUNING);
|
|
||||||
// Render settings: SERVER-persisted (data/render-settings.json via
|
// Render settings: SERVER-persisted (data/render-settings.json via
|
||||||
// /api/graph/render-settings). Loaded once after mount; every user
|
// /api/graph/render-settings) — sizes AND the region/physics sections
|
||||||
// change saves back, debounced. Defaults render until the load
|
// (user-facing since 2026-09-13; the dev-session era is over). Loaded
|
||||||
// resolves — a fraction of a second at worst.
|
// once after mount; every user change saves back, debounced. Defaults
|
||||||
const [settings, setSettings] = useState<RenderSettings>(DEFAULT_RENDER_SETTINGS);
|
// render until the load resolves — a fraction of a second at worst.
|
||||||
|
const [settings, setSettings] = useState<GraphRenderSettings>(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);
|
const settingsRef = useRef(settings);
|
||||||
settingsRef.current = settings;
|
settingsRef.current = settings;
|
||||||
// True once the user has moved a control this mount: the server copy
|
// True once the user has moved a control this mount: the server copy
|
||||||
@@ -625,42 +603,82 @@ export function GraphView() {
|
|||||||
className="accent-primary"
|
className="accent-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{/* Regions (dev): quick-tuning number inputs. Session state
|
{/* Regions: the blurred cluster tints. Sliders + a fixed-set
|
||||||
only - never persisted. Bad/empty values fall back to
|
dropdown for the bitmap resolution (it's a discrete
|
||||||
the last sane tuning inside the renderer. */}
|
choice, not a free number). Server-persisted like the
|
||||||
|
sizes; every range end was sweep-validated on the real
|
||||||
|
graph. */}
|
||||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Regions (dev)
|
Regions
|
||||||
</span>
|
</span>
|
||||||
<NumberField
|
<SettingSlider
|
||||||
label="Dilation (px at fit)"
|
label="Dilation"
|
||||||
value={regionTuning.dilation}
|
min={0}
|
||||||
|
max={100}
|
||||||
step={5}
|
step={5}
|
||||||
onChange={(v) => 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) },
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<SettingSlider
|
||||||
label="Blur (bitmap px)"
|
label="Blur"
|
||||||
value={regionTuning.blur}
|
min={0}
|
||||||
step={1}
|
max={100}
|
||||||
onChange={(v) => setRegionTuning((t) => ({ ...t, blur: v }))}
|
step={5}
|
||||||
|
value={toOffSliderPosition(regionTuning.blur, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max)}
|
||||||
|
display={regionTuning.blur <= 0 ? "off" : sizeDisplay(regionTuning.blur)}
|
||||||
|
onChange={(p) =>
|
||||||
|
changeSettings((cur) => ({
|
||||||
|
...cur,
|
||||||
|
regions: { ...cur.regions, blur: fromOffSliderPosition(p, REGION_BLUR_RANGE.min, REGION_BLUR_RANGE.max) },
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<SettingSlider
|
||||||
label="Bitmap resolution"
|
|
||||||
value={regionTuning.bitmap}
|
|
||||||
step={64}
|
|
||||||
onChange={(v) => setRegionTuning((t) => ({ ...t, bitmap: v }))}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="Edge alpha"
|
label="Edge alpha"
|
||||||
value={regionTuning.edgeAlpha}
|
min={0}
|
||||||
step={0.05}
|
max={100}
|
||||||
onChange={(v) => setRegionTuning((t) => ({ ...t, edgeAlpha: v }))}
|
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) },
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
|
<label className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||||
|
<span>Bitmap resolution</span>
|
||||||
|
<select
|
||||||
|
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
|
||||||
|
value={regionTuning.bitmap}
|
||||||
|
onChange={(e) =>
|
||||||
|
changeSettings((cur) => ({
|
||||||
|
...cur,
|
||||||
|
regions: { ...cur.regions, bitmap: Number(e.target.value) },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{REGION_BITMAP_OPTIONS.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/* Physics (user-facing): the live sim's forces. Every value feeds
|
{/* Physics: the live sim's forces. Every value feeds the SAME
|
||||||
the SAME engine the server settle runs (createGraphSimulation);
|
engine the server settle runs (createGraphSimulation);
|
||||||
changing one rebuilds the sim from the current positions —
|
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. */}
|
||||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Physics
|
Physics
|
||||||
@@ -671,9 +689,9 @@ export function GraphView() {
|
|||||||
className="rounded border bg-background px-1 py-0.5 text-xs"
|
className="rounded border bg-background px-1 py-0.5 text-xs"
|
||||||
value={physicsTuning.depthMode}
|
value={physicsTuning.depthMode}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setPhysicsTuning((t) => ({
|
changeSettings((cur) => ({
|
||||||
...t,
|
...cur,
|
||||||
depthMode: e.target.value as PhysicsTuning["depthMode"],
|
physics: { ...cur.physics, depthMode: e.target.value as GraphRenderSettings["physics"]["depthMode"] },
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -682,59 +700,122 @@ export function GraphView() {
|
|||||||
<option value="radial">Radial (outward)</option>
|
<option value="radial">Radial (outward)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<NumberField
|
<SettingSlider
|
||||||
label="Depth strength"
|
label="Depth strength"
|
||||||
value={physicsTuning.depthStrength}
|
min={0}
|
||||||
step={0.05}
|
max={100}
|
||||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, depthStrength: v }))}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="Depth spacing"
|
|
||||||
value={physicsTuning.depthSpacing}
|
|
||||||
step={10}
|
|
||||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, depthSpacing: v }))}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="Base ring radius"
|
|
||||||
value={physicsTuning.depthBaseRadius}
|
|
||||||
step={20}
|
|
||||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, depthBaseRadius: v }))}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="Node repulsion"
|
|
||||||
value={physicsTuning.chargeStrength}
|
|
||||||
step={10}
|
|
||||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, chargeStrength: v }))}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="Edge attraction"
|
|
||||||
value={physicsTuning.linkStrength}
|
|
||||||
step={0.25}
|
|
||||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, linkStrength: v }))}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="Edge length"
|
|
||||||
value={physicsTuning.linkDistance}
|
|
||||||
step={5}
|
step={5}
|
||||||
onChange={(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) },
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<SettingSlider
|
||||||
|
label="Depth spacing"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={toSliderPosition(physicsTuning.depthSpacing, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max)}
|
||||||
|
display={sizeDisplay(physicsTuning.depthSpacing)}
|
||||||
|
onChange={(p) =>
|
||||||
|
changeSettings((cur) => ({
|
||||||
|
...cur,
|
||||||
|
physics: { ...cur.physics, depthSpacing: fromSliderPosition(p, DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max) },
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<SettingSlider
|
||||||
|
label="Base ring radius"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={toSliderPosition(physicsTuning.depthBaseRadius, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max)}
|
||||||
|
display={sizeDisplay(physicsTuning.depthBaseRadius)}
|
||||||
|
onChange={(p) =>
|
||||||
|
changeSettings((cur) => ({
|
||||||
|
...cur,
|
||||||
|
physics: { ...cur.physics, depthBaseRadius: fromSliderPosition(p, DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max) },
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<SettingSlider
|
||||||
|
label="Node repulsion"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={toSliderPosition(-physicsTuning.chargeStrength, -CHARGE_RANGE.max, -CHARGE_RANGE.min)}
|
||||||
|
display={`-${Math.round(-physicsTuning.chargeStrength)}`}
|
||||||
|
onChange={(p) =>
|
||||||
|
changeSettings((cur) => ({
|
||||||
|
...cur,
|
||||||
|
physics: { ...cur.physics, chargeStrength: -fromSliderPosition(p, -CHARGE_RANGE.max, -CHARGE_RANGE.min) },
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<SettingSlider
|
||||||
|
label="Edge attraction"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={toSliderPosition(physicsTuning.linkStrength, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max)}
|
||||||
|
display={sizeDisplay(physicsTuning.linkStrength)}
|
||||||
|
onChange={(p) =>
|
||||||
|
changeSettings((cur) => ({
|
||||||
|
...cur,
|
||||||
|
physics: { ...cur.physics, linkStrength: fromSliderPosition(p, LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max) },
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<SettingSlider
|
||||||
|
label="Edge length"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={toSliderPosition(physicsTuning.linkDistance, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max)}
|
||||||
|
display={sizeDisplay(physicsTuning.linkDistance)}
|
||||||
|
onChange={(p) =>
|
||||||
|
changeSettings((cur) => ({
|
||||||
|
...cur,
|
||||||
|
physics: { ...cur.physics, linkDistance: fromSliderPosition(p, LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max) },
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<SettingSlider
|
||||||
label="Central pull"
|
label="Central pull"
|
||||||
value={physicsTuning.gravity}
|
min={0}
|
||||||
step={0.01}
|
max={100}
|
||||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, gravity: v }))}
|
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) },
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<SettingSlider
|
||||||
label="Cluster pull"
|
label="Cluster pull"
|
||||||
value={physicsTuning.clusterPull}
|
min={0}
|
||||||
step={0.05}
|
max={100}
|
||||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, clusterPull: v }))}
|
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) },
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
<NumberField
|
<SettingSlider
|
||||||
label="Friction"
|
label="Friction"
|
||||||
value={physicsTuning.velocityDecay}
|
min={0}
|
||||||
step={0.05}
|
max={100}
|
||||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, velocityDecay: v }))}
|
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) },
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+183
-13
@@ -16,6 +16,14 @@
|
|||||||
* Persisted values keep these units across the renderer swap.
|
* 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 {
|
export interface GraphRenderSettings {
|
||||||
nodeSize: number;
|
nodeSize: number;
|
||||||
edgeWidth: number;
|
edgeWidth: number;
|
||||||
@@ -26,21 +34,14 @@ export interface GraphRenderSettings {
|
|||||||
labelSize: number;
|
labelSize: number;
|
||||||
/** Toggle for the cluster region labels (hulls always draw). */
|
/** Toggle for the cluster region labels (hulls always draw). */
|
||||||
clusterLabels: boolean;
|
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);
|
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),
|
labelSize: num(r.labelSize, DEFAULT_RENDER_SETTINGS.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max),
|
||||||
clusterLabels:
|
clusterLabels:
|
||||||
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.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<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);
|
||||||
|
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<PhysicsTuning> | 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 ────────────────────────────────────────────────────────
|
// ─── The slider mapping ────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// EVERY slider is log-scaled over its setting's range with the DEFAULT at
|
// 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_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
|
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). */
|
/** Setting value → slider position [0, 100] (log scale, default at 50). */
|
||||||
export function toSliderPosition(value: number, min: number, max: number): number {
|
export function toSliderPosition(value: number, min: number, max: number): number {
|
||||||
const v = clamp(value, min, max);
|
const v = clamp(value, min, max);
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ import {
|
|||||||
fitTransform,
|
fitTransform,
|
||||||
labelAlpha,
|
labelAlpha,
|
||||||
pickNearest,
|
pickNearest,
|
||||||
sanitizePhysicsTuning,
|
|
||||||
sanitizeRegionTuning,
|
|
||||||
zoomRatioOf,
|
zoomRatioOf,
|
||||||
DEFAULT_PHYSICS_TUNING,
|
|
||||||
DEFAULT_REGION_TUNING,
|
|
||||||
LABELS_FADE_SPAN,
|
LABELS_FADE_SPAN,
|
||||||
} from "../components/graph-canvas.ts";
|
} 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)", () => {
|
describe("zoomRatioOf (the sigma-compatible camera ratio)", () => {
|
||||||
test("1 at fit zoom, smaller when zoomed in, larger when zoomed out", () => {
|
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 });
|
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.dilation, DEFAULT_REGION_TUNING.dilation); // NaN -> default
|
||||||
assert.equal(t.blur, 0);
|
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);
|
assert.equal(t.edgeAlpha, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,10 +218,10 @@ describe("sanitizePhysicsTuning (physics controls)", () => {
|
|||||||
velocityDecay: 3,
|
velocityDecay: 3,
|
||||||
});
|
});
|
||||||
assert.equal(t.chargeStrength, DEFAULT_PHYSICS_TUNING.chargeStrength);
|
assert.equal(t.chargeStrength, DEFAULT_PHYSICS_TUNING.chargeStrength);
|
||||||
assert.equal(t.linkStrength, 5);
|
assert.equal(t.linkStrength, 4); // sweep-validated cap (8 explodes, 5 wobbles)
|
||||||
assert.equal(t.linkDistance, 1);
|
assert.equal(t.linkDistance, 10);
|
||||||
assert.equal(t.gravity, 1);
|
assert.equal(t.gravity, 0.48);
|
||||||
assert.equal(t.clusterPull, 0);
|
assert.equal(t.clusterPull, 0);
|
||||||
assert.equal(t.velocityDecay, 0.99);
|
assert.equal(t.velocityDecay, 0.9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,15 +9,31 @@ import assert from "node:assert/strict";
|
|||||||
import { describe, it as test } from "node:test";
|
import { describe, it as test } from "node:test";
|
||||||
import {
|
import {
|
||||||
ARROW_LENGTH_RANGE,
|
ARROW_LENGTH_RANGE,
|
||||||
|
CHARGE_RANGE,
|
||||||
|
CLUSTER_PULL_RANGE,
|
||||||
DEFAULT_RENDER_SETTINGS,
|
DEFAULT_RENDER_SETTINGS,
|
||||||
|
DEPTH_BASE_RADIUS_RANGE,
|
||||||
|
DEPTH_SPACING_RANGE,
|
||||||
|
DEPTH_STRENGTH_RANGE,
|
||||||
EDGE_WIDTH_RANGE,
|
EDGE_WIDTH_RANGE,
|
||||||
|
GRAVITY_RANGE,
|
||||||
LABEL_SIZE_RANGE,
|
LABEL_SIZE_RANGE,
|
||||||
LABEL_ZOOM_MAX,
|
LABEL_ZOOM_MAX,
|
||||||
LABEL_ZOOM_MIN,
|
LABEL_ZOOM_MIN,
|
||||||
|
LINK_DISTANCE_RANGE,
|
||||||
|
LINK_STRENGTH_RANGE,
|
||||||
NODE_SIZE_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,
|
fromSliderPosition,
|
||||||
labelZoomDisplay,
|
labelZoomDisplay,
|
||||||
sanitizeRenderSettings,
|
sanitizeRenderSettings,
|
||||||
|
toOffSliderPosition,
|
||||||
toSliderPosition,
|
toSliderPosition,
|
||||||
} from "../src/core/render-settings.ts";
|
} from "../src/core/render-settings.ts";
|
||||||
|
|
||||||
@@ -64,10 +80,45 @@ describe("sanitizeRenderSettings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a fully valid object passes through unchanged", () => {
|
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);
|
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", () => {
|
test("the pre-wave stored shape (no labelSize) still loads", () => {
|
||||||
// localStorage-era records lacked labelSize — must not shift anything.
|
// 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({ 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(4), "8×");
|
||||||
assert.equal(labelZoomDisplay(0.0625), "0.13×"); // rounded to 2 decimals
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user