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 type { Simulation, SimulationLinkDatum } from "d3-force";
|
||||||
import { clusterHue, type Pt } from "./cluster-geometry.ts";
|
import { clusterHue, type Pt } from "./cluster-geometry.ts";
|
||||||
import {
|
import {
|
||||||
|
CHARGE_STRENGTH,
|
||||||
|
CLUSTER_PULL_STRENGTH,
|
||||||
createGraphSimulation,
|
createGraphSimulation,
|
||||||
INCREMENTAL_GRAVITY,
|
INCREMENTAL_GRAVITY,
|
||||||
|
LINK_DISTANCE,
|
||||||
type GraphRenderSettings,
|
type GraphRenderSettings,
|
||||||
type LayoutNode,
|
type LayoutNode,
|
||||||
type Position,
|
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
|
/** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA
|
||||||
* (Float32, stride 4, length w*h*4). Premultiplying first is what keeps
|
* (Float32, stride 4, length w*h*4). Premultiplying first is what keeps
|
||||||
* the blur halo-free: blurring non-premultiplied RGB over varying alpha
|
* 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. */
|
/** Region labels: cluster centroid + hue, computed at splat build. */
|
||||||
private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = [];
|
private regionMeta: { label: string | null; cx: number; cy: number; hue: number }[] = [];
|
||||||
private regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
|
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 regionBuiltVersion = -1;
|
||||||
private frameCount = 0; // draw counter for the live-sim splat throttle
|
private frameCount = 0; // draw counter for the live-sim splat throttle
|
||||||
private labelLines = new Map<string, string[]>();
|
private labelLines = new Map<string, string[]>();
|
||||||
@@ -590,18 +641,8 @@ export class GraphCanvasRenderer {
|
|||||||
this.simActive = false;
|
this.simActive = false;
|
||||||
this.dragId = null;
|
this.dragId = null;
|
||||||
this.simMoved = false;
|
this.simMoved = false;
|
||||||
if (data.nodes.length > 0) {
|
this.simCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
|
||||||
const cx = (minX + maxX) / 2;
|
this.buildSim();
|
||||||
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.geoVersion++; // new data → rebuild world geometry
|
||||||
this.markDirty();
|
this.markDirty();
|
||||||
}
|
}
|
||||||
@@ -621,6 +662,17 @@ export class GraphCanvasRenderer {
|
|||||||
this.markDirty();
|
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 {
|
setVisible(visible: ReadonlySet<string> | null): void {
|
||||||
this.visible = visible;
|
this.visible = visible;
|
||||||
this.geoVersion++; // member visibility changes hulls + paths
|
this.geoVersion++; // member visibility changes hulls + paths
|
||||||
@@ -1013,6 +1065,30 @@ export class GraphCanvasRenderer {
|
|||||||
ctx.font = prevFont;
|
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.
|
/** Rasterize cluster regions into the low-res world-space bitmap.
|
||||||
* Runs on data/visibility/settings changes at rest, and every
|
* Runs on data/visibility/settings changes at rest, and every
|
||||||
* REGION_EVERY-th frame while the live sim moves nodes (the in-between
|
* 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 { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
||||||
import Graph from "graphology";
|
import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/core";
|
||||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
|
||||||
import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core";
|
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_PHYSICS_TUNING,
|
||||||
DEFAULT_REGION_TUNING,
|
DEFAULT_REGION_TUNING,
|
||||||
GraphCanvasRenderer,
|
GraphCanvasRenderer,
|
||||||
type GraphRenderSettings,
|
type GraphRenderSettings,
|
||||||
|
type PhysicsTuning,
|
||||||
type RegionTuning,
|
type RegionTuning,
|
||||||
} from "./graph-canvas.ts";
|
} from "./graph-canvas.ts";
|
||||||
import {
|
import {
|
||||||
@@ -231,6 +231,10 @@ export function GraphView() {
|
|||||||
// render settings, which live on the server). Number inputs so any
|
// render settings, which live on the server). Number inputs so any
|
||||||
// value can be tried quickly.
|
// value can be tried quickly.
|
||||||
const [regionTuning, setRegionTuning] = useState<RegionTuning>(DEFAULT_REGION_TUNING);
|
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
|
// Render settings: SERVER-persisted (data/render-settings.json via
|
||||||
// /api/graph/render-settings). Loaded once after mount; every user
|
// /api/graph/render-settings). Loaded once after mount; every user
|
||||||
// change saves back, debounced. Defaults render until the load
|
// change saves back, debounced. Defaults render until the load
|
||||||
@@ -387,36 +391,11 @@ export function GraphView() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const graph = new Graph();
|
// Bootstrap fallback: the ledger is absent (fresh graph) — run
|
||||||
for (const node of snapshot.nodes) {
|
// the SAME engine the server settle uses (d3-force via
|
||||||
// Seeded start positions (FA2 requires non-zero x/y) keep the
|
// incrementalLayout, seeded deterministic) and freeze the result.
|
||||||
// frozen layout stable for a given node set across remounts.
|
// Deterministic for a given node set across remounts.
|
||||||
const rand = seededRandom(node.id);
|
posRef.current = incrementalLayout(snapshot.nodes, snapshot.edges, new Map());
|
||||||
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;
|
|
||||||
return () => {
|
return () => {
|
||||||
posRef.current = null;
|
posRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -509,6 +488,12 @@ export function GraphView() {
|
|||||||
rendererRef.current?.setRegionTuning(regionTuning);
|
rendererRef.current?.setRegionTuning(regionTuning);
|
||||||
}, [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
|
// Position the detail card: below the clicked node, flipped above near
|
||||||
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
||||||
// before paint) because the flip needs the card's measured height.
|
// 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 }))}
|
onChange={(v) => setRegionTuning((t) => ({ ...t, edgeAlpha: v }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
fitTransform,
|
fitTransform,
|
||||||
labelAlpha,
|
labelAlpha,
|
||||||
pickNearest,
|
pickNearest,
|
||||||
|
sanitizePhysicsTuning,
|
||||||
sanitizeRegionTuning,
|
sanitizeRegionTuning,
|
||||||
zoomRatioOf,
|
zoomRatioOf,
|
||||||
|
DEFAULT_PHYSICS_TUNING,
|
||||||
DEFAULT_REGION_TUNING,
|
DEFAULT_REGION_TUNING,
|
||||||
LABELS_FADE_SPAN,
|
LABELS_FADE_SPAN,
|
||||||
} from "../components/graph-canvas.ts";
|
} 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]);
|
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