Physics controls + FA2 fallback retirement
User-facing live-sim controls (settings panel, Physics section): node repulsion, edge attraction, edge length, central pull, cluster pull, friction — session-only number inputs feeding createGraphSimulation (the SAME engine the server settle runs, so the feel IS the physics). Changing a value rebuilds the sim from current positions; drag pins (fx/fy live on the node objects) survive; a mid-settle change re-anneals. The graph-view FA2 bootstrap fallback is rewritten on incrementalLayout (d3-force): the last graphology import in the shell is gone.
This commit is contained in:
+88
-12
@@ -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<RegionTuning> | 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<PhysicsTuning> | 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<string, string[]>();
|
||||
@@ -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<PhysicsTuning>): void {
|
||||
this.physicsTuning = sanitizePhysicsTuning(input);
|
||||
this.buildSim();
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
setVisible(visible: ReadonlySet<string> | 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
|
||||
|
||||
+63
-33
@@ -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<RegionTuning>(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<PhysicsTuning>(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<string, { x: number; y: number }>();
|
||||
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 }))}
|
||||
/>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Physics
|
||||
</span>
|
||||
<NumberField
|
||||
label="Node repulsion"
|
||||
value={physicsTuning.chargeStrength}
|
||||
step={10}
|
||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, chargeStrength: v }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Edge attraction"
|
||||
value={physicsTuning.linkStrength}
|
||||
step={0.25}
|
||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, linkStrength: v }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Edge length"
|
||||
value={physicsTuning.linkDistance}
|
||||
step={5}
|
||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, linkDistance: v }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Central pull"
|
||||
value={physicsTuning.gravity}
|
||||
step={0.01}
|
||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, gravity: v }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Cluster pull"
|
||||
value={physicsTuning.clusterPull}
|
||||
step={0.05}
|
||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, clusterPull: v }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Friction"
|
||||
value={physicsTuning.velocityDecay}
|
||||
step={0.05}
|
||||
onChange={(v) => setPhysicsTuning((t) => ({ ...t, velocityDecay: v }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user