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:
@@ -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) });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+337
-65
@@ -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<string, Position>) => 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<string> | 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<LayoutNode, SimulationLinkDatum<LayoutNode>> | null = null;
|
||||
private simById = new Map<string, LayoutNode>();
|
||||
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<typeof setTimeout> | 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<string, string[]>();
|
||||
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<string, Position> = {};
|
||||
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<string, number>();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, Position> {
|
||||
if (typeof input !== "object" || input === null) return {};
|
||||
const raw = input as Record<string, unknown>;
|
||||
|
||||
// Only ids the graph actually has (first line wins, like getGraph).
|
||||
const known = new Map<string, boolean>();
|
||||
for (const line of stores.graphStore.readNodes()) {
|
||||
if (!known.has(line.id)) known.set(line.id, true);
|
||||
}
|
||||
|
||||
const positions: Record<string, Position> = {};
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+9
-90
@@ -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<number>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user