diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 58ea245..5b3c71a 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -55,8 +55,11 @@ import { zoom, zoomIdentity, type ZoomBehavior, type ZoomTransform } from "d3-zo import type { Simulation, SimulationLinkDatum } from "d3-force"; import { clusterHue, type Pt } from "./cluster-geometry.ts"; import { + CHARGE_STRENGTH, + CLUSTER_PULL_STRENGTH, createGraphSimulation, INCREMENTAL_GRAVITY, + LINK_DISTANCE, type GraphRenderSettings, type LayoutNode, type Position, @@ -196,6 +199,51 @@ export function sanitizeRegionTuning(input: Partial | null | undef }; } +/** User-facing live-sim physics controls (2026-09-13, user request): + * every value feeds createGraphSimulation — the SAME engine the server + * settle runs — so tuning the feel changes exactly what a drag relaxes + * toward. Session state, never persisted. The cluster pull is the + * "virtual node" realized as a centroid force (O(n)/tick, measured + * indistinguishable from off). */ +export interface PhysicsTuning { + /** Node repulsion (d3 charge, negative = push apart). */ + chargeStrength: number; + /** Edge attraction: link-spring stiffness multiplier (1 = d3 default). */ + linkStrength: number; + /** Link spring rest length (world units). */ + linkDistance: number; + /** Central pull toward the graph centroid. */ + gravity: number; + /** Per-cluster pull toward the cluster centroid. */ + clusterPull: number; + /** Per-tick velocity damping (d3 default 0.4). */ + velocityDecay: number; +} + +export const DEFAULT_PHYSICS_TUNING: PhysicsTuning = { + chargeStrength: CHARGE_STRENGTH, + linkStrength: 1, + linkDistance: LINK_DISTANCE, + gravity: INCREMENTAL_GRAVITY, + clusterPull: CLUSTER_PULL_STRENGTH, + velocityDecay: 0.4, +}; + +/** Clamp anything into a usable tuning (number inputs can be empty/NaN). */ +export function sanitizePhysicsTuning(input: Partial | null | undefined): PhysicsTuning { + const d = DEFAULT_PHYSICS_TUNING; + const num = (v: unknown, fallback: number): number => + typeof v === "number" && Number.isFinite(v) ? v : fallback; + return { + chargeStrength: Math.min(Math.max(num(input?.chargeStrength, d.chargeStrength), -1000), -10), + linkStrength: Math.min(Math.max(num(input?.linkStrength, d.linkStrength), 0), 5), + linkDistance: Math.min(Math.max(num(input?.linkDistance, d.linkDistance), 1), 300), + gravity: Math.min(Math.max(num(input?.gravity, d.gravity), 0), 1), + clusterPull: Math.min(Math.max(num(input?.clusterPull, d.clusterPull), 0), 2), + velocityDecay: Math.min(Math.max(num(input?.velocityDecay, d.velocityDecay), 0), 0.99), + }; +} + /** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA * (Float32, stride 4, length w*h*4). Premultiplying first is what keeps * the blur halo-free: blurring non-premultiplied RGB over varying alpha @@ -504,6 +552,9 @@ export class GraphCanvasRenderer { /** Region labels: cluster centroid + hue, computed at splat build. */ private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = []; private regionTuning: RegionTuning = DEFAULT_REGION_TUNING; + private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING; + /** Gravity center for the live sim (graph bbox centroid at setData). */ + private simCenter: Pt = { x: 0, y: 0 }; private regionBuiltVersion = -1; private frameCount = 0; // draw counter for the live-sim splat throttle private labelLines = new Map(); @@ -590,18 +641,8 @@ export class GraphCanvasRenderer { 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.simCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }; + this.buildSim(); this.geoVersion++; // new data → rebuild world geometry this.markDirty(); } @@ -621,6 +662,17 @@ export class GraphCanvasRenderer { this.markDirty(); } + /** Live-sim physics controls: rebuilds the sim from the CURRENT + * node positions (same node objects — x/y and any drag pin fx/fy + * survive; velocities restart clean, which reads as "re-relax with + * the new feel"). A mid-settle change re-anneals, which is the + * honest response to new physics. */ + setPhysicsTuning(input: Partial): void { + this.physicsTuning = sanitizePhysicsTuning(input); + this.buildSim(); + this.markDirty(); + } + setVisible(visible: ReadonlySet | null): void { this.visible = visible; this.geoVersion++; // member visibility changes hulls + paths @@ -1013,6 +1065,30 @@ export class GraphCanvasRenderer { ctx.font = prevFont; } + /** (Re)create the live sim from data.nodes at their current + * positions + the current physics tuning. Called by setData and + * setPhysicsTuning. */ + private buildSim(): void { + const data = this.data; + if (!data || data.nodes.length === 0) { + this.sim = null; + this.simById = new Map(); + return; + } + const t = this.physicsTuning; + this.sim = createGraphSimulation(data.nodes, data.edges, { + gravity: { x: this.simCenter.x, y: this.simCenter.y, strength: t.gravity }, + alphaDecay: LIVE_ALPHA_DECAY, + velocityDecay: t.velocityDecay, + chargeStrength: t.chargeStrength, + linkStrength: t.linkStrength, + linkDistance: t.linkDistance, + clusterPull: + data.clusters.length > 0 ? { clusters: data.clusters, strength: t.clusterPull } : undefined, + }); + this.simById = new Map(data.nodes.map((n) => [n.id, n as LayoutNode])); + } + /** 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 diff --git a/components/graph-view.tsx b/components/graph-view.tsx index ac81d15..c0f64fb 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -64,13 +64,13 @@ import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; -import Graph from "graphology"; -import forceAtlas2 from "graphology-layout-forceatlas2"; -import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core"; +import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/core"; import { + DEFAULT_PHYSICS_TUNING, DEFAULT_REGION_TUNING, GraphCanvasRenderer, type GraphRenderSettings, + type PhysicsTuning, type RegionTuning, } from "./graph-canvas.ts"; import { @@ -231,6 +231,10 @@ export function GraphView() { // render settings, which live on the server). Number inputs so any // value can be tried quickly. const [regionTuning, setRegionTuning] = useState(DEFAULT_REGION_TUNING); + // Live-sim physics controls: session state, never persisted. Changes + // rebuild the renderer's sim from the current node positions — the + // same engine the server settle runs, so the feel IS the physics. + const [physicsTuning, setPhysicsTuning] = useState(DEFAULT_PHYSICS_TUNING); // Render settings: SERVER-persisted (data/render-settings.json via // /api/graph/render-settings). Loaded once after mount; every user // change saves back, debounced. Defaults render until the load @@ -387,36 +391,11 @@ export function GraphView() { }; } } - const graph = new Graph(); - for (const node of snapshot.nodes) { - // Seeded start positions (FA2 requires non-zero x/y) keep the - // frozen layout stable for a given node set across remounts. - const rand = seededRandom(node.id); - graph.addNode(node.id, { - x: (rand() - 0.5) * 100, - y: (rand() - 0.5) * 100, - }); - } - for (const edge of snapshot.edges) { - // Snapshot edges are already merged by (from,to,type); the guard - // is just belt-and-braces for the simple graphology graph. - if (!graph.hasEdge(edge.from, edge.to)) { - graph.addEdge(edge.from, edge.to); - } - } - - // Run-then-freeze: one synchronous pass, writes x/y, then done. - // Barnes-Hut comes on automatically above ~500 nodes (the vision's - // first escalation rung — see scripts/bench-graph.ts for the numbers). - const layoutSettings = { - ...forceAtlas2.inferSettings(graph), - ...(graph.order > 500 ? { barnesHutOptimize: true } : {}), - }; - forceAtlas2.assign(graph, { iterations: 100, settings: layoutSettings }); - - const positions = new Map(); - graph.forEachNode((id, attrs) => positions.set(id, { x: attrs.x, y: attrs.y })); - posRef.current = positions; + // Bootstrap fallback: the ledger is absent (fresh graph) — run + // the SAME engine the server settle uses (d3-force via + // incrementalLayout, seeded deterministic) and freeze the result. + // Deterministic for a given node set across remounts. + posRef.current = incrementalLayout(snapshot.nodes, snapshot.edges, new Map()); return () => { posRef.current = null; }; @@ -509,6 +488,12 @@ export function GraphView() { rendererRef.current?.setRegionTuning(regionTuning); }, [regionTuning]); + // Feed #5 — physics tuning: rebuilds the live sim in place (drag pins + // survive; a mid-settle change re-anneals, the honest response). + useEffect(() => { + rendererRef.current?.setPhysicsTuning(physicsTuning); + }, [physicsTuning]); + // Position the detail card: below the clicked node, flipped above near // the bottom edge, clamped horizontally. Imperative (ref + layout effect, // before paint) because the flip needs the card's measured height. @@ -672,6 +657,51 @@ export function GraphView() { onChange={(v) => setRegionTuning((t) => ({ ...t, edgeAlpha: v }))} /> + {/* Physics (user-facing): the live sim's forces. Every value feeds + the SAME engine the server settle runs (createGraphSimulation); + changing one rebuilds the sim from the current positions — + drag mid-settle re-anneals, pins survive. Session-only. */} +
+ + Physics + + setPhysicsTuning((t) => ({ ...t, chargeStrength: v }))} + /> + setPhysicsTuning((t) => ({ ...t, linkStrength: v }))} + /> + setPhysicsTuning((t) => ({ ...t, linkDistance: v }))} + /> + setPhysicsTuning((t) => ({ ...t, gravity: v }))} + /> + setPhysicsTuning((t) => ({ ...t, clusterPull: v }))} + /> + setPhysicsTuning((t) => ({ ...t, velocityDecay: v }))} + /> +
)} {snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && ( diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index 6c57911..87aaa71 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -17,8 +17,10 @@ import { fitTransform, labelAlpha, pickNearest, + sanitizePhysicsTuning, sanitizeRegionTuning, zoomRatioOf, + DEFAULT_PHYSICS_TUNING, DEFAULT_REGION_TUNING, LABELS_FADE_SPAN, } from "../components/graph-canvas.ts"; @@ -194,3 +196,29 @@ describe("boxBlurRound (premultiplied region blur)", () => { assert.deepEqual([...b], [1, 2, 3, 4, 5, 6, 7, 8]); }); }); + +describe("sanitizePhysicsTuning (physics controls)", () => { + test("defaults survive untouched; partial input keeps the rest", () => { + assert.deepEqual(sanitizePhysicsTuning(undefined), DEFAULT_PHYSICS_TUNING); + const half = sanitizePhysicsTuning({ clusterPull: 0.6 }); + assert.equal(half.clusterPull, 0.6); + assert.equal(half.chargeStrength, DEFAULT_PHYSICS_TUNING.chargeStrength); + }); + + test("garbage clamps: NaN falls back, out-of-range clamps", () => { + const t = sanitizePhysicsTuning({ + chargeStrength: Number.NaN, + linkStrength: 99, + linkDistance: -5, + gravity: 7, + clusterPull: -1, + velocityDecay: 3, + }); + assert.equal(t.chargeStrength, DEFAULT_PHYSICS_TUNING.chargeStrength); + assert.equal(t.linkStrength, 5); + assert.equal(t.linkDistance, 1); + assert.equal(t.gravity, 1); + assert.equal(t.clusterPull, 0); + assert.equal(t.velocityDecay, 0.99); + }); +});