From 05712037b2465770c38544082e03955b2afc80d2 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 13 Sep 2026 09:17:05 +0300 Subject: [PATCH] Live physics drag + splat cluster regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- app/api/graph/positions/route.ts | 24 ++ components/cluster-geometry.ts | 75 +----- components/graph-canvas.ts | 402 ++++++++++++++++++++++++++----- components/graph-view.tsx | 9 + src/server/commit-positions.ts | 57 +++++ src/server/index.ts | 1 + tests/clusters.test.ts | 99 +------- 7 files changed, 444 insertions(+), 223 deletions(-) create mode 100644 app/api/graph/positions/route.ts create mode 100644 src/server/commit-positions.ts diff --git a/app/api/graph/positions/route.ts b/app/api/graph/positions/route.ts new file mode 100644 index 0000000..da0d280 --- /dev/null +++ b/app/api/graph/positions/route.ts @@ -0,0 +1,24 @@ +/** + * PUT /api/graph/positions — the live-physics drag commit. The client + * anneals a drag to rest and sends the new positions; the server + * sanitizes (finite coords, known ids only), writes the ledger, and if + * the graph grew mid-drag runs one incremental settle to absorb the new + * nodes with the client's arrangement as anchors. See + * src/server/commit-positions.ts. + * + * PUT → 200 {positions: {...}} with whatever survived sanitizing + * (empty object = nothing written). + */ +import { commitPositions } from "@/src/server"; + +export const dynamic = "force-dynamic"; + +export async function PUT(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "invalid JSON body" }, { status: 400 }); + } + return Response.json({ positions: commitPositions(body) }); +} diff --git a/components/cluster-geometry.ts b/components/cluster-geometry.ts index b122323..a81bd83 100644 --- a/components/cluster-geometry.ts +++ b/components/cluster-geometry.ts @@ -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(); -} diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index e2373bb..19c345d 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -52,8 +52,15 @@ import { select } from "d3-selection"; import { zoom, zoomIdentity, type ZoomBehavior, type ZoomTransform } from "d3-zoom"; -import { clusterHue, convexHull, offsetPolygon, traceRoundedPath, type Pt } from "./cluster-geometry.ts"; -import type { GraphRenderSettings } from "@/src/core"; +import type { Simulation, SimulationLinkDatum } from "d3-force"; +import { clusterHue, type Pt } from "./cluster-geometry.ts"; +import { + createGraphSimulation, + INCREMENTAL_GRAVITY, + type GraphRenderSettings, + type LayoutNode, + type Position, +} from "@/src/core"; export type { GraphRenderSettings }; @@ -105,6 +112,9 @@ export interface RendererCallbacks { onStageClick: () => void; /** Any camera move (pan/zoom) — the caller dismisses anchored UI. */ onCameraMove: () => void; + /** The live sim settled after a drag: commit the new rest state + * (the caller PUTs it to the positions ledger). */ + onCommit: (positions: Record) => void; } // ─── Constants (ported verbatim from the sigma view) ──────────────────────── @@ -128,19 +138,26 @@ const PICKING_PAD = 10; /** Click vs drag threshold: pointer travel under this many px is a click. */ const CLICK_SLOP = 4; +/** Live-sim tuning — see the class doc comment. */ +const LIVE_ALPHA_DECAY = 0.05; // snappier anneal than the settle's 0.0228 +const DRAG_ALPHA_TARGET = 0.3; // d3-drag's own reheat constant +const SIM_SETTLE_ALPHA = 0.005; // below this, the sim counts as settled +const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel) -/** Cluster hull styling: layered low-alpha fills tint each region, plus - * a SOLID viewer-relative border. (The fills were briefly removed on a - * misreading of "no glow": they are the REGION BACKGROUNDS, not the - * glow. The jagged-when-zoomed look is the straight-line hull - * approximation, polishable later once the layout settles.) */ -const HULL_FILL_LAYERS = [ - [24, 0.025], - [19, 0.04], - [14, 0.06], -] as const; -const HULL_STROKE_PAD = 14; -const HULL_CORNER_RADIUS = 9; +/** Cluster region styling — a low-res world-space SPLAT bitmap (the + * density-field technique, Canvas-2D edition): every member node paints + * a soft radial-gradient disc and every intra-cluster edge a round-cap + * stroke; upscaling the bitmap with bilinear smoothing IS the blur. + * Replaces the convex-hull fills + borders: concave cluster shapes are + * finally correct (the hull bridged every gap with straight lines — + * the jagged close-up look), there is no border to lag behind, and a + * live-physics frame pays one tiny blit instead of 32 clusters × 4 + * offset polygons re-rasterized at DPR 2 (the old GPU coil-whine). */ +const REGION_KERNEL = 30; // screen px at fit — soft disc radius +const REGION_NODE_ALPHA = 0.2; // per disc; overlaps saturate toward the core +const REGION_EDGE_ALPHA = 0.08; // round-cap strokes, width = kernel × 0.8 +const REGION_BITMAP = 256; // max bitmap dimension; cell = worldSpan / this +const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest const REGION_LABEL_SIZE = 14; /** All "px" constants above are screen px AT FIT ZOOM. The renderer scales @@ -322,6 +339,21 @@ export class GraphCanvasRenderer { private settings: GraphRenderSettings | null = null; private visible: ReadonlySet | null = null; + // ── Live physics ── + // One d3-force sim shared with the server settle (createGraphSimulation + // in @/src/core): dragging pins fx/fy and reheats (alphaTarget), release + // un-pins and the sim anneals; once alpha drops below SIM_SETTLE_ALPHA + // the new rest state is debounced-committed via onCommit. The sim mutates + // data.nodes IN PLACE (they ARE the sim nodes — zero copying), so every + // tick just bumps geoVersion and the draw loop rebuilds geometry. + private sim: Simulation> | null = null; + private simById = new Map(); + private simActive = false; + private dragId: string | null = null; + private dragOrigin: Pt | null = null; + private simMoved = false; // any real movement since the last commit + private commitTimer: ReturnType | null = null; + private frameId: number | null = null; private dirty = true; /** True while tick() runs: markDirty inside the loop (d3's zoom event @@ -361,14 +393,13 @@ export class GraphCanvasRenderer { private nodesPath: Path2D | null = null; private shaftsPath: Path2D = new Path2D(); private headsPath: Path2D = new Path2D(); - private hullShapes: { - fill: Path2D[]; - stroke: Path2D; - label: string | null; - cx: number; // world centroid - cy: number; - hue: number; - }[] = []; + private regionCanvas: HTMLCanvasElement | null = null; + /** World rect the bitmap covers (drawImage target coords). */ + private regionRect: { minX: number; minY: number; w: number; h: number } | null = null; + /** Region labels: cluster centroid + hue, computed at splat build. */ + private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = []; + private regionBuiltVersion = -1; + private frameCount = 0; // draw counter for the live-sim splat throttle private labelLines = new Map(); private worldLabelFont = ""; private world: WorldSizes = { nodeR: 0, glowOuter: 0, labelSize: 0, labelGap: 0 }; @@ -443,6 +474,28 @@ export class GraphCanvasRenderer { } this.worldBBox = { minX, maxX, minY, maxY }; if (!this.hasFit && data.nodes.length > 0) this.fit(); + // Rebuild the live sim around the fresh nodes. New data means the + // server spoke (merge or refetch); any pending commit is moot and + // the graph is at rest until the next drag. + if (this.commitTimer) { + clearTimeout(this.commitTimer); + this.commitTimer = null; + } + this.simActive = false; + this.dragId = null; + this.simMoved = false; + if (data.nodes.length > 0) { + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + this.sim = createGraphSimulation(data.nodes, data.edges, { + gravity: { x: cx, y: cy, strength: INCREMENTAL_GRAVITY }, + alphaDecay: LIVE_ALPHA_DECAY, + }); + this.simById = new Map(data.nodes.map((n) => [n.id, n as LayoutNode])); + } else { + this.sim = null; + this.simById = new Map(); + } this.geoVersion++; // new data → rebuild world geometry this.markDirty(); } @@ -478,6 +531,10 @@ export class GraphCanvasRenderer { destroy(): void { if (this.frameId !== null) cancelAnimationFrame(this.frameId); + if (this.commitTimer) clearTimeout(this.commitTimer); + this.sim = null; + this.simActive = false; + this.dragId = null; this.resizeObserver.disconnect(); select(this.canvas).on(".zoom", null); this.canvas.removeEventListener("wheel", this.onWheel); @@ -506,18 +563,92 @@ export class GraphCanvasRenderer { }; private onPointerMove = (e: PointerEvent) => { - const { x, y } = this.eventPos(e); - const node = this.pickAt(x, y); + const pos = this.eventPos(e); + if (this.dragId) { + const n = this.simById.get(this.dragId); + if (n) { + const moved = + this.dragOrigin !== null && + Math.hypot(pos.x - this.dragOrigin.x, pos.y - this.dragOrigin.y) > CLICK_SLOP; + if (moved) { + // First real movement: reheat and glue the node to the cursor. + if (this.commitTimer) { + clearTimeout(this.commitTimer); + this.commitTimer = null; + } + this.sim?.alphaTarget(DRAG_ALPHA_TARGET); + this.simActive = true; + this.markDirty(); + } + const { x: tx, y: ty, k } = this.transform; + n.fx = (pos.x - tx) / k; + n.fy = (pos.y - ty) / k; + } + return; + } + const node = this.pickAt(pos.x, pos.y); if ((node?.id ?? null) !== this.hoverId) this.setHover(node?.id ?? null); this.canvas.style.cursor = node ? "pointer" : ""; }; private onPointerDown = (e: PointerEvent) => { - this.downPos = this.eventPos(e); + const pos = this.eventPos(e); + const node = this.pickAt(pos.x, pos.y); + if (node && this.sim) { + // Node press: pin the node (at ITS OWN position — the cursor + // may be inside the pick pad, off-center). preventDefault kills + // the compatibility mousedown, so d3-zoom never starts a pan. + // Physics only reheats once the pointer exceeds CLICK_SLOP — + // a plain click stays perfectly still. + e.preventDefault(); + try { + this.canvas.setPointerCapture(e.pointerId); + } catch { + /* capture is best-effort */ + } + this.dragId = node.id; + this.dragOrigin = pos; + const n = this.simById.get(node.id); + if (n) { + n.fx = n.x; + n.fy = n.y; + } + this.canvas.style.cursor = "grabbing"; + this.markDirty(); + return; + } + this.downPos = pos; this.zoomTarget = null; // a pan takes over — cancel any zoom glide }; private onPointerUp = (e: PointerEvent) => { + // Node press ending: click (no slop) → select; drag → release-and-relax. + if (this.dragId) { + const id = this.dragId; + this.dragId = null; + this.dragOrigin = null; + try { + this.canvas.releasePointerCapture(e.pointerId); + } catch { + /* already released */ + } + this.canvas.style.cursor = ""; + const n = this.simById.get(id); + if (n) { + n.fx = null; + n.fy = null; + } + if (this.simActive) { + // Real drag: the sim anneals to the new rest state; advanceSim + // detects the settle and schedules the commit. + this.sim?.alphaTarget(0); + this.markDirty(); + } else { + // Never heated: a plain click on the node. + this.callbacks.onSelect(id, this.eventPos(e)); + } + return; + } if (!this.downPos) return; const pos = this.eventPos(e); const moved = Math.hypot(pos.x - this.downPos.x, pos.y - this.downPos.y); @@ -529,6 +660,7 @@ export class GraphCanvasRenderer { }; private onPointerLeave = () => { + if (this.dragId) return; // pointer capture keeps the drag alive this.downPos = null; this.setHover(null); }; @@ -591,10 +723,12 @@ export class GraphCanvasRenderer { this.lastTickAt = now; const hoverAnim = this.advanceHover(); const zoomAnim = this.advanceZoom(dt); + const simAnim = this.advanceSim(); this.draw(); // Keep frames coming ONLY while an animation is in motion (hover - // fade or zoom glide); an idle graph schedules no rAF at all. - this.animating = hoverAnim || zoomAnim; + // fade, zoom glide, or live physics); an idle graph schedules no + // rAF at all. + this.animating = hoverAnim || zoomAnim || simAnim; } finally { this.inTick = false; } @@ -606,6 +740,42 @@ export class GraphCanvasRenderer { } } + /** One live-sim step; returns true while physics is still in motion. + * Runs only while simActive (drag reheat or post-release anneal). */ + private advanceSim(): boolean { + if (!this.sim || !this.simActive) return false; + this.sim.tick(); + // Movement check: did any node actually move (a click-pin that + // never reheated leaves everything exactly at rest)? + for (const n of this.sim.nodes()) { + if (n.vx || n.vy) { + this.simMoved = true; + break; + } + } + this.geoVersion++; // positions changed → geometry rebuild in draw() + // Settled (drag released long ago, alpha annealed away)? + if (!this.dragId && this.sim.alpha() < SIM_SETTLE_ALPHA) { + this.simActive = false; + this.scheduleCommit(); + return false; + } + return true; + } + + /** Debounced rest-state commit. A new drag or setData cancels it. */ + private scheduleCommit(): void { + if (this.commitTimer) clearTimeout(this.commitTimer); + this.commitTimer = setTimeout(() => { + this.commitTimer = null; + if (!this.data || !this.simMoved) return; + this.simMoved = false; + const positions: Record = {}; + for (const n of this.data.nodes) positions[n.id] = { x: n.x, y: n.y }; + this.callbacks.onCommit(positions); + }, COMMIT_DEBOUNCE_MS); + } + /** Hover fade step; returns true while still moving. */ private advanceHover(): boolean { if (this.hoverFade === this.hoverTarget) return false; @@ -710,30 +880,9 @@ export class GraphCanvasRenderer { this.shaftsPath = shafts; this.headsPath = heads; - // Cluster hulls: convex hull of member positions (world = raw data - // coordinates, no projection at all), three offset fill layers, - // and a SOLID boundary path. - this.hullShapes = []; - for (const cluster of data.clusters) { - const pts: Pt[] = []; - for (const id of cluster.members) { - if (!isVisible(id)) continue; - const n = this.nodeById.get(id); - if (n) pts.push({ x: n.x, y: n.y }); - } - const hull = convexHull(pts); - if (hull.length < 3) continue; - const fills = HULL_FILL_LAYERS.map(([pad]) => { - const path = new Path2D(); - traceRoundedPath(path, offsetPolygon(hull, pad * s), HULL_CORNER_RADIUS * s); - return path; - }); - const stroke = new Path2D(); - traceRoundedPath(stroke, offsetPolygon(hull, HULL_STROKE_PAD * s), HULL_CORNER_RADIUS * s); - const cx = pts.reduce((sum, p) => sum + p.x, 0) / pts.length; - const cy = pts.reduce((sum, p) => sum + p.y, 0) / pts.length; - this.hullShapes.push({ fill: fills, stroke, label: cluster.label || null, cx, cy, hue: clusterHue(cluster.id) }); - } + // Cluster regions are NOT built here: they rasterize into their own + // low-res bitmap (see buildRegions), on their own rebuild schedule + // (live-sim frames skip 2 of 3 rebuilds and blit the last bitmap). // Label wrapping: measureText once per node per geometry build, // never per frame. Wrap width keeps the bigger-label/bigger-wrap @@ -749,6 +898,121 @@ export class GraphCanvasRenderer { ctx.font = prevFont; } + /** Rasterize cluster regions into the low-res world-space bitmap. + * Runs on data/visibility/settings changes at rest, and every + * REGION_EVERY-th frame while the live sim moves nodes (the in-between + * frames blit the previous bitmap — one sim step of staleness at + * 120fps is invisible). Positions come straight from data.nodes. */ + private buildRegions(): void { + const data = this.data; + if (!data || data.clusters.length === 0) { + this.regionCanvas = null; + this.regionRect = null; + this.regionMeta = []; + return; + } + const visible = this.visible; + const isVisible = (id: string): boolean => !visible || visible.has(id); + + // World rect over VISIBLE cluster members (regions cover members + // and their intra-cluster edges — nothing else), padded by the + // kernel so discs can bleed past the extremes. + const s = 1 / this.kFit; + const kernel = REGION_KERNEL * s; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const c of data.clusters) { + for (const id of c.members) { + if (!isVisible(id)) continue; + const n = this.nodeById.get(id); + if (!n) continue; + if (n.x - kernel < minX) minX = n.x - kernel; + if (n.x + kernel > maxX) maxX = n.x + kernel; + if (n.y - kernel < minY) minY = n.y - kernel; + if (n.y + kernel > maxY) maxY = n.y + kernel; + } + } + if (minX > maxX) { + this.regionCanvas = null; + this.regionRect = null; + this.regionMeta = []; + return; + } + + // Bitmap sizing: longest world span maps to REGION_BITMAP px. + const spanX = maxX - minX; + const spanY = maxY - minY; + const cell = Math.max(spanX, spanY) / REGION_BITMAP; + const bw = Math.max(1, Math.ceil(spanX / cell)); + const bh = Math.max(1, Math.ceil(spanY / cell)); + let rc = this.regionCanvas; + if (!rc) { + rc = document.createElement("canvas"); + this.regionCanvas = rc; + } + if (rc.width !== bw || rc.height !== bh) { + rc.width = bw; + rc.height = bh; // also clears + } + const rctx = rc.getContext("2d"); + if (!rctx) return; + rctx.clearRect(0, 0, bw, bh); + rctx.lineCap = "round"; + const R = kernel / cell; + const edgeW = R * 0.8 * 2; + + // Which cluster is a node in (for intra-cluster edges)? + const clusterOf = new Map(); + data.clusters.forEach((c, i) => { + for (const id of c.members) clusterOf.set(id, i); + }); + + this.regionMeta = []; + data.clusters.forEach((cluster, ci) => { + const hue = clusterHue(cluster.id); + const members: Pt[] = []; + const alpha = (a: number): string => `hsla(${hue}, 65%, 60%, ${a})`; + + // Discs: one soft radial gradient per member node. + rctx.fillStyle = alpha(REGION_NODE_ALPHA); + for (const id of cluster.members) { + if (!isVisible(id)) continue; + const n = this.nodeById.get(id); + if (!n) continue; + members.push({ x: n.x, y: n.y }); + const px = (n.x - minX) / cell; + const py = (n.y - minY) / cell; + const grad = rctx.createRadialGradient(px, py, 0, px, py, R); + grad.addColorStop(0, alpha(REGION_NODE_ALPHA)); + grad.addColorStop(1, alpha(0)); + rctx.fillStyle = grad; + rctx.fillRect(px - R, py - R, R * 2, R * 2); + } + // Minkowski strokes: intra-cluster edges with round caps make + // the region follow its own linkage, not just its node dots. + rctx.strokeStyle = alpha(REGION_EDGE_ALPHA); + rctx.lineWidth = edgeW; + rctx.beginPath(); + for (const e of data.edges) { + if (clusterOf.get(e.from) !== ci) continue; + if (clusterOf.get(e.to) !== ci) continue; + const a = this.nodeById.get(e.from); + const b = this.nodeById.get(e.to); + if (!a || !b) continue; + rctx.moveTo((a.x - minX) / cell, (a.y - minY) / cell); + rctx.lineTo((b.x - minX) / cell, (b.y - minY) / cell); + } + rctx.stroke(); + + // Label anchor: member centroid (world coords). + if (members.length > 0) { + const cx = members.reduce((sum, p) => sum + p.x, 0) / members.length; + const cy = members.reduce((sum, p) => sum + p.y, 0) / members.length; + this.regionMeta.push({ label: cluster.label || null, cx, cy, hue }); + } + }); + this.regionRect = { minX, minY, w: spanX, h: spanY }; + } + /** THE draw function: every layer, one frame, one camera. All drawing * happens in WORLD coordinates under the camera transform; nothing is * recomputed per frame except layer styling. */ @@ -765,6 +1029,16 @@ export class GraphCanvasRenderer { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); + this.frameCount++; + // Regions: rebuild on stale geometry at rest; during the live sim, + // every REGION_EVERY-th frame (others blit the last bitmap). + if ( + this.regionBuiltVersion !== this.geoVersion && + (!this.simActive || this.frameCount % REGION_EVERY === 0) + ) { + this.buildRegions(); + this.regionBuiltVersion = this.geoVersion; + } if (this.builtVersion !== this.geoVersion) this.buildGeometry(); if (!this.nodesPath) return; @@ -777,16 +1051,14 @@ export class GraphCanvasRenderer { const regionAlpha = 1 - nodeAlpha; const { nodeR, glowOuter } = this.world; - // 1-2. Cluster hulls: cached world paths — soft layered region - // fills, then a SOLID viewer-relative hairline border. - for (const hs of this.hullShapes) { - for (let i = 0; i < hs.fill.length; i++) { - ctx.fillStyle = `hsla(${hs.hue}, 65%, 60%, ${HULL_FILL_LAYERS[i]![1]})`; - ctx.fill(hs.fill[i]!); - } - ctx.strokeStyle = `hsla(${hs.hue}, 65%, 62%, 0.5)`; - ctx.lineWidth = 1.5 / k; // constant screen px - ctx.stroke(hs.stroke); + // 1. Cluster regions: one tiny world-space bitmap blit. The upscale + // blur is the region softness; adjacent cluster tints blend + // source-over where they overlap (decided: multiply is invisible + // on the dark background). + if (this.regionCanvas && this.regionRect) { + const { minX, minY, w, h } = this.regionRect; + ctx.imageSmoothingEnabled = true; + ctx.drawImage(this.regionCanvas, minX, minY, w, h); } // 3. Edges: shafts + arrowheads from cached paths; width constant on @@ -814,10 +1086,10 @@ export class GraphCanvasRenderer { ctx.font = `${LABEL_WEIGHT} ${REGION_LABEL_SIZE / k}px ${data.fontFamily}`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - for (const hs of this.hullShapes) { - if (!hs.label) continue; - ctx.fillStyle = `hsla(${hs.hue}, 55%, 78%, 0.85)`; - ctx.fillText(hs.label, hs.cx, hs.cy); + for (const rm of this.regionMeta) { + if (!rm.label) continue; + ctx.fillStyle = `hsla(${rm.hue}, 55%, 78%, 0.85)`; + ctx.fillText(rm.label, rm.cx, rm.cy); } ctx.restore(); } diff --git a/components/graph-view.tsx b/components/graph-view.tsx index b283aee..76c68f5 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -408,6 +408,15 @@ export function GraphView() { onStageClick: () => setSelected(null), // Any camera move detaches the card from its node — close it. onCameraMove: () => setSelected(null), + // Live physics settled: persist the new rest state. Fire-and- + // forget — the next snapshot fetch carries the ledger anyway. + onCommit: (positions) => { + void fetch("/api/graph/positions", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(positions), + }).catch((err) => console.error("[graph] positions commit failed", err)); + }, }); rendererRef.current = renderer; return () => { diff --git a/src/server/commit-positions.ts b/src/server/commit-positions.ts new file mode 100644 index 0000000..8178cf7 --- /dev/null +++ b/src/server/commit-positions.ts @@ -0,0 +1,57 @@ +/** + * Server-side commit for the live physics drag (PUT /api/graph/positions). + * The client's renderer anneals a drag to rest and PUTs the new rest + * state; the server trusts it for the nodes it knows about and, if the + * graph grew while the user was dragging (a merge landed), runs ONE + * incremental settle (settleGraphLayout) so the fresh nodes are absorbed + * into the frame the user just arranged — client positions act as + * anchors, never as a clobber. + * + * Every body is sanitized: garbage coordinates clamp or drop, unknown + * ids are discarded, nothing throws. + */ +import type { Position } from "../core/contracts.ts"; +import { getGraph } from "../core/graph.ts"; +import { settleGraphLayout } from "../core/layout.ts"; +import { stores } from "./stores.ts"; + +/** Max |x| or |y| accepted — NaN/Infinity clamp here, absurd too. */ +const MAX_COORD = 1e6; + +/** + * Accepts the raw PUT body (a Record of id → {x, y}) and returns the + * sanitized positions actually persisted (empty object = nothing written, + * e.g. a body with no known ids — the caller reports but never fails). + */ +export function commitPositions(input: unknown): Record { + if (typeof input !== "object" || input === null) return {}; + const raw = input as Record; + + // Only ids the graph actually has (first line wins, like getGraph). + const known = new Map(); + for (const line of stores.graphStore.readNodes()) { + if (!known.has(line.id)) known.set(line.id, true); + } + + const positions: Record = {}; + for (const [id, value] of Object.entries(raw)) { + if (!known.has(id)) continue; + if (typeof value !== "object" || value === null) continue; + const { x, y } = value as Record; + if (typeof x !== "number" || typeof y !== "number" || !Number.isFinite(x) || !Number.isFinite(y)) + continue; + positions[id] = { + x: Math.min(Math.max(x, -MAX_COORD), MAX_COORD), + y: Math.min(Math.max(y, -MAX_COORD), MAX_COORD), + }; + } + if (Object.keys(positions).length === 0) return {}; + + stores.graphStore.writePositions(positions); + + // The graph grew past what the client knew (a merge landed mid-drag): + // absorb the new nodes with the client's rest state as anchors. + const unique = getGraph(stores.graphStore).nodes.length; + if (unique > Object.keys(positions).length) settleGraphLayout(stores.graphStore); + return positions; +} \ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts index 7163d12..b482018 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -7,6 +7,7 @@ export * from "./chat-handler.ts"; export * from "./chats.ts"; export * from "./graph.ts"; +export * from "./commit-positions.ts"; export * from "./jobs.ts"; export * from "./stores.ts"; export { readRenderSettings, writeRenderSettings } from "../store/render-settings.ts"; diff --git a/tests/clusters.test.ts b/tests/clusters.test.ts index 84cd467..7436141 100644 --- a/tests/clusters.test.ts +++ b/tests/clusters.test.ts @@ -7,7 +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 { clusterHue } from "../components/cluster-geometry.ts"; import type { Community } from "../src/core/clusters.ts"; import type { ClusterLabeling } from "../src/core/contracts.ts"; @@ -73,95 +73,14 @@ 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("clusterHue", () => { + test("is stable per id and spreads", () => { + // Same id → same hue, always; different ids → usually different hues. + assert.equal(clusterHue("cluster-a"), clusterHue("cluster-a")); + const hues = new Set(); + for (let i = 0; i < 40; i++) hues.add(clusterHue(`cluster-${i}`)); + assert.ok(hues.size > 20, `expected spread, got ${hues.size} distinct hues for 40 ids`); + for (const h of hues) assert.ok(h >= 0 && h < 360); }); });