Files
learnharness/components/graph-canvas.ts
T

1406 lines
53 KiB
TypeScript
Raw Normal View History

"use client";
/**
* The hand-rolled single-canvas graph renderer (decided 2026-09-13 after
* the research pass — docs/research/graph-rendering.md, Option B).
*
* Replaces sigma.js. Why: sigma gave us WebGL node/edge rendering,
* picking, and a camera, but our cluster regions had to live on foreign
* canvases injected next to its private DOM stack, drawn from a separate
* event loop — so hulls lagged behind nodes during pan, raced sigma's
* canvas sizing on renderer rebuilds, and every camera event re-rasterized
* the full hull canvas (GPU spikes + audible coil whine). At our scale
* (338 → low thousands of nodes) one Canvas 2D is comfortably inside
* 60fps territory, and a single loop makes the desync class of bugs
* structurally impossible.
*
* Architecture (the canonical pattern — browser.engineering, MDN
* "Optimizing canvas", web.dev canvas performance):
* - The renderer is a DUMB DRAW FUNCTION over plain data: nodes with
* world-space positions, edges, clusters, colors, settings. Layout
* (forceAtlas2) and filtering happen OUTSIDE, in the React component;
* this module never computes a position.
* - ONE canvas, ONE requestAnimationFrame loop with a dirty flag. Input
* and DOM events only mutate state and mark dirty; the loop draws at
* most once per frame with every layer under the same camera —
* hull fills → hull strokes → edges → nodes → region labels → node
* labels → hover. Nothing can ever lag anything else.
* - Camera: d3-zoom supplies the verified pan / zoom-to-cursor / pinch
* math (screen = world·k + [x,y]). The camera lives in the renderer
* instance, so data refetches and settings changes never move it.
* - Picking: nearest-node distance check (µs at our scale), with a
* PICKING_PAD so the whole hover halo is hit-testable.
*
* Zoom-gated labels (the Obsidian text-fade feel): sigma's camera "ratio"
* convention is kept for the settings' persisted semantics — ratio 1 =
* fit zoom, SMALLER = zoomed in (ratio = kFit / k). Node labels are
* fully visible at ratio ≤ labelZoom and fade out across LABELS_FADE_SPAN
* of ratio; cluster labels fade in over the exact same span (sums to 1)
* so the two never coexist. All labels gate together — no per-node
* popping.
*
* The hover glow is unchanged in look: a soft halo in the node's own
* color at a constant radius, opacity cross-faded over ~180ms, with the
* name brightening on the SAME factor. Only opacity animates; the only
* per-frame work when idle is a dirty-flag check (no rAF is scheduled at
* all until something changes).
*
* Unit tests cover the pure helpers (tests/graph-canvas.test.ts); the
* class itself is DOM-bound and exercised live. Module scope stays
* DOM-free so the file imports cleanly under Node.
*/
import { select } from "d3-selection";
import { zoom, zoomIdentity, type ZoomBehavior, type ZoomTransform } from "d3-zoom";
2026-09-13 09:17:05 +03:00
import type { Simulation, SimulationLinkDatum } from "d3-force";
import { clusterHue, type Pt } from "./cluster-geometry.ts";
import {
2026-09-13 09:52:14 +03:00
CHARGE_STRENGTH,
CLUSTER_PULL_STRENGTH,
2026-09-13 09:17:05 +03:00
createGraphSimulation,
INCREMENTAL_GRAVITY,
2026-09-13 09:52:14 +03:00
LINK_DISTANCE,
type DepthAnchorMode,
2026-09-13 09:17:05 +03:00
type GraphRenderSettings,
type LayoutNode,
type Position,
} from "@/src/core";
export type { GraphRenderSettings };
// ─── Public data contract ──────────────────────────────────────────────────
/** One node to draw: world-space position + the short glanceable label. */
export interface CanvasNode {
id: string;
x: number;
y: number;
label: string;
}
/** One directed edge (follows | supports); arrow points prerequisite → dependent. */
export interface CanvasEdge {
from: string;
to: string;
}
/** One cluster region: member node ids + the LLM-coined label (or null). */
export interface CanvasCluster {
id: string;
label: string | null;
members: string[];
}
/** Theme colors, resolved to #rrggbb by the caller (canvas wants hex,
* and the renderer must not read CSS itself). */
export interface CanvasColors {
node: string;
edge: string;
label: string;
hoverText: string;
}
export interface CanvasData {
nodes: CanvasNode[];
edges: CanvasEdge[];
clusters: CanvasCluster[];
colors: CanvasColors;
/** Body font family (labels must match the app typography). */
fontFamily: string;
}
export interface RendererCallbacks {
/** A node was clicked (screen position included for card anchoring). */
onSelect: (nodeId: string, screenPos: Pt) => void;
/** The empty stage was clicked. */
onStageClick: () => void;
/** Any camera move (pan/zoom) — the caller dismisses anchored UI. */
onCameraMove: () => void;
2026-09-13 09:17:05 +03:00
/** The live sim settled after a drag: commit the new rest state
* (the caller PUTs it to the positions ledger). */
onCommit: (positions: Record<string, Position>) => void;
}
// ─── Constants (ported verbatim from the sigma view) ────────────────────────
const LABEL_SIZE = 12; // px — the default label size (settings.labelSize scales it)
const LABEL_WEIGHT = "500";
const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip
const LABEL_GAP = 2; // px between node edge and first label line (was 6 — user: way too far)
const LABEL_LINE_HEIGHT = 1.3;
/** Zoom span (camera-ratio units) over which labels fade together. */
export const LABELS_FADE_SPAN = 0.5;
const GLOW_SCALE = 1.7; // halo outer radius = node radius x this (proportional, like blurring a copy of the disc)
const GLOW_ALPHA = 0.3; // halo opacity at the disc edge, fading outward
const HOVER_FADE_MS = 180; // hover halo opacity fade length
const ZOOM_EASE_MS = 45; // wheel-zoom glide: exponential-ease time constant (~250ms per notch)
/** Extra pickable radius (px) beyond the disc — the whole halo is
* hit-testable (the old flicker fix, minus the picking shader). */
const PICKING_PAD = 10;
/** Click vs drag threshold: pointer travel under this many px is a click. */
const CLICK_SLOP = 4;
2026-09-13 09:17:05 +03:00
/** Live-sim tuning — see the class doc comment. */
const LIVE_ALPHA_DECAY = 0.05; // snappier anneal than the settle's 0.0228
const DRAG_ALPHA_TARGET = 0.3; // d3-drag's own reheat constant
const SIM_SETTLE_ALPHA = 0.005; // below this, the sim counts as settled
const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel)
2026-09-13 09:17:05 +03:00
/** Cluster region styling — a low-res world-space SPLAT bitmap (the
* density-field technique, Canvas-2D edition): every member node paints
* a soft radial-gradient disc and every intra-cluster edge a round-cap
* stroke; upscaling the bitmap with bilinear smoothing IS the blur.
* Replaces the convex-hull fills + borders: concave cluster shapes are
* finally correct (the hull bridged every gap with straight lines —
* the jagged close-up look), there is no border to lag behind, and a
* live-physics frame pays one tiny blit instead of 32 clusters × 4
* offset polygons re-rasterized at DPR 2 (the old GPU coil-whine). */
const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest
const REGION_LABEL_SIZE = 14;
/** Dev-tunable region rendering (2026-09-13, user request): plain number
* inputs in the settings panel — session-only, never persisted — so the
* look can be tuned live against the real graph. A cluster is its
* CONNECTED SKELETON: the region is the dilated line network ONLY (no
* per-node discs — they double-counted where nodes sit on edges and
* made hubs hot). Blur is REAL: a premultiplied 3-pass box blur on the
* bitmap (bilinear upscale alone was no blur at all — zoom-in showed
* tent-ramp chunkiness, the "low-quality JPEG" look). */
export interface RegionTuning {
/** Dilation half-width of the round-cap edge strokes, screen px at
* fit zoom (the expansion of the cluster skeleton). */
dilation: number;
/** Box-blur radius in bitmap px (3 passes ≈ Gaussian). 0 = off. */
blur: number;
/** Max bitmap dimension (cell = worldSpan / this). */
bitmap: number;
/** Per-stroke alpha; overlapping strokes stack toward the core. */
edgeAlpha: number;
}
export const DEFAULT_REGION_TUNING: RegionTuning = {
dilation: 28,
blur: 10,
bitmap: 384,
edgeAlpha: 0.35,
};
/** Clamp anything into a usable tuning (number inputs can be empty/NaN). */
export function sanitizeRegionTuning(input: Partial<RegionTuning> | null | undefined): RegionTuning {
const d = DEFAULT_REGION_TUNING;
const num = (v: unknown, fallback: number): number =>
typeof v === "number" && Number.isFinite(v) ? v : fallback;
return {
dilation: Math.min(Math.max(num(input?.dilation, d.dilation), 4), 250),
blur: Math.min(Math.max(num(input?.blur, d.blur), 0), 40),
bitmap: Math.round(Math.min(Math.max(num(input?.bitmap, d.bitmap), 128), 1024)),
edgeAlpha: Math.min(Math.max(num(input?.edgeAlpha, d.edgeAlpha), 0.02), 1),
};
}
2026-09-13 09:52:14 +03:00
/** User-facing live-sim physics controls (2026-09-13, user request):
* every value feeds createGraphSimulation — the SAME engine the server
* settle runs — so tuning the feel changes exactly what a drag relaxes
* toward. Session state, never persisted. The cluster pull is the
* "virtual node" realized as a centroid force (O(n)/tick, measured
* indistinguishable from off). */
export interface PhysicsTuning {
/** Node repulsion (d3 charge, negative = push apart). */
chargeStrength: number;
/** Edge attraction: link-spring stiffness multiplier (1 = d3 default). */
linkStrength: number;
/** Link spring rest length (world units). */
linkDistance: number;
/** Central pull toward the graph centroid. */
gravity: number;
/** Per-cluster pull toward the cluster centroid. */
clusterPull: number;
/** Per-tick velocity damping (d3 default 0.4). */
velocityDecay: number;
/** Inference-depth anchoring mode (see DepthAnchor in core). */
depthMode: DepthAnchorMode;
/** Depth-anchor pull strength. */
depthStrength: number;
/** Spacing between inference levels (world units). */
depthSpacing: number;
/** Inner ring radius, radial mode (world units). */
depthBaseRadius: number;
2026-09-13 09:52:14 +03:00
}
export const DEFAULT_PHYSICS_TUNING: PhysicsTuning = {
chargeStrength: CHARGE_STRENGTH,
linkStrength: 1,
linkDistance: LINK_DISTANCE,
gravity: INCREMENTAL_GRAVITY,
clusterPull: CLUSTER_PULL_STRENGTH,
velocityDecay: 0.4,
depthMode: "off",
// 0.8 by sweep on the real graph: 215/216 arrows read upward with
// organic sag (mean |dev| from level ~34u at spacing 60); 1.5 gives
// crisp rows (dev ~18) if the promoted default wants them.
depthStrength: 0.8,
depthSpacing: 60,
depthBaseRadius: 220,
2026-09-13 09:52:14 +03:00
};
/** Clamp anything into a usable tuning (number inputs can be empty/NaN). */
export function sanitizePhysicsTuning(input: Partial<PhysicsTuning> | null | undefined): PhysicsTuning {
const d = DEFAULT_PHYSICS_TUNING;
const num = (v: unknown, fallback: number): number =>
typeof v === "number" && Number.isFinite(v) ? v : fallback;
return {
chargeStrength: Math.min(Math.max(num(input?.chargeStrength, d.chargeStrength), -1000), -10),
linkStrength: Math.min(Math.max(num(input?.linkStrength, d.linkStrength), 0), 5),
linkDistance: Math.min(Math.max(num(input?.linkDistance, d.linkDistance), 1), 300),
gravity: Math.min(Math.max(num(input?.gravity, d.gravity), 0), 1),
clusterPull: Math.min(Math.max(num(input?.clusterPull, d.clusterPull), 0), 2),
velocityDecay: Math.min(Math.max(num(input?.velocityDecay, d.velocityDecay), 0), 0.99),
depthMode: input?.depthMode === "vertical" || input?.depthMode === "radial" ? input.depthMode : "off",
depthStrength: Math.min(Math.max(num(input?.depthStrength, d.depthStrength), 0), 2),
depthSpacing: Math.min(Math.max(num(input?.depthSpacing, d.depthSpacing), 1), 500),
depthBaseRadius: Math.min(Math.max(num(input?.depthBaseRadius, d.depthBaseRadius), 0), 2000),
2026-09-13 09:52:14 +03:00
};
}
/** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA
* (Float32, stride 4, length w*h*4). Premultiplying first is what keeps
* the blur halo-free: blurring non-premultiplied RGB over varying alpha
* bleeds solid color into transparent space. Pure: DOM-free, tested. */
export function boxBlurRound(
src: Float32Array,
tmp: Float32Array,
dst: Float32Array,
w: number,
h: number,
radius: number,
): void {
if (radius <= 0) {
dst.set(src);
return;
}
const div = radius * 2 + 1;
// Horizontal: src → tmp (clamped window, running sum).
for (let y = 0; y < h; y++) {
const row = y * w * 4;
let sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
for (let x = -radius; x <= radius; x++) {
const i = row + Math.min(Math.max(x, 0), w - 1) * 4;
sum0 += src[i]!;
sum1 += src[i + 1]!;
sum2 += src[i + 2]!;
sum3 += src[i + 3]!;
}
for (let x = 0; x < w; x++) {
const o = row + x * 4;
tmp[o] = sum0 / div;
tmp[o + 1] = sum1 / div;
tmp[o + 2] = sum2 / div;
tmp[o + 3] = sum3 / div;
const addI = row + Math.min(x + radius + 1, w - 1) * 4;
const subI = row + Math.max(x - radius, 0) * 4;
sum0 += src[addI]! - src[subI]!;
sum1 += src[addI + 1]! - src[subI + 1]!;
sum2 += src[addI + 2]! - src[subI + 2]!;
sum3 += src[addI + 3]! - src[subI + 3]!;
}
}
// Vertical: tmp → dst.
for (let x = 0; x < w; x++) {
const col = x * 4;
let sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
for (let y = -radius; y <= radius; y++) {
const i = col + Math.min(Math.max(y, 0), h - 1) * w * 4;
sum0 += tmp[i]!;
sum1 += tmp[i + 1]!;
sum2 += tmp[i + 2]!;
sum3 += tmp[i + 3]!;
}
for (let y = 0; y < h; y++) {
const o = col + y * w * 4;
dst[o] = sum0 / div;
dst[o + 1] = sum1 / div;
dst[o + 2] = sum2 / div;
dst[o + 3] = sum3 / div;
const addI = col + Math.min(y + radius + 1, h - 1) * w * 4;
const subI = col + Math.max(y - radius, 0) * w * 4;
sum0 += tmp[addI]! - tmp[subI]!;
sum1 += tmp[addI + 1]! - tmp[subI + 1]!;
sum2 += tmp[addI + 2]! - tmp[subI + 2]!;
sum3 += tmp[addI + 3]! - tmp[subI + 3]!;
}
}
}
/** All "px" constants above are screen px AT FIT ZOOM. The renderer scales
* them into world units (÷ kFit) once per geometry build; camera zoom then
* scales nodes/labels/arrowheads with the graph (Obsidian-style), while
* edge widths and hull borders stay viewer-relative hairlines (÷ k). */
const clamp01 = (v: number) => Math.min(Math.max(v, 0), 1);
// ─── Pure helpers (unit-tested) ─────────────────────────────────────────────
/**
* The sigma-compatible "camera ratio": 1 at fit zoom, SMALLER when zoomed
* in (ratio = kFit / k). The persisted labelZoom setting is denominated in
* these units, so the gate semantics survive the renderer swap unchanged.
*/
export function zoomRatioOf(kFit: number, k: number): number {
return kFit / k;
}
/**
* Node-label opacity at a given camera ratio: 1 at/below the labelZoom
* threshold, fading to 0 across LABELS_FADE_SPAN of ratio. Cluster labels
* use `1 - this` over the same span — the two cross-fade and never
* coexist at full opacity.
*/
export function labelAlpha(ratio: number, labelZoom: number, span = LABELS_FADE_SPAN): number {
return clamp01(1 - (ratio - labelZoom) / span);
}
/** The camera transform that frames `bbox` in a w×h viewport with padding. */
export function fitTransform(
bbox: { minX: number; maxX: number; minY: number; maxY: number },
w: number,
h: number,
padding: number,
): { x: number; y: number; k: number } {
const bw = Math.max(bbox.maxX - bbox.minX, 1e-6);
const bh = Math.max(bbox.maxY - bbox.minY, 1e-6);
const k = Math.min((w - 2 * padding) / bw, (h - 2 * padding) / bh);
const cx = (bbox.minX + bbox.maxX) / 2;
const cy = (bbox.minY + bbox.maxY) / 2;
return { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
}
/** The arrowhead geometry for a directed edge a→b: shaft endpoints
* (clear of both node discs) and the head triangle (tip on the target
* disc edge, wideness = 0.8 × head length — the sigma arrow shape). */
export function arrowGeometry(
a: Pt,
b: Pt,
fromRadius: number,
toRadius: number,
headLength: number,
): { shaftFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < 1e-6) return null; // degenerate (coincident) — skip
const ux = dx / len;
const uy = dy / len;
const tip = { x: b.x - ux * toRadius, y: b.y - uy * toRadius };
const base = { x: tip.x - ux * headLength, y: tip.y - uy * headLength };
const halfW = 0.4 * headLength;
return {
shaftFrom: { x: a.x + ux * fromRadius, y: a.y + uy * fromRadius },
shaftTo: base,
tip,
left: { x: base.x - uy * halfW, y: base.y + ux * halfW },
right: { x: base.x + uy * halfW, y: base.y - ux * halfW },
};
}
/** Nearest node within maxDist of a screen point (linear scan — µs at
* our scale; a spatial grid only earns its keep past ~10k nodes). */
export function pickNearest<T extends { id: string; x: number; y: number }>(
nodes: T[],
px: number,
py: number,
maxDist: number,
): T | null {
let best: T | null = null;
let bestD = maxDist * maxDist; // compare squared distances
for (const n of nodes) {
const d = (n.x - px) ** 2 + (n.y - py) ** 2;
if (d < bestD) {
bestD = d;
best = n;
}
}
return best;
}
// ─── Canvas text helpers (ported verbatim) ─────────────────────────────────
/** "#rrggbb" + alpha → "rgba(...)". */
function rgbaFromHex(hex: string, alpha: number): string {
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/** Word-wrap for canvas text. Never truncates: overlong single words are
* hard-broken by character so no line ever exceeds maxWidth. */
function wrapLabel(context: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const lines: string[] = [];
for (const word of text.split(/\s+/).filter(Boolean)) {
const last = lines.length > 0 ? lines[lines.length - 1]! : null;
if (last !== null) {
const joined = `${last} ${word}`;
if (context.measureText(joined).width <= maxWidth) {
lines[lines.length - 1] = joined;
continue;
}
}
let chunk = "";
for (const char of word) {
if (chunk && context.measureText(`${chunk}${char}`).width > maxWidth) {
lines.push(chunk);
chunk = char;
} else {
chunk += char;
}
}
if (chunk) lines.push(chunk);
}
return lines;
}
/** World-unit sizes for the label pass, derived once per geometry build. */
interface WorldSizes {
nodeR: number; // node disc radius, world units
glowOuter: number; // hover halo outer radius, world units
labelSize: number; // node label font, world units
labelGap: number; // node edge → first label line, world units
}
/** The pre-wrapped node name, centered below the node in WORLD units —
* identical geometry at rest and on hover, so hover only changes the
* color, nothing jumps. Lines are wrapped once per geometry build, never
* per frame. */
function drawNameLines(
context: CanvasRenderingContext2D,
x: number,
y: number,
lines: string[],
world: WorldSizes,
color: string,
): void {
context.fillStyle = color;
context.textAlign = "center";
context.textBaseline = "top";
const firstTop = y + world.nodeR + world.labelGap;
for (let i = 0; i < lines.length; i++) {
context.fillText(lines[i]!, x, firstTop + i * world.labelSize * LABEL_LINE_HEIGHT);
}
}
// ─── The renderer ───────────────────────────────────────────────────────────
export class GraphCanvasRenderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private callbacks: RendererCallbacks;
private zoomBehavior: ZoomBehavior<HTMLCanvasElement, unknown>;
private transform: ZoomTransform = zoomIdentity;
/** Set once the first fit happens; the camera then survives setData. */
private hasFit = false;
/** The k that frames the whole graph — the label-gate's "1×". */
private kFit = 1;
/** World-space bounds of the current nodes — fit() and the resize-time
* kFit refresh share it (computed once per setData, not per resize). */
private worldBBox: { minX: number; maxX: number; minY: number; maxY: number } | null = null;
private data: CanvasData | null = null;
/** id -> node for the current data (avoids O(n^2) scans). */
private nodeById = new Map<string, CanvasNode>();
private settings: GraphRenderSettings | null = null;
private visible: ReadonlySet<string> | null = null;
2026-09-13 09:17:05 +03:00
// ── Live physics ──
// One d3-force sim shared with the server settle (createGraphSimulation
// in @/src/core): dragging pins fx/fy and reheats (alphaTarget), release
// un-pins and the sim anneals; once alpha drops below SIM_SETTLE_ALPHA
// the new rest state is debounced-committed via onCommit. The sim mutates
// data.nodes IN PLACE (they ARE the sim nodes — zero copying), so every
// tick just bumps geoVersion and the draw loop rebuilds geometry.
private sim: Simulation<LayoutNode, SimulationLinkDatum<LayoutNode>> | null = null;
private simById = new Map<string, LayoutNode>();
private simActive = false;
private dragId: string | null = null;
private dragOrigin: Pt | null = null;
private simMoved = false; // any real movement since the last commit
private commitTimer: ReturnType<typeof setTimeout> | null = null;
private frameId: number | null = null;
private dirty = true;
/** True while tick() runs: markDirty inside the loop (d3's zoom event
* fires from advanceZoom) must NOT schedule — the loop schedules
* itself below. Un-guarded, each glide frame leaked an extra queued
* tick and draws multiplied per displayed frame (20fps zoom bug). */
private inTick = false;
// Hover transition: fade 0 = resting, 1 = fully hovered; the animating
// flag keeps the loop scheduled until the fade settles (then rAF stops).
private hoverId: string | null = null;
/** The node the glow is drawn on. OUTLIVES hoverId on hover-out so the
* fade has something to draw (hoverId nulls instantly; without this
* the glow popped off in one frame instead of fading). Cleared when
* the outgoing fade completes. */
private hoverNode: CanvasNode | null = null;
private hoverFade = 0;
private hoverFrom = 0;
private hoverTarget = 0;
private hoverStart = 0;
// Smooth wheel zoom: wheel events accumulate a target scale (cursor-
// anchored); each tick eases the camera toward it instead of jumping.
private zoomTarget: { k: number; x: number; y: number } | null = null;
/** Clock for frame-rate-independent easing (updated every tick). */
private lastTickAt = 0;
private animating = false;
private resizeObserver: ResizeObserver;
private downPos: Pt | null = null;
// ── Cached world geometry ─────────────────────────────────────────────
// Everything above is drawn per frame; everything below is derived from
// data+visibility+settings+kFit and rebuilt ONLY when one of those
// changes (geoVersion). Camera moves just re-rasterize the same paths.
private geoVersion = 0; // bump = geometry inputs changed
private builtVersion = -1; // version the cache was built at
private nodesPath: Path2D | null = null;
private shaftsPath: Path2D = new Path2D();
private headsPath: Path2D = new Path2D();
2026-09-13 09:17:05 +03:00
private regionCanvas: HTMLCanvasElement | null = null;
/** World rect the bitmap covers (drawImage target coords). */
private regionRect: { minX: number; minY: number; w: number; h: number } | null = null;
/** Region labels: cluster centroid + hue, computed at splat build. */
private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = [];
private regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
2026-09-13 09:52:14 +03:00
private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING;
/** Gravity center for the live sim (graph bbox centroid at setData). */
private simCenter: Pt = { x: 0, y: 0 };
2026-09-13 09:17:05 +03:00
private regionBuiltVersion = -1;
private frameCount = 0; // draw counter for the live-sim splat throttle
private labelLines = new Map<string, string[]>();
private worldLabelFont = "";
private world: WorldSizes = { nodeR: 0, glowOuter: 0, labelSize: 0, labelGap: 0 };
constructor(canvas: HTMLCanvasElement, callbacks: RendererCallbacks) {
this.canvas = canvas;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2d context unavailable");
this.ctx = ctx;
this.callbacks = callbacks;
// d3-zoom owns pan / wheel zoom-to-cursor / touch pinch; every
// transform change just marks dirty (draws happen in OUR loop).
this.zoomBehavior = zoom<HTMLCanvasElement, unknown>()
.scaleExtent([0.02, 40])
.on("zoom", ({ transform }) => {
this.transform = transform;
this.markDirty();
this.callbacks.onCameraMove();
});
select(canvas)
.call(this.zoomBehavior)
// No surprise dblclick-zoom (sigma had none).
.on("dblclick.zoom", null)
// Wheel is OURS now: d3's instant per-notch jump felt janky; the
// eased glide below replaces it (pan and pinch stay d3's).
.on("wheel.zoom", null);
canvas.addEventListener("wheel", this.onWheel, { passive: false });
canvas.addEventListener("pointermove", this.onPointerMove);
canvas.addEventListener("pointerdown", this.onPointerDown);
canvas.addEventListener("pointerup", this.onPointerUp);
canvas.addEventListener("pointerleave", this.onPointerLeave);
this.resizeObserver = new ResizeObserver(() => {
this.syncSize();
if (!this.hasFit) this.fit(); // first real size → fit once
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;
this.geoVersion++; // resize redefines the fit-zoom scale
}
}
// Redraw SYNCHRONOUSLY: syncSize may have reallocated the backing
// store (which clears the canvas), and during live window resizes
// rAF is starved by relayout — a deferred draw would leave the
// canvas blank for the whole drag (the resize-blanking bug).
this.draw();
});
this.resizeObserver.observe(canvas);
this.syncSize();
}
// ── Public API ──
setData(data: CanvasData): void {
this.data = data;
this.nodeById = new Map(data.nodes.map((n) => [n.id, n]));
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of data.nodes) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
this.worldBBox = { minX, maxX, minY, maxY };
if (!this.hasFit && data.nodes.length > 0) this.fit();
2026-09-13 09:17:05 +03:00
// Rebuild the live sim around the fresh nodes. New data means the
// server spoke (merge or refetch); any pending commit is moot and
// the graph is at rest until the next drag.
if (this.commitTimer) {
clearTimeout(this.commitTimer);
this.commitTimer = null;
}
this.simActive = false;
this.dragId = null;
this.simMoved = false;
2026-09-13 09:52:14 +03:00
this.simCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
this.buildSim();
this.geoVersion++; // new data → rebuild world geometry
this.markDirty();
}
setSettings(settings: GraphRenderSettings): void {
this.settings = settings;
this.geoVersion++; // sizes/wrap width changed → rebuild geometry
this.markDirty();
}
/** Dev tuning for region rendering (session-only, never persisted):
* invalid numbers fall back to defaults — the number inputs can be
* emptied while typing. Forces a region bitmap rebuild. */
setRegionTuning(input: Partial<RegionTuning>): void {
this.regionTuning = sanitizeRegionTuning(input);
this.regionBuiltVersion = -2; // never equals geoVersion → rebuild
this.markDirty();
}
2026-09-13 09:52:14 +03:00
/** Live-sim physics controls: rebuilds the sim from the CURRENT
* node positions (same node objects — x/y and any drag pin fx/fy
* survive; velocities restart clean, which reads as "re-relax with
* the new feel"). A mid-settle change re-anneals, which is the
* honest response to new physics. */
setPhysicsTuning(input: Partial<PhysicsTuning>): void {
this.physicsTuning = sanitizePhysicsTuning(input);
this.buildSim();
// Reheat: a tuning change re-anneals the layout from its current
// state, so mode toggles (off/vertical/radial) visibly RE-FORM the
// graph instead of waiting for the next drag. Mid-drag is safe —
// buildSim preserved the pinned node's fx/fy.
if (this.sim) {
this.sim.alpha(1);
this.simActive = true;
}
2026-09-13 09:52:14 +03:00
this.markDirty();
}
setVisible(visible: ReadonlySet<string> | null): void {
this.visible = visible;
this.geoVersion++; // member visibility changes hulls + paths
this.markDirty();
}
/** Frame the whole graph (padding included). Keeps the camera on later
* setData calls — call fit() only for an explicit re-frame. */
fit(): void {
if (!this.data || this.data.nodes.length === 0 || !this.worldBBox) return;
const w = this.canvas.clientWidth;
const h = this.canvas.clientHeight;
if (w < 10 || h < 10) return; // layout not ready yet; the ResizeObserver retries
const t = fitTransform(this.worldBBox, w, h, 40);
this.kFit = t.k;
this.hasFit = true;
this.geoVersion++; // kFit defines the world-unit scale
select(this.canvas).call(
this.zoomBehavior.transform,
zoomIdentity.translate(t.x, t.y).scale(t.k),
);
}
destroy(): void {
if (this.frameId !== null) cancelAnimationFrame(this.frameId);
2026-09-13 09:17:05 +03:00
if (this.commitTimer) clearTimeout(this.commitTimer);
this.sim = null;
this.simActive = false;
this.dragId = null;
this.resizeObserver.disconnect();
select(this.canvas).on(".zoom", null);
this.canvas.removeEventListener("wheel", this.onWheel);
this.canvas.removeEventListener("pointermove", this.onPointerMove);
this.canvas.removeEventListener("pointerdown", this.onPointerDown);
this.canvas.removeEventListener("pointerup", this.onPointerUp);
this.canvas.removeEventListener("pointerleave", this.onPointerLeave);
this.canvas.style.cursor = "";
}
// ── 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) => {
2026-09-13 09:17:05 +03:00
const pos = this.eventPos(e);
if (this.dragId) {
const n = this.simById.get(this.dragId);
if (n) {
const moved =
this.dragOrigin !== null &&
Math.hypot(pos.x - this.dragOrigin.x, pos.y - this.dragOrigin.y) > CLICK_SLOP;
if (moved) {
// First real movement: reheat and glue the node to the cursor.
if (this.commitTimer) {
clearTimeout(this.commitTimer);
this.commitTimer = null;
}
this.sim?.alphaTarget(DRAG_ALPHA_TARGET);
this.simActive = true;
this.markDirty();
}
const { x: tx, y: ty, k } = this.transform;
n.fx = (pos.x - tx) / k;
n.fy = (pos.y - ty) / k;
}
return;
}
const node = this.pickAt(pos.x, pos.y);
if ((node?.id ?? null) !== this.hoverId) this.setHover(node?.id ?? null);
this.canvas.style.cursor = node ? "pointer" : "";
};
private onPointerDown = (e: PointerEvent) => {
2026-09-13 09:17:05 +03:00
const pos = this.eventPos(e);
const node = this.pickAt(pos.x, pos.y);
if (node && this.sim) {
// Node press: pin the node (at ITS OWN position — the cursor
// may be inside the pick pad, off-center). preventDefault kills
// the compatibility mousedown, so d3-zoom never starts a pan.
// Physics only reheats once the pointer exceeds CLICK_SLOP —
// a plain click stays perfectly still.
e.preventDefault();
try {
this.canvas.setPointerCapture(e.pointerId);
} catch {
/* capture is best-effort */
}
this.dragId = node.id;
this.dragOrigin = pos;
const n = this.simById.get(node.id);
if (n) {
n.fx = n.x;
n.fy = n.y;
}
this.canvas.style.cursor = "grabbing";
this.markDirty();
return;
}
this.downPos = pos;
this.zoomTarget = null; // a pan takes over — cancel any zoom glide
};
private onPointerUp = (e: PointerEvent) => {
2026-09-13 09:17:05 +03:00
// Node press ending: click (no slop) → select; drag → release-and-relax.
if (this.dragId) {
const id = this.dragId;
this.dragId = null;
this.dragOrigin = null;
try {
this.canvas.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
this.canvas.style.cursor = "";
const n = this.simById.get(id);
if (n) {
n.fx = null;
n.fy = null;
}
if (this.simActive) {
// Real drag: the sim anneals to the new rest state; advanceSim
// detects the settle and schedules the commit.
this.sim?.alphaTarget(0);
this.markDirty();
} else {
// Never heated: a plain click on the node.
this.callbacks.onSelect(id, this.eventPos(e));
}
return;
}
if (!this.downPos) return;
const pos = this.eventPos(e);
const moved = Math.hypot(pos.x - this.downPos.x, pos.y - this.downPos.y);
this.downPos = null;
if (moved > CLICK_SLOP) return; // that was a drag (pan), not a click
const node = this.pickAt(pos.x, pos.y);
if (node) this.callbacks.onSelect(node.id, pos);
else this.callbacks.onStageClick();
};
private onPointerLeave = () => {
2026-09-13 09:17:05 +03:00
if (this.dragId) return; // pointer capture keeps the drag alive
this.downPos = null;
this.setHover(null);
};
// ── Internals ──
private eventPos(e: { clientX: number; clientY: number }): Pt {
const rect = this.canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
private pickAt(px: number, py: number): CanvasNode | null {
if (!this.data || !this.settings) return null;
// Screen positions, filtered to visible nodes.
const { x: tx, y: ty, k } = this.transform;
// Discs scale with the camera now: their screen radius is
// nodeSize × k/kFit; the halo pad stays a constant screen pad.
const maxDist = this.settings.nodeSize * (k / this.kFit) + PICKING_PAD;
const projected: { id: string; x: number; y: number }[] = [];
for (const n of this.data.nodes) {
if (this.visible && !this.visible.has(n.id)) continue;
projected.push({ id: n.id, x: n.x * k + tx, y: n.y * k + ty });
}
const hit = pickNearest(projected, px, py, maxDist);
if (!hit) return null;
return this.nodeById.get(hit.id) ?? null;
}
private setHover(nodeId: string | null): void {
if (nodeId === this.hoverId && this.hoverTarget === (nodeId ? 1 : 0)) return;
this.hoverId = nodeId;
// Hover-out deliberately KEEPS hoverNode: the glow must fade out
// from the last hovered node, not vanish with the id.
if (nodeId) this.hoverNode = this.nodeById.get(nodeId) ?? null;
// Rebase the animation from the CURRENT fade: rapid hover toggles
// glide instead of snapping (ported from the sigma-era behavior).
this.hoverFrom = this.hoverFade;
this.hoverTarget = nodeId ? 1 : 0;
this.hoverStart = performance.now();
this.animating = true;
this.markDirty();
}
private markDirty(): void {
this.dirty = true;
if (this.frameId === null && !this.inTick) {
this.frameId = requestAnimationFrame(() => {
this.frameId = null;
this.tick();
});
}
}
private tick(): void {
this.inTick = true;
try {
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);
2026-09-13 09:17:05 +03:00
const simAnim = this.advanceSim();
this.draw();
// Keep frames coming ONLY while an animation is in motion (hover
2026-09-13 09:17:05 +03:00
// fade, zoom glide, or live physics); an idle graph schedules no
// rAF at all.
this.animating = hoverAnim || zoomAnim || simAnim;
} finally {
this.inTick = false;
}
if (this.animating) {
this.frameId = requestAnimationFrame(() => {
this.frameId = null;
this.tick();
});
}
}
2026-09-13 09:17:05 +03:00
/** One live-sim step; returns true while physics is still in motion.
* Runs only while simActive (drag reheat or post-release anneal). */
private advanceSim(): boolean {
if (!this.sim || !this.simActive) return false;
this.sim.tick();
// Movement check: did any node actually move (a click-pin that
// never reheated leaves everything exactly at rest)?
for (const n of this.sim.nodes()) {
if (n.vx || n.vy) {
this.simMoved = true;
break;
}
}
this.geoVersion++; // positions changed → geometry rebuild in draw()
// Settled (drag released long ago, alpha annealed away)?
if (!this.dragId && this.sim.alpha() < SIM_SETTLE_ALPHA) {
this.simActive = false;
this.scheduleCommit();
return false;
}
return true;
}
/** Debounced rest-state commit. A new drag or setData cancels it. */
private scheduleCommit(): void {
if (this.commitTimer) clearTimeout(this.commitTimer);
this.commitTimer = setTimeout(() => {
this.commitTimer = null;
if (!this.data || !this.simMoved) return;
this.simMoved = false;
const positions: Record<string, Position> = {};
for (const n of this.data.nodes) positions[n.id] = { x: n.x, y: n.y };
this.callbacks.onCommit(positions);
}, COMMIT_DEBOUNCE_MS);
}
/** Hover fade step; returns true while still moving. */
private advanceHover(): boolean {
if (this.hoverFade === this.hoverTarget) return false;
const t = Math.min((performance.now() - this.hoverStart) / HOVER_FADE_MS, 1);
this.hoverFade = this.hoverFrom + (this.hoverTarget - this.hoverFrom) * t;
// Outgoing fade finished — release the glow node.
if (this.hoverTarget === 0 && this.hoverFade === 0) this.hoverNode = null;
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.002) {
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
* drawing then happens in CSS pixels through the DPR transform. */
private syncSize(): void {
const w = this.canvas.clientWidth;
const h = this.canvas.clientHeight;
const dpr = window.devicePixelRatio || 1;
const bw = Math.max(1, Math.round(w * dpr));
const bh = Math.max(1, Math.round(h * dpr));
if (this.canvas.width !== bw || this.canvas.height !== bh) {
this.canvas.width = bw;
this.canvas.height = bh;
}
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
/** Derive ALL world geometry from data + visibility + settings + kFit.
* Runs only when geoVersion moves — never per frame, never per camera
* change. Zoom/pan pay zero geometry math, only rasterization. */
private buildGeometry(): void {
this.builtVersion = this.geoVersion;
const data = this.data;
const settings = this.settings;
if (!data || !settings) return;
// Screen px at fit zoom → world units.
const s = 1 / this.kFit;
const nodeR = settings.nodeSize * s;
this.world = {
nodeR,
glowOuter: nodeR * GLOW_SCALE,
labelSize: settings.labelSize * s,
labelGap: LABEL_GAP * s,
};
this.worldLabelFont = `${LABEL_WEIGHT} ${settings.labelSize * s}px ${data.fontFamily}`;
const arrowLen = settings.arrowLength * s;
const visible = this.visible;
const isVisible = (id: string) => !visible || visible.has(id);
// Node discs: one Path2D, one fill() per frame.
const nodesPath = new Path2D();
for (const n of data.nodes) {
if (!isVisible(n.id)) continue;
nodesPath.moveTo(n.x + nodeR, n.y);
nodesPath.arc(n.x, n.y, nodeR, 0, Math.PI * 2);
}
this.nodesPath = nodesPath;
// Edge shafts + arrowheads: tip lands on the target disc edge.
const shafts = new Path2D();
const heads = new Path2D();
for (const e of data.edges) {
if (!isVisible(e.from) || !isVisible(e.to)) continue;
const a = this.nodeById.get(e.from);
const b = this.nodeById.get(e.to);
if (!a || !b) continue;
const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen);
if (!g) continue;
shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y);
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
heads.moveTo(g.tip.x, g.tip.y);
heads.lineTo(g.left.x, g.left.y);
heads.lineTo(g.right.x, g.right.y);
heads.closePath();
}
this.shaftsPath = shafts;
this.headsPath = heads;
2026-09-13 09:17:05 +03:00
// Cluster regions are NOT built here: they rasterize into their own
// low-res bitmap (see buildRegions), on their own rebuild schedule
// (live-sim frames skip 2 of 3 rebuilds and blit the last bitmap).
// Label wrapping: measureText once per node per geometry build,
// never per frame. Wrap width keeps the bigger-label/bigger-wrap
// behavior from the sigma era.
this.labelLines = new Map();
const ctx = this.ctx;
const prevFont = ctx.font;
ctx.font = this.worldLabelFont;
const maxW = LABEL_MAX_WIDTH * (settings.labelSize / LABEL_SIZE) * s;
for (const n of data.nodes) {
if (isVisible(n.id)) this.labelLines.set(n.id, wrapLabel(ctx, n.label, maxW));
}
ctx.font = prevFont;
}
2026-09-13 09:52:14 +03:00
/** (Re)create the live sim from data.nodes at their current
* positions + the current physics tuning. Called by setData and
* setPhysicsTuning. */
private buildSim(): void {
const data = this.data;
if (!data || data.nodes.length === 0) {
this.sim = null;
this.simById = new Map();
return;
}
const t = this.physicsTuning;
this.sim = createGraphSimulation(data.nodes, data.edges, {
gravity: { x: this.simCenter.x, y: this.simCenter.y, strength: t.gravity },
alphaDecay: LIVE_ALPHA_DECAY,
velocityDecay: t.velocityDecay,
chargeStrength: t.chargeStrength,
linkStrength: t.linkStrength,
linkDistance: t.linkDistance,
clusterPull:
data.clusters.length > 0 ? { clusters: data.clusters, strength: t.clusterPull } : undefined,
depthAnchor:
t.depthMode === "off"
? undefined
: {
mode: t.depthMode,
strength: t.depthStrength,
spacing: t.depthSpacing,
baseRadius: t.depthBaseRadius,
center: this.simCenter,
},
2026-09-13 09:52:14 +03:00
});
this.simById = new Map(data.nodes.map((n) => [n.id, n as LayoutNode]));
}
2026-09-13 09:17:05 +03:00
/** Rasterize cluster regions into the low-res world-space bitmap.
* Runs on data/visibility/settings changes at rest, and every
* REGION_EVERY-th frame while the live sim moves nodes (the in-between
* frames blit the previous bitmap — one sim step of staleness at
* 120fps is invisible). Positions come straight from data.nodes. */
private buildRegions(): void {
const data = this.data;
if (!data || data.clusters.length === 0) {
this.regionCanvas = null;
this.regionRect = null;
this.regionMeta = [];
return;
}
const visible = this.visible;
const isVisible = (id: string): boolean => !visible || visible.has(id);
const tuning = this.regionTuning;
2026-09-13 09:17:05 +03:00
const s = 1 / this.kFit;
// World rect over VISIBLE cluster members (regions cover members
// and their intra-cluster edges — nothing else).
2026-09-13 09:17:05 +03:00
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const c of data.clusters) {
for (const id of c.members) {
if (!isVisible(id)) continue;
const n = this.nodeById.get(id);
if (!n) continue;
if (n.x < minX) minX = n.x;
if (n.x > maxX) maxX = n.x;
if (n.y < minY) minY = n.y;
if (n.y > maxY) maxY = n.y;
2026-09-13 09:17:05 +03:00
}
}
if (minX > maxX) {
this.regionCanvas = null;
this.regionRect = null;
this.regionMeta = [];
return;
}
// Bitmap sizing: longest world span maps to `bitmap` px. The rect
// is padded by the stroke half-width (dilation) plus the blur
// reach so nothing clips at the bitmap edge.
const cell = Math.max(maxX - minX, maxY - minY) / tuning.bitmap;
const padWorld = tuning.dilation * s + (tuning.blur * cell) * 2;
minX -= padWorld;
minY -= padWorld;
maxX += padWorld;
maxY += padWorld;
2026-09-13 09:17:05 +03:00
const spanX = maxX - minX;
const spanY = maxY - minY;
const bw = Math.max(1, Math.ceil(spanX / cell));
const bh = Math.max(1, Math.ceil(spanY / cell));
let rc = this.regionCanvas;
if (!rc) {
rc = document.createElement("canvas");
this.regionCanvas = rc;
}
if (rc.width !== bw || rc.height !== bh) {
rc.width = bw;
rc.height = bh; // also clears
}
// willReadFrequently: the blur step getImageData/putImageData every
// rebuild — Chrome keeps this canvas on the CPU instead of GPU-
// shuttling it.
const rctx = rc.getContext("2d", { willReadFrequently: true });
2026-09-13 09:17:05 +03:00
if (!rctx) return;
rctx.clearRect(0, 0, bw, bh);
rctx.lineCap = "round";
rctx.lineWidth = (tuning.dilation * s * 2) / cell;
2026-09-13 09:17:05 +03:00
// Which cluster is a node in (for intra-cluster edges)?
const clusterOf = new Map<string, number>();
data.clusters.forEach((c, i) => {
for (const id of c.members) clusterOf.set(id, i);
});
// The region IS the dilated skeleton: intra-cluster edges as
// round-cap strokes (Minkowski dilation — points become discs at
// line ends via the caps). NO per-node discs: a cluster is its
// connected points, and discs double-counted where nodes sit on
// edges (user-identified hot spots).
2026-09-13 09:17:05 +03:00
this.regionMeta = [];
data.clusters.forEach((cluster, ci) => {
const hue = clusterHue(cluster.id);
rctx.strokeStyle = `hsla(${hue}, 65%, 60%, ${tuning.edgeAlpha})`;
2026-09-13 09:17:05 +03:00
rctx.beginPath();
let members = 0;
let sumX = 0;
let sumY = 0;
2026-09-13 09:17:05 +03:00
for (const e of data.edges) {
if (clusterOf.get(e.from) !== ci) continue;
if (clusterOf.get(e.to) !== ci) continue;
const a = this.nodeById.get(e.from);
const b = this.nodeById.get(e.to);
if (!a || !b) continue;
rctx.moveTo((a.x - minX) / cell, (a.y - minY) / cell);
rctx.lineTo((b.x - minX) / cell, (b.y - minY) / cell);
}
rctx.stroke();
// Label anchor: member centroid (world coords).
for (const id of cluster.members) {
if (!isVisible(id)) continue;
const n = this.nodeById.get(id);
if (!n) continue;
members++;
sumX += n.x;
sumY += n.y;
}
if (members > 0) {
this.regionMeta.push({ label: cluster.label || null, cx: sumX / members, cy: sumY / members, hue });
2026-09-13 09:17:05 +03:00
}
});
// THE blur: 3 box-blur rounds on the premultiplied bitmap. Bilinear
// upscaling alone is no blur at all (tent ramps — the JPEG look);
// this makes the field genuinely smooth before the blit.
if (tuning.blur > 0) {
const img = rctx.getImageData(0, 0, bw, bh);
const n = bw * bh * 4;
const a = new Float32Array(n);
for (let i = 0; i < n; i += 4) {
const alpha = img.data[i + 3]! / 255;
a[i] = img.data[i]! * alpha;
a[i + 1] = img.data[i + 1]! * alpha;
a[i + 2] = img.data[i + 2]! * alpha;
a[i + 3] = img.data[i + 3]!;
}
const b = new Float32Array(n);
const tmp = new Float32Array(n); // scratch inside each round only
boxBlurRound(a, tmp, b, bw, bh, tuning.blur); // a → b
boxBlurRound(b, tmp, a, bw, bh, tuning.blur); // b → a
boxBlurRound(a, tmp, b, bw, bh, tuning.blur); // a → b (result)
// Unpremultiply back into pixels.
for (let i = 0; i < n; i += 4) {
const alpha = b[i + 3]!;
if (alpha <= 0) {
img.data[i] = 0;
img.data[i + 1] = 0;
img.data[i + 2] = 0;
img.data[i + 3] = 0;
} else {
// Un-premultiply: pm = c * a/255, so c = pm / a * 255.
img.data[i] = Math.round((b[i]! / alpha) * 255);
img.data[i + 1] = Math.round((b[i + 1]! / alpha) * 255);
img.data[i + 2] = Math.round((b[i + 2]! / alpha) * 255);
img.data[i + 3] = Math.round(alpha);
}
}
rctx.putImageData(img, 0, 0);
}
2026-09-13 09:17:05 +03:00
this.regionRect = { minX, minY, w: spanX, h: spanY };
}
/** THE draw function: every layer, one frame, one camera. All drawing
* happens in WORLD coordinates under the camera transform; nothing is
* recomputed per frame except layer styling. */
private draw(): void {
this.dirty = false;
const data = this.data;
const settings = this.settings;
if (!data || !settings) return;
const w = this.canvas.clientWidth;
const h = this.canvas.clientHeight;
const dpr = window.devicePixelRatio || 1;
const ctx = this.ctx;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
2026-09-13 09:17:05 +03:00
this.frameCount++;
// Regions: rebuild on stale geometry at rest; during the live sim,
// every REGION_EVERY-th frame (others blit the last bitmap).
if (
this.regionBuiltVersion !== this.geoVersion &&
(!this.simActive || this.frameCount % REGION_EVERY === 0)
) {
this.buildRegions();
this.regionBuiltVersion = this.geoVersion;
}
if (this.builtVersion !== this.geoVersion) this.buildGeometry();
if (!this.nodesPath) return;
const { x: tx, y: ty, k } = this.transform;
// THE camera: one transform, then everything is world coordinates.
ctx.setTransform(dpr * k, 0, 0, dpr * k, dpr * tx, dpr * ty);
const ratio = zoomRatioOf(this.kFit, k);
const nodeAlpha = labelAlpha(ratio, settings.labelZoom);
const regionAlpha = 1 - nodeAlpha;
const { nodeR, glowOuter } = this.world;
2026-09-13 09:17:05 +03:00
// 1. Cluster regions: one tiny world-space bitmap blit. The upscale
// blur is the region softness; adjacent cluster tints blend
// source-over where they overlap (decided: multiply is invisible
// on the dark background).
if (this.regionCanvas && this.regionRect) {
const { minX, minY, w, h } = this.regionRect;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(this.regionCanvas, minX, minY, w, h);
}
// 3. Edges: shafts + arrowheads from cached paths; width constant on
// screen (÷ k), geometry in world units (scales with the graph).
if (settings.edgeWidth > 0) {
ctx.strokeStyle = data.colors.edge;
ctx.fillStyle = data.colors.edge;
ctx.lineWidth = settings.edgeWidth / k;
ctx.lineCap = "round";
ctx.stroke(this.shaftsPath);
ctx.fill(this.headsPath);
}
// 4. Nodes: one cached Path2D, one fill.
ctx.fillStyle = data.colors.node;
ctx.fill(this.nodesPath);
// 5. Cluster region labels — inverse-gated on THE zoom control.
// Viewer-relative by design (UI, not graph content): drawn at the
// world centroid with a font of REGION_LABEL_SIZE / k, which is a
// constant 18px on screen at any zoom.
if (settings.clusterLabels && regionAlpha > 0.01) {
ctx.save();
ctx.globalAlpha = regionAlpha;
ctx.font = `${LABEL_WEIGHT} ${REGION_LABEL_SIZE / k}px ${data.fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
2026-09-13 09:17:05 +03:00
for (const rm of this.regionMeta) {
if (!rm.label) continue;
ctx.fillStyle = `hsla(${rm.hue}, 55%, 78%, 0.85)`;
ctx.fillText(rm.label, rm.cx, rm.cy);
}
ctx.restore();
}
// 6. Node labels — one global gate, faded together. World-scaled
// with the graph (they grow as you zoom in, Obsidian-style).
if (nodeAlpha > 0.01) {
ctx.save();
ctx.globalAlpha = nodeAlpha;
ctx.font = this.worldLabelFont;
for (const [id, lines] of this.labelLines) {
const n = this.nodeById.get(id);
if (!n) continue;
drawNameLines(ctx, n.x, n.y, lines, this.world, data.colors.label);
}
ctx.restore();
}
// 7. Hover glow (topmost): halo in the node's color + the brightened
// name, both on the SAME fade factor. World units like everything
// else. Shows even when labels are zoomed out (hover is its own
// affordance, not a label).
if (this.hoverNode && this.hoverFade > 0.02) {
const n = this.visible && !this.visible.has(this.hoverNode.id)
? null // filtered out by search — no glow on hidden nodes
: this.hoverNode;
if (n) {
const glow = ctx.createRadialGradient(n.x, n.y, nodeR, n.x, n.y, glowOuter);
glow.addColorStop(0, rgbaFromHex(data.colors.node, GLOW_ALPHA * this.hoverFade));
glow.addColorStop(1, rgbaFromHex(data.colors.node, 0));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(n.x, n.y, glowOuter, 0, Math.PI * 2);
ctx.arc(n.x, n.y, nodeR, 0, Math.PI * 2, true);
ctx.fill("evenodd");
ctx.save();
ctx.globalAlpha = this.hoverFade;
ctx.font = this.worldLabelFont;
drawNameLines(ctx, n.x, n.y, this.labelLines.get(n.id) ?? [], this.world, data.colors.hoverText);
ctx.restore();
}
}
}
}