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
+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
});
});