Live physics drag + splat cluster regions

The renderer grows a live d3 sim (createGraphSimulation, shared with
the server settle): pointerdown on a node pins fx/fy and pointer capture
(preventDefault kills the compat mousedown, so d3-zoom never pans); the
first CLICK_SLOP-exceeding move reheats to alphaTarget 0.3; release
un-pins and the sim anneals (release-and-relax) — alpha < 0.005 settles
it, a 250ms debounced onCommit PUTs the rest state. Verified live: 123fps
during a 320ms drag (regions rebuilding every 3rd frame), 85-tick settle,
then zero idle frames. A slop-free press stays a click (select, no reheat,
no commit). PUT /api/graph/positions sanitizes (finite coords, known ids)
and if the graph grew mid-drag runs ONE incremental settle with the
client's arrangement as anchors (50-id PUT test: anchored drift 0.15).

Cluster regions swap convex hulls for a low-res world-space SPLAT bitmap:
per-member radial-gradient discs (alpha 0.2) + intra-cluster edges as
round-cap strokes (Minkowski dilation — concave shapes finally render),
upscaled with bilinear smoothing (the upscale IS the blur, no ctx.filter:
Safari). One tiny blit per frame replaces 32 clusters x 4 offset
Path2D polygons re-rasterized at DPR 2 (the old GPU coil-whine path);
during the live sim the bitmap rebuilds every 3rd frame. cluster-geometry.ts
retires convexHull/offsetPolygon/traceRoundedPath (tests follow; clusterHue
stays). Region labels stay (centroid + clusterHue, inverse-gated on zoom).
This commit is contained in:
amosblomqvist
2026-09-13 09:17:05 +03:00
parent 7eceb58303
commit 05712037b2
7 changed files with 444 additions and 223 deletions
+7 -68
View File
@@ -1,14 +1,11 @@
/**
* CLUSTER HULL GEOMETRY (vision §1) — pure presentation math for the
* knowledge-graph regions. Extracted from graph-view.tsx so the normal
* direction and hull correctness are UNIT-TESTED (the first version's
* offset normal pointed INWARD in screen coordinates — in y-down screen
* space, monotone chain yields clockwise-on-screen hulls, whose outward
* normal is (dy, -dx), not (-dy, dx) — which made every boundary SHRINK
* 14px per vertex and self-intersect into spiky garbage as soon as a
* cluster projected smaller than the padding; shapes churned on every
* zoom frame. See tests/clusters.test.ts: "offsetPolygon moves every
* vertex AWAY from the centroid".)
* Cluster color identity — pure presentation math for the knowledge-graph
* regions. (The hull geometry that used to live here — convexHull,
* outwardNormal, offsetPolygon, traceRoundedPath — was RETIRED with the
* convex-hull renderer: regions are now a low-res world-space splat
* bitmap in graph-canvas.ts, which renders concave cluster shapes
* correctly and costs one blit per frame instead of 32 clusters × 4
* offset polygons. The file keeps the name for the barrel imports.)
*/
export type Pt = { x: number; y: number };
@@ -20,61 +17,3 @@ export function clusterHue(id: string): number {
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) % 360;
return h;
}
/** Convex hull (Andrew monotone chain). In y-DOWN screen coordinates this
* yields a CLOCKWISE-on-screen ring — the orientation
* outwardNormal/offsetPolygon below are written for. */
export 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 for CLOCKWISE-on-screen rings
* (y-down coords). (dy, -dx): for the top edge of a square traversed
* left→right, this points up-screen — away from the interior. */
export 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 —
* each corner moves by up to ~1.41×pad, giving rounded-square-ish
* corners before the arc rounding is even applied). */
export 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). */
export function traceRoundedPath(ctx: CanvasRenderingContext2D | Path2D, 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();
}