// 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 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 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? 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? 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 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> 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 = {}; 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 = >{}; for (final entry in crossingResult.edgeVirtualChain.entries) { final virtuals = entry.value; if (virtuals.isEmpty) continue; final points = []; 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 = >{ 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 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 reversed; } _CycleResult _removeCycles(List nodes, List edges) { // Adjacency list for the original graph. final adj = >{}; for (final e in edges) { adj.putIfAbsent(e.from, () => []).add(e); } // DFS coloring: 0 = unvisited, 1 = on-stack, 2 = done. final color = {for (final n in nodes) n: 0}; final reversed = {}; void visit(String v) { color[v] = 1; for (final e in adj[v] ?? const []) { 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 = [ 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 _assignLayers({ required List nodeIds, required List edges, required Map? preassigned, }) { final adj = >{}; final inDeg = {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 = [ for (final entry in inDeg.entries) if (entry.value == 0) entry.key, ]; final topo = []; final indeg = Map.from(inDeg); while (queue.isNotEmpty) { final n = queue.removeAt(0); topo.add(n); for (final m in adj[n] ?? const []) { 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 = {}; // 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 layerOf; /// For each layer, the ordered list of node IDs (real and virtual). final List> layerOrder; /// Adjacency to upper neighbors (one layer above): node → list of node IDs. final Map> upper; /// Adjacency to lower neighbors. final Map> lower; /// For each original edge ID, the chain of virtual node IDs it passed /// through (empty for single-layer edges). final Map> edgeVirtualChain; /// Total crossings remaining after the best sweep. final int crossingCount; /// How many virtual nodes were inserted. final int virtualCount; } _CrossingResult _reduceCrossings({ required List nodeIds, required List edges, required Map layers, required SugiyamaConfig config, }) { // Step 1: insert virtual nodes for multi-layer edges. final layerOf = Map.from(layers); final upper = >{}; final lower = >{}; final edgeVirtualChain = >{}; 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 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>.generate(layerCount, (_) => []); 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> _cloneOrder(List> source) { return [for (final layer in source) List.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> layerOrder, int layerIndex, Map> adjacency, List reference, ) { final indexOf = { for (var i = 0; i < reference.length; i++) reference[i]: i, }; final layer = layerOrder[layerIndex]; final keys = {}; for (var i = 0; i < layer.length; i++) { final id = layer[i]; final neighbors = adjacency[id] ?? const []; final indexes = [ 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> layerOrder, Map> 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 top, List bottom, Map> 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 = { for (var i = 0; i < top.length; i++) top[i]: i, }; final pairs = []; for (var bi = 0; bi < bottom.length; bi++) { final neighbors = upper[bottom[bi]] ?? const []; final sorted = [ 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 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 = []; 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 x; /// Y coordinate of every node center. final Map y; } _CoordResult _assignCoordinates({ required Set realNodeIds, required Map layerOf, required List> layerOrder, required Map> upper, required Map> lower, required Map nodeSizes, required SugiyamaConfig config, }) { // Y coordinates: layer * layerSpacing, adjusted for max node height per layer. final y = {}; 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 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 _simpleXAssignment( List> layerOrder, Map nodeSizes, SugiyamaConfig config, ) { final x = {}; 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 _brandesKopf({ required List> layerOrder, required Map> upper, required Map> lower, required Map 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 = >[]; for (final goingDown in [false, true]) { for (final goingLeft in [false, true]) { final root = {}; final align = {}; 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 = {}; 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 _markType1Conflicts( List> layerOrder, Map> upper, ) { final conflicts = {}; for (var l = 1; l < layerOrder.length; l++) { final top = layerOrder[l - 1]; final bot = layerOrder[l]; final topIndex = { 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 = [ for (final u in upper[v] ?? const []) 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 []) { 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> layerOrder, required Map> adjacency, required Set type1Conflicts, required Map root, required Map align, required bool goingDown, required bool goingLeft, }) { final iter = goingDown ? List.generate(layerOrder.length, (i) => i) : List.generate(layerOrder.length, (i) => layerOrder.length - 1 - i); for (final layerIdx in iter) { final layer = layerOrder[layerIdx]; var r = goingLeft ? -1 : layerOrder.fold(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 []; 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 = { 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 _horizontalCompaction({ required List> layerOrder, required Map root, required Map align, required Map 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 = {}; final shift = {}; final x = {}; 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> layerOrder, String id) { for (var i = 0; i < layerOrder.length; i++) { if (layerOrder[i].contains(id)) return i; } return -1; }