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
+31
View File
@@ -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));
}
+116 -39
View File
@@ -53,6 +53,9 @@
import { select } from "d3-selection"; import { select } from "d3-selection";
import { zoom, zoomIdentity, type ZoomBehavior, type ZoomTransform } from "d3-zoom"; import { zoom, zoomIdentity, type ZoomBehavior, type ZoomTransform } from "d3-zoom";
import { clusterHue, convexHull, offsetPolygon, traceRoundedPath, type Pt } from "./cluster-geometry.ts"; 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 ────────────────────────────────────────────────── // ─── Public data contract ──────────────────────────────────────────────────
@@ -86,18 +89,6 @@ export interface CanvasColors {
hoverText: string; 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 { export interface CanvasData {
nodes: CanvasNode[]; nodes: CanvasNode[];
edges: CanvasEdge[]; edges: CanvasEdge[];
@@ -118,7 +109,7 @@ export interface RendererCallbacks {
// ─── Constants (ported verbatim from the sigma view) ──────────────────────── // ─── 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_WEIGHT = "500";
const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip 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 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_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 GLOW_ALPHA = 0.45; // halo opacity at the disc edge, fading outward
const HOVER_FADE_MS = 180; // hover halo opacity fade length 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 /** Extra pickable radius (px) beyond the disc — the whole halo is
* hit-testable (the old flicker fix, minus the picking shader). */ * hit-testable (the old flicker fix, minus the picking shader). */
@@ -280,19 +272,22 @@ function drawName(
x: number, x: number,
y: number, y: number,
size: number, size: number,
labelSize: number,
label: string, label: string,
font: string, font: string,
color: string, color: string,
): void { ): void {
context.save(); context.save();
context.font = `${LABEL_WEIGHT} ${LABEL_SIZE}px ${font}`; context.font = `${LABEL_WEIGHT} ${labelSize}px ${font}`;
const lines = wrapLabel(context, label, LABEL_MAX_WIDTH); // 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.fillStyle = color;
context.textAlign = "center"; context.textAlign = "center";
context.textBaseline = "top"; context.textBaseline = "top";
const firstTop = y + size + LABEL_GAP; const firstTop = y + size + LABEL_GAP;
for (let i = 0; i < lines.length; i++) { 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(); context.restore();
} }
@@ -309,6 +304,9 @@ export class GraphCanvasRenderer {
private hasFit = false; private hasFit = false;
/** The k that frames the whole graph — the label-gate's "1×". */ /** The k that frames the whole graph — the label-gate's "1×". */
private kFit = 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; private data: CanvasData | null = null;
/** id -> node for the current data (avoids O(n^2) scans). */ /** id -> node for the current data (avoids O(n^2) scans). */
@@ -326,6 +324,11 @@ export class GraphCanvasRenderer {
private hoverFrom = 0; private hoverFrom = 0;
private hoverTarget = 0; private hoverTarget = 0;
private hoverStart = 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 animating = false;
private resizeObserver: ResizeObserver; private resizeObserver: ResizeObserver;
@@ -350,8 +353,12 @@ export class GraphCanvasRenderer {
select(canvas) select(canvas)
.call(this.zoomBehavior) .call(this.zoomBehavior)
// No surprise dblclick-zoom (sigma had none). // 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("pointermove", this.onPointerMove);
canvas.addEventListener("pointerdown", this.onPointerDown); canvas.addEventListener("pointerdown", this.onPointerDown);
canvas.addEventListener("pointerup", this.onPointerUp); canvas.addEventListener("pointerup", this.onPointerUp);
@@ -360,7 +367,18 @@ export class GraphCanvasRenderer {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
this.syncSize(); this.syncSize();
if (!this.hasFit) this.fit(); // first real size → fit once 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.resizeObserver.observe(canvas);
this.syncSize(); this.syncSize();
@@ -371,6 +389,17 @@ export class GraphCanvasRenderer {
setData(data: CanvasData): void { setData(data: CanvasData): void {
this.data = data; this.data = data;
this.nodeById = new Map(data.nodes.map((n) => [n.id, n])); 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(); if (!this.hasFit && data.nodes.length > 0) this.fit();
this.markDirty(); this.markDirty();
} }
@@ -388,21 +417,11 @@ export class GraphCanvasRenderer {
/** Frame the whole graph (padding included). Keeps the camera on later /** Frame the whole graph (padding included). Keeps the camera on later
* setData calls — call fit() only for an explicit re-frame. */ * setData calls — call fit() only for an explicit re-frame. */
fit(): void { 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 w = this.canvas.clientWidth;
const h = this.canvas.clientHeight; const h = this.canvas.clientHeight;
if (w < 10 || h < 10) return; // layout not ready yet; the ResizeObserver retries if (w < 10 || h < 10) return; // layout not ready yet; the ResizeObserver retries
let minX = Infinity; const t = fitTransform(this.worldBBox, w, h, 40);
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);
this.kFit = t.k; this.kFit = t.k;
this.hasFit = true; this.hasFit = true;
select(this.canvas).call( select(this.canvas).call(
@@ -415,6 +434,7 @@ export class GraphCanvasRenderer {
if (this.frameId !== null) cancelAnimationFrame(this.frameId); if (this.frameId !== null) cancelAnimationFrame(this.frameId);
this.resizeObserver.disconnect(); this.resizeObserver.disconnect();
select(this.canvas).on(".zoom", null); select(this.canvas).on(".zoom", null);
this.canvas.removeEventListener("wheel", this.onWheel);
this.canvas.removeEventListener("pointermove", this.onPointerMove); this.canvas.removeEventListener("pointermove", this.onPointerMove);
this.canvas.removeEventListener("pointerdown", this.onPointerDown); this.canvas.removeEventListener("pointerdown", this.onPointerDown);
this.canvas.removeEventListener("pointerup", this.onPointerUp); this.canvas.removeEventListener("pointerup", this.onPointerUp);
@@ -424,6 +444,21 @@ export class GraphCanvasRenderer {
// ── Event handlers (state mutation only — never draw) ── // ── 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) => { private onPointerMove = (e: PointerEvent) => {
const { x, y } = this.eventPos(e); const { x, y } = this.eventPos(e);
const node = this.pickAt(x, y); const node = this.pickAt(x, y);
@@ -433,6 +468,7 @@ export class GraphCanvasRenderer {
private onPointerDown = (e: PointerEvent) => { private onPointerDown = (e: PointerEvent) => {
this.downPos = this.eventPos(e); this.downPos = this.eventPos(e);
this.zoomTarget = null; // a pan takes over — cancel any zoom glide
}; };
private onPointerUp = (e: PointerEvent) => { private onPointerUp = (e: PointerEvent) => {
@@ -453,7 +489,7 @@ export class GraphCanvasRenderer {
// ── Internals ── // ── Internals ──
private eventPos(e: PointerEvent): Pt { private eventPos(e: { clientX: number; clientY: number }): Pt {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top }; return { x: e.clientX - rect.left, y: e.clientY - rect.top };
} }
@@ -496,10 +532,16 @@ export class GraphCanvasRenderer {
} }
private tick(): void { 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(); this.draw();
// Keep frames coming ONLY while the hover fade is in motion; an // Keep frames coming ONLY while an animation is in motion (hover
// idle graph schedules no rAF at all. // fade or zoom glide); an idle graph schedules no rAF at all.
this.animating = hoverAnim || zoomAnim;
if (this.animating) { if (this.animating) {
this.frameId = requestAnimationFrame(() => { this.frameId = requestAnimationFrame(() => {
this.frameId = null; this.frameId = null;
@@ -508,11 +550,39 @@ export class GraphCanvasRenderer {
} }
} }
private advanceHover(): void { /** Hover fade step; returns true while still moving. */
if (!this.animating) return; private advanceHover(): boolean {
if (this.hoverFade === this.hoverTarget) return false;
const t = Math.min((performance.now() - this.hoverStart) / HOVER_FADE_MS, 1); const t = Math.min((performance.now() - this.hoverStart) / HOVER_FADE_MS, 1);
this.hoverFade = this.hoverFrom + (this.hoverTarget - this.hoverFrom) * t; 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 /** Resize the backing store to the CSS box × devicePixelRatio; all
@@ -645,7 +715,11 @@ export class GraphCanvasRenderer {
this.ctx.globalAlpha = nodeAlpha; this.ctx.globalAlpha = nodeAlpha;
for (const [id, p] of pos) { for (const [id, p] of pos) {
const n = nodeById.get(id); 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(); this.ctx.restore();
} }
@@ -669,7 +743,10 @@ export class GraphCanvasRenderer {
this.ctx.fill("evenodd"); this.ctx.fill("evenodd");
this.ctx.save(); this.ctx.save();
this.ctx.globalAlpha = this.hoverFade; 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(); this.ctx.restore();
} }
} }
+87 -45
View File
@@ -50,10 +50,15 @@
* an open card closes if its node is filtered out. * an open card closes if its node is filtered out.
* *
* Rendering settings (2026-09-13): the gear in the search panel reveals * Rendering settings (2026-09-13): the gear in the search panel reveals
* four sliders (node size, edge width, arrowhead length, the zoom * five sliders (node size, edge width, arrowhead length, label size, the
* threshold) plus the cluster-labels checkbox, persisted per browser * zoom threshold) plus the cluster-labels checkbox. SERVER-persisted
* (localStorage). Applied by just redrawing — no rebuild, no re-layout, * (data/render-settings.json via /api/graph/render-settings): loaded
* and the camera never moves. * 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"; import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
@@ -63,6 +68,13 @@ import Graph from "graphology";
import forceAtlas2 from "graphology-layout-forceatlas2"; import forceAtlas2 from "graphology-layout-forceatlas2";
import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core"; import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core";
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; 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 uniform node look (no state classes — canvas wants hex, not oklch):
* the accent color, until the state model lands. */ * the accent color, until the state model lands. */
@@ -70,27 +82,10 @@ const NODE_COLOR_TOKEN = "--primary";
// ---- Render settings (the settings-panel controls) ------------------------ // ---- Render settings (the settings-panel controls) ------------------------
// //
// Three basic controls (user-decided 2026-09-13): node disc size, edge line // The shape, defaults, sanitizer, and slider mappings live in
// width, and arrowhead length (px). Defaults reflect the pre-panel look // src/core/render-settings.ts (shared with the server store + tests).
// (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.
type RenderSettings = GraphRenderSettings; 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<RenderSettings> | 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 * 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. // Search panel state: the query text + the settings section toggle.
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
// Render settings (node size / edge width / arrowhead): loaded from // Render settings: SERVER-persisted (data/render-settings.json via
// localStorage AFTER mount (an initial-state read would mismatch the // /api/graph/render-settings). Loaded once after mount; every user
// server-rendered HTML), saved on every change. // change saves back, debounced. Defaults render until the load
// resolves — a fraction of a second at worst.
const [settings, setSettings] = useState<RenderSettings>(DEFAULT_RENDER_SETTINGS); const [settings, setSettings] = useState<RenderSettings>(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(() => { useEffect(() => {
try { fetch("/api/graph/render-settings")
const stored = localStorage.getItem(RENDER_SETTINGS_KEY); .then((res) => (res.ok ? (res.json() as Promise<unknown>) : null))
if (stored) setSettings(sanitizeRenderSettings(JSON.parse(stored))); .then((raw) => {
} catch { // Apply only if the user hasn't already moved a slider —
// corrupt storage → defaults, never crash the view // 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(() => { useEffect(() => {
try { if (!userTouched.current) return; // mount-run / server-apply: no save
localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify(settings)); if (!serverLoaded.current) {
} catch { savePending.current = true; // hold until the GET settles
// storage full/blocked → in-memory settings still work return;
} }
const id = setTimeout(() => saveToServer(settings), 400);
return () => clearTimeout(id);
}, [settings]); }, [settings]);
// The renderer instance lives across snapshot refetches and settings // The renderer instance lives across snapshot refetches and settings
@@ -474,7 +504,7 @@ export function GraphView() {
max={14} max={14}
step={1} step={1}
value={settings.nodeSize} value={settings.nodeSize}
onChange={(v) => setSettings((cur) => ({ ...cur, nodeSize: v }))} onChange={(v) => changeSettings((cur) => ({ ...cur, nodeSize: v }))}
/> />
<SettingSlider <SettingSlider
label="Edge width" label="Edge width"
@@ -482,7 +512,7 @@ export function GraphView() {
max={4} max={4}
step={0.5} step={0.5}
value={settings.edgeWidth} value={settings.edgeWidth}
onChange={(v) => setSettings((cur) => ({ ...cur, edgeWidth: v }))} onChange={(v) => changeSettings((cur) => ({ ...cur, edgeWidth: v }))}
/> />
<SettingSlider <SettingSlider
label="Arrowhead size" label="Arrowhead size"
@@ -490,23 +520,35 @@ export function GraphView() {
max={15} max={15}
step={1} step={1}
value={settings.arrowLength} value={settings.arrowLength}
onChange={(v) => setSettings((cur) => ({ ...cur, arrowLength: v }))} onChange={(v) => changeSettings((cur) => ({ ...cur, arrowLength: v }))}
/> />
<SettingSlider
label="Label size"
min={6}
max={24}
step={1}
value={settings.labelSize}
onChange={(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. */}
<SettingSlider <SettingSlider
label="Labels until zoom" label="Labels until zoom"
min={0.25} min={0}
max={4} max={1000}
step={0.25} step={5}
value={settings.labelZoom} value={labelZoomToSlider(settings.labelZoom)}
display={`${settings.labelZoom}×`} display={labelZoomDisplay(settings.labelZoom)}
onChange={(v) => setSettings((cur) => ({ ...cur, labelZoom: v }))} onChange={(v) => changeSettings((cur) => ({ ...cur, labelZoom: sliderToLabelZoom(v) }))}
/> />
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground"> <label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Cluster labels</span> <span>Cluster labels</span>
<input <input
type="checkbox" type="checkbox"
checked={settings.clusterLabels} checked={settings.clusterLabels}
onChange={(e) => setSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))} onChange={(e) => changeSettings((cur) => ({ ...cur, clusterLabels: e.target.checked }))}
className="accent-primary" className="accent-primary"
/> />
</label> </label>
+6
View File
@@ -84,3 +84,9 @@ export const LEDGER_FILES = {
* log. Read-modify-write like a chat file, not append-only like the ledgers. * log. Read-modify-write like a chat file, not append-only like the ledgers.
*/ */
export const SOURCES_FILE = path.join(PATHS.graph, "sources.json"); 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");
+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 };
+99
View File
@@ -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
});
});