diff --git a/components/cluster-geometry.ts b/components/cluster-geometry.ts new file mode 100644 index 0000000..15d9a80 --- /dev/null +++ b/components/cluster-geometry.ts @@ -0,0 +1,80 @@ +/** + * 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(); +} diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 0797f44..ba185cd 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -79,6 +79,7 @@ import Graph from "graphology"; import forceAtlas2 from "graphology-layout-forceatlas2"; import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering"; import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core"; +import { clusterHue, convexHull, offsetPolygon, traceRoundedPath, type Pt } from "./cluster-geometry.ts"; /** The uniform node look (no state classes — WebGL wants hex, not oklch): * the accent color, until the state model lands. */ @@ -107,8 +108,11 @@ interface RenderSettings { * 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 }; +const DEFAULT_RENDER_SETTINGS: RenderSettings = { nodeSize: 6, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true }; const RENDER_SETTINGS_KEY = "graph-render-settings"; const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); /** Defensively merge stored settings (stale/partial/corrupt → defaults). */ @@ -119,6 +123,7 @@ function sanitizeRenderSettings(raw: unknown): RenderSettings { edgeWidth: clamp(Number(r?.edgeWidth ?? DEFAULT_RENDER_SETTINGS.edgeWidth), 0.5, 4), arrowLength: clamp(Number(r?.arrowLength ?? DEFAULT_RENDER_SETTINGS.arrowLength), 2, 15), labelZoom: clamp(Number(r?.labelZoom ?? DEFAULT_RENDER_SETTINGS.labelZoom), 0.25, 4), + clusterLabels: typeof r?.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels, }; } @@ -423,71 +428,9 @@ 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(); -} +// 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() { const containerRef = useRef(null); @@ -797,32 +740,38 @@ export function GraphView() { 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 */ + // 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(); } - hullCtx.fillStyle = `hsla(${hue}, 65%, 60%, 0.08)`; - hullCtx.fill(); - hullCtx.filter = "none"; + // 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). - if (!cluster.label) continue; + // 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); } }; - renderer.on("afterRender", drawClusters); + // 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); @@ -858,9 +807,11 @@ export function GraphView() { // 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 @@ -1028,6 +979,15 @@ export function GraphView() { display={`${settings.labelZoom}×`} onChange={(v) => setSettings((cur) => ({ ...cur, labelZoom: v }))} /> + )} {snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && ( diff --git a/tests/clusters.test.ts b/tests/clusters.test.ts index 8050101..84cd467 100644 --- a/tests/clusters.test.ts +++ b/tests/clusters.test.ts @@ -7,6 +7,7 @@ import { strict as assert } from "node:assert"; import { describe, test } from "node:test"; import { detectCommunities, matchClusterLabelings, MIN_CLUSTER_SIZE } from "../src/core/clusters.ts"; +import { clusterHue, convexHull, offsetPolygon, outwardNormal } from "../components/cluster-geometry.ts"; import type { Community } from "../src/core/clusters.ts"; import type { ClusterLabeling } from "../src/core/contracts.ts"; @@ -72,6 +73,98 @@ describe("matchClusterLabelings", () => { }); }); +describe("hull geometry (the inward-normal regression)", () => { + // Screen coordinates: y grows DOWN. The first implementation used + // (-dy, dx) as the "outward" normal — which points INWARD for the + // clockwise-on-screen rings monotone chain produces here, shrinking + // every hull by the pad and inverting it entirely once a cluster + // projected smaller than ~2x the pad (spiky shape churn on zoom). + + const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(a.x - b.x, a.y - b.y); + + test("outwardNormal points away from a square's interior (screen coords)", () => { + const square = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, + ]; + const center = { x: 5, y: 5 }; + for (let i = 0; i < 4; i++) { + const a = square[i]!; + const b = square[(i + 1) % 4]!; + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + const n = outwardNormal(a, b); + // The normal translated from the edge midpoint moves AWAY from the center. + const moved = { x: mid.x + n.x, y: mid.y + n.y }; + assert.ok(dist(moved, center) > dist(mid, center), `edge ${i}: normal points inward`); + } + }); + + test("offsetPolygon moves every vertex AWAY from the centroid", () => { + const hull = convexHull([ + { x: 0, y: 0 }, { x: 40, y: 3 }, { x: 55, y: 30 }, { x: 20, y: 44 }, { x: 2, y: 25 }, + ]); + const centroid = { + x: hull.reduce((s, p) => s + p.x, 0) / hull.length, + y: hull.reduce((s, p) => s + p.y, 0) / hull.length, + }; + for (const [i, v] of hull.entries()) { + const moved = offsetPolygon(hull, 14)[i]!; + assert.ok( + dist(moved, centroid) > dist(v, centroid), + `vertex ${i} moved inward (was ${dist(v, centroid).toFixed(1)}, now ${dist(moved, centroid).toFixed(1)})`, + ); + } + }); + + test("offsetPolygon stays sane when the hull is SMALLER than the pad (the zoom-out case)", () => { + // A 10px cluster padded by 14px: must still be a simple polygon + // (no vertex crossing the centroid, no inversion). + const hull = convexHull([ + { x: 0, y: 0 }, { x: 10, y: 1 }, { x: 9, y: 9 }, { x: 1, y: 8 }, + ]); + const centroid = { + x: hull.reduce((s, p) => s + p.x, 0) / hull.length, + y: hull.reduce((s, p) => s + p.y, 0) / hull.length, + }; + const moved = offsetPolygon(hull, 14); + for (const v of moved) { + assert.ok(dist(v, centroid) >= 10, `vertex collapsed past the centroid: ${JSON.stringify(v)}`); + } + // Vertex ORDER stays angularly monotone around the centroid (no self-intersection). + const angles = moved.map((v) => Math.atan2(v.y - centroid.y, v.x - centroid.x)); + let prev = angles[0]!; + for (const a of angles.slice(1)) { + let d = a - prev; + while (d > Math.PI) d -= 2 * Math.PI; + while (d < -Math.PI) d += 2 * Math.PI; + assert.ok(d !== 0, "duplicate angle — degenerate offset"); + prev = a; + } + }); + + test("convexHull returns a clockwise-on-screen ring (the orientation the normal math assumes)", () => { + const hull = convexHull([ + { x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, + ]); + // Signed area in code coords: clockwise-on-screen (y-down) = positive. + let area = 0; + for (let i = 0; i < hull.length; i++) { + const a = hull[i]!; + const b = hull[(i + 1) % hull.length]!; + area += a.x * b.y - b.x * a.y; + } + assert.ok(area / 2 > 0, `expected positive (clockwise-on-screen) area, got ${area / 2}`); + }); + + test("clusterHue is stable per id and spreads", () => { + assert.equal(clusterHue("clu_abc123def456"), clusterHue("clu_abc123def456")); + const hues = new Set(Array.from({ length: 40 }, (_, i) => clusterHue(`clu_${i}`))); + assert.ok(hues.size > 25, `expected spread hues, got ${hues.size}`); + }); +}); + describe("detectCommunities", () => { test("two dense cliques joined by a single bridge split into two communities", () => { const left = ids(4, "L");