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:
amosblomqvist
2026-09-13 09:52:14 +03:00
parent 975a78a304
commit 7ebdf065c8
3 changed files with 179 additions and 45 deletions
+88 -12
View File
@@ -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
View File
@@ -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() !== "" && (
+28
View File
@@ -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);
});
});