Files
tasq/lib/screens/network_map/layout/sugiyama_layout.dart
T
2026-05-21 06:29:14 +08:00

1044 lines
34 KiB
Dart

// Sugiyama layered graph layout — pure-Dart implementation.
//
// This file implements the four classical Sugiyama phases:
// 1. Cycle removal (greedy DFS back-edge reversal)
// 2. Layer assignment (role-driven + longest-path fallback)
// 3. Crossing reduction (virtual nodes + alternating barycenter-median sweeps)
// 4. Coordinate assignment (Brandes-Köpf, four-way average)
//
// The output is a [SugiyamaResult] with final (x, y) positions for every real
// node plus polyline waypoints for any edge that spans more than one layer.
// Virtual nodes used internally during Phase 3/4 are NOT rendered as visible
// nodes; their coordinates only inform the edge waypoints.
//
// The file imports only `dart:math` and `dart:ui` so the layout can be safely
// dispatched to an isolate via `compute()` without pulling in Flutter binding
// initialization.
import 'dart:math' as math;
import 'dart:ui';
// ─── Public API ────────────────────────────────────────────────────────────
class SugiyamaInput {
SugiyamaInput({
required this.nodeIds,
required this.edges,
this.preassignedLayers,
this.nodeSizes,
this.config = const SugiyamaConfig(),
});
/// All real (non-virtual) node IDs participating in the graph.
final List<String> nodeIds;
/// Directed edges. The layout treats them as undirected for layering and
/// crossing reduction, but the direction is preserved for downstream
/// renderers that may care about arrow style.
final List<SugiyamaEdge> edges;
/// Optional layer hints. If a node ID maps to an int here, that layer is
/// used as a constraint during Phase 2. Nodes without a hint get longest-
/// path assignment, respecting hinted nodes as floor/ceiling constraints.
final Map<String, int>? preassignedLayers;
/// Optional per-node bounding box. Larger nodes get more horizontal space
/// during Phase 4 compaction. If not provided, [SugiyamaConfig.defaultNodeSize]
/// is used.
final Map<String, Size>? nodeSizes;
final SugiyamaConfig config;
}
class SugiyamaEdge {
const SugiyamaEdge({required this.id, required this.from, required this.to});
/// Stable opaque identifier used for downstream rendering (port label chips
/// look this up). Not used by the layout algorithm itself.
final String id;
final String from;
final String to;
}
class SugiyamaConfig {
const SugiyamaConfig({
this.layerSpacing = 144,
this.nodeSpacing = 96,
this.maxSweeps = 24,
this.useBrandesKopf = true,
this.defaultNodeSize = const Size(160, 110),
this.virtualNodeCap = 1000,
});
/// Vertical distance between consecutive layers (top of next layer to top
/// of current layer). Used as the per-layer Y stride; per-layer adjustment
/// based on actual node heights is applied during coordinate assignment.
final double layerSpacing;
/// Minimum horizontal spacing between centers of two same-layer nodes.
final double nodeSpacing;
/// Hard upper bound on barycenter sweeps. Most graphs converge in <8.
final int maxSweeps;
/// When false, use a simpler median-X coordinate assignment instead of
/// Brandes-Köpf. Useful as a debugging fallback if the alignment phase
/// misbehaves on pathological inputs.
final bool useBrandesKopf;
/// Default bounding box used when [SugiyamaInput.nodeSizes] doesn't list a
/// specific node. Matches the standard DeviceNode dimensions.
final Size defaultNodeSize;
/// Maximum virtual nodes to insert during Phase 3. If the graph would
/// exceed this (many long-span edges), Phase 3 falls back to barycenter
/// without virtuals — quality degrades gracefully rather than crashing.
final int virtualNodeCap;
}
class SugiyamaResult {
SugiyamaResult({
required this.nodePositions,
required this.edgeWaypoints,
required this.canvasSize,
required this.crossingCount,
required this.layerCount,
required this.computeTime,
required this.virtualNodeCount,
});
/// Top-left position of every real node.
final Map<String, Offset> nodePositions;
/// For each edge ID whose span > 1 layer, a list of intermediate (x, y)
/// waypoints (one per virtual node it passed through). Edges that span a
/// single layer have no entry here; the renderer should draw them straight.
final Map<String, List<Offset>> edgeWaypoints;
/// Bounding box of the entire layout. Includes node width/height at the
/// extremes plus a half-spacing margin.
final Size canvasSize;
/// Total crossings remaining after Phase 3. Useful for diagnostics; a value
/// near 0 means the layout is very clean.
final int crossingCount;
/// Number of layers in the final layered graph (max layer index + 1).
final int layerCount;
/// Wall-clock time for the entire layout pipeline.
final Duration computeTime;
/// Number of virtual nodes inserted during Phase 3. For diagnostics; 0 if
/// the cap was exceeded and Phase 3 fell back to no-virtuals mode.
final int virtualNodeCount;
}
/// Compute a Sugiyama layout for the given input. Synchronous; safe to call
/// from an isolate via `compute()`.
SugiyamaResult computeSugiyamaLayout(SugiyamaInput input) {
final stopwatch = Stopwatch()..start();
if (input.nodeIds.isEmpty) {
stopwatch.stop();
return SugiyamaResult(
nodePositions: const {},
edgeWaypoints: const {},
canvasSize: const Size(600, 400),
crossingCount: 0,
layerCount: 0,
computeTime: stopwatch.elapsed,
virtualNodeCount: 0,
);
}
// Phase 1 — cycle removal: build a DAG so layering is well-defined.
final cycleResult = _removeCycles(input.nodeIds, input.edges);
// Phase 2 — layer assignment.
final layerMap = _assignLayers(
nodeIds: input.nodeIds,
edges: cycleResult.acyclicEdges,
preassigned: input.preassignedLayers,
);
// Phase 3 — virtual nodes + crossing reduction.
final crossingResult = _reduceCrossings(
nodeIds: input.nodeIds,
edges: cycleResult.acyclicEdges,
layers: layerMap,
config: input.config,
);
// Phase 4 — coordinate assignment (Brandes-Köpf or simple).
final coords = _assignCoordinates(
realNodeIds: input.nodeIds.toSet(),
layerOf: crossingResult.layerOf,
layerOrder: crossingResult.layerOrder,
upper: crossingResult.upper,
lower: crossingResult.lower,
nodeSizes: input.nodeSizes ?? const {},
config: input.config,
);
// Build final node positions (real nodes only) and edge waypoints.
final positions = <String, Offset>{};
for (final id in input.nodeIds) {
final x = coords.x[id];
final y = coords.y[id];
if (x != null && y != null) {
final size = (input.nodeSizes ?? const {})[id] ?? input.config.defaultNodeSize;
// Position is top-left; coords are node centers.
positions[id] = Offset(x - size.width / 2, y - size.height / 2);
}
}
final waypoints = <String, List<Offset>>{};
for (final entry in crossingResult.edgeVirtualChain.entries) {
final virtuals = entry.value;
if (virtuals.isEmpty) continue;
final points = <Offset>[];
for (final v in virtuals) {
final x = coords.x[v];
final y = coords.y[v];
if (x != null && y != null) points.add(Offset(x, y));
}
if (points.isNotEmpty) waypoints[entry.key] = points;
}
// Compute canvas bounds.
double minX = double.infinity, minY = double.infinity;
double maxX = -double.infinity, maxY = -double.infinity;
for (final id in input.nodeIds) {
final pos = positions[id];
if (pos == null) continue;
final size = (input.nodeSizes ?? const {})[id] ?? input.config.defaultNodeSize;
if (pos.dx < minX) minX = pos.dx;
if (pos.dy < minY) minY = pos.dy;
if (pos.dx + size.width > maxX) maxX = pos.dx + size.width;
if (pos.dy + size.height > maxY) maxY = pos.dy + size.height;
}
// Normalize so the top-left of the bounding box is at (margin, margin).
final margin = input.config.nodeSpacing;
final offsetX = margin - (minX.isFinite ? minX : 0);
final offsetY = margin - (minY.isFinite ? minY : 0);
final normalizedPositions = {
for (final entry in positions.entries)
entry.key: entry.value.translate(offsetX, offsetY),
};
final normalizedWaypoints = <String, List<Offset>>{
for (final entry in waypoints.entries)
entry.key: [for (final p in entry.value) p.translate(offsetX, offsetY)],
};
final width =
(maxX.isFinite ? maxX : 600) - (minX.isFinite ? minX : 0) + margin * 2;
final height =
(maxY.isFinite ? maxY : 400) - (minY.isFinite ? minY : 0) + margin * 2;
stopwatch.stop();
return SugiyamaResult(
nodePositions: normalizedPositions,
edgeWaypoints: normalizedWaypoints,
canvasSize: Size(width, height),
crossingCount: crossingResult.crossingCount,
layerCount: crossingResult.layerOrder.length,
computeTime: stopwatch.elapsed,
virtualNodeCount: crossingResult.virtualCount,
);
}
// ─── Phase 1: Cycle removal ────────────────────────────────────────────────
class _CycleResult {
_CycleResult({required this.acyclicEdges, required this.reversed});
/// Edges with cycle-breaking back-edges reversed. The total edge count
/// equals the input edge count.
final List<SugiyamaEdge> acyclicEdges;
/// IDs of edges that were reversed. Stored for downstream renderers that
/// may want to draw the reversed direction differently (we currently don't).
final Set<String> reversed;
}
_CycleResult _removeCycles(List<String> nodes, List<SugiyamaEdge> edges) {
// Adjacency list for the original graph.
final adj = <String, List<SugiyamaEdge>>{};
for (final e in edges) {
adj.putIfAbsent(e.from, () => []).add(e);
}
// DFS coloring: 0 = unvisited, 1 = on-stack, 2 = done.
final color = <String, int>{for (final n in nodes) n: 0};
final reversed = <String>{};
void visit(String v) {
color[v] = 1;
for (final e in adj[v] ?? const <SugiyamaEdge>[]) {
final c = color[e.to] ?? 0;
if (c == 1) {
// Back-edge: this would close a cycle. Mark for reversal.
reversed.add(e.id);
} else if (c == 0) {
visit(e.to);
}
}
color[v] = 2;
}
for (final n in nodes) {
if ((color[n] ?? 0) == 0) visit(n);
}
final acyclicEdges = <SugiyamaEdge>[
for (final e in edges)
if (reversed.contains(e.id))
SugiyamaEdge(id: e.id, from: e.to, to: e.from)
else
e,
];
return _CycleResult(acyclicEdges: acyclicEdges, reversed: reversed);
}
// ─── Phase 2: Layer assignment ─────────────────────────────────────────────
/// Longest-path assignment, respecting preassigned layers as floor constraints.
/// Returns a map from node id → layer index (0 = topmost).
Map<String, int> _assignLayers({
required List<String> nodeIds,
required List<SugiyamaEdge> edges,
required Map<String, int>? preassigned,
}) {
final adj = <String, List<String>>{};
final inDeg = <String, int>{for (final n in nodeIds) n: 0};
for (final e in edges) {
adj.putIfAbsent(e.from, () => []).add(e.to);
inDeg[e.to] = (inDeg[e.to] ?? 0) + 1;
}
// Topological order via Kahn's algorithm.
final queue = <String>[
for (final entry in inDeg.entries)
if (entry.value == 0) entry.key,
];
final topo = <String>[];
final indeg = Map<String, int>.from(inDeg);
while (queue.isNotEmpty) {
final n = queue.removeAt(0);
topo.add(n);
for (final m in adj[n] ?? const <String>[]) {
indeg[m] = (indeg[m] ?? 0) - 1;
if (indeg[m] == 0) queue.add(m);
}
}
// Handle any leftovers (shouldn't happen post-Phase-1 but guard for safety).
for (final n in nodeIds) {
if (!topo.contains(n)) topo.add(n);
}
final layer = <String, int>{};
// Helper to enforce preassigned hints as both floor AND ceiling for the
// hinted node itself. Non-hinted nodes use computed layer freely.
int initialLayerFor(String n) {
final hint = preassigned?[n];
return hint ?? 0;
}
for (final n in topo) {
var best = initialLayerFor(n);
// For preassigned nodes, the hint is authoritative.
if (preassigned?.containsKey(n) ?? false) {
layer[n] = preassigned![n]!;
continue;
}
// For unassigned nodes, longest-path from parents.
var hasParent = false;
for (final e in edges) {
if (e.to != n) continue;
hasParent = true;
final parentLayer = layer[e.from];
if (parentLayer != null && parentLayer + 1 > best) {
best = parentLayer + 1;
}
}
if (!hasParent) {
// Source node with no hint → put on the topmost free layer.
best = 0;
}
layer[n] = best;
}
// Second pass: push sinks down so they don't crowd above their parents.
// For each unassigned node, ensure layer ≥ max(parent.layer) + 1.
for (final n in topo) {
if (preassigned?.containsKey(n) ?? false) continue;
var maxParent = -1;
for (final e in edges) {
if (e.to != n) continue;
final p = layer[e.from];
if (p != null && p > maxParent) maxParent = p;
}
if (maxParent >= 0 && (layer[n] ?? 0) <= maxParent) {
layer[n] = maxParent + 1;
}
}
// Normalize: shift so the minimum layer is 0.
if (layer.isEmpty) return layer;
final minLayer = layer.values.reduce(math.min);
if (minLayer != 0) {
for (final k in layer.keys.toList()) {
layer[k] = layer[k]! - minLayer;
}
}
return layer;
}
// ─── Phase 3: Crossing reduction (virtual nodes + barycenter sweeps) ──────
class _CrossingResult {
_CrossingResult({
required this.layerOf,
required this.layerOrder,
required this.upper,
required this.lower,
required this.edgeVirtualChain,
required this.crossingCount,
required this.virtualCount,
});
/// Final layer index for every node (real and virtual).
final Map<String, int> layerOf;
/// For each layer, the ordered list of node IDs (real and virtual).
final List<List<String>> layerOrder;
/// Adjacency to upper neighbors (one layer above): node → list of node IDs.
final Map<String, List<String>> upper;
/// Adjacency to lower neighbors.
final Map<String, List<String>> lower;
/// For each original edge ID, the chain of virtual node IDs it passed
/// through (empty for single-layer edges).
final Map<String, List<String>> edgeVirtualChain;
/// Total crossings remaining after the best sweep.
final int crossingCount;
/// How many virtual nodes were inserted.
final int virtualCount;
}
_CrossingResult _reduceCrossings({
required List<String> nodeIds,
required List<SugiyamaEdge> edges,
required Map<String, int> layers,
required SugiyamaConfig config,
}) {
// Step 1: insert virtual nodes for multi-layer edges.
final layerOf = Map<String, int>.from(layers);
final upper = <String, List<String>>{};
final lower = <String, List<String>>{};
final edgeVirtualChain = <String, List<String>>{};
var virtualCounter = 0;
var virtualCap = config.virtualNodeCap;
void connect(String from, String to) {
upper.putIfAbsent(to, () => []).add(from);
lower.putIfAbsent(from, () => []).add(to);
}
for (final e in edges) {
final lFrom = layerOf[e.from];
final lTo = layerOf[e.to];
if (lFrom == null || lTo == null) continue;
if (lFrom == lTo) {
// Same-layer edge: rare in well-formed network data. We still record
// adjacency so crossing reduction sees it, but it cannot affect crossings.
connect(e.from, e.to);
continue;
}
final low = math.min(lFrom, lTo);
final high = math.max(lFrom, lTo);
if (high - low == 1) {
connect(e.from, e.to);
continue;
}
// Insert virtual nodes for every intermediate layer.
if (virtualCounter + (high - low - 1) > virtualCap) {
// Cap exceeded — degrade gracefully: connect endpoints directly.
// The renderer will draw a straight line, which is uglier but stable.
connect(e.from, e.to);
continue;
}
final chain = <String>[];
String prev = lFrom < lTo ? e.from : e.to;
final startLayer = lFrom < lTo ? lFrom : lTo;
final endLayer = lFrom < lTo ? lTo : lFrom;
final endpoint = lFrom < lTo ? e.to : e.from;
for (var l = startLayer + 1; l < endLayer; l++) {
final vid = '__v${virtualCounter++}';
layerOf[vid] = l;
chain.add(vid);
connect(prev, vid);
prev = vid;
}
connect(prev, endpoint);
edgeVirtualChain[e.id] = chain;
}
// Step 2: initial ordering per layer (real nodes by input order, virtuals
// appended). We'll refine via sweeps.
final layerCount =
layerOf.values.isEmpty ? 0 : layerOf.values.reduce(math.max) + 1;
final layerOrder = List<List<String>>.generate(layerCount, (_) => <String>[]);
for (final id in nodeIds) {
final l = layerOf[id];
if (l != null) layerOrder[l].add(id);
}
for (final entry in layerOf.entries) {
if (!entry.key.startsWith('__v')) continue;
layerOrder[entry.value].add(entry.key);
}
// Step 3: alternating down + up sweeps using median barycenter.
var crossings = _countAllCrossings(layerOrder, upper);
var bestCrossings = crossings;
var bestOrder = _cloneOrder(layerOrder);
var noImprovement = 0;
for (var sweep = 0; sweep < config.maxSweeps && noImprovement < 4; sweep++) {
final isDown = sweep.isEven;
if (isDown) {
// Top → bottom: layer l reorders by median X of its upper neighbors.
for (var l = 1; l < layerCount; l++) {
_reorderByMedian(layerOrder, l, upper, layerOrder[l - 1]);
}
} else {
// Bottom → top: layer l reorders by median X of its lower neighbors.
for (var l = layerCount - 2; l >= 0; l--) {
_reorderByMedian(layerOrder, l, lower, layerOrder[l + 1]);
}
}
final c = _countAllCrossings(layerOrder, upper);
if (c < bestCrossings) {
bestCrossings = c;
bestOrder = _cloneOrder(layerOrder);
noImprovement = 0;
} else {
noImprovement++;
}
crossings = c;
}
return _CrossingResult(
layerOf: layerOf,
layerOrder: bestOrder,
upper: upper,
lower: lower,
edgeVirtualChain: edgeVirtualChain,
crossingCount: bestCrossings,
virtualCount: virtualCounter,
);
}
List<List<String>> _cloneOrder(List<List<String>> source) {
return [for (final layer in source) List<String>.from(layer)];
}
/// Reorder the given layer by the median index of each node's neighbors in
/// [reference] (the adjacent layer). Nodes with no neighbors keep their
/// relative order.
void _reorderByMedian(
List<List<String>> layerOrder,
int layerIndex,
Map<String, List<String>> adjacency,
List<String> reference,
) {
final indexOf = <String, int>{
for (var i = 0; i < reference.length; i++) reference[i]: i,
};
final layer = layerOrder[layerIndex];
final keys = <String, double>{};
for (var i = 0; i < layer.length; i++) {
final id = layer[i];
final neighbors = adjacency[id] ?? const <String>[];
final indexes = <int>[
for (final n in neighbors)
if (indexOf.containsKey(n)) indexOf[n]!,
]..sort();
if (indexes.isEmpty) {
keys[id] = i.toDouble(); // stable: keep current position
} else if (indexes.length.isOdd) {
keys[id] = indexes[indexes.length ~/ 2].toDouble();
} else {
// Even count: use the average of the two middle indexes (Eades-Wormald).
final m1 = indexes[indexes.length ~/ 2 - 1];
final m2 = indexes[indexes.length ~/ 2];
keys[id] = (m1 + m2) / 2;
}
}
layer.sort((a, b) {
final cmp = (keys[a] ?? 0).compareTo(keys[b] ?? 0);
if (cmp != 0) return cmp;
// Stable tiebreak by current index.
return layer.indexOf(a).compareTo(layer.indexOf(b));
});
}
/// Count edge crossings between every pair of adjacent layers. O(E log E) via
/// the standard merge-sort inversion count, applied per layer pair.
int _countAllCrossings(
List<List<String>> layerOrder, Map<String, List<String>> upper) {
var total = 0;
for (var l = 1; l < layerOrder.length; l++) {
total += _countCrossingsBetween(layerOrder[l - 1], layerOrder[l], upper);
}
return total;
}
int _countCrossingsBetween(
List<String> top,
List<String> bottom,
Map<String, List<String>> upper,
) {
// Build the list of (top-index, bottom-index) pairs sorted by bottom-index.
// Crossings = inversions in the list of top-indexes when read in bottom-order.
final topIndex = <String, int>{
for (var i = 0; i < top.length; i++) top[i]: i,
};
final pairs = <int>[];
for (var bi = 0; bi < bottom.length; bi++) {
final neighbors = upper[bottom[bi]] ?? const <String>[];
final sorted = <int>[
for (final n in neighbors)
if (topIndex.containsKey(n)) topIndex[n]!,
]..sort();
pairs.addAll(sorted);
}
// Inversion count via merge sort.
return _mergeSortInversions(pairs, 0, pairs.length - 1);
}
int _mergeSortInversions(List<int> a, int lo, int hi) {
if (lo >= hi) return 0;
final mid = (lo + hi) ~/ 2;
var count = _mergeSortInversions(a, lo, mid) +
_mergeSortInversions(a, mid + 1, hi);
// Merge.
final tmp = <int>[];
var i = lo;
var j = mid + 1;
while (i <= mid && j <= hi) {
if (a[i] <= a[j]) {
tmp.add(a[i++]);
} else {
tmp.add(a[j++]);
count += mid - i + 1;
}
}
while (i <= mid) {
tmp.add(a[i++]);
}
while (j <= hi) {
tmp.add(a[j++]);
}
for (var k = 0; k < tmp.length; k++) {
a[lo + k] = tmp[k];
}
return count;
}
// ─── Phase 4: Coordinate assignment ───────────────────────────────────────
class _CoordResult {
_CoordResult({required this.x, required this.y});
/// X coordinate of every node center (real and virtual).
final Map<String, double> x;
/// Y coordinate of every node center.
final Map<String, double> y;
}
_CoordResult _assignCoordinates({
required Set<String> realNodeIds,
required Map<String, int> layerOf,
required List<List<String>> layerOrder,
required Map<String, List<String>> upper,
required Map<String, List<String>> lower,
required Map<String, Size> nodeSizes,
required SugiyamaConfig config,
}) {
// Y coordinates: layer * layerSpacing, adjusted for max node height per layer.
final y = <String, double>{};
var cursorY = 0.0;
for (var l = 0; l < layerOrder.length; l++) {
var maxH = config.defaultNodeSize.height;
for (final id in layerOrder[l]) {
final h = nodeSizes[id]?.height ?? config.defaultNodeSize.height;
if (h > maxH) maxH = h;
}
final layerY = cursorY + maxH / 2;
for (final id in layerOrder[l]) {
y[id] = layerY;
}
cursorY += maxH + config.layerSpacing;
}
// X coordinates:
// - Brandes-Köpf when enabled: 4-pass alignment + averaging.
// - Fallback: simple per-layer compaction using median-X.
Map<String, double> x;
if (config.useBrandesKopf) {
x = _brandesKopf(
layerOrder: layerOrder,
upper: upper,
lower: lower,
nodeSizes: nodeSizes,
config: config,
);
} else {
x = _simpleXAssignment(layerOrder, nodeSizes, config);
}
return _CoordResult(x: x, y: y);
}
/// Simple per-layer X assignment: nodes laid out left-to-right with
/// [nodeSpacing] between centers, using max(node width, defaultNodeSize.width)
/// per node.
Map<String, double> _simpleXAssignment(
List<List<String>> layerOrder,
Map<String, Size> nodeSizes,
SugiyamaConfig config,
) {
final x = <String, double>{};
for (final layer in layerOrder) {
var cursor = 0.0;
for (final id in layer) {
final w = nodeSizes[id]?.width ?? config.defaultNodeSize.width;
x[id] = cursor + w / 2;
cursor += w + config.nodeSpacing;
}
}
// Center each layer horizontally by shifting to the global midpoint.
var globalMaxX = 0.0;
for (final layer in layerOrder) {
if (layer.isEmpty) continue;
final lastId = layer.last;
final lastX = x[lastId] ?? 0;
final lastW =
nodeSizes[lastId]?.width ?? config.defaultNodeSize.width;
if (lastX + lastW / 2 > globalMaxX) globalMaxX = lastX + lastW / 2;
}
for (final layer in layerOrder) {
if (layer.isEmpty) continue;
final lastId = layer.last;
final lastX = x[lastId] ?? 0;
final lastW =
nodeSizes[lastId]?.width ?? config.defaultNodeSize.width;
final layerW = lastX + lastW / 2;
final shift = (globalMaxX - layerW) / 2;
if (shift > 0) {
for (final id in layer) {
x[id] = (x[id] ?? 0) + shift;
}
}
}
return x;
}
/// Brandes-Köpf horizontal coordinate assignment. Four passes (combinations of
/// up/down and left/right), then averages the resulting X coordinates.
///
/// Reference: U. Brandes & B. Köpf, "Fast and Simple Horizontal Coordinate
/// Assignment", Graph Drawing 2001.
Map<String, double> _brandesKopf({
required List<List<String>> layerOrder,
required Map<String, List<String>> upper,
required Map<String, List<String>> lower,
required Map<String, Size> nodeSizes,
required SugiyamaConfig config,
}) {
// Step 1: mark "type 1" conflicts — edges between two non-virtual nodes
// that cross an edge involving a virtual node. These are the "hard" cases.
final type1Conflicts = _markType1Conflicts(layerOrder, upper);
// Run 4 passes: (up, left), (up, right), (down, left), (down, right).
final passResults = <Map<String, double>>[];
for (final goingDown in [false, true]) {
for (final goingLeft in [false, true]) {
final root = <String, String>{};
final align = <String, String>{};
for (final layer in layerOrder) {
for (final id in layer) {
root[id] = id;
align[id] = id;
}
}
_verticalAlign(
layerOrder: layerOrder,
adjacency: goingDown ? lower : upper,
type1Conflicts: type1Conflicts,
root: root,
align: align,
goingDown: goingDown,
goingLeft: goingLeft,
);
final x = _horizontalCompaction(
layerOrder: layerOrder,
root: root,
align: align,
nodeSizes: nodeSizes,
config: config,
goingLeft: goingLeft,
);
passResults.add(x);
}
}
// Step 2: balance — for each node, average the four x coordinates after
// normalizing each pass to the same min-X. Brandes-Köpf paper uses median
// of 4 values, which is equivalent to average for our 4-pass case.
final balanced = <String, double>{};
for (final layer in layerOrder) {
for (final id in layer) {
final xs = [
for (final r in passResults)
if (r[id] != null) r[id]!,
]..sort();
if (xs.isEmpty) continue;
// Average of the middle two (or all 4): produces stable result.
if (xs.length >= 2) {
balanced[id] = (xs[xs.length ~/ 2 - 1] + xs[xs.length ~/ 2]) / 2;
} else {
balanced[id] = xs.first;
}
}
}
return balanced;
}
/// Mark edges that are type-1 conflicts in Brandes-Köpf terminology: an edge
/// (u, v) is a conflict if it crosses an edge (u', v') where exactly one of
/// u/v is virtual and one of u'/v' is also virtual (different orientation).
/// We approximate by checking: if both endpoints are virtual, the edge is
/// never the "conflicting" one; otherwise, it conflicts with any virtual-to-
/// virtual edge that crosses it.
Set<String> _markType1Conflicts(
List<List<String>> layerOrder,
Map<String, List<String>> upper,
) {
final conflicts = <String>{};
for (var l = 1; l < layerOrder.length; l++) {
final top = layerOrder[l - 1];
final bot = layerOrder[l];
final topIndex = <String, int>{
for (var i = 0; i < top.length; i++) top[i]: i,
};
// For each bottom node, find its upper neighbors and sort by top-index.
var k0 = 0;
var k1 = top.length - 1;
for (var bi = 0; bi < bot.length; bi++) {
final v = bot[bi];
final isVirtual = v.startsWith('__v');
if (!isVirtual && bi < bot.length - 1) continue;
// For inner-segment endpoints (both virtual), tighten search bounds.
if (isVirtual || bi == bot.length - 1) {
final neighborTopIndexes = <int>[
for (final u in upper[v] ?? const <String>[])
if (topIndex.containsKey(u)) topIndex[u]!,
]..sort();
if (neighborTopIndexes.isEmpty) continue;
final k1Local = neighborTopIndexes.last;
// Walk preceding bottom nodes from k0 to bi, marking conflicts.
for (var bj = k0; bj <= bi; bj++) {
final bjNode = bot[bj];
for (final un in upper[bjNode] ?? const <String>[]) {
final ui = topIndex[un];
if (ui == null) continue;
if (ui < k0 || ui > k1Local) {
// This edge crosses an inner segment — type-1 conflict.
conflicts.add('$un|$bjNode');
}
}
}
k0 = bi + 1;
k1 = k1Local;
}
}
// k1 used only as scratch; keep loop tidy
if (k1 < 0) k1 = top.length - 1;
}
return conflicts;
}
/// Vertical alignment phase: link each node to its median neighbor in
/// [adjacency] (upper for "up" passes, lower for "down" passes). Skip linkings
/// that would conflict with already-marked type-1 conflicts.
void _verticalAlign({
required List<List<String>> layerOrder,
required Map<String, List<String>> adjacency,
required Set<String> type1Conflicts,
required Map<String, String> root,
required Map<String, String> align,
required bool goingDown,
required bool goingLeft,
}) {
final iter = goingDown
? List<int>.generate(layerOrder.length, (i) => i)
: List<int>.generate(layerOrder.length, (i) => layerOrder.length - 1 - i);
for (final layerIdx in iter) {
final layer = layerOrder[layerIdx];
var r = goingLeft ? -1 : layerOrder.fold<int>(0, (acc, l) => math.max(acc, l.length));
final orderedNodes = goingLeft ? layer : layer.reversed.toList();
for (final v in orderedNodes) {
final neighbors = adjacency[v] ?? const <String>[];
if (neighbors.isEmpty) continue;
// Build sorted list of neighbor indexes within their layer.
final adjLayerIdx = goingDown ? layerIdx + 1 : layerIdx - 1;
if (adjLayerIdx < 0 || adjLayerIdx >= layerOrder.length) continue;
final adjLayer = layerOrder[adjLayerIdx];
final adjIndex = <String, int>{
for (var i = 0; i < adjLayer.length; i++) adjLayer[i]: i,
};
final sortedNeighbors = <(int, String)>[
for (final n in neighbors)
if (adjIndex.containsKey(n)) (adjIndex[n]!, n),
]..sort((a, b) => a.$1.compareTo(b.$1));
if (sortedNeighbors.isEmpty) continue;
// Pick the median neighbor(s) per Brandes-Köpf.
final m1 = (sortedNeighbors.length - 1) ~/ 2;
final m2 = sortedNeighbors.length ~/ 2;
final medians = m1 == m2 ? [sortedNeighbors[m1]] : [sortedNeighbors[m1], sortedNeighbors[m2]];
for (final (mIdx, mNode) in medians) {
if (align[v] != v) continue; // already aligned
// Check direction: only consider neighbors strictly to one side of r.
final wantStrictlyAfter = !goingLeft;
if ((wantStrictlyAfter && mIdx > r) ||
(!wantStrictlyAfter && mIdx < r)) {
// Check type-1 conflict.
final edgeKey = goingDown ? '$v|$mNode' : '$mNode|$v';
if (type1Conflicts.contains(edgeKey)) continue;
align[mNode] = v;
root[v] = root[mNode] ?? mNode;
align[v] = root[v]!;
r = mIdx;
}
}
}
}
}
/// Horizontal compaction: lay out aligned chains while respecting minimum
/// spacing. The resulting X positions are then optionally mirrored for
/// "leftward" passes.
Map<String, double> _horizontalCompaction({
required List<List<String>> layerOrder,
required Map<String, String> root,
required Map<String, String> align,
required Map<String, Size> nodeSizes,
required SugiyamaConfig config,
required bool goingLeft,
}) {
// Each root node anchors a chain. We compute x for each root, then propagate
// to its aligned descendants.
final sink = <String, String>{};
final shift = <String, double>{};
final x = <String, double>{};
for (final layer in layerOrder) {
for (final id in layer) {
sink[id] = id;
shift[id] = double.infinity;
x[id] = double.nan;
}
}
// Position roots in topological scan order; descendants inherit.
void placeBlock(String v) {
if (!x[v]!.isNaN) return;
x[v] = 0;
var w = v;
do {
final layerOfW = _findLayer(layerOrder, w);
if (layerOfW == -1) break;
final layer = layerOrder[layerOfW];
final idx = layer.indexOf(w);
if (goingLeft ? idx < layer.length - 1 : idx > 0) {
final pred = goingLeft ? layer[idx + 1] : layer[idx - 1];
final u = root[pred]!;
placeBlock(u);
final widthPred =
nodeSizes[pred]?.width ?? config.defaultNodeSize.width;
final widthW = nodeSizes[w]?.width ?? config.defaultNodeSize.width;
final minDelta = (widthPred + widthW) / 2 + config.nodeSpacing;
if (sink[v] == v) sink[v] = sink[u]!;
if (sink[v] != sink[u]) {
final candidate = goingLeft
? x[u]! - x[v]! - minDelta
: x[u]! + minDelta - x[v]!;
final s = shift[sink[u]!]!;
shift[sink[u]!] = goingLeft
? (s == double.infinity ? candidate : math.max(s, candidate))
: (s == double.infinity ? candidate : math.min(s, candidate));
} else {
x[v] = goingLeft
? math.min(x[v]!, x[u]! - minDelta)
: math.max(x[v]!, x[u]! + minDelta);
}
}
w = align[w]!;
} while (w != v);
}
for (final layer in layerOrder) {
for (final id in layer) {
if (root[id] == id) placeBlock(id);
}
}
// Propagate root coordinates to aligned descendants and apply shifts.
for (final layer in layerOrder) {
for (final id in layer) {
final r = root[id]!;
var xr = x[r]!;
if (xr.isNaN) xr = 0;
final s = shift[sink[r]!]!;
final adjusted = s.isFinite ? xr + s : xr;
x[id] = adjusted;
}
}
// Normalize: shift so minimum x = 0.
final values = x.values.where((v) => v.isFinite).toList();
if (values.isEmpty) return x;
final minX = values.reduce(math.min);
for (final k in x.keys.toList()) {
final v = x[k]!;
x[k] = v.isFinite ? v - minX : 0;
}
return x;
}
int _findLayer(List<List<String>> layerOrder, String id) {
for (var i = 0; i < layerOrder.length; i++) {
if (layerOrder[i].contains(id)) return i;
}
return -1;
}