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