Files
learnharness/components/cluster-geometry.ts
T
amosblomqvist 9df8044d61 Cluster hulls: fix inverted offset normal, kill per-frame blur, labels toggle
Three problems from the first cut (user report 2026-09-13), all rooted
and fixed:

1. Boundaries reshaping on zoom — an inward offset normal. In y-down
   screen coordinates, monotone chain yields clockwise-on-screen hulls,
   whose OUTWARD normal is (dy, -dx); the code used (-dy, dx), so the
   14px padding pulled every hull vertex 14-20px INWARD. Zoomed in this
   mostly passed; zoomed out, once a cluster projected smaller than ~2x
   the pad, vertices crossed the centroid and the polygon self-
   intersected — spiky garbage re-forming every frame. Geometry moved to
   components/cluster-geometry.ts (exported, unit-tested) with the
   corrected normal; tests pin the direction (offset moves every vertex
   AWAY from the centroid; stays sane when the hull is smaller than the
   pad; hull orientation asserted clockwise-on-screen).

2. GPU ~100% + audible coil whine on any interaction — the hull redraw
   was hooked to sigma's afterRender, which fires for EVERY render
   including ~a dozen hover-animation frames per hover, and each redraw
   rasterized ~32 fills through ctx.filter blur(12px), a GPU-heavy
   full-canvas filter. Now: redraws happen ONLY on camera updates and
   resizes (hover leaves the hull canvas untouched — verified live),
   and the blur is replaced with layered low-alpha fills (soft edge at
   a fraction of the cost, no canvas filter at all).

3. Cluster labels cluttering the view — a persisted 'Cluster labels'
   checkbox in the rendering settings (hulls stay, text toggles).

377/377 tests, typecheck, lint, build green. Live-verified: hover does
not repaint hulls, zoom does; far zoom-out yields 3 contiguous tinted
regions (no fragmentation); toggle clears/returns labels and persists.
2026-09-13 04:23:32 +03:00

81 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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".)
*/
export type Pt = { x: number; y: number };
/** Stable hue per cluster id (content-hash): the region keeps its color
* across reshuffles because the id only changes when the members do. */
export function clusterHue(id: string): number {
let h = 0;
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, 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();
}