feat(graph): deterministic force-directed layout solver (T-323)

Fruchterman-Reingold (clide-owned, no layout package): nodes repel, edges
attract, cooled over iterations into a readable layout. Deterministic — a
fixed circular seed, no RNG — so the graph view is stable across rebuilds
and the solver is unit-tested (6 cases). Flutter-free; the interactive graph
pane (render, pan/zoom, hover, filter, pql wiring) builds on this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 00:03:44 +02:00
co-authored by Claude Opus 4.8
parent c167a37ea7
commit bcef3a6fca
4 changed files with 281 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
/// Force-directed graph layout (T-323) — a clide-owned Fruchterman-Reingold
/// solver (own-the-rendering-stack: no layout package).
///
/// Nodes repel each other (an inverse-distance "Coulomb" force); edges pull
/// their endpoints together (a "spring"). Iterating with a cooling temperature
/// settles the graph into a readable layout. DETERMINISTIC — a fixed circular
/// seed (no RNG) means the same graph always lays out identically, so the view
/// is stable across rebuilds and the solver is unit-testable.
///
/// Flutter-free: pure Dart (dart:math), runs under `dart test`. The graph PANE
/// (rendering, pan/zoom, hover, filter) builds on top of this.
library;
import 'dart:math' as math;
/// A laid-out 2D point.
typedef GraphPoint = ({double x, double y});
class _Vec {
_Vec(this.x, this.y);
double x, y;
}
class ForceLayout {
/// Lay out [nodeIds] connected by [edges] (pairs of node ids) in a
/// [width]×[height] area over [iterations] steps. Edges referencing an unknown
/// node are ignored. Returns each node's settled position, clamped to the area.
static Map<String, GraphPoint> compute(List<String> nodeIds, List<(String, String)> edges, {double width = 800, double height = 600, int iterations = 200}) {
final n = nodeIds.length;
if (n == 0) return const {};
final cx = width / 2, cy = height / 2;
if (n == 1) return {nodeIds.first: (x: cx, y: cy)};
// Deterministic circular seed.
final pos = <String, _Vec>{};
for (var i = 0; i < n; i++) {
final a = 2 * math.pi * i / n;
pos[nodeIds[i]] = _Vec(cx + math.cos(a) * width / 4, cy + math.sin(a) * height / 4);
}
final valid = edges.where((e) => pos.containsKey(e.$1) && pos.containsKey(e.$2) && e.$1 != e.$2).toList();
final k = math.sqrt(width * height / n); // ideal edge length
var temp = width / 10;
for (var iter = 0; iter < iterations; iter++) {
final disp = {for (final id in nodeIds) id: _Vec(0, 0)};
// Repulsion between every pair.
for (var i = 0; i < n; i++) {
for (var j = i + 1; j < n; j++) {
final a = pos[nodeIds[i]]!, b = pos[nodeIds[j]]!;
var dx = a.x - b.x, dy = a.y - b.y;
var dist = math.sqrt(dx * dx + dy * dy);
if (dist < 0.01) {
dx = 0.01 * (i.isEven ? 1 : -1);
dy = 0.01;
dist = 0.01;
}
final force = k * k / dist;
final ux = dx / dist, uy = dy / dist;
disp[nodeIds[i]]!
..x += ux * force
..y += uy * force;
disp[nodeIds[j]]!
..x -= ux * force
..y -= uy * force;
}
}
// Attraction along edges.
for (final e in valid) {
final a = pos[e.$1]!, b = pos[e.$2]!;
final dx = a.x - b.x, dy = a.y - b.y;
final dist = math.max(0.01, math.sqrt(dx * dx + dy * dy));
final force = dist * dist / k;
final ux = dx / dist, uy = dy / dist;
disp[e.$1]!
..x -= ux * force
..y -= uy * force;
disp[e.$2]!
..x += ux * force
..y += uy * force;
}
// Apply, capped by the temperature, clamped to the area.
for (final id in nodeIds) {
final d = disp[id]!;
final len = math.max(0.01, math.sqrt(d.x * d.x + d.y * d.y));
final step = math.min(len, temp);
final p = pos[id]!;
p.x = (p.x + d.x / len * step).clamp(0.0, width);
p.y = (p.y + d.y / len * step).clamp(0.0, height);
}
temp *= 0.95; // cool
}
return {for (final id in nodeIds) id: (x: pos[id]!.x, y: pos[id]!.y)};
}
}