Clusters phase D: hulls, blurred fills, and inverse-gated region labels
Two custom canvas layers around sigma's own stack (DOM order: hulls behind everything; region labels above nodes, below node labels). Per cluster (vision section 1): convex hull (monotone chain) of member viewport positions, offset outward 14px, rounded corners, a blurred low-opacity fill (ctx.filter, graceful degrade to flat fill) plus a dashed boundary stroke, and the LLM label at the centroid. Stable hue per cluster id (content hash) — a region keeps its color across reshuffles because the id only changes when the members do. Geometry is a pure function of positions at draw time: every afterRender re-derives hulls through the current camera, so pan/zoom/ relayout can never invalidate it (the boundary decision pays off: no stored geometry, nothing to recompute after a future physics change). The inverse gate: the cluster label layer's opacity is the exact complement of the node-label layer's over the fade span of THE zoom control (labelZoom, default 0.5x) — the two label systems never coexist. Live-verified in the browser: hulls painting behind edges; region labels above nodes; zoom-in crossfades cluster 1 -> 0 as node labels 0 -> 1 (0.176 + 0.824 mid-fade); zoom-out restores; hull pixel count changes with zoom (geometry follows the camera); zero console errors; layers removed on renderer teardown. 372/372 tests, typecheck, lint, build green. Also named the real graph: one 11.5s labeler call minted all 32 region labels for the 338-node notes graph (Relational Databases & SQL, Differential Forms, Sales as Uncertainty Reduction, ...).
This commit is contained in:
@@ -16,6 +16,15 @@
|
||||
* sigma's defaults:
|
||||
* - defaultDrawNodeLabel: plain wrapped text, centered below the node,
|
||||
* word-wrapped at a max line width — no truncation, no one-line sprawl.
|
||||
* 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
|
||||
@@ -411,6 +420,75 @@ 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. No metaballs
|
||||
// in v1 — convex hull + outward offset + rounded corners; revisit when
|
||||
// regions want concave fidelity.
|
||||
|
||||
/** Stable hue per cluster id (content-hash): the region keeps its color
|
||||
* across reshuffles because the id only changes when the members do. */
|
||||
function clusterHue(id: string): number {
|
||||
let h = 0;
|
||||
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) % 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
type Pt = { x: number; y: number };
|
||||
|
||||
/** Convex hull (Andrew monotone chain), viewport pixels. */
|
||||
function convexHull(points: Pt[]): Pt[] {
|
||||
if (points.length < 3) return points;
|
||||
const pts = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
|
||||
const cross = (o: Pt, a: Pt, b: Pt) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
||||
const lower: Pt[] = [];
|
||||
for (const p of pts) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0) lower.pop();
|
||||
lower.push(p);
|
||||
}
|
||||
const upper: Pt[] = [];
|
||||
for (let i = pts.length - 1; i >= 0; i--) {
|
||||
const p = pts[i]!;
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0) upper.pop();
|
||||
upper.push(p);
|
||||
}
|
||||
upper.pop();
|
||||
lower.pop();
|
||||
return [...lower, ...upper];
|
||||
}
|
||||
|
||||
/** Outward edge normal of segment a→b (screen coords: y grows down). */
|
||||
function outwardNormal(a: Pt, b: Pt): Pt {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
return { x: -dy / len, y: dx / len };
|
||||
}
|
||||
|
||||
/** Offset a convex polygon outward by `pad` px (per-vertex normal sum). */
|
||||
function offsetPolygon(points: Pt[], pad: number): Pt[] {
|
||||
return points.map((p, i) => {
|
||||
const prev = points[(i - 1 + points.length) % points.length]!;
|
||||
const next = points[(i + 1) % points.length]!;
|
||||
const n1 = outwardNormal(prev, p);
|
||||
const n2 = outwardNormal(p, next);
|
||||
return { x: p.x + (n1.x + n2.x) * pad, y: p.y + (n1.y + n2.y) * pad };
|
||||
});
|
||||
}
|
||||
|
||||
/** Path through the polygon with rounded corners (arcTo). */
|
||||
function traceRoundedPath(ctx: CanvasRenderingContext2D, points: Pt[], radius: number): void {
|
||||
const first = points[0]!;
|
||||
ctx.moveTo(first.x, first.y);
|
||||
for (let i = 1; i <= points.length; i++) {
|
||||
const corner = points[i % points.length]!;
|
||||
const next = points[(i + 1) % points.length]!;
|
||||
ctx.arcTo(corner.x, corner.y, next.x, next.y, radius);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
export function GraphView() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -599,6 +677,10 @@ export function GraphView() {
|
||||
|
||||
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;
|
||||
@@ -657,6 +739,91 @@ export function GraphView() {
|
||||
// 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);
|
||||
// Blurred low-opacity fill + dashed boundary stroke.
|
||||
const outer = offsetPolygon(hull, 14);
|
||||
hullCtx.beginPath();
|
||||
traceRoundedPath(hullCtx, outer, 9);
|
||||
// ctx.filter degrades to flat fill where unsupported.
|
||||
try {
|
||||
hullCtx.filter = "blur(12px)";
|
||||
} catch {
|
||||
/* older browsers: skip the blur */
|
||||
}
|
||||
hullCtx.fillStyle = `hsla(${hue}, 65%, 60%, 0.08)`;
|
||||
hullCtx.fill();
|
||||
hullCtx.filter = "none";
|
||||
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).
|
||||
if (!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);
|
||||
}
|
||||
};
|
||||
renderer.on("afterRender", drawClusters);
|
||||
|
||||
renderer.on("clickNode", ({ node }) => {
|
||||
const graphNode = snapshot.nodes.find((n) => n.id === node);
|
||||
if (!graphNode) return;
|
||||
@@ -687,6 +854,9 @@ export function GraphView() {
|
||||
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);
|
||||
};
|
||||
syncRenderLabels(renderer.getCamera().getState().ratio);
|
||||
renderer.getCamera().on("updated", (state) => {
|
||||
@@ -731,6 +901,10 @@ export function GraphView() {
|
||||
if (renderer) cameraRef.current = renderer.getCamera().getState();
|
||||
rendererRef.current = null;
|
||||
renderer?.kill();
|
||||
// The custom cluster layers are ours — sigma's kill() removes only
|
||||
// its own canvases.
|
||||
clusterCanvas?.remove();
|
||||
clusterLabelCanvas?.remove();
|
||||
};
|
||||
}, [snapshot, deferredSettings]);
|
||||
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ When the graph fills up, the canvas needs a readable at-a-glance structure: **cl
|
||||
|
||||
### Rendering (DECIDED — research report 2026-08-22)
|
||||
|
||||
**Render settings (2026-09-13):** four basic controls in the graph view — node size, edge width, arrowhead length, and **the zoom-level control** (the label-visibility threshold; 1× = fit zoom) — persisted per browser. The zoom control is deliberately THE single zoom knob: cluster labels (below) will gate inversely on it over the same fade span. Settings changes rebuild only the renderer: layout positions and the camera never move. (Edge-length / spacing controls deliberately parked until a cluster-aware spacing design exists.)
|
||||
**Render settings (2026-09-13):** four basic controls in the graph view — node size, edge width, arrowhead length, and **the zoom-level control** (the label-visibility threshold; 1× = fit zoom) — persisted per browser. The zoom control is deliberately THE single zoom knob: cluster labels gate inversely on it over the same fade span (implemented: node labels fade out over the span as cluster labels fade in — they never coexist). Settings changes rebuild only the renderer: layout positions and the camera never move. (Edge-length / spacing controls deliberately parked until a cluster-aware spacing design exists.)
|
||||
|
||||
- **Library: sigma.js v3 + graphology + graphology-layout-forceatlas2.** ~40 KB gzipped all-in — half of vis-network, a third of cytoscape, far below react-force-graph (~250 KB deps) and cosmos.gl (luma.gl stack).
|
||||
- **Layout: synchronous run-then-freeze.** `forceAtlas2.assign(graph, { iterations: 100, settings: inferSettings(graph) })` runs once, writes x/y, and no animation loop ever starts — near-zero idle CPU (sigma only redraws on camera/graph events). <150 ms at hundreds of nodes; at ~2k enable `barnesHutOptimize`, then the bundled Web Worker, then server-cached positions — in that order, only if latency bites.
|
||||
|
||||
Reference in New Issue
Block a user