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
+183 -13
View File
@@ -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<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 ────────────────────────────────────────────────────────
//
// 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);