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:
amosblomqvist
2026-09-13 17:06:18 +03:00
parent 0af63113f5
commit 04a0236c36
5 changed files with 492 additions and 247 deletions
+6 -103
View File
@@ -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<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
* (Float32, stride 4, length w*h*4). Premultiplying first is what keeps