- LABEL_GAP 6 -> 2 (user: labels sit way too far below their nodes).
- Hover glow is now PROPORTIONAL to the disc — outer radius = node radius
x 1.7 (GLOW_SCALE), matching the blur-a-copy-of-the-disc mental model,
instead of the old constant +6px pad that read huge on small nodes and
thin on big ones. GLOW_ALPHA 0.45 -> 0.3.
- Hover-OUT fade was broken by construction: draw() gated the glow on
hoverId, which nulls instantly on leave, so the 180ms fade-out ran
invisibly and the glow popped off in one frame. New hoverNode field
outlives hoverId through the outgoing fade (cleared when the fade
completes); verified live: 8 distinct frames over ~200ms then stable.
- Sliders rescaled per spec: ALL five are now log-scaled 0-100 with the
default dead-center at 50 (ranges chosen as default/4..default*4:
node/arrow/label size 0.5-8, edge width 0.25-4, labelZoom 0.0625-4),
coarse step 5 (21 stops; labels-until-zoom was "way too granular" at
201). Generic toSliderPosition/fromSliderPosition replace the
labelZoom-only pair; sanitize clamps follow the same range constants;
size sliders display compact values ("2", "1.6", "0.75").
EOF
103 lines
4.8 KiB
TypeScript
103 lines
4.8 KiB
TypeScript
/**
|
||
* Graph render settings: the shape, the defaults, the sanitizer, and the
|
||
* slider mapping (log-scaled, default-centered) — shared by the client (components/graph-canvas.ts,
|
||
* graph-view.tsx), the server store (src/store/render-settings.ts, which
|
||
* persists them to data/render-settings.json), and the API route.
|
||
*
|
||
* This module is pure: no DOM, no d3, no node APIs — safe everywhere.
|
||
*
|
||
* Settings are server-persisted (render-settings.json); the localStorage era
|
||
* is over. Whatever arrives from disk or the network is passed through
|
||
* sanitizeRenderSettings before use — never trusted raw.
|
||
*
|
||
* The labelZoom semantics: the sigma-compatible "camera ratio" threshold
|
||
* (1 = fit zoom, SMALLER = zoomed in). Node labels show at ratio <= labelZoom
|
||
* and fade over LABELS_FADE_SPAN; cluster region labels fade in inversely.
|
||
* Persisted values keep these units across the renderer swap.
|
||
*/
|
||
|
||
export interface GraphRenderSettings {
|
||
nodeSize: number;
|
||
edgeWidth: number;
|
||
arrowLength: number;
|
||
/** Camera-ratio threshold for node labels (see module doc). */
|
||
labelZoom: number;
|
||
/** Node + hover label font size, px at fit zoom (scales with camera). */
|
||
labelSize: number;
|
||
/** Toggle for the cluster region labels (hulls always draw). */
|
||
clusterLabels: boolean;
|
||
}
|
||
|
||
// 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);
|
||
|
||
/**
|
||
* Coerce arbitrary JSON (server file, network body, localStorage leftovers)
|
||
* into valid settings: missing keys fall back to defaults, out-of-range
|
||
* values clamp, wrong types reset. Never throws.
|
||
*/
|
||
export function sanitizeRenderSettings(raw: unknown): GraphRenderSettings {
|
||
const r = (raw ?? {}) as Partial<GraphRenderSettings>;
|
||
const num = (v: unknown, fallback: number, min: number, max: number) => {
|
||
const n = typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
||
return clamp(n, min, max);
|
||
};
|
||
return {
|
||
nodeSize: num(r.nodeSize, DEFAULT_RENDER_SETTINGS.nodeSize, NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max),
|
||
edgeWidth: num(r.edgeWidth, DEFAULT_RENDER_SETTINGS.edgeWidth, EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max),
|
||
arrowLength: num(r.arrowLength, DEFAULT_RENDER_SETTINGS.arrowLength, ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max),
|
||
// Range matches the log slider's span [LABEL_ZOOM_MIN, LABEL_ZOOM_MAX].
|
||
labelZoom: num(r.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX),
|
||
labelSize: num(r.labelSize, DEFAULT_RENDER_SETTINGS.labelSize, LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max),
|
||
clusterLabels:
|
||
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
|
||
};
|
||
}
|
||
|
||
// ─── The slider mapping ────────────────────────────────────────────────────────
|
||
//
|
||
// EVERY slider is log-scaled over its setting's range with the DEFAULT at
|
||
// position 50 (dead center), per the user's spec: centered defaults, coarse
|
||
// steps (the UI steps by 5 → 21 stops), no fine-grained linear tracks.
|
||
// Ranges are chosen so each default is the geometric mean of its ends.
|
||
|
||
export const NODE_SIZE_RANGE = { min: 0.5, max: 8 } as const;
|
||
export const EDGE_WIDTH_RANGE = { min: 0.25, max: 4 } as const;
|
||
export const ARROW_LENGTH_RANGE = { min: 0.5, max: 8 } as const;
|
||
export const LABEL_SIZE_RANGE = { min: 0.5, max: 8 } as const;
|
||
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
|
||
|
||
/** 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);
|
||
const logSpan = Math.log(max / min);
|
||
return (100 * Math.log(v / min)) / logSpan;
|
||
}
|
||
|
||
/** Slider position [0, 100] → setting value (inverse of toSliderPosition). */
|
||
export function fromSliderPosition(position: number, min: number, max: number): number {
|
||
const p = clamp(position, 0, 100) / 100;
|
||
const logSpan = Math.log(max / min);
|
||
return min * Math.exp(p * logSpan);
|
||
}
|
||
|
||
/** Display units for labelZoom: normalized so the default (0.5) reads as 1. */
|
||
export function labelZoomDisplay(labelZoom: number): string {
|
||
const v = Math.round((labelZoom / DEFAULT_RENDER_SETTINGS.labelZoom) * 100) / 100;
|
||
return `${v}×`;
|
||
}
|