Graph view refinements: server-persisted settings, label size, eased zoom, resize-blank fix

Five refinements to the hand-rolled renderer, all user-directed:

1. Settings are SERVER-persisted (data/render-settings.json via new
   GET/PUT /api/graph/render-settings) - one source of truth for every
   browser, replacing localStorage. The client loads once after mount,
   saves debounced on user changes, and never lets a slow GET clobber a
   faster user (or mounting PUT defaults over the server copy). The
   shape/defaults/sanitizer moved to src/core/render-settings.ts - pure,
   shared by client, server store, and tests; every disk/network value
   passes through sanitizeRenderSettings.

2. New "Label size" control (default 12px - the previous hardcoded size).
   Font, wrap width, and line height all scale; verified live: +76k px
   with the label gate open.

3. "Labels until zoom" slider reworked: log-scaled over 0.0625-4 so the
   useful band (0.25-0.5 camera-ratio units) sits at 33%/50% of the
   track instead of crammed at the far left of the old linear 0.25-4
   slider. Display units are normalized: the default 0.5 reads as "1x",
   0.25 as "0.5x". The underlying labelZoom semantics are unchanged,
   so existing values survive.

4. Smooth wheel zoom: d3-zoom instant per-notch jumps (x1.2, bang) are
   replaced by an eased glide - wheel events accumulate a cursor-anchored
   target scale and each tick eases the camera toward it (exponential,
   120ms constant, frame-rate independent). Pan/pinch stay d3-owned; a
   pan cancels a glide in progress. Verified live: 8-frame glide.

5. Resize blanking fixed (graph vanished for the duration of a window
   drag, reappearing ~300ms after). Cause: every resize event
   reallocated the canvas backing store - which CLEARS it - and the
   redraw was deferred to rAF, which live resizes starve via relayout,
   so the canvas sat wiped for the whole drag. Fix: the ResizeObserver
   callback now redraws SYNCHRONOUSLY after reallocating (verified via
   a probe RO firing in the same delivery batch: canvas fully painted).
   kFit (the label gate 1x reference) is also recomputed on resize from
   the now-cached world bbox - the camera itself still persists.

tests/render-settings.test.ts: 10 tests pinning the sanitizer (garbage/
partial/clamps, legacy no-labelSize records) and the slider mapping
(default dead-center at 500, monotonic, exact round-trips, display
units). 402/402 green; typecheck, lint, build pass.
This commit is contained in:
amosblomqvist
2026-09-13 05:56:27 +03:00
parent 4e48d13fac
commit 8e2d2118a6
7 changed files with 473 additions and 85 deletions
+93
View File
@@ -0,0 +1,93 @@
/**
* Graph render settings: the shape, the defaults, the sanitizer, and the
* label-zoom slider mapping — 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. */
labelSize: number;
/** Toggle for the cluster region labels (hulls always draw). */
clusterLabels: boolean;
}
export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = {
nodeSize: 6,
edgeWidth: 1,
arrowLength: 5,
labelZoom: 0.5,
labelSize: 12,
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, 2, 14),
edgeWidth: num(r.edgeWidth, DEFAULT_RENDER_SETTINGS.edgeWidth, 0.5, 4),
arrowLength: num(r.arrowLength, DEFAULT_RENDER_SETTINGS.arrowLength, 2, 15),
// Range matches the log slider's span [LABEL_ZOOM_MIN, LABEL_ZOOM_MAX].
labelZoom: num(r.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom, 0.0625, 4),
labelSize: num(r.labelSize, DEFAULT_RENDER_SETTINGS.labelSize, 6, 24),
clusterLabels:
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
};
}
// ─── The "Labels until zoom" slider mapping ──────────────────────────────────
//
// The slider is LOG-scaled over the labelZoom range so the useful band
// (0.25–0.5) sits mid-slider instead of crammed at the far left of a linear
// 0.25–4 track. Display units are normalized to the default: labelZoom 0.5
// shows as "1×", 0.25 as "0.5×" — the default reads as 1, per spec.
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
/** labelZoom → slider position [0, 1000] (log scale). 0.5 lands at 500. */
export function labelZoomToSlider(labelZoom: number): number {
const clamped = clamp(labelZoom, LABEL_ZOOM_MIN, LABEL_ZOOM_MAX);
const logSpan = Math.log(LABEL_ZOOM_MAX / LABEL_ZOOM_MIN);
return (1000 * Math.log(clamped / LABEL_ZOOM_MIN)) / logSpan;
}
/** Slider position [0, 1000] → labelZoom (inverse of labelZoomToSlider). */
export function sliderToLabelZoom(position: number): number {
const p = clamp(position, 0, 1000) / 1000;
const logSpan = Math.log(LABEL_ZOOM_MAX / LABEL_ZOOM_MIN);
return LABEL_ZOOM_MIN * Math.exp(p * logSpan);
}
/** Display units: 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}×`;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Server-side persistence for the graph render settings (the settings-panel
* controls): data/render-settings.json — read-modify-write, NOT a ledger.
*
* The shape, defaults, and sanitizer live in src/core/render-settings.ts
* (pure, shared with the client); this module only moves bytes. Whatever
* comes off disk is sanitized by the caller on load — and we sanitize on
* write too, so the file can never hold an out-of-range value that would
* silently shift a slider.
*/
import fs from "node:fs";
import path from "node:path";
import { RENDER_SETTINGS_FILE } from "../../config.ts";
import { DEFAULT_RENDER_SETTINGS, sanitizeRenderSettings, type GraphRenderSettings } from "../core/render-settings.ts";
/** The persisted settings, or null when no file exists yet (first boot). */
export function readRenderSettings(): GraphRenderSettings | null {
let raw: string;
try {
raw = fs.readFileSync(RENDER_SETTINGS_FILE, "utf-8");
} catch {
return null; // never booted / unreadable → caller uses defaults
}
try {
return sanitizeRenderSettings(JSON.parse(raw));
} catch {
return null; // corrupt → defaults, never crash the view
}
}
/** Persist settings (sanitized). Returns what was written. */
export function writeRenderSettings(raw: unknown): GraphRenderSettings {
const settings = sanitizeRenderSettings(raw);
fs.mkdirSync(path.dirname(RENDER_SETTINGS_FILE), { recursive: true });
fs.writeFileSync(RENDER_SETTINGS_FILE, JSON.stringify(settings, null, "\t"), "utf-8");
return settings;
}
export { DEFAULT_RENDER_SETTINGS };