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:
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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}×`;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user