Files
clide/lib/src/graph/force_layout.dart
T
jpmschweitzerandClaude Opus 4.8 bcef3a6fca 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>
2026-06-30 00:03:44 +02:00

100 lines
3.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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)};
}
}