Renderer swap: drop sigma.js for a hand-rolled single-canvas 2D renderer
Implements Option B from docs/research/graph-rendering.md. sigma.js v3 (WebGL) is replaced by one canvas, one rAF loop with a dirty flag, and d3-zoom for the camera — eliminating the entire bug class from the sigma-era bolt-on architecture: - Hull/node desync during pan (two independent render loops) is now structurally impossible: hull fills, dashed hull strokes, edges with arrowheads, node discs, region labels, node labels, and hover glow are all drawn in ONE frame under ONE camera. - The settings-change canvas-sizing race (everything shifting up-left) is gone: settings changes only redraw; the canvas and camera persist (verified live: camera identical to 15 decimals across a slider drag). - The GPU floor is gone: idle schedules NO frames at all (verified via pixel-identity over 600ms idle); pan redraws exactly once per frame. components/graph-canvas.ts: GraphCanvasRenderer class plus pure helpers (zoomRatioOf, labelAlpha, fitTransform, arrowGeometry, pickNearest). The sigma camera-ratio convention (1 = fit, smaller = zoomed in) is kept verbatim so persisted labelZoom settings survive the swap; node labels and cluster region labels cross-fade over the same LABELS_FADE_SPAN (0.5, unchanged). Picking is a nearest-node distance check with the 10px pad that replaces the old padded-picking vertex shader. Hull math stays in components/cluster-geometry.ts, reused as-is. No ctx.filter, ever. graph-view.tsx keeps every feature: fetch/refetch on runs-empty, FA2 run-then-freeze layout, localStorage render settings + sanitizers, search filtering with edge drops and card-close, detail card with flipped anchoring and per-source provenance, settings panel, import button, pipeline status chips. Only the renderer plumbing changed. tests/graph-canvas.test.ts pins the pure math: ratio direction, label gate + inverse complement (alphas sum to 1), fit transform padding, arrowhead tip-on-disc-edge and symmetry, picking pad/ties/null cases. Live-verified end-to-end on the real 338-node graph (dev server port 3002): single canvas, paint at fit, hover cursor+glow+fade, click card open/anchored/close, stage click, search filter to zero with hint, label gate opens via slider and zoom, cluster-labels toggle, zoom-to- cursor 0.56px anchor drift, camera persistence across settings, zero idle repaints. sigma dependency removed from package.json; d3-zoom and d3-selection added. Note: the settings feed effect needed isEmpty in its deps — without it the renderer mounted with settings=null and every draw early-returned (blank canvas); caught live, not by typecheck.
This commit is contained in:
@@ -0,0 +1,677 @@
|
|||||||
|
"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";
|
||||||
|
import { clusterHue, convexHull, offsetPolygon, traceRoundedPath, type Pt } from "./cluster-geometry.ts";
|
||||||
|
|
||||||
|
// ─── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants (ported verbatim from the sigma view) ────────────────────────
|
||||||
|
|
||||||
|
const LABEL_SIZE = 12; // px (sigma's labelSize)
|
||||||
|
const LABEL_WEIGHT = "500";
|
||||||
|
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_LINE_HEIGHT = 1.3;
|
||||||
|
/** Zoom span (camera-ratio units) over which labels fade together. */
|
||||||
|
export const LABELS_FADE_SPAN = 0.5;
|
||||||
|
|
||||||
|
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 HOVER_FADE_MS = 180; // hover halo opacity fade length
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
/** Cluster hull styling: layered low-alpha fills approximate the soft
|
||||||
|
* translucent tint (a canvas blur filter rasterizes on the GPU per call
|
||||||
|
* — the original coil-whine driver; never bring ctx.filter back). */
|
||||||
|
const HULL_FILL_LAYERS = [
|
||||||
|
[24, 0.025],
|
||||||
|
[19, 0.04],
|
||||||
|
[14, 0.06],
|
||||||
|
] as const;
|
||||||
|
const HULL_STROKE_PAD = 14;
|
||||||
|
const HULL_CORNER_RADIUS = 9;
|
||||||
|
const HULL_DASH = [7, 7] as const;
|
||||||
|
const REGION_LABEL_SIZE = 18;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wrapped node name, centered below the node — identical geometry at
|
||||||
|
* rest and on hover, so hover only changes the color, nothing jumps. */
|
||||||
|
function drawName(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
size: number,
|
||||||
|
label: string,
|
||||||
|
font: string,
|
||||||
|
color: string,
|
||||||
|
): void {
|
||||||
|
context.save();
|
||||||
|
context.font = `${LABEL_WEIGHT} ${LABEL_SIZE}px ${font}`;
|
||||||
|
const lines = wrapLabel(context, label, LABEL_MAX_WIDTH);
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "top";
|
||||||
|
const firstTop = y + size + LABEL_GAP;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
context.fillText(lines[i]!, x, firstTop + i * LABEL_SIZE * LABEL_LINE_HEIGHT);
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
private frameId: number | null = null;
|
||||||
|
private dirty = true;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
private hoverFade = 0;
|
||||||
|
private hoverFrom = 0;
|
||||||
|
private hoverTarget = 0;
|
||||||
|
private hoverStart = 0;
|
||||||
|
private animating = false;
|
||||||
|
|
||||||
|
private resizeObserver: ResizeObserver;
|
||||||
|
private downPos: Pt | null = null;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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
|
||||||
|
this.markDirty();
|
||||||
|
});
|
||||||
|
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]));
|
||||||
|
if (!this.hasFit && data.nodes.length > 0) this.fit();
|
||||||
|
this.markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettings(settings: GraphRenderSettings): void {
|
||||||
|
this.settings = settings;
|
||||||
|
this.markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisible(visible: ReadonlySet<string> | null): void {
|
||||||
|
this.visible = visible;
|
||||||
|
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) return;
|
||||||
|
const w = this.canvas.clientWidth;
|
||||||
|
const h = this.canvas.clientHeight;
|
||||||
|
if (w < 10 || h < 10) return; // layout not ready yet; the ResizeObserver retries
|
||||||
|
let minX = Infinity;
|
||||||
|
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.hasFit = true;
|
||||||
|
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);
|
||||||
|
this.resizeObserver.disconnect();
|
||||||
|
select(this.canvas).on(".zoom", null);
|
||||||
|
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) ──
|
||||||
|
|
||||||
|
private onPointerMove = (e: PointerEvent) => {
|
||||||
|
const { x, y } = this.eventPos(e);
|
||||||
|
const node = this.pickAt(x, y);
|
||||||
|
if ((node?.id ?? null) !== this.hoverId) this.setHover(node?.id ?? null);
|
||||||
|
this.canvas.style.cursor = node ? "pointer" : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPointerDown = (e: PointerEvent) => {
|
||||||
|
this.downPos = this.eventPos(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPointerUp = (e: PointerEvent) => {
|
||||||
|
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 = () => {
|
||||||
|
this.downPos = null;
|
||||||
|
this.setHover(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Internals ──
|
||||||
|
|
||||||
|
private eventPos(e: PointerEvent): 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;
|
||||||
|
const maxDist = this.settings.nodeSize + 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;
|
||||||
|
// 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.frameId = requestAnimationFrame(() => {
|
||||||
|
this.frameId = null;
|
||||||
|
this.tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick(): void {
|
||||||
|
this.advanceHover();
|
||||||
|
this.draw();
|
||||||
|
// Keep frames coming ONLY while the hover fade is in motion; an
|
||||||
|
// idle graph schedules no rAF at all.
|
||||||
|
if (this.animating) {
|
||||||
|
this.frameId = requestAnimationFrame(() => {
|
||||||
|
this.frameId = null;
|
||||||
|
this.tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private advanceHover(): void {
|
||||||
|
if (!this.animating) return;
|
||||||
|
const t = Math.min((performance.now() - this.hoverStart) / HOVER_FADE_MS, 1);
|
||||||
|
this.hoverFade = this.hoverFrom + (this.hoverTarget - this.hoverFrom) * t;
|
||||||
|
if (t >= 1) this.animating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** THE draw function: every layer, one frame, one camera. */
|
||||||
|
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;
|
||||||
|
this.ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const { x: tx, y: ty, k } = this.transform;
|
||||||
|
const nodeById = new Map<string, CanvasNode>();
|
||||||
|
const ratio = zoomRatioOf(this.kFit, k);
|
||||||
|
const nodeAlpha = labelAlpha(ratio, settings.labelZoom);
|
||||||
|
const regionAlpha = 1 - nodeAlpha;
|
||||||
|
|
||||||
|
// Screen positions for every VISIBLE node (one pass), plus the
|
||||||
|
// byId lookup so the label/hover passes never go O(n^2).
|
||||||
|
const pos = new Map<string, Pt>();
|
||||||
|
for (const n of data.nodes) {
|
||||||
|
if (this.visible && !this.visible.has(n.id)) continue;
|
||||||
|
pos.set(n.id, { x: n.x * k + tx, y: n.y * k + ty });
|
||||||
|
nodeById.set(n.id, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1–2. Cluster hulls: soft layered fills, then the dashed boundary.
|
||||||
|
for (const cluster of data.clusters) {
|
||||||
|
const pts: Pt[] = [];
|
||||||
|
for (const id of cluster.members) {
|
||||||
|
const p = pos.get(id);
|
||||||
|
if (p) pts.push(p);
|
||||||
|
}
|
||||||
|
const hull = convexHull(pts);
|
||||||
|
if (hull.length < 3) continue;
|
||||||
|
const hue = clusterHue(cluster.id);
|
||||||
|
for (const [pad, alpha] of HULL_FILL_LAYERS) {
|
||||||
|
this.ctx.beginPath();
|
||||||
|
traceRoundedPath(this.ctx, offsetPolygon(hull, pad), HULL_CORNER_RADIUS);
|
||||||
|
this.ctx.fillStyle = `hsla(${hue}, 65%, 60%, ${alpha})`;
|
||||||
|
this.ctx.fill();
|
||||||
|
}
|
||||||
|
this.ctx.beginPath();
|
||||||
|
traceRoundedPath(this.ctx, offsetPolygon(hull, HULL_STROKE_PAD), HULL_CORNER_RADIUS);
|
||||||
|
this.ctx.setLineDash([...HULL_DASH]);
|
||||||
|
this.ctx.strokeStyle = `hsla(${hue}, 65%, 62%, 0.5)`;
|
||||||
|
this.ctx.lineWidth = 1.5;
|
||||||
|
this.ctx.stroke();
|
||||||
|
this.ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Edges: shaft + arrowhead (tip lands on the target disc edge).
|
||||||
|
if (settings.edgeWidth > 0) {
|
||||||
|
this.ctx.strokeStyle = data.colors.edge;
|
||||||
|
this.ctx.fillStyle = data.colors.edge;
|
||||||
|
this.ctx.lineWidth = settings.edgeWidth;
|
||||||
|
this.ctx.lineCap = "round";
|
||||||
|
for (const e of data.edges) {
|
||||||
|
const from = pos.get(e.from);
|
||||||
|
const to = pos.get(e.to);
|
||||||
|
if (!from || !to) continue; // edge drops when either endpoint is hidden
|
||||||
|
const g = arrowGeometry(from, to, settings.nodeSize, settings.nodeSize, settings.arrowLength);
|
||||||
|
if (!g) continue;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(g.shaftFrom.x, g.shaftFrom.y);
|
||||||
|
this.ctx.lineTo(g.shaftTo.x, g.shaftTo.y);
|
||||||
|
this.ctx.stroke();
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(g.tip.x, g.tip.y);
|
||||||
|
this.ctx.lineTo(g.left.x, g.left.y);
|
||||||
|
this.ctx.lineTo(g.right.x, g.right.y);
|
||||||
|
this.ctx.closePath();
|
||||||
|
this.ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Nodes: plain discs.
|
||||||
|
this.ctx.fillStyle = data.colors.node;
|
||||||
|
for (const [, p] of pos) {
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(p.x, p.y, settings.nodeSize, 0, Math.PI * 2);
|
||||||
|
this.ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Cluster region labels — inverse-gated on THE zoom control:
|
||||||
|
// they fade in over the exact span node labels fade out. Drawn
|
||||||
|
// above the nodes, below the node labels (the old layer order).
|
||||||
|
if (settings.clusterLabels && regionAlpha > 0.01) {
|
||||||
|
this.ctx.save();
|
||||||
|
this.ctx.globalAlpha = regionAlpha;
|
||||||
|
this.ctx.font = `${LABEL_WEIGHT} ${REGION_LABEL_SIZE}px ${data.fontFamily}`;
|
||||||
|
this.ctx.textAlign = "center";
|
||||||
|
this.ctx.textBaseline = "middle";
|
||||||
|
for (const cluster of data.clusters) {
|
||||||
|
if (!cluster.label) continue;
|
||||||
|
const pts: Pt[] = [];
|
||||||
|
for (const id of cluster.members) {
|
||||||
|
const p = pos.get(id);
|
||||||
|
if (p) pts.push(p);
|
||||||
|
}
|
||||||
|
if (pts.length === 0) continue;
|
||||||
|
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
|
||||||
|
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
|
||||||
|
const hue = clusterHue(cluster.id);
|
||||||
|
this.ctx.fillStyle = `hsla(${hue}, 55%, 78%, 0.85)`;
|
||||||
|
this.ctx.fillText(cluster.label, cx, cy);
|
||||||
|
}
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Node labels — one global gate, faded together (no per-node pop).
|
||||||
|
if (nodeAlpha > 0.01) {
|
||||||
|
this.ctx.save();
|
||||||
|
this.ctx.globalAlpha = nodeAlpha;
|
||||||
|
for (const [id, p] of pos) {
|
||||||
|
const n = nodeById.get(id);
|
||||||
|
if (n) drawName(this.ctx, p.x, p.y, settings.nodeSize, n.label, data.fontFamily, data.colors.label);
|
||||||
|
}
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Hover glow (topmost): halo in the node's color + the brightened
|
||||||
|
// name, both on the SAME fade factor. Shows even when labels are
|
||||||
|
// zoomed out (hover is its own affordance, not a label).
|
||||||
|
if (this.hoverId && this.hoverFade > 0.02) {
|
||||||
|
const p = pos.get(this.hoverId);
|
||||||
|
const n = p ? nodeById.get(this.hoverId) : null;
|
||||||
|
if (p && n) {
|
||||||
|
const inner = settings.nodeSize;
|
||||||
|
const outer = settings.nodeSize + GLOW_PAD;
|
||||||
|
const glow = this.ctx.createRadialGradient(p.x, p.y, inner, p.x, p.y, outer);
|
||||||
|
glow.addColorStop(0, rgbaFromHex(data.colors.node, GLOW_ALPHA * this.hoverFade));
|
||||||
|
glow.addColorStop(1, rgbaFromHex(data.colors.node, 0));
|
||||||
|
this.ctx.fillStyle = glow;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(p.x, p.y, outer, 0, Math.PI * 2);
|
||||||
|
this.ctx.arc(p.x, p.y, inner, 0, Math.PI * 2, true);
|
||||||
|
this.ctx.fill("evenodd");
|
||||||
|
this.ctx.save();
|
||||||
|
this.ctx.globalAlpha = this.hoverFade;
|
||||||
|
drawName(this.ctx, p.x, p.y, settings.nodeSize, n.label, data.fontFamily, data.colors.hoverText);
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
-621
@@ -3,73 +3,57 @@
|
|||||||
/**
|
/**
|
||||||
* The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)").
|
* The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)").
|
||||||
*
|
*
|
||||||
* sigma.js v3 + graphology + forceAtlas2, run-then-freeze: ONE synchronous
|
* ARCHITECTURE (2026-09-13, decided after the research pass —
|
||||||
* forceAtlas2.assign (100 iterations, inferSettings) inside a useEffect
|
* docs/research/graph-rendering.md, Option B): sigma.js is GONE. The
|
||||||
* writes x/y. No layout animation ever starts; the ONLY animation is the
|
* renderer is now a hand-rolled single Canvas 2D (./graph-canvas.ts) —
|
||||||
* short hover fade (enter/leave transitions), strictly bounded in time
|
* a DUMB DRAW FUNCTION over plain data with ONE requestAnimationFrame
|
||||||
* and cancelled on unmount — idle CPU stays near zero. Physics is
|
* loop and a dirty flag. Everything that made the sigma era fragile is
|
||||||
* placement only: pan/zoom, no dragging, no editing.
|
* structurally impossible now: hulls cannot lag nodes (one loop, one
|
||||||
|
* camera, one frame), settings changes cannot shift anything (the
|
||||||
|
* camera lives in the renderer and no rebuild ever happens), and the
|
||||||
|
* per-camera-event full-canvas repaints (GPU spikes + coil whine) are
|
||||||
|
* gone (at most one draw per frame, and NO frame at all while idle).
|
||||||
*
|
*
|
||||||
* Labels render the node `name` — the very short, glanceable label the
|
* Layout (graphology + forceAtlas2) runs-then-freezes exactly as
|
||||||
* extractor coins (a glossary-headword-style handle); the full-precision
|
* before: ONE synchronous 100-iteration pass inside a useEffect writes
|
||||||
* `statement` lives on the detail card. Custom canvas drawers replace
|
* world-space x/y, then the positions are handed to the renderer as
|
||||||
* sigma's defaults:
|
* data. No layout animation ever starts; the only animation is the
|
||||||
* - defaultDrawNodeLabel: plain wrapped text, centered below the node,
|
* short hover fade, strictly bounded in time — idle CPU is zero (the
|
||||||
* word-wrapped at a max line width — no truncation, no one-line sprawl.
|
* loop schedules no rAF until something actually changes).
|
||||||
* Cluster hulls (vision §1) live on TWO custom canvas layers around
|
|
||||||
* sigma's own stack: a hull layer behind everything (blurred low-opacity
|
|
||||||
* fill + dashed boundary per cluster, stable hue from the cluster id)
|
|
||||||
* and a region-label layer above nodes but below node labels (the label
|
|
||||||
* at the centroid, inverse-gated on THE zoom control — node labels fade
|
|
||||||
* out over the span as cluster labels fade in, so they never coexist).
|
|
||||||
* Geometry is a pure function of member positions at draw time: every
|
|
||||||
* afterRender re-derives hulls through the current camera, so pan/zoom
|
|
||||||
* can never invalidate it.
|
|
||||||
* - defaultDrawNodeHover: NO background box (sigma's default is a
|
|
||||||
* hardcoded white one). Instead the hovered node gets a soft glow
|
|
||||||
* halo in the node's color at a constant radius, flush with the
|
|
||||||
* disc edge, and its name brightens — the "this is clickable,
|
|
||||||
* click for the full fact" affordance. Entering/leaving hover fades
|
|
||||||
* ONE transition factor shared by halo and text alike, so the
|
|
||||||
* halo's opacity and the text's muted→bright cross-fade stay in
|
|
||||||
* sync (no growth, no pulse; interpolating from the current value
|
|
||||||
* means rapid hover toggles glide instead of snapping). Hover
|
|
||||||
* always shows the name, even when labels are zoomed out.
|
|
||||||
* Node hit-testing is the disc PLUS a pad: sigma picks nodes from a
|
|
||||||
* half-resolution framebuffer (no v3 setting enlarges hitboxes), and the
|
|
||||||
* glow halo is bigger than the stock hitbox — touching the halo
|
|
||||||
* de-hovered the node, which oscillated (the hover flicker). A
|
|
||||||
* NodeCircleProgram subclass (PICKING_VERTEX_SHADER below) adds
|
|
||||||
* PICKING_PAD_PX of pickable radius under #ifdef PICKING_MODE only, per
|
|
||||||
* the maintainer-endorsed v3 pattern (#1493); the visible disc stays
|
|
||||||
* pixel-identical, and the whole halo becomes hoverable/clickable.
|
|
||||||
* Labels are zoom-gated GLOBALLY with a fade: all names appear at once
|
|
||||||
* when the camera is at fit zoom or closer (ratio <= 1), then fade out
|
|
||||||
* together across a short zoom span before label rendering switches off —
|
|
||||||
* no per-node size gating, so labels never pop in one by one. (The Obsidian
|
|
||||||
* text-fade feel, applied to the whole graph.)
|
|
||||||
*
|
*
|
||||||
* The search panel (top-right overlay): a search box matching name or
|
* The component's job is everything AROUND drawing: fetch + refetch the
|
||||||
* statement text — the transitional node-state classes are gone (2026-09-08;
|
* snapshot, run the layout, resolve theme colors to hex, filter via the
|
||||||
* nodes render uniformly until the state model ideation lands), so there
|
* search box, own the settings panel / detail card / import button /
|
||||||
* are no state legend rows or toggles anymore. Filtering flows through
|
* pipeline chips, and FEED the renderer (data, settings, visibility).
|
||||||
* sigma's official
|
* The renderer's contract stays presentation-only — which is exactly
|
||||||
* nodeReducer/edgeReducer settings (`hidden`), so rendering, hover,
|
* what the upcoming playground and cluster-first layout work needs: a
|
||||||
* picking, and labels all respect it; edges drop when either endpoint is
|
* renderer that draws whatever data it is given, never questioning it.
|
||||||
* hidden, and an open detail card closes if its node is filtered out.
|
|
||||||
* Clicking a node opens the detail card anchored just below the node's
|
|
||||||
* viewport position (flipping above near the bottom edge); any camera move
|
|
||||||
* (pan/zoom) dismisses it. The `requires` edges render as arrows pointing
|
|
||||||
* prerequisite → dependent.
|
|
||||||
*
|
*
|
||||||
* Rendering settings (2026-09-13): a gear in the search panel reveals four
|
* Visual semantics, all preserved verbatim from the sigma era:
|
||||||
* sliders — node size, edge width, arrowhead length, and THE zoom-level
|
* - Node labels (the short extractor-coined names) wrap under the
|
||||||
* control (label visibility threshold; 1× = fit zoom) — persisted per browser
|
* node; one GLOBAL zoom gate fades them together (no per-node pop).
|
||||||
* (localStorage) and applied by REBUILDING ONLY THE RENDERER: the layout
|
* - Hover: a soft glow halo in the node's own color at a constant
|
||||||
* effect (graph + forceAtlas2) is untouched by settings, and the camera
|
* radius, opacity cross-faded ~180ms with the name brightening on
|
||||||
* position carries across rebuilds, so tweaking a slider never re-fits or
|
* the same factor; the whole halo is hit-testable (picking pad).
|
||||||
* re-lays-out the view. The arrowhead is a program option (head px =
|
* - Cluster hulls (vision §1): soft layered translucent fills + a
|
||||||
* thickness × lengthToThicknessRatio × 2), solved from the two settings.
|
* dashed boundary per cluster, stable hue from the cluster id; the
|
||||||
|
* region label at the centroid fades in over the exact zoom span
|
||||||
|
* node labels fade out (THE single zoom control) — and is
|
||||||
|
* toggleable in the settings panel (hulls always draw).
|
||||||
|
* - The persisted labelZoom setting keeps sigma's camera-ratio
|
||||||
|
* convention (1 = fit zoom, SMALLER = zoomed in): labels until
|
||||||
|
* zoom 0.5x means labels appear once zoomed in past 2x fit.
|
||||||
|
* - Click a node -> the detail card anchored just below it (flipping
|
||||||
|
* above near the bottom edge); any camera move dismisses it.
|
||||||
|
* Clicking the empty stage closes it. Search filtering hides nodes
|
||||||
|
* (and their edges) from rendering, picking, hover, AND labels;
|
||||||
|
* an open card closes if its node is filtered out.
|
||||||
|
*
|
||||||
|
* Rendering settings (2026-09-13): the gear in the search panel reveals
|
||||||
|
* four sliders (node size, edge width, arrowhead length, the zoom
|
||||||
|
* threshold) plus the cluster-labels checkbox, persisted per browser
|
||||||
|
* (localStorage). Applied 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";
|
||||||
@@ -77,41 +61,22 @@ import Link from "next/link";
|
|||||||
import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
||||||
import Graph from "graphology";
|
import Graph from "graphology";
|
||||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
import forceAtlas2 from "graphology-layout-forceatlas2";
|
||||||
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
|
|
||||||
import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core";
|
import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core";
|
||||||
import { clusterHue, convexHull, offsetPolygon, traceRoundedPath, type Pt } from "./cluster-geometry.ts";
|
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
||||||
|
|
||||||
/** The uniform node look (no state classes — WebGL 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. */
|
||||||
const NODE_COLOR_TOKEN = "--primary";
|
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
|
// Three basic controls (user-decided 2026-09-13): node disc size, edge line
|
||||||
// width, and arrowhead length (px). The arrowhead program couples head size
|
// width, and arrowhead length (px). Defaults reflect the pre-panel look
|
||||||
// to thickness via lengthToThicknessRatio — the renderer derives the ratio
|
// (edge 1px + its stock 5px head) except nodeSize, which dropped 8 → 6 for
|
||||||
// from the two settings, so the controls stay independent. Defaults reflect
|
// dense graphs (user: "nodes are quite large"). Persisted per browser in
|
||||||
// the pre-panel look (edge 1px + its stock 5px head) except nodeSize, which
|
// localStorage.
|
||||||
// dropped 8 → 6 for dense graphs (user: "nodes are quite large"). Persisted
|
|
||||||
// per browser in localStorage.
|
|
||||||
|
|
||||||
interface RenderSettings {
|
type RenderSettings = GraphRenderSettings;
|
||||||
nodeSize: number;
|
|
||||||
edgeWidth: number;
|
|
||||||
arrowLength: number;
|
|
||||||
/**
|
|
||||||
* The single zoom-level control (camera ratio at/below which node labels
|
|
||||||
* appear; 1 = fit zoom, higher = labels visible further zoomed out). It
|
|
||||||
* will ALSO drive cluster labels (vision §1, DECIDED 2026-09-13) —
|
|
||||||
* inversely: cluster/domain labels fade in over the same span as node
|
|
||||||
* labels fade out — so the two never coexist. Kept in the persisted
|
|
||||||
* settings for exactly that reuse.
|
|
||||||
*/
|
|
||||||
labelZoom: number;
|
|
||||||
/** Toggle for the cluster region labels (the hulls stay) — they can
|
|
||||||
* crowd the screen at fit zoom with many regions (user, 2026-09-13). */
|
|
||||||
clusterLabels: boolean;
|
|
||||||
}
|
|
||||||
const DEFAULT_RENDER_SETTINGS: RenderSettings = { nodeSize: 6, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true };
|
const DEFAULT_RENDER_SETTINGS: RenderSettings = { nodeSize: 6, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true };
|
||||||
const RENDER_SETTINGS_KEY = "graph-render-settings";
|
const RENDER_SETTINGS_KEY = "graph-render-settings";
|
||||||
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
|
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
|
||||||
@@ -130,7 +95,7 @@ function sanitizeRenderSettings(raw: unknown): RenderSettings {
|
|||||||
/**
|
/**
|
||||||
* 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
|
||||||
* query is empty or matches its name or statement (case-insensitive).
|
* query is empty or matches its name or statement (case-insensitive).
|
||||||
* Exported for tests; the sigma reducers just consume its verdict.
|
* Exported for tests; the visibility feed just consumes its verdict.
|
||||||
*/
|
*/
|
||||||
export function isNodeVisible(node: Pick<GraphNode, "name" | "statement">, query: string): boolean {
|
export function isNodeVisible(node: Pick<GraphNode, "name" | "statement">, query: string): boolean {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -138,223 +103,7 @@ export function isNodeVisible(node: Pick<GraphNode, "name" | "statement">, query
|
|||||||
return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q);
|
return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Label drawing (canvas) ---------------------------------------------
|
// ---- Color helpers (canvas wants hex, not oklch) ---------------------------
|
||||||
//
|
|
||||||
// sigma's defaults draw the label on ONE line to the right of the node with
|
|
||||||
// a white box on hover. We replace both: names wrap under the node at a max
|
|
||||||
// line width, muted at rest; on hover the node pulses a soft glow in its
|
|
||||||
// own state color and the name brightens (no background box).
|
|
||||||
|
|
||||||
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_LINE_HEIGHT = 1.3;
|
|
||||||
/** Zoom span (camera-ratio units) over which labels fade out together. */
|
|
||||||
const LABELS_FADE_SPAN = 0.5;
|
|
||||||
|
|
||||||
// Hover glow: a soft halo ring in the node's own color at a CONSTANT
|
|
||||||
// radius, starting flush at the disc edge (no gap — a gap reads as a
|
|
||||||
// false outline/border between node and glow). Only the halo's OPACITY
|
|
||||||
// animates (hover enter/leave); it never grows and never pulses.
|
|
||||||
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 HOVER_FADE_MS = 180; // hover halo opacity fade length
|
|
||||||
|
|
||||||
// ---- Padded picking (WebGL) ----------------------------------------------
|
|
||||||
//
|
|
||||||
// sigma picks nodes by reading a single pixel from a picking framebuffer
|
|
||||||
// rendered at HALF the CSS resolution: small nodes get tiny, ragged hit
|
|
||||||
// edges, and our glow halo (GLOW_PAD beyond the disc) is NOT pickable, so
|
|
||||||
// moving the cursor from the disc onto its own halo de-hovered it — an
|
|
||||||
// oscillation loop (the hover flicker). sigma v3 has no setting for hit
|
|
||||||
// padding; the maintainer-endorsed pattern (#1493, fixed officially in v4
|
|
||||||
// as `nodePickingPadding`) is a node program whose vertex shader enlarges
|
|
||||||
// the disc ONLY in the picking shader pass.
|
|
||||||
|
|
||||||
/** Extra radius (px) added to each node's PICKABLE disc (picking layer
|
|
||||||
* only — the visible disc is untouched). >= GLOW_PAD so the entire hover
|
|
||||||
* halo is hit-testable. */
|
|
||||||
const PICKING_PAD_PX = 10;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The node-circle vertex shader, verbatim from sigma 3.0.3
|
|
||||||
* (dist/index-fad77a13.esm.js, SHADER_SOURCE$5) with ONE change: under
|
|
||||||
* `#ifdef PICKING_MODE` (sigma compiles this source twice — visible render
|
|
||||||
* vs picking readback — so the visible pass never takes this branch) the
|
|
||||||
* size gains PICKING_PAD_PX of radius, which is the same on-screen unit as
|
|
||||||
* `data.size` (the px radius the canvas drawers use for the halo).
|
|
||||||
*/
|
|
||||||
const PICKING_VERTEX_SHADER = /* glsl */ `
|
|
||||||
attribute vec4 a_id;
|
|
||||||
attribute vec4 a_color;
|
|
||||||
attribute vec2 a_position;
|
|
||||||
attribute float a_size;
|
|
||||||
attribute float a_angle;
|
|
||||||
|
|
||||||
uniform mat3 u_matrix;
|
|
||||||
uniform float u_sizeRatio;
|
|
||||||
uniform float u_correctionRatio;
|
|
||||||
|
|
||||||
varying vec4 v_color;
|
|
||||||
varying vec2 v_diffVector;
|
|
||||||
varying float v_radius;
|
|
||||||
varying float v_border;
|
|
||||||
|
|
||||||
const float bias = 255.0 / 254.0;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
float pickedSize = a_size;
|
|
||||||
#ifdef PICKING_MODE
|
|
||||||
// Picking pass only: enlarge the hitbox by PICKING_PAD_PX of radius.
|
|
||||||
pickedSize += ${PICKING_PAD_PX.toFixed(1)};
|
|
||||||
#endif
|
|
||||||
float size = pickedSize * u_correctionRatio / u_sizeRatio * 4.0;
|
|
||||||
vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle));
|
|
||||||
vec2 position = a_position + diffVector;
|
|
||||||
gl_Position = vec4(
|
|
||||||
(u_matrix * vec3(position, 1)).xy,
|
|
||||||
0,
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
v_diffVector = diffVector;
|
|
||||||
v_radius = size / 2.0;
|
|
||||||
|
|
||||||
#ifdef PICKING_MODE
|
|
||||||
// For picking mode, we use the ID as the color:
|
|
||||||
v_color = a_id;
|
|
||||||
#else
|
|
||||||
// For normal mode, we use the color:
|
|
||||||
v_color = a_color;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
v_color.a *= bias;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
/** "#rrggbb" + alpha → "rgba(...)" (tokenHex only ever emits #rrggbb). */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The wrapped node name (the short label), centered below the node — used by both drawers
|
|
||||||
* with identical geometry so hover only changes the color, nothing jumps. */
|
|
||||||
function drawName(
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
data: { label?: string | null; x: number; y: number; size: number },
|
|
||||||
settings: { labelSize: number; labelWeight: string; labelFont: string },
|
|
||||||
color: string,
|
|
||||||
): void {
|
|
||||||
if (!data.label) return;
|
|
||||||
const size = settings.labelSize;
|
|
||||||
context.save();
|
|
||||||
context.font = `${settings.labelWeight} ${size}px ${settings.labelFont}`;
|
|
||||||
const lines = wrapLabel(context, data.label, LABEL_MAX_WIDTH);
|
|
||||||
context.fillStyle = color;
|
|
||||||
context.textAlign = "center";
|
|
||||||
context.textBaseline = "top";
|
|
||||||
const firstTop = data.y + data.size + LABEL_GAP;
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
context.fillText(lines[i]!, data.x, firstTop + i * size * LABEL_LINE_HEIGHT);
|
|
||||||
}
|
|
||||||
context.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The hovered-state paint at a given transition factor (0 = resting,
|
|
||||||
* 1 = fully hovered): a glow halo in the node's own state color at a
|
|
||||||
* CONSTANT radius (opacity fades in/out; the halo never grows) starting
|
|
||||||
* flush at the disc edge (the evenodd punch-out keeps the disc itself
|
|
||||||
* unpainted). The name is drawn at partial alpha in the bright hover
|
|
||||||
* color — the muted resting label on the layer beneath shows through,
|
|
||||||
* so text and halo cross-fade on the SAME factor and stay in sync. */
|
|
||||||
function paintHover(
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
data: { label?: string | null; x: number; y: number; size: number; color: string },
|
|
||||||
settings: { labelSize: number; labelWeight: string; labelFont: string },
|
|
||||||
fade: number,
|
|
||||||
hoverTextColor: string,
|
|
||||||
): void {
|
|
||||||
context.save();
|
|
||||||
if (fade > 0.02) {
|
|
||||||
const inner = data.size;
|
|
||||||
const outer = data.size + GLOW_PAD;
|
|
||||||
const glow = context.createRadialGradient(data.x, data.y, inner, data.x, data.y, outer);
|
|
||||||
glow.addColorStop(0, rgbaFromHex(data.color, GLOW_ALPHA * fade));
|
|
||||||
glow.addColorStop(1, rgbaFromHex(data.color, 0));
|
|
||||||
context.fillStyle = glow;
|
|
||||||
context.beginPath();
|
|
||||||
context.arc(data.x, data.y, outer, 0, Math.PI * 2);
|
|
||||||
context.arc(data.x, data.y, inner, 0, Math.PI * 2, true);
|
|
||||||
context.fill("evenodd");
|
|
||||||
}
|
|
||||||
// Same fade factor as the halo: text and glow rise together. The
|
|
||||||
// bright name at partial alpha over the muted resting label beneath is
|
|
||||||
// a smooth muted→bright cross-fade rather than an instant color swap.
|
|
||||||
context.globalAlpha = fade;
|
|
||||||
drawName(context, data, settings, hoverTextColor);
|
|
||||||
context.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mutable hover-transition state shared between the drawers and the
|
|
||||||
* effect's enter/leave animation. */
|
|
||||||
interface HoverState {
|
|
||||||
fade: number;
|
|
||||||
/** Set on every sigma-driven hover paint; the leave animation draws
|
|
||||||
* through it directly, since sigma stops calling drawHover once the
|
|
||||||
* mouse has left the node. */
|
|
||||||
repaint: ((fade: number) => void) | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Make the pair of canvas label drawers bound to the theme's colors. */
|
|
||||||
function makeLabelDrawers(labelColor: string, hoverTextColor: string, hover: HoverState) {
|
|
||||||
const drawLabel: NodeLabelDrawingFunction = (context, data, settings) => {
|
|
||||||
drawName(context, data, settings, labelColor);
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawHover: NodeHoverDrawingFunction = (context, data, settings) => {
|
|
||||||
hover.repaint = (fade) => {
|
|
||||||
context.save();
|
|
||||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
||||||
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
|
|
||||||
context.restore();
|
|
||||||
if (fade > 0) paintHover(context, data, settings, fade, hoverTextColor);
|
|
||||||
};
|
|
||||||
paintHover(context, data, settings, hover.fade, hoverTextColor);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { drawLabel, drawHover };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Color helpers (WebGL/canvas want hex, not oklch) --------------------
|
|
||||||
|
|
||||||
/** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */
|
/** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */
|
||||||
function cssColorToHex(css: string): string {
|
function cssColorToHex(css: string): string {
|
||||||
@@ -383,7 +132,8 @@ interface Selection {
|
|||||||
/** The sources that touched this node: chat ids (deep-linkable sessions)
|
/** The sources that touched this node: chat ids (deep-linkable sessions)
|
||||||
* and notes-source ids ("src_…" — imported files, static entries). */
|
* and notes-source ids ("src_…" — imported files, static entries). */
|
||||||
sourceIds: string[];
|
sourceIds: string[];
|
||||||
/** Node position in container pixels (from graphToViewport at click time). */
|
/** Node position in container pixels (at click time; camera moves
|
||||||
|
* dismiss the card, so it never needs re-anchoring). */
|
||||||
anchor: { x: number; y: number };
|
anchor: { x: number; y: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,18 +175,17 @@ function SettingSlider({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── cluster hulls (vision §1, DECIDED 2026-09-13) ────────────────────────────
|
|
||||||
// Boundary geometry is a PURE FUNCTION OF POSITIONS AT DRAW TIME: every
|
|
||||||
// redraw re-derives the hull from current member positions mapped through
|
|
||||||
// the camera, so pan/zoom/relayout can never invalidate it. The math
|
|
||||||
// lives in ./cluster-geometry.ts (unit-tested — see that file's header
|
|
||||||
// for the inward-normal bug this extraction is named after).
|
|
||||||
|
|
||||||
export function GraphView() {
|
export function GraphView() {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
||||||
const [selected, setSelected] = useState<Selection | null>(null);
|
const [selected, setSelected] = useState<Selection | null>(null);
|
||||||
|
// The latest snapshot for the renderer's click callbacks (they are
|
||||||
|
// constructed once, so they read through this ref, never a closure).
|
||||||
|
const snapshotRef = useRef<GraphSnapshot | null>(null);
|
||||||
|
snapshotRef.current = snapshot;
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -460,20 +209,14 @@ export function GraphView() {
|
|||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
// The sigma instance and its graphology graph live inside the mount
|
// The renderer instance lives across snapshot refetches and settings
|
||||||
// effects (data: graph+layout; render: renderer); refs expose them to
|
// changes (its camera is never reset by either); `posRef` carries the
|
||||||
// the filters effect, and `rendererEpoch` re-triggers it when a new
|
// frozen layout out of the layout effect.
|
||||||
// renderer is constructed. `cameraRef` carries the camera position
|
const rendererRef = useRef<GraphCanvasRenderer | null>(null);
|
||||||
// across renderer rebuilds (settings changes must never re-fit the
|
const posRef = useRef<Map<string, { x: number; y: number }> | null>(null);
|
||||||
// view), and `hoverStateRef` lets the layout effect's hover repainter
|
|
||||||
// reach the CURRENT renderer's drawers.
|
|
||||||
const rendererRef = useRef<import("sigma").default | null>(null);
|
|
||||||
const graphRef = useRef<Graph | null>(null);
|
|
||||||
const [rendererEpoch, setRendererEpoch] = useState(0);
|
|
||||||
const cameraRef = useRef<{ x: number; y: number; ratio: number; angle: number } | null>(null);
|
|
||||||
|
|
||||||
// The id set that survives the current search — consumed by the sigma
|
// The id set that survives the current search — consumed by the
|
||||||
// reducers and the "no matches" hint.
|
// visibility feed and the "no matches" hint.
|
||||||
const visibleIds = useMemo(() => {
|
const visibleIds = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
if (snapshot)
|
if (snapshot)
|
||||||
@@ -545,9 +288,8 @@ export function GraphView() {
|
|||||||
|
|
||||||
// Build the graphology graph and lay it out ONCE per snapshot: seeded
|
// Build the graphology graph and lay it out ONCE per snapshot: seeded
|
||||||
// start positions, one synchronous forceAtlas2 pass, then freeze. The
|
// start positions, one synchronous forceAtlas2 pass, then freeze. The
|
||||||
// renderer is a SEPARATE effect — render settings change only the
|
// renderer only ever receives the frozen positions as data — layout is
|
||||||
// renderer; the layout (positions live in this graph instance) is
|
// never recomputed for a settings tweak or a refetch of identical data.
|
||||||
// never recomputed for a settings tweak.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || snapshot.nodes.length === 0) return;
|
if (!snapshot || snapshot.nodes.length === 0) return;
|
||||||
const graph = new Graph();
|
const graph = new Graph();
|
||||||
@@ -556,9 +298,6 @@ export function GraphView() {
|
|||||||
// frozen layout stable for a given node set across remounts.
|
// frozen layout stable for a given node set across remounts.
|
||||||
const rand = seededRandom(node.id);
|
const rand = seededRandom(node.id);
|
||||||
graph.addNode(node.id, {
|
graph.addNode(node.id, {
|
||||||
label: node.name,
|
|
||||||
size: DEFAULT_RENDER_SETTINGS.nodeSize,
|
|
||||||
color: tokenHex(NODE_COLOR_TOKEN),
|
|
||||||
x: (rand() - 0.5) * 100,
|
x: (rand() - 0.5) * 100,
|
||||||
y: (rand() - 0.5) * 100,
|
y: (rand() - 0.5) * 100,
|
||||||
});
|
});
|
||||||
@@ -567,11 +306,7 @@ export function GraphView() {
|
|||||||
// Snapshot edges are already merged by (from,to,type); the guard
|
// Snapshot edges are already merged by (from,to,type); the guard
|
||||||
// is just belt-and-braces for the simple graphology graph.
|
// is just belt-and-braces for the simple graphology graph.
|
||||||
if (!graph.hasEdge(edge.from, edge.to)) {
|
if (!graph.hasEdge(edge.from, edge.to)) {
|
||||||
graph.addEdge(edge.from, edge.to, {
|
graph.addEdge(edge.from, edge.to);
|
||||||
type: "arrow",
|
|
||||||
color: tokenHex("--ring"),
|
|
||||||
size: DEFAULT_RENDER_SETTINGS.edgeWidth,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,300 +319,85 @@ export function GraphView() {
|
|||||||
};
|
};
|
||||||
forceAtlas2.assign(graph, { iterations: 100, settings: layoutSettings });
|
forceAtlas2.assign(graph, { iterations: 100, settings: layoutSettings });
|
||||||
|
|
||||||
// The render effect (declared next) builds its Sigma over THIS
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
// instance in the same commit; on snapshot change it re-runs too
|
graph.forEachNode((id, attrs) => positions.set(id, { x: attrs.x, y: attrs.y }));
|
||||||
// (snapshot is in its deps) and picks the new graph up.
|
posRef.current = positions;
|
||||||
graphRef.current = graph;
|
|
||||||
return () => {
|
return () => {
|
||||||
graphRef.current = null;
|
posRef.current = null;
|
||||||
};
|
};
|
||||||
}, [snapshot]);
|
}, [snapshot]);
|
||||||
|
|
||||||
// Mount sigma over the laid-out graph. Re-runs when the snapshot changes
|
// Construct the renderer once the canvas exists; destroy when it goes
|
||||||
// (new graph) or the (deferred) render settings change — nothing else.
|
// away (the empty-graph placeholder replaces it). The callbacks are
|
||||||
// Sizes are stamped onto the graph here, and the arrowhead geometry is
|
// created once and read through refs, so they never go stale.
|
||||||
// a PROGRAM option (head px = thickness × lengthToThicknessRatio × 2),
|
const isEmpty = snapshot === null || snapshot.nodes.length === 0;
|
||||||
// so both flow through a renderer rebuild while the layout and the
|
|
||||||
// camera position (carried via cameraRef) stay put.
|
|
||||||
const deferredSettings = useDeferredValue(settings);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || snapshot.nodes.length === 0) return;
|
if (isEmpty) return;
|
||||||
const container = containerRef.current;
|
const canvas = canvasRef.current;
|
||||||
const graph = graphRef.current;
|
if (!canvas) return;
|
||||||
if (!container || !graph) return;
|
const renderer = new GraphCanvasRenderer(canvas, {
|
||||||
|
onSelect: (nodeId, pos) => {
|
||||||
const s = deferredSettings;
|
const snap = snapshotRef.current;
|
||||||
// Stamp the size-controlled attributes (idempotent; the old renderer
|
const node = snap?.nodes.find((n) => n.id === nodeId);
|
||||||
// died in cleanup — this graph is renderer-less right now).
|
if (!snap || !node) return;
|
||||||
graph.updateEachNodeAttributes((_node, attrs) => ({ ...attrs, size: s.nodeSize }));
|
|
||||||
graph.updateEachEdgeAttributes((_edge, attrs) => ({ ...attrs, size: s.edgeWidth }));
|
|
||||||
|
|
||||||
const labelColor = tokenHex("--muted-foreground");
|
|
||||||
const hoverTextColor = tokenHex("--foreground");
|
|
||||||
// Mutable hover-transition state: 0 = resting, 1 = fully hovered.
|
|
||||||
const hover: HoverState = { fade: 0, repaint: null };
|
|
||||||
const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor, hover);
|
|
||||||
|
|
||||||
let killed = false;
|
|
||||||
let renderer: import("sigma").default | null = null;
|
|
||||||
// The custom cluster layers (hull + region label canvases) — created
|
|
||||||
// inside the dynamic-import callback, removed in this effect's cleanup.
|
|
||||||
let clusterCanvas: HTMLCanvasElement | null = null;
|
|
||||||
let clusterLabelCanvas: HTMLCanvasElement | null = null;
|
|
||||||
// rAF id of the hover fade transition — the ONLY animation (see the
|
|
||||||
// file header). Lives out here so the effect cleanup can cancel it.
|
|
||||||
let hoverAnimId: number | null = null;
|
|
||||||
// Both sigma and its rendering package touch WebGL globals at module
|
|
||||||
// scope, so both load dynamically (SSR-safe; the effect is browser-only).
|
|
||||||
void Promise.all([import("sigma"), import("sigma/rendering")]).then(
|
|
||||||
([{ default: Sigma }, { NodeCircleProgram, createEdgeArrowProgram }]) => {
|
|
||||||
if (killed) return;
|
|
||||||
// The arrowhead program with our ratios: solve
|
|
||||||
// lengthToThicknessRatio for the desired head length
|
|
||||||
// (head px = thickness × ratio × 2), keeping the stock head
|
|
||||||
// SHAPE (wideness = 0.8 × length, as the 2.5/2 defaults).
|
|
||||||
const thicknessRatio = s.arrowLength / (2 * s.edgeWidth);
|
|
||||||
const arrowProgram = createEdgeArrowProgram({
|
|
||||||
lengthToThicknessRatio: thicknessRatio,
|
|
||||||
widenessToThicknessRatio: thicknessRatio * 0.8,
|
|
||||||
});
|
|
||||||
// Padded picking (see PICKING_VERTEX_SHADER): the visible disc is
|
|
||||||
// pixel-identical to the stock circle program; the PICKABLE disc
|
|
||||||
// gains PICKING_PAD_PX of radius, so the whole hover halo is
|
|
||||||
// hit-testable and small nodes are comfortable to hover/click.
|
|
||||||
// (Maintainer-endorsed pattern for sigma v3; v4 ships this as
|
|
||||||
// the `nodePickingPadding` setting.)
|
|
||||||
const PaddedNodeCircleProgram = class extends NodeCircleProgram {
|
|
||||||
getDefinition() {
|
|
||||||
return {
|
|
||||||
...super.getDefinition(),
|
|
||||||
VERTEX_SHADER_SOURCE: PICKING_VERTEX_SHADER,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
renderer = new Sigma(graph, container, {
|
|
||||||
nodeProgramClasses: { circle: PaddedNodeCircleProgram },
|
|
||||||
edgeProgramClasses: { arrow: arrowProgram },
|
|
||||||
defaultEdgeType: "arrow",
|
|
||||||
// All labels appear/disappear together: no per-node size gating
|
|
||||||
// (threshold 0) and NO sigma label-grid declutter (density →
|
|
||||||
// ∞) — clustered nodes otherwise lose their cells' label
|
|
||||||
// quota and pop in late, un-faded, mid-zoom. The camera-ratio
|
|
||||||
// gate below is the ONLY label culling: every label appears at
|
|
||||||
// the same zoom with the same fade.
|
|
||||||
labelRenderedSizeThreshold: 0,
|
|
||||||
labelDensity: Number.MAX_SAFE_INTEGER,
|
|
||||||
// Match the app typography (Geist via the body's stack).
|
|
||||||
labelFont: getComputedStyle(document.body).fontFamily,
|
|
||||||
labelWeight: "500",
|
|
||||||
labelSize: 12,
|
|
||||||
labelColor: { color: labelColor },
|
|
||||||
renderEdgeLabels: false,
|
|
||||||
defaultDrawNodeLabel: drawLabel,
|
|
||||||
defaultDrawNodeHover: drawHover,
|
|
||||||
});
|
|
||||||
graphRef.current = graph;
|
|
||||||
rendererRef.current = renderer;
|
|
||||||
setRendererEpoch((e) => e + 1);
|
|
||||||
// A renderer rebuild (settings tweak) must never re-fit the
|
|
||||||
// view: restore the carried camera position, if any.
|
|
||||||
if (cameraRef.current) renderer.getCamera().setState(cameraRef.current);
|
|
||||||
|
|
||||||
// ── Cluster hulls (vision §1): two custom canvas layers around
|
|
||||||
// sigma's own stack (DOM order: edges, nodes, [labels], edgeLabels,
|
|
||||||
// labels, ...). The HULL layer sits behind everything; the region
|
|
||||||
// LABEL layer sits above nodes but below node labels — the two
|
|
||||||
// label systems are inverse-gated on the same zoom control, so
|
|
||||||
// they never coexist (see syncRenderLabels below).
|
|
||||||
const sigmaCanvases = renderer.getCanvases();
|
|
||||||
// Local consts so the draw closure below stays narrowed.
|
|
||||||
const hullCanvas = (clusterCanvas = document.createElement("canvas"));
|
|
||||||
hullCanvas.style.position = "absolute";
|
|
||||||
container.insertBefore(hullCanvas, container.firstChild ?? null);
|
|
||||||
const regionCanvas = (clusterLabelCanvas = document.createElement("canvas"));
|
|
||||||
regionCanvas.style.position = "absolute";
|
|
||||||
// Actual DOM order (sigma v3): edges, edgeLabels, nodes, labels, …
|
|
||||||
// so "before labels" = above nodes, below node labels — the two
|
|
||||||
// label systems are inverse-gated and never coexist anyway.
|
|
||||||
sigmaCanvases.labels?.parentNode?.insertBefore(regionCanvas, sigmaCanvases.labels);
|
|
||||||
// All hull geometry is derived per draw — positions through the
|
|
||||||
// CURRENT camera — so any pan/zoom/render stays correct.
|
|
||||||
const drawClusters = () => {
|
|
||||||
const r = renderer;
|
|
||||||
if (!r || snapshot.clusters.length === 0) return;
|
|
||||||
const ref = sigmaCanvases.nodes;
|
|
||||||
if (!ref) return;
|
|
||||||
const cssW = ref.clientWidth;
|
|
||||||
const cssH = ref.clientHeight;
|
|
||||||
if (cssW === 0 || cssH === 0) return;
|
|
||||||
const dpr = ref.width / cssW;
|
|
||||||
const hullCtx = hullCanvas.getContext("2d");
|
|
||||||
const labelCtx = regionCanvas.getContext("2d");
|
|
||||||
if (!hullCtx || !labelCtx) return;
|
|
||||||
for (const canvas of [hullCanvas, regionCanvas]) {
|
|
||||||
if (canvas.width !== ref.width || canvas.height !== ref.height) {
|
|
||||||
canvas.width = ref.width;
|
|
||||||
canvas.height = ref.height;
|
|
||||||
}
|
|
||||||
canvas.style.width = ref.style.width || `${cssW}px`;
|
|
||||||
canvas.style.height = ref.style.height || `${cssH}px`;
|
|
||||||
}
|
|
||||||
hullCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
labelCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
hullCtx.clearRect(0, 0, cssW, cssH);
|
|
||||||
labelCtx.clearRect(0, 0, cssW, cssH);
|
|
||||||
labelCtx.textAlign = "center";
|
|
||||||
labelCtx.textBaseline = "middle";
|
|
||||||
labelCtx.font = `500 18px ${getComputedStyle(document.body).fontFamily}`;
|
|
||||||
for (const cluster of snapshot.clusters) {
|
|
||||||
const pts: Pt[] = [];
|
|
||||||
for (const id of cluster.members) {
|
|
||||||
if (!graph.hasNode(id)) continue;
|
|
||||||
const a = graph.getNodeAttributes(id);
|
|
||||||
const vp = r.graphToViewport({ x: a.x ?? 0, y: a.y ?? 0 });
|
|
||||||
if (vp) pts.push(vp);
|
|
||||||
}
|
|
||||||
const hull = convexHull(pts);
|
|
||||||
if (hull.length < 3) continue;
|
|
||||||
const hue = clusterHue(cluster.id);
|
|
||||||
// Soft translucent tint WITHOUT ctx.filter: a canvas blur
|
|
||||||
// filter rasterizes on the GPU per call — hooked to every
|
|
||||||
// render that was ~32 blurred fills per frame (sustained
|
|
||||||
// ~100% GPU rasterization + audible coil whine; reported
|
|
||||||
// 2026-09-13). Layered low-alpha fills approximate the
|
|
||||||
// same soft edge for a fraction of the cost.
|
|
||||||
for (const [pad, alpha] of [[24, 0.025], [19, 0.04], [14, 0.06]] as const) {
|
|
||||||
hullCtx.beginPath();
|
|
||||||
traceRoundedPath(hullCtx, offsetPolygon(hull, pad), 9);
|
|
||||||
hullCtx.fillStyle = `hsla(${hue}, 65%, 60%, ${alpha})`;
|
|
||||||
hullCtx.fill();
|
|
||||||
}
|
|
||||||
// Dashed boundary on the innermost offset.
|
|
||||||
hullCtx.beginPath();
|
|
||||||
traceRoundedPath(hullCtx, offsetPolygon(hull, 14), 9);
|
|
||||||
hullCtx.setLineDash([7, 7]);
|
|
||||||
hullCtx.strokeStyle = `hsla(${hue}, 65%, 62%, 0.5)`;
|
|
||||||
hullCtx.lineWidth = 1.5;
|
|
||||||
hullCtx.stroke();
|
|
||||||
hullCtx.setLineDash([]);
|
|
||||||
// The region label at the centroid (inverse-gated layer) —
|
|
||||||
// skippable entirely via the settings toggle.
|
|
||||||
if (!settings.clusterLabels || !cluster.label) continue;
|
|
||||||
const centroid = hull.reduce((acc, p) => ({ x: acc.x + p.x / hull.length, y: acc.y + p.y / hull.length }), { x: 0, y: 0 });
|
|
||||||
labelCtx.fillStyle = `hsla(${hue}, 55%, 78%, 0.85)`;
|
|
||||||
labelCtx.fillText(cluster.label, centroid.x, centroid.y);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Redraw ONLY when the view actually moves: camera updates and
|
|
||||||
// resizes. NOT on sigma's afterRender — hover animations re-render
|
|
||||||
// sigma many frames per interaction, and hulls don't care.
|
|
||||||
renderer.on("resize", drawClusters);
|
|
||||||
|
|
||||||
renderer.on("clickNode", ({ node }) => {
|
|
||||||
const graphNode = snapshot.nodes.find((n) => n.id === node);
|
|
||||||
if (!graphNode) return;
|
|
||||||
const pos = graph.getNodeAttributes(node);
|
|
||||||
const anchor = renderer?.graphToViewport({ x: pos.x ?? 0, y: pos.y ?? 0 });
|
|
||||||
setSelected({
|
setSelected({
|
||||||
node: graphNode,
|
node,
|
||||||
sourceIds: snapshot.nodeSessions[node] ?? [],
|
sourceIds: snap.nodeSessions[node.id] ?? [],
|
||||||
anchor: anchor ?? { x: 0, y: 0 },
|
anchor: pos,
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
onStageClick: () => setSelected(null),
|
||||||
|
// Any camera move detaches the card from its node — close it.
|
||||||
|
onCameraMove: () => setSelected(null),
|
||||||
});
|
});
|
||||||
// Clicking the empty stage closes the node card.
|
rendererRef.current = renderer;
|
||||||
renderer.on("clickStage", () => setSelected(null));
|
|
||||||
// One global label gate with a fade: names are fully visible at fit
|
|
||||||
// zoom or closer, then fade out together across LABELS_FADE_SPAN of
|
|
||||||
// zoom (the labels canvas layer's opacity), and only once fully
|
|
||||||
// faded does label rendering switch off. Any pan/zoom also detaches
|
|
||||||
// the card from its node — close it.
|
|
||||||
const labelsCanvas = renderer.getCanvases().labels;
|
|
||||||
let labelsRendering: boolean | null = null;
|
|
||||||
const syncRenderLabels = (ratio: number) => {
|
|
||||||
// 0 at the labelZoom threshold → 1 at the end of the fade span
|
|
||||||
const fade = (ratio - s.labelZoom) / LABELS_FADE_SPAN;
|
|
||||||
const alpha = Math.min(Math.max(1 - fade, 0), 1);
|
|
||||||
const shouldRender = alpha > 0;
|
|
||||||
if (shouldRender !== labelsRendering) {
|
|
||||||
labelsRendering = shouldRender;
|
|
||||||
renderer?.setSetting("renderLabels", shouldRender);
|
|
||||||
}
|
|
||||||
if (labelsCanvas) labelsCanvas.style.opacity = alpha.toFixed(3);
|
|
||||||
// The inverse gate (vision §1): cluster labels fade in over
|
|
||||||
// the exact span node labels fade out — THE one zoom control.
|
|
||||||
regionCanvas.style.opacity = (1 - alpha).toFixed(3);
|
|
||||||
};
|
|
||||||
drawClusters();
|
|
||||||
syncRenderLabels(renderer.getCamera().getState().ratio);
|
|
||||||
renderer.getCamera().on("updated", (state) => {
|
|
||||||
syncRenderLabels(state.ratio);
|
|
||||||
drawClusters();
|
|
||||||
setSelected(null);
|
|
||||||
});
|
|
||||||
// The hover fade: entering schedules renders (sigma re-invokes
|
|
||||||
// drawHover with the new fade); leaving draws through the captured
|
|
||||||
// repainter, since sigma stops calling drawHover once unhovered.
|
|
||||||
// Both directions are bounded (~HOVER_FADE_MS) and cancel each
|
|
||||||
// other, so idle CPU returns to zero after every transition.
|
|
||||||
const animateHoverTo = (target: number) => {
|
|
||||||
if (hoverAnimId !== null) {
|
|
||||||
cancelAnimationFrame(hoverAnimId);
|
|
||||||
hoverAnimId = null;
|
|
||||||
}
|
|
||||||
const from = hover.fade;
|
|
||||||
if (from === target) {
|
|
||||||
if (target === 0) hover.repaint?.(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const start = performance.now();
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const t = Math.min((now - start) / HOVER_FADE_MS, 1);
|
|
||||||
hover.fade = from + (target - from) * t;
|
|
||||||
if (target === 0) hover.repaint?.(hover.fade);
|
|
||||||
else renderer?.scheduleRefresh();
|
|
||||||
hoverAnimId = t < 1 ? requestAnimationFrame(tick) : null;
|
|
||||||
};
|
|
||||||
hoverAnimId = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
renderer.on("enterNode", () => animateHoverTo(1));
|
|
||||||
renderer.on("leaveNode", () => animateHoverTo(0));
|
|
||||||
renderer.on("leaveStage", () => animateHoverTo(0));
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
killed = true;
|
|
||||||
if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId);
|
|
||||||
// Carry the camera across rebuilds — a settings tweak keeps the
|
|
||||||
// user's pan/zoom (and the detail-card anchors stay valid).
|
|
||||||
if (renderer) cameraRef.current = renderer.getCamera().getState();
|
|
||||||
rendererRef.current = null;
|
rendererRef.current = null;
|
||||||
renderer?.kill();
|
renderer.destroy();
|
||||||
// The custom cluster layers are ours — sigma's kill() removes only
|
|
||||||
// its own canvases.
|
|
||||||
clusterCanvas?.remove();
|
|
||||||
clusterLabelCanvas?.remove();
|
|
||||||
};
|
};
|
||||||
}, [snapshot, deferredSettings]);
|
}, [isEmpty]);
|
||||||
|
|
||||||
// Filtering through sigma's official reducers: hidden nodes drop out of
|
// Feed #1 — data: positions + edges + clusters + resolved colors. Runs
|
||||||
// rendering, picking, hover, AND labels; edges drop when either endpoint
|
// after the layout effect (same commit, declaration order) and after
|
||||||
// is hidden. Re-runs whenever filters change or a renderer is (re)built
|
// the construct effect, so the renderer exists and positions are fresh.
|
||||||
// (epoch), so the reducers always close over current values.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const renderer = rendererRef.current;
|
const renderer = rendererRef.current;
|
||||||
const graph = graphRef.current;
|
const positions = posRef.current;
|
||||||
if (!renderer || !graph) return;
|
if (!renderer || !snapshot || snapshot.nodes.length === 0 || !positions) return;
|
||||||
renderer.setSetting("nodeReducer", (key, attrs) =>
|
renderer.setData({
|
||||||
visibleIds.has(key) ? attrs : { ...attrs, hidden: true },
|
nodes: snapshot.nodes.map((n) => ({
|
||||||
);
|
id: n.id,
|
||||||
renderer.setSetting("edgeReducer", (key, attrs) => {
|
x: positions.get(n.id)?.x ?? 0,
|
||||||
const source = graph.source(key);
|
y: positions.get(n.id)?.y ?? 0,
|
||||||
const target = graph.target(key);
|
label: n.name,
|
||||||
return visibleIds.has(source) && visibleIds.has(target) ? attrs : { ...attrs, hidden: true };
|
})),
|
||||||
|
edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })),
|
||||||
|
clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })),
|
||||||
|
colors: {
|
||||||
|
node: tokenHex(NODE_COLOR_TOKEN),
|
||||||
|
edge: tokenHex("--ring"),
|
||||||
|
label: tokenHex("--muted-foreground"),
|
||||||
|
hoverText: tokenHex("--foreground"),
|
||||||
|
},
|
||||||
|
// Match the app typography (Geist via the body's stack).
|
||||||
|
fontFamily: getComputedStyle(document.body).fontFamily,
|
||||||
});
|
});
|
||||||
|
}, [snapshot, isEmpty]);
|
||||||
|
|
||||||
|
// Feed #2 — settings: just marks the frame dirty. No rebuild, no
|
||||||
|
// re-layout, no camera touch (the whole point of the new renderer).
|
||||||
|
const deferredSettings = useDeferredValue(settings);
|
||||||
|
useEffect(() => {
|
||||||
|
rendererRef.current?.setSettings(deferredSettings);
|
||||||
|
}, [deferredSettings, isEmpty]);
|
||||||
|
|
||||||
|
// Feed #3 — visibility: hidden nodes drop out of rendering, picking,
|
||||||
|
// hover, AND labels; edges drop when either endpoint is hidden.
|
||||||
|
useEffect(() => {
|
||||||
|
rendererRef.current?.setVisible(query.trim() ? visibleIds : null);
|
||||||
// The open detail card cannot outlive its node's visibility.
|
// The open detail card cannot outlive its node's visibility.
|
||||||
setSelected((sel) => (sel && visibleIds.has(sel.node.id) ? sel : null));
|
setSelected((sel) => (sel && visibleIds.has(sel.node.id) ? sel : null));
|
||||||
}, [rendererEpoch, visibleIds]);
|
}, [visibleIds, query]);
|
||||||
|
|
||||||
// Position the detail card: below the clicked node, flipped above near
|
// Position the detail card: below the clicked node, flipped above near
|
||||||
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
||||||
@@ -903,8 +423,6 @@ export function GraphView() {
|
|||||||
card.style.visibility = "visible";
|
card.style.visibility = "visible";
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
const isEmpty = snapshot === null || snapshot.nodes.length === 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
@@ -920,7 +438,11 @@ export function GraphView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div ref={containerRef} className="h-full w-full" />
|
<div ref={containerRef} className="h-full w-full">
|
||||||
|
{/* THE single canvas: every layer (hulls, edges, nodes, both
|
||||||
|
label systems, hover) draws into it, once per frame. */}
|
||||||
|
<canvas ref={canvasRef} className="block h-full w-full touch-none" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Legend + filters: search box and per-state toggle rows. */}
|
{/* Legend + filters: search box and per-state toggle rows. */}
|
||||||
@@ -1076,7 +598,7 @@ export function GraphView() {
|
|||||||
<div key={sourceId} className="flex items-center gap-1.5">
|
<div key={sourceId} className="flex items-center gap-1.5">
|
||||||
<Link
|
<Link
|
||||||
href={`/chat/${sourceId}`}
|
href={`/chat/${sourceId}`}
|
||||||
className="min-w-0 flex-1 truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent"
|
className="min-w-0 flex-1 truncate rounded-md px-1 py-1 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
{snapshot?.sessions[sourceId] ?? sourceId}
|
{snapshot?.sessions[sourceId] ?? sourceId}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
Generated
+2
-11
@@ -24,6 +24,8 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"graphology": "^0.26.0",
|
"graphology": "^0.26.0",
|
||||||
"graphology-communities-louvain": "^2.0.2",
|
"graphology-communities-louvain": "^2.0.2",
|
||||||
@@ -43,7 +45,6 @@
|
|||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-jsx-parser": "^2.4.1",
|
"react-jsx-parser": "^2.4.1",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"sigma": "^3.0.3",
|
|
||||||
"streamdown": "^2.5.0",
|
"streamdown": "^2.5.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tokenlens": "^1.3.1",
|
"tokenlens": "^1.3.1",
|
||||||
@@ -13342,16 +13343,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sigma": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"events": "^3.3.0",
|
|
||||||
"graphology-utils": "^2.5.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
+2
-1
@@ -29,6 +29,8 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"graphology": "^0.26.0",
|
"graphology": "^0.26.0",
|
||||||
"graphology-communities-louvain": "^2.0.2",
|
"graphology-communities-louvain": "^2.0.2",
|
||||||
@@ -48,7 +50,6 @@
|
|||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-jsx-parser": "^2.4.1",
|
"react-jsx-parser": "^2.4.1",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"sigma": "^3.0.3",
|
|
||||||
"streamdown": "^2.5.0",
|
"streamdown": "^2.5.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tokenlens": "^1.3.1",
|
"tokenlens": "^1.3.1",
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Pure-helper tests for the hand-rolled canvas renderer
|
||||||
|
* (components/graph-canvas.ts). The renderer class itself is DOM-bound
|
||||||
|
* (canvas, rAF, ResizeObserver) and is verified live in the browser; the
|
||||||
|
* math it runs on every frame must be exact, so it is pinned here.
|
||||||
|
*
|
||||||
|
* The camera-ratio convention is the load-bearing contract with the
|
||||||
|
* persisted settings: sigma's ratio (1 = fit, SMALLER = zoomed in) is kept
|
||||||
|
* verbatim so stored labelZoom values survive the renderer swap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it as test } from "node:test";
|
||||||
|
import {
|
||||||
|
arrowGeometry,
|
||||||
|
fitTransform,
|
||||||
|
labelAlpha,
|
||||||
|
pickNearest,
|
||||||
|
zoomRatioOf,
|
||||||
|
LABELS_FADE_SPAN,
|
||||||
|
} from "../components/graph-canvas.ts";
|
||||||
|
|
||||||
|
describe("zoomRatioOf (the sigma-compatible camera ratio)", () => {
|
||||||
|
test("1 at fit zoom, smaller when zoomed in, larger when zoomed out", () => {
|
||||||
|
assert.equal(zoomRatioOf(2, 2), 1); // at fit
|
||||||
|
assert.equal(zoomRatioOf(2, 4), 0.5); // zoomed in 2x -> ratio halves
|
||||||
|
assert.equal(zoomRatioOf(2, 1), 2); // zoomed out 2x -> ratio doubles
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("labelAlpha (the single zoom gate)", () => {
|
||||||
|
test("fully visible at/below the threshold, faded out after the span", () => {
|
||||||
|
assert.equal(labelAlpha(0.5, 0.5), 1); // at the threshold
|
||||||
|
assert.equal(labelAlpha(0.25, 0.5), 1); // zoomed further in: stays 1
|
||||||
|
assert.equal(labelAlpha(0.75, 0.5), 0.5); // mid-fade
|
||||||
|
assert.equal(labelAlpha(1.0, 0.5), 0); // one span out: gone
|
||||||
|
assert.equal(labelAlpha(2.0, 0.5), 0); // far out: clamped
|
||||||
|
});
|
||||||
|
|
||||||
|
test("node labels and cluster labels cross-fade over the exact same span", () => {
|
||||||
|
// The renderer draws cluster labels at 1 - labelAlpha; the two must
|
||||||
|
// always sum to 1 within the fade and never coexist at full opacity.
|
||||||
|
for (const ratio of [0, 0.25, 0.5, 0.6, 0.75, 0.9, 1, 2, 4]) {
|
||||||
|
const node = labelAlpha(ratio, 0.5);
|
||||||
|
const cluster = 1 - node;
|
||||||
|
assert.ok(node + cluster > 0.999 && node + cluster < 1.001, `sum at ratio ${ratio}`);
|
||||||
|
assert.ok(node <= 1 && cluster <= 1, `clamped at ratio ${ratio}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the span constant is unchanged from the sigma era (stored settings depend on it)", () => {
|
||||||
|
assert.equal(LABELS_FADE_SPAN, 0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fitTransform", () => {
|
||||||
|
test("centers the bbox and respects padding", () => {
|
||||||
|
// A 100x50 graph in a 500x300 viewport, 40px padding:
|
||||||
|
// available 420x220 -> k = min(4.2, 4.4) = 4.2
|
||||||
|
const t = fitTransform({ minX: -50, maxX: 50, minY: -25, maxY: 25 }, 500, 300, 40);
|
||||||
|
assert.ok(Math.abs(t.k - 4.2) < 1e-9, `k=${t.k}`);
|
||||||
|
assert.ok(Math.abs(t.x - 250) < 1e-9, `x=${t.x}`); // centered
|
||||||
|
assert.ok(Math.abs(t.y - 150) < 1e-9, `y=${t.y}`);
|
||||||
|
// World center maps to viewport center:
|
||||||
|
assert.ok(Math.abs(0 * t.k + t.x - 250) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never returns a non-positive scale for degenerate bboxes", () => {
|
||||||
|
const t = fitTransform({ minX: 5, maxX: 5, minY: 5, maxY: 5 }, 100, 100, 10);
|
||||||
|
assert.ok(t.k > 0 && Number.isFinite(t.k));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("arrowGeometry", () => {
|
||||||
|
test("the tip lands exactly on the target disc edge", () => {
|
||||||
|
const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5);
|
||||||
|
assert.ok(g, "non-degenerate edge");
|
||||||
|
assert.ok(Math.abs(g!.tip.x - 94) < 1e-9 && Math.abs(g!.tip.y) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the shaft starts outside the source disc", () => {
|
||||||
|
const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5)!;
|
||||||
|
assert.ok(Math.abs(g.shaftFrom.x - 6) < 1e-9 && Math.abs(g.shaftFrom.y) < 1e-9);
|
||||||
|
// Shaft ends at the head base; head is drawn from base to tip.
|
||||||
|
assert.ok(Math.abs(g.shaftTo.x - 89) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the head triangle is symmetric around the shaft axis (0.8x wideness)", () => {
|
||||||
|
const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5)!;
|
||||||
|
// halfW = 0.4 x headLength = 2; both base corners share x, mirrored y.
|
||||||
|
assert.ok(Math.abs(Math.abs(g.left.y) - 2) < 1e-9 && Math.abs(Math.abs(g.right.y) - 2) < 1e-9);
|
||||||
|
assert.ok(Math.abs(g.left.y + g.right.y) < 1e-9); // mirror
|
||||||
|
assert.ok(Math.abs(g.left.x - g.right.x) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works at arbitrary angles (diagonal stays normalized)", () => {
|
||||||
|
const a = { x: 0, y: 0 };
|
||||||
|
const b = { x: 60, y: 80 }; // len 100, dir (0.6, 0.8)
|
||||||
|
const g = arrowGeometry(a, b, 10, 10, 8)!;
|
||||||
|
assert.ok(Math.abs(g.tip.x - (60 - 0.6 * 10)) < 1e-9 && Math.abs(g.tip.y - (80 - 0.8 * 10)) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("null for coincident endpoints (never a NaN arrow)", () => {
|
||||||
|
assert.equal(arrowGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 6, 6, 5), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickNearest (distance-check picking)", () => {
|
||||||
|
const nodes = [
|
||||||
|
{ id: "a", x: 10, y: 10 },
|
||||||
|
{ id: "b", x: 50, y: 50 },
|
||||||
|
{ id: "c", x: 90, y: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("picks the nearest node within the pad", () => {
|
||||||
|
assert.equal(pickNearest(nodes, 12, 12, 16)?.id, "a");
|
||||||
|
assert.equal(pickNearest(nodes, 48, 52, 16)?.id, "b");
|
||||||
|
assert.equal(pickNearest(nodes, 88, 8, 16)?.id, "c");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the picking pad extends the hitbox beyond the disc", () => {
|
||||||
|
// 20px away, only reachable because the pad covers it.
|
||||||
|
assert.equal(pickNearest(nodes, 28, 18, 25)?.id, "a");
|
||||||
|
// Beyond the pad: nothing.
|
||||||
|
assert.equal(pickNearest(nodes, 30, 40, 3), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ties resolve deterministically (first scanned wins)", () => {
|
||||||
|
const pair = [
|
||||||
|
{ id: "first", x: 0, y: 0 },
|
||||||
|
{ id: "second", x: 10, y: 0 },
|
||||||
|
];
|
||||||
|
assert.equal(pickNearest(pair, 5, 0, 10)?.id, "first");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty node list picks nothing", () => {
|
||||||
|
assert.equal(pickNearest([], 0, 0, 100), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user