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,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 };
|
||||
Reference in New Issue
Block a user