Cluster gravity: per-cluster centroid pull force in the engine

The 'virtual node' realized as a centroid force: two allocation-free
O(n) passes per tick (membership resolved once at sim creation;
Float64Array sums). Measured indistinguishable from force-off at 338
nodes (271ms vs 272ms per 400-tick settle).

- createGraphSimulation: SimOptions.clusterPull + linkStrength +
  velocityDecay (physics controls plumbing); degree-scaled link
  stiffness so linkStrength=1 is exactly d3's default
- settleGraphLayout split into layout-settle.ts (it computes the
  Louvain partition for the pull); layout.ts stays browser-barrel-pure
- barrel no longer re-exports clusters.ts: graphology + louvain are
  fully out of the client bundle (verified against built chunks)
- replay passes the per-batch partition; CLUSTER_PULL_STRENGTH=0.15
  tuned by sweep: cluster spread 63->36 world units, centroid
  separation and total span unchanged (no blob merge, no collapse)
This commit is contained in:
amosblomqvist
2026-09-13 09:52:12 +03:00
parent 60686d7831
commit 975a78a304
7 changed files with 183 additions and 41 deletions
+6 -5
View File
@@ -10,6 +10,7 @@
* npx tsx scripts/replay-layout.ts
*/
import { graphStore } from "../src/store/graph.ts";
import { detectCommunities } from "../src/core/clusters.ts";
import { incrementalLayout } from "../src/core/layout.ts";
import type { GraphNode, NodeMapping, Position } from "../src/core/contracts.ts";
@@ -69,11 +70,11 @@ function main() {
const activeEdges = edges.filter((e) => delivered.has(e.from) && delivered.has(e.to));
const before = new Map(positions);
positions = incrementalLayout(
nodes.filter((n) => delivered.has(n.id)),
activeEdges,
positions,
);
const batchNodes = nodes.filter((n) => delivered.has(n.id));
positions = incrementalLayout(batchNodes, activeEdges, positions, {
// The same partition the real settle would see for this batch.
clusters: detectCommunities(batchNodes, activeEdges).communities,
});
for (const [id, prev] of before) {
const now = positions.get(id)!;
const shift = Math.hypot(prev.x - now.x, prev.y - now.y);