From 8e2d2118a6f3c7113041b644d3faabae27ffc54c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 13 Sep 2026 05:56:27 +0300 Subject: [PATCH] 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. --- app/api/graph/render-settings/route.ts | 31 +++++ components/graph-canvas.ts | 155 ++++++++++++++++++------- components/graph-view.tsx | 134 +++++++++++++-------- config.ts | 6 + src/core/render-settings.ts | 93 +++++++++++++++ src/store/render-settings.ts | 40 +++++++ tests/render-settings.test.ts | 99 ++++++++++++++++ 7 files changed, 473 insertions(+), 85 deletions(-) create mode 100644 app/api/graph/render-settings/route.ts create mode 100644 src/core/render-settings.ts create mode 100644 src/store/render-settings.ts create mode 100644 tests/render-settings.test.ts diff --git a/app/api/graph/render-settings/route.ts b/app/api/graph/render-settings/route.ts new file mode 100644 index 0000000..3ee8719 --- /dev/null +++ b/app/api/graph/render-settings/route.ts @@ -0,0 +1,31 @@ +/** + * GET/PUT /api/graph/render-settings — server persistence for the graph + * view's rendering controls (node/edge/arrow/label sizes, the label-zoom + * threshold, the cluster-labels toggle). The file lives at + * data/render-settings.json (config.RENDER_SETTINGS_FILE). + * + * GET → 200 {settings} once saved, 200 null on first boot (client keeps + * in-memory defaults until the user changes something). + * PUT → 200 {settings} (the sanitized copy actually persisted — the + * client may echo it to stay honest with clamped values). + * + * Every body is passed through sanitizeRenderSettings (src/core) before + * touching disk: garbage clamps or falls back to defaults, never throws. + */ +import { readRenderSettings, writeRenderSettings } from "@/src/store/render-settings.ts"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return Response.json(readRenderSettings()); +} + +export async function PUT(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "invalid JSON body" }, { status: 400 }); + } + return Response.json(writeRenderSettings(body)); +} diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 741587a..3c57853 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -53,6 +53,9 @@ import { select } from "d3-selection"; import { zoom, zoomIdentity, type ZoomBehavior, type ZoomTransform } from "d3-zoom"; import { clusterHue, convexHull, offsetPolygon, traceRoundedPath, type Pt } from "./cluster-geometry.ts"; +import type { GraphRenderSettings } from "../src/core/render-settings.ts"; + +export type { GraphRenderSettings }; // ─── Public data contract ────────────────────────────────────────────────── @@ -86,18 +89,6 @@ export interface CanvasColors { hoverText: string; } -/** The rendering controls (persisted by the caller in localStorage). */ -export interface GraphRenderSettings { - nodeSize: number; - edgeWidth: number; - arrowLength: number; - /** Camera-ratio threshold for node labels (1 = fit zoom, smaller = - * zoomed in); cluster labels fade in over the same span, inversely. */ - labelZoom: number; - /** Toggle for the cluster region labels (hulls always draw). */ - clusterLabels: boolean; -} - export interface CanvasData { nodes: CanvasNode[]; edges: CanvasEdge[]; @@ -118,7 +109,7 @@ export interface RendererCallbacks { // ─── Constants (ported verbatim from the sigma view) ──────────────────────── -const LABEL_SIZE = 12; // px (sigma's labelSize) +const LABEL_SIZE = 12; // px — the default label size (settings.labelSize scales it) const LABEL_WEIGHT = "500"; const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip const LABEL_GAP = 6; // px between node edge and first label line @@ -129,6 +120,7 @@ export const LABELS_FADE_SPAN = 0.5; const GLOW_PAD = 9; // px the halo extends beyond the node disc (constant) const GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward const HOVER_FADE_MS = 180; // hover halo opacity fade length +const ZOOM_EASE_MS = 120; // wheel-zoom glide: exponential-ease time constant /** Extra pickable radius (px) beyond the disc — the whole halo is * hit-testable (the old flicker fix, minus the picking shader). */ @@ -280,19 +272,22 @@ function drawName( x: number, y: number, size: number, + labelSize: number, label: string, font: string, color: string, ): void { context.save(); - context.font = `${LABEL_WEIGHT} ${LABEL_SIZE}px ${font}`; - const lines = wrapLabel(context, label, LABEL_MAX_WIDTH); + context.font = `${LABEL_WEIGHT} ${labelSize}px ${font}`; + // Wrap width scales with the font so bigger labels wrap at the same + // word count, not earlier. + const lines = wrapLabel(context, label, LABEL_MAX_WIDTH * (labelSize / LABEL_SIZE)); context.fillStyle = color; context.textAlign = "center"; context.textBaseline = "top"; const firstTop = y + size + LABEL_GAP; for (let i = 0; i < lines.length; i++) { - context.fillText(lines[i]!, x, firstTop + i * LABEL_SIZE * LABEL_LINE_HEIGHT); + context.fillText(lines[i]!, x, firstTop + i * labelSize * LABEL_LINE_HEIGHT); } context.restore(); } @@ -309,6 +304,9 @@ export class GraphCanvasRenderer { private hasFit = false; /** The k that frames the whole graph — the label-gate's "1×". */ private kFit = 1; + /** World-space bounds of the current nodes — fit() and the resize-time + * kFit refresh share it (computed once per setData, not per resize). */ + private worldBBox: { minX: number; maxX: number; minY: number; maxY: number } | null = null; private data: CanvasData | null = null; /** id -> node for the current data (avoids O(n^2) scans). */ @@ -326,6 +324,11 @@ export class GraphCanvasRenderer { private hoverFrom = 0; private hoverTarget = 0; private hoverStart = 0; + // Smooth wheel zoom: wheel events accumulate a target scale (cursor- + // anchored); each tick eases the camera toward it instead of jumping. + private zoomTarget: { k: number; x: number; y: number } | null = null; + /** Clock for frame-rate-independent easing (updated every tick). */ + private lastTickAt = 0; private animating = false; private resizeObserver: ResizeObserver; @@ -350,8 +353,12 @@ export class GraphCanvasRenderer { select(canvas) .call(this.zoomBehavior) // No surprise dblclick-zoom (sigma had none). - .on("dblclick.zoom", null); + .on("dblclick.zoom", null) + // Wheel is OURS now: d3's instant per-notch jump felt janky; the + // eased glide below replaces it (pan and pinch stay d3's). + .on("wheel.zoom", null); + canvas.addEventListener("wheel", this.onWheel, { passive: false }); canvas.addEventListener("pointermove", this.onPointerMove); canvas.addEventListener("pointerdown", this.onPointerDown); canvas.addEventListener("pointerup", this.onPointerUp); @@ -360,7 +367,18 @@ export class GraphCanvasRenderer { this.resizeObserver = new ResizeObserver(() => { this.syncSize(); if (!this.hasFit) this.fit(); // first real size → fit once - this.markDirty(); + else if (this.worldBBox) { + // Keep the label-gate's "1×" honest: kFit is viewport-bound, + // so a resize changes it even though the camera persists. + const w = this.canvas.clientWidth; + const h = this.canvas.clientHeight; + if (w >= 10 && h >= 10) this.kFit = fitTransform(this.worldBBox, w, h, 40).k; + } + // Redraw SYNCHRONOUSLY: syncSize may have reallocated the backing + // store (which clears the canvas), and during live window resizes + // rAF is starved by relayout — a deferred draw would leave the + // canvas blank for the whole drag (the resize-blanking bug). + this.draw(); }); this.resizeObserver.observe(canvas); this.syncSize(); @@ -371,6 +389,17 @@ export class GraphCanvasRenderer { setData(data: CanvasData): void { this.data = data; this.nodeById = new Map(data.nodes.map((n) => [n.id, n])); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const n of data.nodes) { + if (n.x < minX) minX = n.x; + if (n.y < minY) minY = n.y; + if (n.x > maxX) maxX = n.x; + if (n.y > maxY) maxY = n.y; + } + this.worldBBox = { minX, maxX, minY, maxY }; if (!this.hasFit && data.nodes.length > 0) this.fit(); this.markDirty(); } @@ -388,21 +417,11 @@ export class GraphCanvasRenderer { /** Frame the whole graph (padding included). Keeps the camera on later * setData calls — call fit() only for an explicit re-frame. */ fit(): void { - if (!this.data || this.data.nodes.length === 0) return; + if (!this.data || this.data.nodes.length === 0 || !this.worldBBox) return; const w = this.canvas.clientWidth; const h = this.canvas.clientHeight; if (w < 10 || h < 10) return; // layout not ready yet; the ResizeObserver retries - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const n of this.data.nodes) { - if (n.x < minX) minX = n.x; - if (n.y < minY) minY = n.y; - if (n.x > maxX) maxX = n.x; - if (n.y > maxY) maxY = n.y; - } - const t = fitTransform({ minX, maxX, minY, maxY }, w, h, 40); + const t = fitTransform(this.worldBBox, w, h, 40); this.kFit = t.k; this.hasFit = true; select(this.canvas).call( @@ -415,6 +434,7 @@ export class GraphCanvasRenderer { if (this.frameId !== null) cancelAnimationFrame(this.frameId); this.resizeObserver.disconnect(); select(this.canvas).on(".zoom", null); + this.canvas.removeEventListener("wheel", this.onWheel); this.canvas.removeEventListener("pointermove", this.onPointerMove); this.canvas.removeEventListener("pointerdown", this.onPointerDown); this.canvas.removeEventListener("pointerup", this.onPointerUp); @@ -424,6 +444,21 @@ export class GraphCanvasRenderer { // ── Event handlers (state mutation only — never draw) ── + /** Wheel: accumulate the zoom target; the camera GLIDES toward it in the + * tick loop (cursor-anchored). Sensitivity formula is d3-zoom's own. */ + private onWheel = (e: WheelEvent) => { + e.preventDefault(); + const delta = -e.deltaY * (e.deltaMode === 1 ? 0.05 : e.deltaMode ? 1 : 0.002); + const { x, y } = this.eventPos(e); + const current = this.zoomTarget?.k ?? this.transform.k; + // Must stay inside the zoomBehavior's scaleExtent [0.02, 40]. + const k = Math.min(Math.max(current * Math.exp(delta), 0.02), 40); + this.zoomTarget = { k, x, y }; + // Fresh easing clock so the glide starts gentle, never half-done. + this.lastTickAt = performance.now(); + this.markDirty(); + }; + private onPointerMove = (e: PointerEvent) => { const { x, y } = this.eventPos(e); const node = this.pickAt(x, y); @@ -433,6 +468,7 @@ export class GraphCanvasRenderer { private onPointerDown = (e: PointerEvent) => { this.downPos = this.eventPos(e); + this.zoomTarget = null; // a pan takes over — cancel any zoom glide }; private onPointerUp = (e: PointerEvent) => { @@ -453,7 +489,7 @@ export class GraphCanvasRenderer { // ── Internals ── - private eventPos(e: PointerEvent): Pt { + private eventPos(e: { clientX: number; clientY: number }): Pt { const rect = this.canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } @@ -496,10 +532,16 @@ export class GraphCanvasRenderer { } private tick(): void { - this.advanceHover(); + const now = performance.now(); + // Clamp the dt: a long idle gap must not teleport the ease forward. + const dt = Math.min(Math.max(now - this.lastTickAt, 8), 100); + this.lastTickAt = now; + const hoverAnim = this.advanceHover(); + const zoomAnim = this.advanceZoom(dt); this.draw(); - // Keep frames coming ONLY while the hover fade is in motion; an - // idle graph schedules no rAF at all. + // Keep frames coming ONLY while an animation is in motion (hover + // fade or zoom glide); an idle graph schedules no rAF at all. + this.animating = hoverAnim || zoomAnim; if (this.animating) { this.frameId = requestAnimationFrame(() => { this.frameId = null; @@ -508,11 +550,39 @@ export class GraphCanvasRenderer { } } - private advanceHover(): void { - if (!this.animating) return; + /** Hover fade step; returns true while still moving. */ + private advanceHover(): boolean { + if (this.hoverFade === this.hoverTarget) return false; const t = Math.min((performance.now() - this.hoverStart) / HOVER_FADE_MS, 1); this.hoverFade = this.hoverFrom + (this.hoverTarget - this.hoverFrom) * t; - if (t >= 1) this.animating = false; + return t < 1; + } + + /** Zoom glide step: ease k toward the wheel target, keeping the world + * point under the cursor pinned. Frame-rate independent (exponential + * approach, time constant ZOOM_EASE_MS). Returns true while gliding. */ + private advanceZoom(dt: number): boolean { + const target = this.zoomTarget; + if (!target) return false; + const t = this.transform; + const blend = 1 - Math.exp(-dt / ZOOM_EASE_MS); + let k = t.k + (target.k - t.k) * blend; + let done = false; + if (Math.abs(target.k - k) / target.k < 0.001) { + k = target.k; + done = true; + } + const scale = k / t.k; + const x = target.x - (target.x - t.x) * scale; + const y = target.y - (target.y - t.y) * scale; + if (done) this.zoomTarget = null; + // Applied through d3-zoom so constraints + the zoom event (markDirty, + // onCameraMove) fire exactly as with any other camera change. + select(this.canvas).call( + this.zoomBehavior.transform, + zoomIdentity.translate(x, y).scale(k), + ); + return !done; } /** Resize the backing store to the CSS box × devicePixelRatio; all @@ -645,7 +715,11 @@ export class GraphCanvasRenderer { this.ctx.globalAlpha = nodeAlpha; for (const [id, p] of pos) { const n = nodeById.get(id); - if (n) drawName(this.ctx, p.x, p.y, settings.nodeSize, n.label, data.fontFamily, data.colors.label); + if (n) + drawName( + this.ctx, p.x, p.y, settings.nodeSize, settings.labelSize, n.label, + data.fontFamily, data.colors.label, + ); } this.ctx.restore(); } @@ -669,7 +743,10 @@ export class GraphCanvasRenderer { this.ctx.fill("evenodd"); this.ctx.save(); this.ctx.globalAlpha = this.hoverFade; - drawName(this.ctx, p.x, p.y, settings.nodeSize, n.label, data.fontFamily, data.colors.hoverText); + drawName( + this.ctx, p.x, p.y, settings.nodeSize, settings.labelSize, n.label, + data.fontFamily, data.colors.hoverText, + ); this.ctx.restore(); } } diff --git a/components/graph-view.tsx b/components/graph-view.tsx index d939bc8..0ad7cf6 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -50,10 +50,15 @@ * an open card closes if its node is filtered out. * * Rendering settings (2026-09-13): the gear in the search panel reveals - * four sliders (node size, edge width, arrowhead length, the zoom - * threshold) plus the cluster-labels checkbox, persisted per browser - * (localStorage). Applied by just redrawing — no rebuild, no re-layout, - * and the camera never moves. + * five sliders (node size, edge width, arrowhead length, label size, the + * zoom threshold) plus the cluster-labels checkbox. SERVER-persisted + * (data/render-settings.json via /api/graph/render-settings): loaded + * once after mount, saved debounced on every user change — one source + * of truth shared by every browser. The label-zoom slider is log-scaled + * (its useful band 0.25–0.5 sits mid-slider, not crammed at the left) + * and displays normalized units where the default 0.5 reads as "1×". + * Settings apply by just redrawing — no rebuild, no re-layout, and the + * camera never moves. */ import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -63,6 +68,13 @@ import Graph from "graphology"; import forceAtlas2 from "graphology-layout-forceatlas2"; import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core"; import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; +import { + DEFAULT_RENDER_SETTINGS, + labelZoomDisplay, + labelZoomToSlider, + sanitizeRenderSettings, + sliderToLabelZoom, +} from "../src/core/render-settings.ts"; /** The uniform node look (no state classes — canvas wants hex, not oklch): * the accent color, until the state model lands. */ @@ -70,27 +82,10 @@ const NODE_COLOR_TOKEN = "--primary"; // ---- Render settings (the settings-panel controls) ------------------------ // -// Three basic controls (user-decided 2026-09-13): node disc size, edge line -// width, and arrowhead length (px). Defaults reflect the pre-panel look -// (edge 1px + its stock 5px head) except nodeSize, which dropped 8 → 6 for -// dense graphs (user: "nodes are quite large"). Persisted per browser in -// localStorage. +// The shape, defaults, sanitizer, and slider mappings live in +// src/core/render-settings.ts (shared with the server store + tests). type RenderSettings = GraphRenderSettings; -const DEFAULT_RENDER_SETTINGS: RenderSettings = { nodeSize: 6, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true }; -const RENDER_SETTINGS_KEY = "graph-render-settings"; -const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); -/** Defensively merge stored settings (stale/partial/corrupt → defaults). */ -function sanitizeRenderSettings(raw: unknown): RenderSettings { - const r = raw as Partial | null; - return { - nodeSize: clamp(Number(r?.nodeSize ?? DEFAULT_RENDER_SETTINGS.nodeSize), 2, 14), - edgeWidth: clamp(Number(r?.edgeWidth ?? DEFAULT_RENDER_SETTINGS.edgeWidth), 0.5, 4), - arrowLength: clamp(Number(r?.arrowLength ?? DEFAULT_RENDER_SETTINGS.arrowLength), 2, 15), - labelZoom: clamp(Number(r?.labelZoom ?? DEFAULT_RENDER_SETTINGS.labelZoom), 0.25, 4), - clusterLabels: typeof r?.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels, - }; -} /** * Pure filter predicate for the search panel: a node is visible when the @@ -189,24 +184,59 @@ export function GraphView() { // Search panel state: the query text + the settings section toggle. const [query, setQuery] = useState(""); const [settingsOpen, setSettingsOpen] = useState(false); - // Render settings (node size / edge width / arrowhead): loaded from - // localStorage AFTER mount (an initial-state read would mismatch the - // server-rendered HTML), saved on every change. + // Render settings: SERVER-persisted (data/render-settings.json via + // /api/graph/render-settings). Loaded once after mount; every user + // change saves back, debounced. Defaults render until the load + // resolves — a fraction of a second at worst. const [settings, setSettings] = useState(DEFAULT_RENDER_SETTINGS); + const settingsRef = useRef(settings); + settingsRef.current = settings; + // True once the user has moved a control this mount: the server copy + // must never clobber a (possibly newer) local change. + const userTouched = useRef(false); + // True once the initial GET settled — saves are held back until then, + // so merely mounting never PUTs defaults over the server copy. + const serverLoaded = useRef(false); + const savePending = useRef(false); + const changeSettings = (update: (cur: RenderSettings) => RenderSettings) => { + userTouched.current = true; + setSettings(update); + }; + const saveToServer = (s: RenderSettings) => { + fetch("/api/graph/render-settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(s), + }).catch((err) => console.error("[graph] settings save failed:", err)); + }; useEffect(() => { - try { - const stored = localStorage.getItem(RENDER_SETTINGS_KEY); - if (stored) setSettings(sanitizeRenderSettings(JSON.parse(stored))); - } catch { - // corrupt storage → defaults, never crash the view - } + fetch("/api/graph/render-settings") + .then((res) => (res.ok ? (res.json() as Promise) : null)) + .then((raw) => { + // Apply only if the user hasn't already moved a slider — + // if they outran the network, their change wins. + if (raw !== null && !userTouched.current) setSettings(sanitizeRenderSettings(raw)); + }) + .catch(() => { + // server unreachable → in-memory defaults keep working + }) + .finally(() => { + serverLoaded.current = true; + // A change made during the fetch was held back — send now. + if (savePending.current) { + savePending.current = false; + saveToServer(settingsRef.current); + } + }); }, []); useEffect(() => { - try { - localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify(settings)); - } catch { - // storage full/blocked → in-memory settings still work + if (!userTouched.current) return; // mount-run / server-apply: no save + if (!serverLoaded.current) { + savePending.current = true; // hold until the GET settles + return; } + const id = setTimeout(() => saveToServer(settings), 400); + return () => clearTimeout(id); }, [settings]); // The renderer instance lives across snapshot refetches and settings @@ -474,7 +504,7 @@ export function GraphView() { max={14} step={1} value={settings.nodeSize} - onChange={(v) => setSettings((cur) => ({ ...cur, nodeSize: v }))} + onChange={(v) => changeSettings((cur) => ({ ...cur, nodeSize: v }))} /> setSettings((cur) => ({ ...cur, edgeWidth: v }))} + onChange={(v) => changeSettings((cur) => ({ ...cur, edgeWidth: v }))} /> setSettings((cur) => ({ ...cur, arrowLength: v }))} + onChange={(v) => changeSettings((cur) => ({ ...cur, arrowLength: v }))} /> + changeSettings((cur) => ({ ...cur, labelSize: v }))} + /> + {/* Log-scaled: the useful band (0.25–0.5 in camera-ratio + units) sits mid-slider; displayed units normalize the + default (0.5) to "1×". Slider position ↔ labelZoom via + src/core/render-settings.ts. */} setSettings((cur) => ({ ...cur, labelZoom: v }))} + min={0} + max={1000} + step={5} + value={labelZoomToSlider(settings.labelZoom)} + display={labelZoomDisplay(settings.labelZoom)} + onChange={(v) => changeSettings((cur) => ({ ...cur, labelZoom: sliderToLabelZoom(v) }))} /> diff --git a/config.ts b/config.ts index 3472ad8..d6039bf 100644 --- a/config.ts +++ b/config.ts @@ -84,3 +84,9 @@ export const LEDGER_FILES = { * log. Read-modify-write like a chat file, not append-only like the ledgers. */ export const SOURCES_FILE = path.join(PATHS.graph, "sources.json"); + +/** + * Graph render settings (the settings-panel controls): one small JSON blob, + * read-modify-write. Shared shape/sanitizer: src/core/render-settings.ts. + */ +export const RENDER_SETTINGS_FILE = path.join(PATHS.graph, "render-settings.json"); diff --git a/src/core/render-settings.ts b/src/core/render-settings.ts new file mode 100644 index 0000000..8946890 --- /dev/null +++ b/src/core/render-settings.ts @@ -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; + 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}×`; +} diff --git a/src/store/render-settings.ts b/src/store/render-settings.ts new file mode 100644 index 0000000..754135c --- /dev/null +++ b/src/store/render-settings.ts @@ -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 }; diff --git a/tests/render-settings.test.ts b/tests/render-settings.test.ts new file mode 100644 index 0000000..55d2b5c --- /dev/null +++ b/tests/render-settings.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for src/core/render-settings.ts — the shared settings contract + * (shape, defaults, sanitizer, label-zoom slider mapping). Pure module, + * pure tests: whatever JSON arrives from disk or the network goes through + * sanitizeRenderSettings and must come out valid, never throw. + */ + +import assert from "node:assert/strict"; +import { describe, it as test } from "node:test"; +import { + DEFAULT_RENDER_SETTINGS, + LABEL_ZOOM_MAX, + LABEL_ZOOM_MIN, + labelZoomDisplay, + labelZoomToSlider, + sanitizeRenderSettings, + sliderToLabelZoom, +} from "../src/core/render-settings.ts"; + +describe("sanitizeRenderSettings", () => { + test("garbage in → full defaults out", () => { + assert.deepEqual(sanitizeRenderSettings(null), DEFAULT_RENDER_SETTINGS); + assert.deepEqual(sanitizeRenderSettings(undefined), DEFAULT_RENDER_SETTINGS); + assert.deepEqual(sanitizeRenderSettings("junk"), DEFAULT_RENDER_SETTINGS); + assert.deepEqual(sanitizeRenderSettings(42), DEFAULT_RENDER_SETTINGS); + }); + + test("partial input → defaults fill the gaps", () => { + const s = sanitizeRenderSettings({ nodeSize: 9 }); + assert.equal(s.nodeSize, 9); + assert.equal(s.edgeWidth, DEFAULT_RENDER_SETTINGS.edgeWidth); + assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); + assert.equal(s.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom); + assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels); + }); + + test("out-of-range values clamp; wrong types reset", () => { + const s = sanitizeRenderSettings({ + nodeSize: 999, + edgeWidth: 0, + arrowLength: -5, + labelZoom: 100, + labelSize: "big" as unknown as number, + clusterLabels: "yes" as unknown as boolean, + }); + assert.equal(s.nodeSize, 14); // clamped to max + assert.equal(s.edgeWidth, 0.5); // clamped to min + assert.equal(s.arrowLength, 2); + assert.equal(s.labelZoom, LABEL_ZOOM_MAX); + assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); // NaN-safe reset + assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels); + }); + + test("a fully valid object passes through unchanged", () => { + const valid = { nodeSize: 8, edgeWidth: 2, arrowLength: 7, labelZoom: 0.25, labelSize: 16, clusterLabels: false }; + assert.deepEqual(sanitizeRenderSettings(valid), valid); + }); + + test("the pre-wave stored shape (no labelSize) still loads", () => { + // localStorage-era records lacked labelSize — must not shift anything. + const s = sanitizeRenderSettings({ nodeSize: 4, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true }); + assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); + assert.equal(s.nodeSize, 4); + }); +}); + +describe("label-zoom slider mapping (log scale)", () => { + test("the default (0.5) sits dead-center at position 500", () => { + assert.equal(Math.round(labelZoomToSlider(DEFAULT_RENDER_SETTINGS.labelZoom)), 500); + }); + + test("the useful band sits mid-slider, not crammed at the left", () => { + // 0.25 → a third of the way in; the old linear 0.25–4 slider had it at 0%. + const p025 = labelZoomToSlider(0.25); + assert.ok(p025 > 250 && p025 < 400, `0.25 at ${p025}`); + assert.ok(labelZoomToSlider(0.5) > p025, "monotonic in labelZoom"); + }); + + test("round-trips both directions across the whole range", () => { + for (const lz of [LABEL_ZOOM_MIN, 0.0625, 0.1, 0.25, 0.5, 1, 2, LABEL_ZOOM_MAX]) { + const back = sliderToLabelZoom(labelZoomToSlider(lz)); + assert.ok(Math.abs(back - lz) < 1e-9, `${lz} -> ${back}`); + const fwd = labelZoomToSlider(sliderToLabelZoom(labelZoomToSlider(lz))); + assert.ok(Math.abs(fwd - labelZoomToSlider(lz)) < 1e-9); + } + }); + + test("slider extremes map to the range ends", () => { + assert.equal(Math.round(sliderToLabelZoom(0) * 1e6) / 1e6, LABEL_ZOOM_MIN); + assert.ok(Math.abs(sliderToLabelZoom(1000) - LABEL_ZOOM_MAX) < 1e-9); + }); + + test("display units normalize the default to 1", () => { + assert.equal(labelZoomDisplay(0.5), "1×"); + assert.equal(labelZoomDisplay(0.25), "0.5×"); + assert.equal(labelZoomDisplay(4), "8×"); + assert.equal(labelZoomDisplay(0.0625), "0.13×"); // rounded to 2 decimals + }); +});