Fixes the zoom frame-rate collapse and rebuilds draw() around a single camera transform, per the user's Obsidian-style sizing proposal. Bug fix first: the eased zoom leaked a queued rAF callback per frame -- advanceZoom applies each step through d3-zoom, whose zoom event called markDirty() from inside the tick loop, scheduling an extra draw while the loop scheduled its own. Draws multiplied per displayed frame; sustained wheel ran at ~20fps against a 120Hz display. markDirty now skips scheduling while inTick (verified: 62 frames/500ms during a 28x zoom burst, matching pan). World-space model: every draw call now runs in graph coordinates under one setTransform(k). Sizes are defined as screen-px-at-fit and converted once per geometry build (/ kFit), so nodes, node labels, arrowheads and the hover halo scale with the graph as you zoom (0.25x painted area at half zoom, exactly k^2) while edge widths and hull borders stay viewer-relative hairlines (/ k). Cluster hull geometry (convex hull + three offset fills + solid boundary -- dashes dropped for now) is built as cached Path2Ds per data/visibility/settings change; camera moves do zero geometry math, making zoom per-frame cost identical to pan. Label wrapping runs once per build, not per frame. Region labels stay viewer-relative (18px at any zoom) as UI chrome. Picking radius scales with the world-sized discs. Glide feel: ZOOM_EASE_MS 120 -> 45, convergence 0.1% -> 0.2%; a notch now settles in ~260ms instead of ~750ms. Resize still paints synchronously. Also fixes barrel-law lint violations from the settings wave (import from @/src/core and @/src/server, not deep module paths).
81 lines
3.2 KiB
TypeScript
81 lines
3.2 KiB
TypeScript
/**
|
||
* 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 | 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();
|
||
}
|