Skip to main content

mermaid_text/layout/
layered.rs

1//! Simplified Sugiyama-inspired layered layout algorithm.
2//!
3//! # Algorithm overview
4//!
5//! 1. **Layer assignment** — topological sort; each node is placed at layer
6//!    `max(layer(predecessor)) + 1` (longest-path from sources).
7//!
8//! 2. **Within-layer ordering** — two passes of the barycenter heuristic:
9//!    forward (left/top-to-right/bottom) and backward.
10//!
11//! 3. **Position computation** — convert (layer, rank) pairs into character-
12//!    grid `(col, row)` coordinates using configurable spacing constants.
13
14use std::collections::HashMap;
15
16use unicode_width::UnicodeWidthStr;
17
18use crate::layout::grid::BAR_THICKNESS;
19use crate::layout::subgraph::{SG_BORDER_PAD, parallel_label_extra};
20use crate::types::{BarOrientation, Direction, Graph, NodeShape, Subgraph};
21
22// ---------------------------------------------------------------------------
23// Configuration
24// ---------------------------------------------------------------------------
25
26/// Extra cells reserved between two adjacent same-layer nodes per subgraph
27/// boundary that separates them.
28///
29/// Each boundary takes `SG_BORDER_PAD` cells of padding on each side of the
30/// subgraph's border line, plus one cell for the border itself. The gap
31/// between two sibling nodes crossing one boundary therefore widens by
32/// `SG_BORDER_PAD + 1`; two boundaries (siblings in different subgraphs of
33/// the same parent) widens by `2 * (SG_BORDER_PAD + 1)`, and so on.
34const SG_GAP_PER_BOUNDARY: usize = SG_BORDER_PAD + 1;
35
36/// Layout spacing constants used by the Sugiyama-inspired layered layout.
37///
38/// These control the amount of whitespace placed between layers (columns in LR
39/// flow, rows in TD flow) and between sibling nodes within the same layer.
40/// Reducing them compacts the output; [`Default`] gives a comfortable reading
41/// size suitable for most terminals.
42///
43/// # Examples
44///
45/// ```
46/// use mermaid_text::layout::layered::LayoutConfig;
47///
48/// let default_cfg = LayoutConfig::default();
49/// assert_eq!(default_cfg.layer_gap, 6);
50/// assert_eq!(default_cfg.node_gap, 2);
51///
52/// let compact = LayoutConfig::with_gaps(2, 1);
53/// assert!(compact.layer_gap < default_cfg.layer_gap);
54/// ```
55#[derive(Debug, Clone, Copy)]
56pub struct LayoutConfig {
57    /// Minimum gap (in characters) between layers (the axis perpendicular to
58    /// the flow direction). The gap accommodates routing corridors and edge
59    /// labels; the renderer may widen it automatically when long labels require
60    /// more space.
61    pub layer_gap: usize,
62    /// Minimum gap (in characters) between sibling nodes in the same layer.
63    pub node_gap: usize,
64    /// Which layout algorithm to use. See [`LayoutBackend`] for the
65    /// trade-offs.
66    pub backend: LayoutBackend,
67}
68
69impl Default for LayoutConfig {
70    fn default() -> Self {
71        Self {
72            layer_gap: 6,
73            node_gap: 2,
74            backend: LayoutBackend::default(),
75        }
76    }
77}
78
79/// Selects which layered-layout backend computes node positions.
80///
81/// **Default since 0.17.0: [`Sugiyama`](LayoutBackend::Sugiyama).**
82///
83/// - [`Sugiyama`](LayoutBackend::Sugiyama) — `ascii-dag`-backed layout with
84///   proper crossing minimisation, long-edge dummy node insertion, and
85///   Brandes-Köpf coordinate assignment. Produces cleaner results on
86///   acyclic flowcharts, dependency graphs, and state diagrams. Supports
87///   subgraphs, parallel-edge widening, and per-subgraph `direction`
88///   overrides. This is the default as of 0.17.0.
89///
90/// - [`Native`](LayoutBackend::Native) — the in-house layered layout.
91///   Stable, fast, and respects every feature we ship. Available as an
92///   explicit opt-out if you need the legacy spacing contract.
93///
94/// - [`LayeredLegacy`](LayoutBackend::LayeredLegacy) — deprecated alias for
95///   [`Native`](LayoutBackend::Native). Provided as a discoverable escape
96///   hatch for callers who relied on the old default behaviour. Will be
97///   removed in **0.18.0**.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum LayoutBackend {
100    /// `ascii-dag`-backed Sugiyama layout — proper crossing minimisation,
101    /// dummy node insertion, and Brandes-Köpf coordinate assignment.
102    /// **This is the default since 0.17.0.**
103    Sugiyama,
104    /// Native in-house layered layout.
105    Native,
106    /// Deprecated alias for [`Native`]. Will be removed in 0.18.0.
107    ///
108    /// If you depended on the pre-0.17.0 default (the in-house layered
109    /// layout), replace this with [`LayoutBackend::Native`]. If you are
110    /// simply omitting `backend` from your `RenderOptions`, the new
111    /// default (`Sugiyama`) will be used automatically.
112    #[deprecated(
113        since = "0.17.0",
114        note = "LayeredLegacy is an alias for Native and will be removed in 0.18.0. \
115                Use LayoutBackend::Native to keep the old layered layout, or remove \
116                the explicit `backend` field to get the new Sugiyama default."
117    )]
118    LayeredLegacy,
119}
120
121impl Default for LayoutBackend {
122    /// Returns [`LayoutBackend::Sugiyama`] — the new default since 0.17.0.
123    fn default() -> Self {
124        Self::Sugiyama
125    }
126}
127
128impl LayoutConfig {
129    /// Build a [`LayoutConfig`] with explicit `layer_gap`/`node_gap` and
130    /// the default backend ([`LayoutBackend::Sugiyama`] since 0.17.0).
131    ///
132    /// Convenience for callers that just want to tune spacing:
133    /// `LayoutConfig::with_gaps(4, 2)` is shorter than the struct
134    /// literal and forward-compatible with new fields.
135    pub const fn with_gaps(layer_gap: usize, node_gap: usize) -> Self {
136        Self {
137            layer_gap,
138            node_gap,
139            // NOTE: `Default::default()` is not usable in `const fn`, so we
140            // name the variant explicitly. Keep in sync with
141            // `LayoutBackend::default()` above.
142            backend: LayoutBackend::Sugiyama,
143        }
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Public entry point
149// ---------------------------------------------------------------------------
150
151/// Character-grid position of a node's top-left corner.
152pub type GridPos = (usize, usize); // (col, row)
153
154/// Output of [`layout`]. Holds real-node positions for every node in the
155/// graph.
156#[derive(Debug, Clone, Default)]
157pub struct LayoutResult {
158    /// Top-left `(col, row)` of every real node. Excludes dummies.
159    pub positions: HashMap<String, GridPos>,
160}
161
162/// Compute character-grid positions for every node in `graph`.
163///
164/// Implements a three-step Sugiyama-inspired layered layout:
165/// 1. **Layer assignment** via longest-path from sources.
166/// 2. **Within-layer ordering** via iterative barycenter heuristic with
167///    best-seen retention.
168/// 3. **Position computation** converting `(layer, rank)` pairs to
169///    `(col, row)` character-grid coordinates.
170///
171/// # Arguments
172///
173/// * `graph`  — the parsed flowchart graph
174/// * `config` — spacing parameters (layer gap and node gap)
175///
176/// # Returns
177///
178/// A map from node ID to `(col, row)` grid position of the node's top-left
179/// corner. The grid origin is `(0, 0)`. Returns an empty map if `graph` has
180/// no nodes.
181///
182/// # Examples
183///
184/// ```
185/// use mermaid_text::{Graph, Node, Edge, Direction, NodeShape};
186/// use mermaid_text::layout::layered::{layout, LayoutConfig};
187///
188/// let mut g = Graph::new(Direction::LeftToRight);
189/// g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
190/// g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
191/// g.edges.push(Edge::new("A", "B", None));
192///
193/// let positions = layout(&g, &LayoutConfig::default()).positions;
194/// // In LR layout, A is to the left of B.
195/// assert!(positions["A"].0 < positions["B"].0);
196/// ```
197pub fn layout(graph: &Graph, config: &LayoutConfig) -> LayoutResult {
198    if graph.nodes.is_empty() {
199        return LayoutResult::default();
200    }
201
202    // 1. Assign layers.
203    let mut layers = assign_layers(graph);
204
205    // 2. Build edge-tuple list and augment long edges with dummy nodes
206    //    (one per intermediate layer). Dagre and graph-easy both do this
207    //    so the within-layer ordering step sees a uniform graph: a long
208    //    edge's "stand-in" dummy in each intermediate layer pulls real
209    //    nodes in that layer away from where the long edge wants to
210    //    travel, opening a clean channel for it. Without dummies a
211    //    long edge weaves through whatever real nodes happen to share
212    //    its target's column-of-best-fit.
213    //
214    //    The dummies live only in `layers` (the rank map) and the
215    //    augmented edge list — they're filtered out of the buckets
216    //    returned by `order_within_layers` before `compute_positions`,
217    //    so the visible output keeps its "real nodes only" geometry.
218    //    The win here is purely that the real nodes themselves end up
219    //    in better positions; A* handles edge routing end-to-end.
220    let real_edges: Vec<(String, String)> = graph
221        .edges
222        .iter()
223        .map(|e| (e.from.clone(), e.to.clone()))
224        .collect();
225    let (augmented_edges, dummy_ids) = augment_long_edges(&real_edges, &mut layers);
226
227    // 3. Group nodes into per-layer lists and sort by barycenter.
228    let ordered_with_dummies =
229        order_within_layers_augmented(graph, &layers, &augmented_edges, &dummy_ids);
230    // Strip dummies from each layer's bucket before geometry — they were
231    // ranking-only, never meant to be drawn.
232    let ordered: Vec<Vec<String>> = ordered_with_dummies
233        .into_iter()
234        .map(|layer| {
235            layer
236                .into_iter()
237                .filter(|id| !dummy_ids.contains(id))
238                .collect()
239        })
240        .collect();
241
242    // 4. Convert to grid coordinates.
243    let positions = compute_positions(graph, &ordered, config);
244
245    LayoutResult { positions }
246}
247
248// ---------------------------------------------------------------------------
249// Orthogonal subgraph helpers
250// ---------------------------------------------------------------------------
251
252/// Return `true` if `direction` is perpendicular (orthogonal) to `parent`.
253///
254/// LR/RL are horizontal; TD/TB/BT are vertical. Two directions are orthogonal
255/// when one is horizontal and the other is vertical.
256fn is_orthogonal(parent: Direction, child: Direction) -> bool {
257    parent.is_horizontal() != child.is_horizontal()
258}
259
260/// Walk the subgraph tree depth-first and collect, for every subgraph whose
261/// `direction` override is *orthogonal* to `parent_direction`, the set of
262/// **direct** node IDs it owns.
263///
264/// Only the *direct* `node_ids` of a matching subgraph are included; if a
265/// perpendicular subgraph itself contains a nested subgraph that is also
266/// perpendicular (relative to the outer graph), that inner subgraph is
267/// collected separately so the caller can treat each level independently.
268///
269/// # Note on deeply-nested alternating directions
270///
271/// TODO: deeply-nested alternating directions (e.g. LR inside TB inside LR)
272/// are not fully supported. Each subgraph is evaluated against the top-level
273/// graph direction only. Contributions from inner perpendicular subgraphs
274/// collapse their own nodes but do not recursively fix the outer collapse.
275fn collect_orthogonal_sets<'a>(
276    subs: &'a [Subgraph],
277    all_subs: &'a [Subgraph],
278    parent_direction: Direction,
279    out: &mut Vec<Vec<String>>,
280) {
281    for sg in subs {
282        if sg
283            .direction
284            .is_some_and(|sg_dir| is_orthogonal(parent_direction, sg_dir))
285        {
286            // This subgraph's direct children should collapse to one layer.
287            out.push(sg.node_ids.clone());
288        }
289        // Recurse into nested subgraphs regardless — a same-direction wrapper
290        // might contain a perpendicular inner subgraph.
291        let children: Vec<Subgraph> = sg
292            .subgraph_ids
293            .iter()
294            .filter_map(|id| all_subs.iter().find(|s| &s.id == id).cloned())
295            .collect();
296        collect_orthogonal_sets(&children, all_subs, parent_direction, out);
297    }
298}
299
300/// Collect all sets of node IDs that belong to orthogonal (perpendicular)
301/// subgraphs relative to the graph's own direction.
302fn orthogonal_node_sets(graph: &Graph) -> Vec<Vec<String>> {
303    let mut result = Vec::new();
304    collect_orthogonal_sets(
305        &graph.subgraphs,
306        &graph.subgraphs,
307        graph.direction,
308        &mut result,
309    );
310    result
311}
312
313// ---------------------------------------------------------------------------
314// Step 1: Layer assignment (longest path from sources)
315// ---------------------------------------------------------------------------
316
317/// Returns a map from node ID to layer index (0 = leftmost/topmost).
318fn assign_layers(graph: &Graph) -> HashMap<String, usize> {
319    let mut layer: HashMap<String, usize> = HashMap::new();
320
321    // Build adjacency: predecessors[id] = list of ids that point TO id
322    let mut predecessors: HashMap<&str, Vec<&str>> = HashMap::new();
323    for node in &graph.nodes {
324        predecessors.entry(node.id.as_str()).or_default();
325    }
326    for edge in &graph.edges {
327        predecessors
328            .entry(edge.to.as_str())
329            .or_default()
330            .push(edge.from.as_str());
331    }
332
333    // Iterative longest-path. We keep running passes until nothing changes.
334    // This handles cycles by capping at max_iter = node_count.
335    let max_iter = graph.nodes.len() + 1;
336    let mut changed = true;
337    let mut iter = 0;
338
339    // Initialise all nodes to layer 0
340    for node in &graph.nodes {
341        layer.insert(node.id.clone(), 0);
342    }
343
344    while changed && iter < max_iter {
345        changed = false;
346        iter += 1;
347        for edge in &graph.edges {
348            let from_layer = layer.get(edge.from.as_str()).copied().unwrap_or(0);
349            let to_layer = layer.entry(edge.to.clone()).or_insert(0);
350            if from_layer + 1 > *to_layer {
351                *to_layer = from_layer + 1;
352                changed = true;
353            }
354        }
355    }
356
357    // Ensure all nodes appear even if they have no edges
358    for node in &graph.nodes {
359        layer.entry(node.id.clone()).or_insert(0);
360    }
361
362    // --- Orthogonal subgraph collapsing ---
363    //
364    // For each subgraph whose direction is perpendicular to the parent's flow
365    // axis, all direct child nodes should occupy a single parent layer. Pull
366    // them to their minimum layer so they form one "band" in the layout, and
367    // then re-run longest-path for the remaining (non-orthogonal) nodes so
368    // they stay properly sequenced after the collapsed band.
369    let ortho_sets = orthogonal_node_sets(graph);
370    if !ortho_sets.is_empty() {
371        // Build flat set of all orthogonal node IDs for fast membership tests.
372        let all_ortho: std::collections::HashSet<&str> = ortho_sets
373            .iter()
374            .flat_map(|s| s.iter().map(String::as_str))
375            .collect();
376
377        // Collapse each set to min layer.
378        for set in &ortho_sets {
379            let present: Vec<&str> = set
380                .iter()
381                .map(String::as_str)
382                .filter(|id| layer.contains_key(*id))
383                .collect();
384            if present.is_empty() {
385                continue;
386            }
387            let min_layer = present.iter().map(|id| layer[*id]).min().unwrap_or(0);
388            for id in &present {
389                layer.insert((*id).to_owned(), min_layer);
390            }
391        }
392
393        // Re-run longest-path for non-orthogonal nodes only, so that nodes
394        // downstream of the collapsed band get their layers updated correctly.
395        // Orthogonal nodes keep their (collapsed) layer; only non-ortho nodes
396        // are re-propagated.
397        let max_iter2 = graph.nodes.len() + 1;
398        let mut changed2 = true;
399        let mut iter2 = 0;
400        while changed2 && iter2 < max_iter2 {
401            changed2 = false;
402            iter2 += 1;
403            for edge in &graph.edges {
404                // Skip propagation INTO orthogonal nodes — their layers are fixed.
405                if all_ortho.contains(edge.to.as_str()) {
406                    continue;
407                }
408                let from_layer = layer.get(edge.from.as_str()).copied().unwrap_or(0);
409                let to_layer = layer.entry(edge.to.clone()).or_insert(0);
410                if from_layer + 1 > *to_layer {
411                    *to_layer = from_layer + 1;
412                    changed2 = true;
413                }
414            }
415        }
416    }
417
418    layer
419}
420
421// ---------------------------------------------------------------------------
422// Step 2: Within-layer ordering (barycenter heuristic)
423// ---------------------------------------------------------------------------
424
425/// Replace every edge that spans more than one layer with a chain of
426/// unit-length segments through dummy nodes, one per intermediate layer.
427///
428/// Returns `(augmented_edges, dummy_ids)`. The dummy IDs are also inserted
429/// into `layers` (mutating it) so callers can bucket them by rank.
430///
431/// Dummy IDs use a sentinel prefix (`__mermaid_text_dummy__`) chosen to be
432/// unlikely to collide with any user-declared node ID.
433fn augment_long_edges(
434    edges: &[(String, String)],
435    layers: &mut HashMap<String, usize>,
436) -> (Vec<(String, String)>, std::collections::HashSet<String>) {
437    let mut augmented = Vec::with_capacity(edges.len());
438    let mut dummy_ids = std::collections::HashSet::new();
439    let mut next_dummy = 0usize;
440    for (from, to) in edges {
441        let from_layer = match layers.get(from) {
442            Some(&l) => l,
443            None => {
444                augmented.push((from.clone(), to.clone()));
445                continue;
446            }
447        };
448        let to_layer = match layers.get(to) {
449            Some(&l) => l,
450            None => {
451                augmented.push((from.clone(), to.clone()));
452                continue;
453            }
454        };
455        // Forward edges only — back-edges and self-edges keep their direct
456        // representation; the perimeter router handles them separately.
457        if to_layer <= from_layer || to_layer - from_layer <= 1 {
458            augmented.push((from.clone(), to.clone()));
459            continue;
460        }
461        let mut prev = from.clone();
462        for inter_layer in (from_layer + 1)..to_layer {
463            let dummy_id = format!("__mermaid_text_dummy__{}", next_dummy);
464            next_dummy += 1;
465            layers.insert(dummy_id.clone(), inter_layer);
466            augmented.push((prev.clone(), dummy_id.clone()));
467            dummy_ids.insert(dummy_id.clone());
468            prev = dummy_id;
469        }
470        augmented.push((prev, to.clone()));
471    }
472    (augmented, dummy_ids)
473}
474
475/// Augmented variant of [`order_within_layers`] that also buckets dummy
476/// nodes (created by [`augment_long_edges`]) into their layers, so the
477/// barycenter sweep treats them as first-class participants. The caller
478/// strips dummies out of the returned buckets before geometry.
479fn order_within_layers_augmented(
480    graph: &Graph,
481    layers: &HashMap<String, usize>,
482    edges: &[(String, String)],
483    dummy_ids: &std::collections::HashSet<String>,
484) -> Vec<Vec<String>> {
485    let max_layer = layers.values().copied().max().unwrap_or(0);
486    let num_layers = max_layer + 1;
487    let mut buckets: Vec<Vec<String>> = vec![Vec::new(); num_layers];
488    // Real nodes first (preserves declaration-order tie breaking).
489    for node in &graph.nodes {
490        if let Some(&l) = layers.get(&node.id) {
491            buckets[l].push(node.id.clone());
492        }
493    }
494    // Then dummies — append at the end of their layer; the barycenter
495    // sweeps will move them into position based on their adjacencies.
496    for id in dummy_ids {
497        if let Some(&l) = layers.get(id) {
498            buckets[l].push(id.clone());
499        }
500    }
501    order_buckets_inner(graph, layers, edges, buckets)
502}
503
504/// Run the iterative barycenter / median / transpose sweep on a
505/// pre-bucketed layer list. Returns the best-seen ordering. Used by
506/// `order_within_layers_augmented` (which buckets real + dummy nodes
507/// from `augment_long_edges`) so the sweep code lives in one place.
508fn order_buckets_inner(
509    graph: &Graph,
510    layers: &HashMap<String, usize>,
511    edges: &[(String, String)],
512    mut buckets: Vec<Vec<String>>,
513) -> Vec<Vec<String>> {
514    // Build successor/predecessor maps for barycenter computation —
515    // from the augmented edge list (long edges replaced by dummy
516    // chains), so dummies receive barycenter "pull" from their owning
517    // edge's source and target and naturally form a straight column.
518    let mut successors: HashMap<&str, Vec<&str>> = HashMap::new();
519    let mut predecessors: HashMap<&str, Vec<&str>> = HashMap::new();
520    for (from, to) in edges {
521        successors
522            .entry(from.as_str())
523            .or_default()
524            .push(to.as_str());
525        predecessors
526            .entry(to.as_str())
527            .or_default()
528            .push(from.as_str());
529    }
530
531    // Per-node layer lookup for the crossing counter. Borrows from `layers`
532    // rather than `buckets` so that it stays live across mutations of the
533    // latter during sweep passes.
534    let node_layer: HashMap<&str, usize> = layers.iter().map(|(id, &l)| (id.as_str(), l)).collect();
535
536    // Iterative refinement: alternate barycenter and median sweeps,
537    // then a transpose local-refinement pass. Pairing barycenter +
538    // median escapes local minima either alone would settle into;
539    // transpose mops up adjacent-pair improvements neither sweep
540    // catches. Best-seen retention guarantees we never ship a worse
541    // ordering than what we found mid-loop.
542    const MAX_PASSES: usize = 8;
543    const NO_IMPROVEMENT_CAP: usize = 4;
544
545    let mut best = buckets.clone();
546    let mut best_crossings = count_crossings(edges, &node_layer, &best);
547    let mut no_improvement = 0usize;
548
549    // Alternate the metric per outer iteration so consecutive passes
550    // sample both heuristics' search trajectories.
551    let metrics = [SortMetric::Barycenter, SortMetric::Median];
552
553    for pass in 0..MAX_PASSES {
554        let metric = metrics[pass % metrics.len()];
555        sort_by_metric(&mut buckets, &predecessors, SweepDirection::Forward, metric);
556        sort_by_metric(&mut buckets, &successors, SweepDirection::Backward, metric);
557        // Transpose runs after each sweep pair — cheaper than another
558        // global sweep and tends to fix the last 1–2 local crossings.
559        transpose_pass(&mut buckets, edges, &node_layer);
560
561        let c = count_crossings(edges, &node_layer, &buckets);
562        if c < best_crossings {
563            best = buckets.clone();
564            best_crossings = c;
565            no_improvement = 0;
566        } else {
567            no_improvement += 1;
568            if no_improvement >= NO_IMPROVEMENT_CAP {
569                break;
570            }
571        }
572
573        if best_crossings == 0 {
574            break;
575        }
576    }
577
578    // Enforce topological order for nodes belonging to orthogonal subgraphs
579    // that were collapsed into the same layer. Without this, barycenter
580    // sorting can place them in arbitrary order, which is fine for crossing
581    // minimisation but wrong visually when they must flow along the orthogonal
582    // axis (e.g. A→B→C left-to-right inside a top-down parent).
583    let ortho_sets = orthogonal_node_sets(graph);
584    if !ortho_sets.is_empty() {
585        for layer_nodes in &mut best {
586            for set in &ortho_sets {
587                let in_layer: Vec<usize> = layer_nodes
588                    .iter()
589                    .enumerate()
590                    .filter(|(_, id)| set.contains(id))
591                    .map(|(i, _)| i)
592                    .collect();
593                if in_layer.len() <= 1 {
594                    continue;
595                }
596                // Collect node IDs as owned strings to avoid holding a shared
597                // borrow of `layer_nodes` while we later mutate it.
598                let internal_ids: Vec<String> =
599                    in_layer.iter().map(|&i| layer_nodes[i].clone()).collect();
600
601                // Topological sort (Kahn's) of the subgraph's internal edges.
602                let internal_set: std::collections::HashSet<&str> =
603                    internal_ids.iter().map(String::as_str).collect();
604                let mut successors: HashMap<&str, Vec<&str>> =
605                    internal_set.iter().map(|&n| (n, Vec::new())).collect();
606                let mut in_degree: HashMap<&str, usize> =
607                    internal_set.iter().map(|&n| (n, 0usize)).collect();
608                for edge in &graph.edges {
609                    if internal_set.contains(edge.from.as_str())
610                        && internal_set.contains(edge.to.as_str())
611                    {
612                        successors
613                            .entry(edge.from.as_str())
614                            .or_default()
615                            .push(edge.to.as_str());
616                        *in_degree.entry(edge.to.as_str()).or_default() += 1;
617                    }
618                }
619                let mut queue: std::collections::VecDeque<&str> = in_degree
620                    .iter()
621                    .filter(|(_, d)| **d == 0)
622                    .map(|(&n, _)| n)
623                    .collect();
624                let mut topo: Vec<String> = Vec::new();
625                while let Some(node) = queue.pop_front() {
626                    topo.push(node.to_owned());
627                    // Clone successor list to avoid borrow conflicts while we
628                    // mutate `in_degree` in the same loop body.
629                    let succs: Vec<&str> = successors.get(node).cloned().unwrap_or_default();
630                    for succ in succs {
631                        let d = in_degree.entry(succ).or_default();
632                        *d = d.saturating_sub(1);
633                        if *d == 0 {
634                            queue.push_back(succ);
635                        }
636                    }
637                }
638                // Write topo order back into the positions these nodes held in
639                // the layer. If Kahn's didn't complete (cycle), fall back to
640                // the existing order to avoid producing wrong output silently.
641                if topo.len() == in_layer.len() {
642                    for (slot, &pos) in in_layer.iter().enumerate() {
643                        layer_nodes[pos] = topo[slot].clone();
644                    }
645                }
646            }
647        }
648    }
649
650    best
651}
652
653/// Direction of a barycenter sweep.
654#[derive(Copy, Clone)]
655enum SweepDirection {
656    /// Sort each layer (except layer 0) by the average position of its
657    /// predecessors in the previous layer.
658    Forward,
659    /// Sort each layer (except the last) by the average position of its
660    /// successors in the next layer.
661    Backward,
662}
663
664/// Sort metric used by [`sort_by_metric`] to pick each node's position
665/// from its neighbour positions.
666///
667/// **Barycenter**: arithmetic mean. Smooth, fast, but skewed by
668/// outliers (one far-away neighbour can drag the position).
669///
670/// **Median**: middle value of sorted neighbours (or average of the
671/// two middle values for even counts). More robust to outliers; in
672/// practice often beats barycenter on crossing count, especially on
673/// dense graphs where some nodes have many far-flung neighbours.
674///
675/// We run both passes alternately in [`order_within_layers`] and keep
676/// the best-seen ordering — pairing them tends to escape local minima
677/// that either metric alone would settle into.
678#[derive(Copy, Clone)]
679enum SortMetric {
680    Barycenter,
681    Median,
682}
683
684/// Sort each layer in `buckets` by `metric` applied to each node's
685/// neighbours in the adjacent layer (predecessors for `Forward`,
686/// successors for `Backward`).
687///
688/// Nodes without neighbours keep their current position via a stable
689/// sort — this prevents the heuristic from shuffling isolated nodes
690/// to position 0.
691fn sort_by_metric(
692    buckets: &mut [Vec<String>],
693    neighbors: &HashMap<&str, Vec<&str>>,
694    dir: SweepDirection,
695    metric: SortMetric,
696) {
697    let num_layers = buckets.len();
698    if num_layers < 2 {
699        return;
700    }
701
702    let layer_iter: Box<dyn Iterator<Item = usize>> = match dir {
703        SweepDirection::Forward => Box::new(1..num_layers),
704        SweepDirection::Backward => Box::new((0..num_layers - 1).rev()),
705    };
706
707    for l in layer_iter {
708        let ref_layer = match dir {
709            SweepDirection::Forward => l - 1,
710            SweepDirection::Backward => l + 1,
711        };
712
713        let ref_positions: HashMap<&str, f64> = buckets[ref_layer]
714            .iter()
715            .enumerate()
716            .map(|(i, id)| (id.as_str(), i as f64))
717            .collect();
718
719        let mut keyed: Vec<(String, f64)> = buckets[l]
720            .iter()
721            .enumerate()
722            .map(|(i, id)| {
723                let mut positions: Vec<f64> = neighbors
724                    .get(id.as_str())
725                    .map(|ns| {
726                        ns.iter()
727                            .map(|n| ref_positions.get(n).copied().unwrap_or(i as f64))
728                            .collect()
729                    })
730                    .unwrap_or_default();
731                let key = if positions.is_empty() {
732                    // Fallback to current position so isolated nodes
733                    // don't drift to 0.
734                    i as f64
735                } else {
736                    match metric {
737                        SortMetric::Barycenter => {
738                            let sum: f64 = positions.iter().sum();
739                            sum / positions.len() as f64
740                        }
741                        SortMetric::Median => median_of_sorted({
742                            positions.sort_by(|a, b| {
743                                a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
744                            });
745                            &positions
746                        }),
747                    }
748                };
749                (id.clone(), key)
750            })
751            .collect();
752
753        keyed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
754        buckets[l] = keyed.into_iter().map(|(id, _)| id).collect();
755    }
756}
757
758/// Median of a slice that's already sorted in ascending order. Returns
759/// `0.0` for an empty slice (the caller filters that case out before
760/// calling). For even-length slices, averages the two middle values
761/// per the standard definition.
762fn median_of_sorted(sorted: &[f64]) -> f64 {
763    debug_assert!(!sorted.is_empty(), "median of empty slice is undefined");
764    let n = sorted.len();
765    if n.is_multiple_of(2) {
766        (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
767    } else {
768        sorted[n / 2]
769    }
770}
771
772/// Local-refinement pass: for each pair of adjacent nodes within each
773/// layer, try swapping them; keep the swap if it strictly reduces the
774/// total crossing count. Repeats per-layer until no swap improves.
775///
776/// This catches local minima that the global barycenter/median sweeps
777/// settle into — e.g. two nodes whose individual barycenters tie but
778/// where one ordering produces fewer crossings than the other.
779///
780/// Returns `true` if any swap was kept (lets the outer loop know
781/// progress was made).
782fn transpose_pass(
783    buckets: &mut [Vec<String>],
784    edges: &[(String, String)],
785    node_layer: &HashMap<&str, usize>,
786) -> bool {
787    let mut any_improved = false;
788    let mut current_crossings = count_crossings(edges, node_layer, buckets);
789
790    let mut improved_this_pass = true;
791    let mut passes_remaining = 4usize; // bound — typically converges in 1–2
792    while improved_this_pass && passes_remaining > 0 {
793        improved_this_pass = false;
794        passes_remaining -= 1;
795
796        for layer_idx in 0..buckets.len() {
797            let layer_len = buckets[layer_idx].len();
798            if layer_len < 2 {
799                continue;
800            }
801            for i in 0..(layer_len - 1) {
802                buckets[layer_idx].swap(i, i + 1);
803                let after = count_crossings(edges, node_layer, buckets);
804                if after < current_crossings {
805                    current_crossings = after;
806                    any_improved = true;
807                    improved_this_pass = true;
808                } else {
809                    // Revert the swap.
810                    buckets[layer_idx].swap(i, i + 1);
811                }
812            }
813        }
814    }
815    any_improved
816}
817
818/// Count the number of edge crossings implied by the given layer ordering.
819///
820/// For each pair of edges `(u1, v1)` and `(u2, v2)` that both span the same
821/// layer gap (`u.layer → v.layer`), they cross iff the relative positions
822/// of `u1,u2` in their layer differ from the relative positions of `v1,v2`.
823/// This is the classic inversion test; O(E²) per gap, which is fine for the
824/// small graphs this crate targets.
825fn count_crossings(
826    edges: &[(String, String)],
827    node_layer: &HashMap<&str, usize>,
828    buckets: &[Vec<String>],
829) -> usize {
830    // Per-layer rank lookup.
831    let mut rank: HashMap<&str, usize> = HashMap::new();
832    for layer_nodes in buckets {
833        for (i, id) in layer_nodes.iter().enumerate() {
834            rank.insert(id.as_str(), i);
835        }
836    }
837
838    // Group edges by the ordered (from_layer, to_layer) gap they cross.
839    // Edges that stay within a single layer or that skip layers still "count"
840    // here because they still produce visual crossings at the rendered gap.
841    let edges_with_gaps: Vec<(usize, usize, usize, usize)> = edges
842        .iter()
843        .filter_map(|(from, to)| {
844            let fl = *node_layer.get(from.as_str())?;
845            let tl = *node_layer.get(to.as_str())?;
846            let fr = *rank.get(from.as_str())?;
847            let tr = *rank.get(to.as_str())?;
848            Some((fl, tl, fr, tr))
849        })
850        .collect();
851
852    let mut total = 0usize;
853    for i in 0..edges_with_gaps.len() {
854        let (fl1, tl1, fr1, tr1) = edges_with_gaps[i];
855        for &(fl2, tl2, fr2, tr2) in &edges_with_gaps[i + 1..] {
856            // Only edges spanning the same gap can cross.
857            if (fl1, tl1) != (fl2, tl2) {
858                continue;
859            }
860            // Inversion test: crosses iff one pair is strictly ordered and the
861            // other pair is strictly ordered in the opposite direction.
862            let from_order = fr1.cmp(&fr2);
863            let to_order = tr1.cmp(&tr2);
864            if from_order != std::cmp::Ordering::Equal
865                && to_order != std::cmp::Ordering::Equal
866                && from_order != to_order
867            {
868                total += 1;
869            }
870        }
871    }
872
873    total
874}
875
876// ---------------------------------------------------------------------------
877// Step 3: Position computation
878// ---------------------------------------------------------------------------
879
880/// Compute the display width of a node (its box width in characters).
881///
882/// Must stay in sync with `NodeGeom::for_node` in `render/unicode.rs`.
883pub(crate) fn node_box_width(graph: &Graph, id: &str) -> usize {
884    if let Some(node) = graph.node(id) {
885        // Multi-line labels are sized by the widest line — line breaks make
886        // the box taller, not wider.
887        let label_width = node.label_width();
888        let inner = label_width + 4; // 2-char padding each side
889        match node.shape {
890            // Diamond renders as a plain rectangle.
891            NodeShape::Diamond => inner,
892            // Circle/Stadium/Hexagon/Asymmetric add 1 extra char on each side
893            // for their distinctive markers inside the border.
894            NodeShape::Circle | NodeShape::Stadium | NodeShape::Hexagon | NodeShape::Asymmetric => {
895                inner + 2
896            }
897            // Subroutine adds 1 extra char on each side for inner vertical bars.
898            NodeShape::Subroutine => inner + 2,
899            // Cylinder: standard width — arcs are drawn at top/bottom centre.
900            NodeShape::Cylinder => inner,
901            // Parallelogram / Trapezoid variants: add 2 extra chars for slant markers.
902            NodeShape::Parallelogram
903            | NodeShape::ParallelogramBackslash
904            | NodeShape::Trapezoid
905            | NodeShape::TrapezoidInverted => inner + 2,
906            // DoubleCircle: needs 4 extra chars for the concentric inner border.
907            NodeShape::DoubleCircle => inner + 4,
908            // Plain shapes (and notes — same width as Rounded).
909            NodeShape::Rectangle | NodeShape::Rounded | NodeShape::Note => inner,
910            // Fork/join bar: perpendicular to flow. Horizontal bars
911            // span 5 cells; vertical bars are a single column.
912            NodeShape::Bar(BarOrientation::Horizontal) => 5,
913            NodeShape::Bar(BarOrientation::Vertical) => BAR_THICKNESS,
914        }
915    } else {
916        6 // fallback
917    }
918}
919
920/// Compute the display height of a node (its box height in characters).
921///
922/// Must stay in sync with `NodeGeom::for_node` in `render/unicode.rs`.
923pub(crate) fn node_box_height(graph: &Graph, id: &str) -> usize {
924    if let Some(node) = graph.node(id) {
925        // Each additional label line adds one interior row to the box.
926        let extra = node.label_line_count().saturating_sub(1);
927        match node.shape {
928            // Standard 3-row shapes: top border + text + bottom border.
929            NodeShape::Diamond
930            | NodeShape::Rectangle
931            | NodeShape::Rounded
932            | NodeShape::Circle
933            | NodeShape::Stadium
934            | NodeShape::Hexagon
935            | NodeShape::Asymmetric
936            | NodeShape::Parallelogram
937            | NodeShape::ParallelogramBackslash
938            | NodeShape::Trapezoid
939            | NodeShape::TrapezoidInverted
940            | NodeShape::Subroutine
941            | NodeShape::Note => 3 + extra,
942            // Cylinder needs 4 rows: top border, lid line, text, bottom border.
943            NodeShape::Cylinder => 4 + extra,
944            // DoubleCircle needs 5 rows for the concentric inner border.
945            NodeShape::DoubleCircle => 5 + extra,
946            // Fork/join bar: perpendicular to flow. Vertical bars span
947            // 5 rows so multiple parallel branches can attach; horizontal
948            // bars are a single row. No label, so no extra rows.
949            NodeShape::Bar(BarOrientation::Vertical) => 5,
950            NodeShape::Bar(BarOrientation::Horizontal) => BAR_THICKNESS,
951        }
952    } else {
953        3
954    }
955}
956
957/// Build a map from node ID to its assigned layer index.
958///
959/// This is a copy of `assign_layers` output, returned here so that
960/// `compute_positions` can look up which layer a given node lives in.
961fn build_node_layer_map(ordered: &[Vec<String>]) -> HashMap<&str, usize> {
962    let mut map = HashMap::new();
963    for (layer_idx, layer_nodes) in ordered.iter().enumerate() {
964        for id in layer_nodes {
965            map.insert(id.as_str(), layer_idx);
966        }
967    }
968    map
969}
970
971/// Compute the minimum inter-layer gap needed to fit all edge labels that
972/// cross the gap between `layer_a` and `layer_b`.
973///
974/// An edge crosses a gap when its source is in layer `layer_a` and its
975/// destination is in layer `layer_b` (or vice-versa for reversed directions).
976/// The gap must be wide enough to display the longest such label plus 2
977/// cells of padding on each side.
978///
979/// Multiple labeled edges from the same source node stacked in the same gap
980/// each occupy 2 rows, so we also account for stacking height.
981fn label_gap(
982    graph: &Graph,
983    node_layer: &HashMap<&str, usize>,
984    layer_a: usize,
985    layer_b: usize,
986    default_gap: usize,
987    parallel_groups: &[Vec<usize>],
988) -> usize {
989    // Collect widths of all labels on edges that cross this gap, and
990    // remember the edge index alongside so we can match against
991    // parallel groups.
992    let crossings: Vec<(usize, usize)> = graph // (edge_idx, label_width)
993        .edges
994        .iter()
995        .enumerate()
996        .filter(|(_, e)| {
997            let fl = node_layer.get(e.from.as_str()).copied().unwrap_or(0);
998            let tl = node_layer.get(e.to.as_str()).copied().unwrap_or(0);
999            // Edge crosses the gap in either direction.
1000            (fl == layer_a && tl == layer_b) || (fl == layer_b && tl == layer_a)
1001        })
1002        .filter_map(|(i, e)| e.label.as_deref().map(|l| (i, UnicodeWidthStr::width(l))))
1003        .collect();
1004
1005    if crossings.is_empty() {
1006        return default_gap;
1007    }
1008
1009    let max_lbl = crossings.iter().map(|(_, w)| *w).max().unwrap_or(0);
1010    let needed_for_width = max_lbl + 2;
1011
1012    // If multiple labels compete for vertical space in the same gap,
1013    // each occupies 2 rows (label + spacer). Keep that many rows free.
1014    let mut widths: Vec<usize> = crossings.iter().map(|(_, w)| *w).collect();
1015    widths.sort_unstable();
1016    let count = widths.len();
1017    let needed_for_stacking = count * 2 + 1;
1018
1019    // Parallel-edge breathing room (Phase 2a of the layout-pass
1020    // widening work — see `docs/scope-parallel-edges.md`). When the
1021    // edges crossing this gap include a parallel-edge group of
1022    // `count >= 2`, the labels would otherwise sit flush against
1023    // each adjacent box / subgraph border (CI/CD `│pass┌`,
1024    // Supervisor `└─creates│`). Add extra gap so each label has at
1025    // least 1 cell of clearance on each side.
1026    let parallel_extra = parallel_groups
1027        .iter()
1028        .filter_map(|group| {
1029            // How many edges of this parallel group cross this gap?
1030            let count_in_gap: usize = group
1031                .iter()
1032                .filter(|&&edge_idx| crossings.iter().any(|(i, _)| *i == edge_idx))
1033                .count();
1034            if count_in_gap < 2 {
1035                return None;
1036            }
1037            // Each additional parallel label past the first needs
1038            // `max_lbl + 2` cells of its own breathing room.
1039            Some((count_in_gap - 1) * (max_lbl + 2))
1040        })
1041        .max()
1042        .unwrap_or(0);
1043
1044    default_gap
1045        .max(needed_for_width + parallel_extra)
1046        .max(needed_for_stacking)
1047}
1048
1049/// Build the subgraph parent map: child subgraph id → parent subgraph id.
1050///
1051/// Subgraphs without a parent entry are top-level. Built once per layout
1052/// run and used to walk a node's full ancestor chain.
1053fn build_subgraph_parent_map(graph: &Graph) -> HashMap<&str, &str> {
1054    let mut m = HashMap::new();
1055    for parent in &graph.subgraphs {
1056        for child_id in &parent.subgraph_ids {
1057            m.insert(child_id.as_str(), parent.id.as_str());
1058        }
1059    }
1060    m
1061}
1062
1063/// Return `node_id`'s subgraph ancestor chain, innermost first.
1064///
1065/// An empty vector means the node is not inside any subgraph. The chain
1066/// starts at the node's immediately-enclosing subgraph and walks outward via
1067/// `parent_map` until it reaches a top-level subgraph.
1068fn node_subgraph_chain<'a>(
1069    node_id: &str,
1070    node_to_sg: &'a HashMap<String, String>,
1071    parent_map: &'a HashMap<&'a str, &'a str>,
1072) -> Vec<&'a str> {
1073    let mut chain = Vec::new();
1074    let Some(sg_id) = node_to_sg.get(node_id) else {
1075        return chain;
1076    };
1077    let mut cur: &str = sg_id.as_str();
1078    chain.push(cur);
1079    while let Some(&parent) = parent_map.get(cur) {
1080        chain.push(parent);
1081        cur = parent;
1082    }
1083    chain
1084}
1085
1086/// Count subgraph borders that must sit between two adjacent same-layer nodes.
1087///
1088/// Chains are innermost-first (as returned by [`node_subgraph_chain`]); the
1089/// common tail is the set of subgraphs that enclose both nodes and therefore
1090/// do not contribute a boundary between them. The remaining entries in each
1091/// chain each add one boundary.
1092///
1093/// Examples:
1094/// - `[X]` vs `[X]` → 0 (same subgraph)
1095/// - `[X]` vs `[]` → 1 (leaving X)
1096/// - `[X]` vs `[Y]` → 2 (leaving X, entering Y)
1097/// - `[X, Z]` vs `[Y, Z]` → 2 (leaving X inside Z, entering Y inside Z)
1098/// - `[X, Z]` vs `[Z]` → 1 (leaving X, Z still encloses both)
1099fn subgraph_boundary_count(chain_a: &[&str], chain_b: &[&str]) -> usize {
1100    let a_len = chain_a.len();
1101    let b_len = chain_b.len();
1102    let mut shared = 0usize;
1103    for i in 1..=a_len.min(b_len) {
1104        if chain_a[a_len - i] == chain_b[b_len - i] {
1105            shared += 1;
1106        } else {
1107            break;
1108        }
1109    }
1110    (a_len - shared) + (b_len - shared)
1111}
1112
1113/// Return the minimum gap (in cells) that must sit between two adjacent
1114/// same-layer nodes given their subgraph memberships.
1115///
1116/// For nodes in the same immediate subgraph (or both outside any subgraph),
1117/// the base `node_gap` is returned. For nodes separated by subgraph
1118/// boundaries, `SG_GAP_PER_BOUNDARY` cells are added per boundary so that
1119/// each subgraph's border line and its `SG_BORDER_PAD` of padding on each
1120/// side all fit without overlapping a neighboring node or sibling subgraph.
1121fn sibling_gap(
1122    node_a: &str,
1123    node_b: &str,
1124    node_to_sg: &HashMap<String, String>,
1125    parent_map: &HashMap<&str, &str>,
1126    base_gap: usize,
1127) -> usize {
1128    let chain_a = node_subgraph_chain(node_a, node_to_sg, parent_map);
1129    let chain_b = node_subgraph_chain(node_b, node_to_sg, parent_map);
1130    let boundaries = subgraph_boundary_count(&chain_a, &chain_b);
1131    base_gap + boundaries * SG_GAP_PER_BOUNDARY
1132}
1133
1134/// Compute the maximum rendered node width (in terminal cells) across ALL
1135/// direct and indirect members of subgraph `sg_id`.
1136///
1137/// This is used by `compute_positions` (TB/BT direction) to guard against
1138/// the case where a node in one subgraph is narrower than the widest node
1139/// in the same subgraph on a different layer — a condition that causes the
1140/// `sibling_gap` formula to under-estimate the horizontal room needed and
1141/// lets an adjacent sibling subgraph's border collide with the wider layer.
1142///
1143/// # Why this helper is needed (Bug B7)
1144///
1145/// In TB layout, `compute_positions` processes each layer independently,
1146/// resetting `col = 0` at the start of each layer.  For two sibling
1147/// subgraphs A and B, the gap between the nodes of A and B in each layer
1148/// is computed with `sibling_gap`, which uses the *current layer's* node
1149/// widths.  But `compute_subgraph_bounds` later wraps A's border around ALL
1150/// of A's nodes across ALL layers — including the widest one.  If A has a
1151/// narrow node in layer 0 but a wide node in layer 1, the gap computed for
1152/// layer 0 is too small: A's border (sized to layer 1's wide node) extends
1153/// further right than B's starting column in layer 0.
1154///
1155/// The fix: when placing B's node in any layer, enforce that B's column
1156/// is at least `sg_col_min[A] + sg_max_width[A] + SG_BORDER_PAD +
1157/// SG_GAP_PER_BOUNDARY` (the rendered right border of A, plus the minimum
1158/// inter-border gap).  `sg_max_width[A]` is pre-computed by this function.
1159fn subgraph_max_node_width(
1160    graph: &Graph,
1161    sg_id: &str,
1162    ordered: &[Vec<String>],
1163    node_to_sg: &HashMap<String, String>,
1164    sg_parent: &HashMap<&str, &str>,
1165) -> usize {
1166    // Filter the full ordered node list — any node whose subgraph ancestry
1167    // includes `sg_id` contributes to this subgraph's rendered width.
1168    let mut max_w = 0usize;
1169    for layer in ordered {
1170        for nid in layer {
1171            let chain = node_subgraph_chain(nid, node_to_sg, sg_parent);
1172            if chain.contains(&sg_id) {
1173                max_w = max_w.max(node_box_width(graph, nid));
1174            }
1175        }
1176    }
1177    max_w
1178}
1179
1180/// Extra columns to add to a layer's width when one or more of its
1181/// nodes lives in a subgraph that contains parallel-edge labels.
1182/// Mirrors the bounds-side calculation so the border wraps cleanly
1183/// around the labels and external nodes get pushed out by the same
1184/// amount, avoiding collisions.
1185fn layer_parallel_label_extra_width(
1186    graph: &Graph,
1187    layer_nodes: &[String],
1188    node_to_sg: &HashMap<String, String>,
1189) -> usize {
1190    layer_parallel_label_extra(graph, layer_nodes, node_to_sg, /* axis_w = */ true)
1191}
1192
1193fn layer_parallel_label_extra_height(
1194    graph: &Graph,
1195    layer_nodes: &[String],
1196    node_to_sg: &HashMap<String, String>,
1197) -> usize {
1198    layer_parallel_label_extra(graph, layer_nodes, node_to_sg, /* axis_w = */ false)
1199}
1200
1201/// Take the max parallel-edge-label extra (per `parallel_label_extra`)
1202/// across the subgraphs that own any of `layer_nodes`. `axis_w` picks
1203/// the width-axis (`true`) or height-axis (`false`) component of the
1204/// returned `(extra_w, extra_h)` tuple.
1205fn layer_parallel_label_extra(
1206    graph: &Graph,
1207    layer_nodes: &[String],
1208    node_to_sg: &HashMap<String, String>,
1209    axis_w: bool,
1210) -> usize {
1211    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
1212    let mut max_extra: usize = 0;
1213    for nid in layer_nodes {
1214        let Some(sg_id) = node_to_sg.get(nid) else {
1215            continue;
1216        };
1217        if !seen.insert(sg_id.as_str()) {
1218            continue;
1219        }
1220        let Some(sg) = graph.find_subgraph(sg_id) else {
1221            continue;
1222        };
1223        let (w, h) = parallel_label_extra(graph, sg);
1224        let extra = if axis_w { w } else { h };
1225        max_extra = max_extra.max(extra);
1226    }
1227    max_extra
1228}
1229
1230/// Convert the ordered layer buckets into character-grid `(col, row)` positions.
1231fn compute_positions(
1232    graph: &Graph,
1233    ordered: &[Vec<String>],
1234    config: &LayoutConfig,
1235) -> HashMap<String, GridPos> {
1236    let mut positions: HashMap<String, GridPos> = HashMap::new();
1237
1238    // Build a node-to-layer map once; used by the label-gap calculation.
1239    let node_layer = build_node_layer_map(ordered);
1240
1241    // Pre-compute parallel-edge groups so `label_gap` can widen the
1242    // inter-layer gap when a parallel group's labels would otherwise
1243    // sit flush against neighbouring boxes / subgraph borders.
1244    let parallel_groups = graph.parallel_edge_groups();
1245
1246    // Subgraph membership lookups — used to widen the gap between two
1247    // adjacent same-layer nodes when a subgraph boundary sits between them.
1248    let node_to_sg = graph.node_to_subgraph();
1249    let sg_parent = build_subgraph_parent_map(graph);
1250
1251    match graph.direction {
1252        Direction::LeftToRight | Direction::RightToLeft => {
1253            // Layers are columns; nodes within a layer are rows.
1254            let mut col = 0usize;
1255
1256            for (layer_idx, layer_nodes) in ordered.iter().enumerate() {
1257                if layer_nodes.is_empty() {
1258                    continue;
1259                }
1260
1261                // Column width = widest node in this layer, plus any
1262                // extra room a containing subgraph needs for its
1263                // parallel-edge labels (TB/BT subgraph stacks members
1264                // vertically — labels run horizontally between them
1265                // and steal column width).
1266                let base_layer_width = layer_nodes
1267                    .iter()
1268                    .map(|id| node_box_width(graph, id))
1269                    .max()
1270                    .unwrap_or(6);
1271                let extra_w = layer_parallel_label_extra_width(graph, layer_nodes, &node_to_sg);
1272                let layer_width = base_layer_width + extra_w;
1273
1274                let mut row = 0usize;
1275                let mut prev: Option<&str> = None;
1276                for id in layer_nodes {
1277                    let h = node_box_height(graph, id);
1278                    // Widen the gap between this node and the previous one
1279                    // if a subgraph boundary sits between them. The leading
1280                    // gap for the first node in the layer is always 0 — the
1281                    // initial subgraph padding is applied globally by
1282                    // `offset_positions_for_subgraphs` in lib.rs.
1283                    if let Some(prev_id) = prev {
1284                        let gap =
1285                            sibling_gap(prev_id, id, &node_to_sg, &sg_parent, config.node_gap);
1286                        // `gap` replaces the node_gap that was added at the
1287                        // end of the previous iteration. Subtract the already-
1288                        // applied node_gap to avoid double-counting.
1289                        row += gap.saturating_sub(config.node_gap);
1290                    }
1291                    positions.insert(id.clone(), (col, row));
1292                    row += h + config.node_gap;
1293                    prev = Some(id.as_str());
1294                }
1295
1296                // Inter-layer gap: at least default, but wide enough for edge
1297                // labels that cross into the next layer.
1298                let gap = if layer_idx + 1 < ordered.len() {
1299                    label_gap(
1300                        graph,
1301                        &node_layer,
1302                        layer_idx,
1303                        layer_idx + 1,
1304                        config.layer_gap,
1305                        &parallel_groups,
1306                    )
1307                } else {
1308                    config.layer_gap
1309                };
1310
1311                col += layer_width + gap;
1312            }
1313
1314            // Reverse column positions for RL direction
1315            if graph.direction == Direction::RightToLeft {
1316                let max_col = positions.values().map(|(c, _)| *c).max().unwrap_or(0);
1317                for (col, _) in positions.values_mut() {
1318                    *col = max_col - *col;
1319                }
1320            }
1321        }
1322
1323        Direction::TopToBottom | Direction::BottomToTop => {
1324            // Layers are rows; nodes within a layer are columns.
1325            let mut row = 0usize;
1326
1327            // Pre-compute the maximum rendered node width for each top-level
1328            // subgraph across ALL layers.  This is the key input for Bug B7's
1329            // fix (see `subgraph_max_node_width` doc).
1330            //
1331            // We cache results here rather than inside the loop because the
1332            // function iterates the full `ordered` slice and we may call it
1333            // once per distinct top-level subgraph, not once per layer — so
1334            // the total cost stays O(nodes * depth) rather than O(layers * nodes).
1335            let mut sg_max_width_cache: HashMap<String, usize> = HashMap::new();
1336
1337            // Track the minimum column that any node in each subgraph has
1338            // been assigned so far (across all processed layers). This is
1339            // the left-anchor used to compute the rendered left border of
1340            // the subgraph, which determines how far right the border extends
1341            // for the widest layer member.
1342            let mut sg_col_min: HashMap<String, usize> = HashMap::new();
1343
1344            for (layer_idx, layer_nodes) in ordered.iter().enumerate() {
1345                if layer_nodes.is_empty() {
1346                    continue;
1347                }
1348
1349                // Row height = tallest node in this layer, plus any
1350                // extra room a containing subgraph needs for its
1351                // parallel-edge labels (LR/RL subgraph stacks members
1352                // horizontally — labels run vertically between them
1353                // and steal row height).
1354                let base_layer_height = layer_nodes
1355                    .iter()
1356                    .map(|id| node_box_height(graph, id))
1357                    .max()
1358                    .unwrap_or(3);
1359                let extra_h = layer_parallel_label_extra_height(graph, layer_nodes, &node_to_sg);
1360                let layer_height = base_layer_height + extra_h;
1361
1362                let mut col = 0usize;
1363                let mut prev: Option<&str> = None;
1364                for id in layer_nodes {
1365                    let w = node_box_width(graph, id);
1366                    if let Some(prev_id) = prev {
1367                        let gap =
1368                            sibling_gap(prev_id, id, &node_to_sg, &sg_parent, config.node_gap);
1369                        col += gap.saturating_sub(config.node_gap);
1370
1371                        // Bug B7 fix: the sibling_gap formula uses the *current
1372                        // layer's* node widths to decide how far right to push
1373                        // the next subgraph's starting column.  But
1374                        // compute_subgraph_bounds sizes the border box using
1375                        // the *widest* node across ALL layers, so a narrow node
1376                        // in the current layer produces an under-estimated gap
1377                        // that lets the sibling border overlap the wide node in
1378                        // another layer.
1379                        //
1380                        // Fix: when crossing a top-level subgraph boundary, also
1381                        // enforce that `col` is at least
1382                        //   sg_col_min[A] + sg_max_width[A] + SG_BORDER_PAD + SG_GAP_PER_BOUNDARY
1383                        // (= the rendered right border of A plus the minimum
1384                        // one-border inter-subgraph clearance).
1385                        //
1386                        // This only applies when both nodes are in different
1387                        // top-level subgraphs (boundary count >= 2); within a
1388                        // single subgraph the sibling_gap formula is exact.
1389                        let chain_prev = node_subgraph_chain(prev_id, &node_to_sg, &sg_parent);
1390                        let chain_curr = node_subgraph_chain(id, &node_to_sg, &sg_parent);
1391                        if subgraph_boundary_count(&chain_prev, &chain_curr) >= 2 {
1392                            // The outermost (last) entry in the chain is the
1393                            // top-level subgraph ID that A belongs to.
1394                            if let Some(&prev_top_sg) = chain_prev.last() {
1395                                let max_w = sg_max_width_cache
1396                                    .entry(prev_top_sg.to_owned())
1397                                    .or_insert_with(|| {
1398                                        subgraph_max_node_width(
1399                                            graph,
1400                                            prev_top_sg,
1401                                            ordered,
1402                                            &node_to_sg,
1403                                            &sg_parent,
1404                                        )
1405                                    });
1406                                // Left anchor of the previous subgraph in
1407                                // this pre-offset coordinate system.
1408                                let anchor = sg_col_min.get(prev_top_sg).copied().unwrap_or(0);
1409                                // Minimum safe column for the new subgraph's
1410                                // node: anchor + widest member + right border
1411                                // pad + one inter-border gap cell.
1412                                let min_col_for_b = anchor
1413                                    + *max_w
1414                                    + SG_BORDER_PAD
1415                                    + SG_GAP_PER_BOUNDARY
1416                                    + config.node_gap;
1417                                col = col.max(min_col_for_b);
1418                            }
1419                        }
1420                    }
1421                    // Record the leftmost column assigned to this top-level subgraph.
1422                    if let Some(top_sg) = node_subgraph_chain(id, &node_to_sg, &sg_parent)
1423                        .last()
1424                        .copied()
1425                    {
1426                        let entry = sg_col_min.entry(top_sg.to_owned()).or_insert(col);
1427                        *entry = (*entry).min(col);
1428                    }
1429                    positions.insert(id.clone(), (col, row));
1430                    col += w + config.node_gap;
1431                    prev = Some(id.as_str());
1432                }
1433
1434                // Inter-layer gap: at least default, but tall enough for edge
1435                // labels that cross into the next layer.
1436                let gap = if layer_idx + 1 < ordered.len() {
1437                    label_gap(
1438                        graph,
1439                        &node_layer,
1440                        layer_idx,
1441                        layer_idx + 1,
1442                        config.layer_gap,
1443                        &parallel_groups,
1444                    )
1445                } else {
1446                    config.layer_gap
1447                };
1448
1449                row += layer_height + gap;
1450            }
1451
1452            // Reverse row positions for BT direction
1453            if graph.direction == Direction::BottomToTop {
1454                let max_row = positions.values().map(|(_, r)| *r).max().unwrap_or(0);
1455                for (_, row) in positions.values_mut() {
1456                    *row = max_row - *row;
1457                }
1458            }
1459        }
1460    }
1461
1462    positions
1463}
1464
1465// ---------------------------------------------------------------------------
1466// Tests
1467// ---------------------------------------------------------------------------
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472    use crate::types::{Direction, Edge, Graph, Node, NodeShape};
1473
1474    fn simple_lr_graph() -> Graph {
1475        let mut g = Graph::new(Direction::LeftToRight);
1476        g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
1477        g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
1478        g.nodes.push(Node::new("C", "C", NodeShape::Rectangle));
1479        g.edges.push(Edge::new("A", "B", None));
1480        g.edges.push(Edge::new("B", "C", None));
1481        g
1482    }
1483
1484    #[test]
1485    fn augment_long_edges_inserts_one_dummy_per_intermediate_layer() {
1486        // Edge A→C spans layers 0→2, so one dummy at layer 1.
1487        // Edge A→D spans layers 0→3, so two dummies at layers 1 and 2.
1488        let mut layers = HashMap::new();
1489        layers.insert("A".into(), 0);
1490        layers.insert("B".into(), 1);
1491        layers.insert("C".into(), 2);
1492        layers.insert("D".into(), 3);
1493        let edges = vec![
1494            ("A".into(), "B".into()), // span 1 — no dummy
1495            ("A".into(), "C".into()), // span 2 — 1 dummy
1496            ("A".into(), "D".into()), // span 3 — 2 dummies
1497        ];
1498        let (augmented, dummies) = augment_long_edges(&edges, &mut layers);
1499        assert_eq!(dummies.len(), 3, "1 + 2 = 3 dummies total");
1500        // Augmented edge list grows by `(span-1) - 0` per long edge: A→C
1501        // becomes A→d0→C (2 edges, was 1), A→D becomes A→d1→d2→D (3
1502        // edges, was 1). Total: 1 (A→B unchanged) + 2 + 3 = 6.
1503        assert_eq!(augmented.len(), 6);
1504        // Every dummy ends up in `layers` at its correct rank.
1505        for d in &dummies {
1506            let l = layers[d];
1507            assert!(l == 1 || l == 2, "dummy {d} landed at unexpected layer {l}");
1508        }
1509    }
1510
1511    #[test]
1512    fn augment_long_edges_skips_back_edges() {
1513        // Back-edge C→A (layer 2 → 0) keeps its direct representation; the
1514        // perimeter router handles it separately.
1515        let mut layers = HashMap::new();
1516        layers.insert("A".into(), 0);
1517        layers.insert("B".into(), 1);
1518        layers.insert("C".into(), 2);
1519        let edges = vec![("C".into(), "A".into())];
1520        let (augmented, dummies) = augment_long_edges(&edges, &mut layers);
1521        assert_eq!(dummies.len(), 0, "back-edges get no dummies");
1522        assert_eq!(augmented, edges);
1523    }
1524
1525    #[test]
1526    fn long_edge_skip_ordering_keeps_real_nodes_in_a_clean_column() {
1527        // graph LR
1528        //   A → B → C → D → E       (chain)
1529        //   A → E                    (long edge spanning 4 layers)
1530        //
1531        // Without dummy augmentation the barycenter sweep sees A→E as
1532        // pulling A toward E's column and vice versa — but no
1533        // intermediate-layer node is influenced. With augmentation the
1534        // sweep places three dummies between A and E, which keeps the
1535        // chain B/C/D anchored on a clean horizontal row.
1536        let mut g = Graph::new(Direction::LeftToRight);
1537        for id in ["A", "B", "C", "D", "E"] {
1538            g.nodes.push(Node::new(id, id, NodeShape::Rectangle));
1539        }
1540        g.edges.push(Edge::new("A", "B", None));
1541        g.edges.push(Edge::new("B", "C", None));
1542        g.edges.push(Edge::new("C", "D", None));
1543        g.edges.push(Edge::new("D", "E", None));
1544        g.edges.push(Edge::new("A", "E", None));
1545        let cfg = LayoutConfig::default();
1546        let pos = layout(&g, &cfg).positions;
1547        // The chain A→B→C→D→E should land on the same row — verifies
1548        // dummies didn't push intermediate nodes off the main flow line.
1549        let row_a = pos["A"].1;
1550        for id in ["B", "C", "D", "E"] {
1551            assert_eq!(
1552                pos[id].1, row_a,
1553                "node {id} should share row with A on a clean LR chain"
1554            );
1555        }
1556    }
1557
1558    #[test]
1559    fn lr_nodes_have_increasing_columns() {
1560        let g = simple_lr_graph();
1561        let cfg = LayoutConfig::default();
1562        let pos = layout(&g, &cfg).positions;
1563        assert!(pos["A"].0 < pos["B"].0);
1564        assert!(pos["B"].0 < pos["C"].0);
1565    }
1566
1567    #[test]
1568    fn td_nodes_have_increasing_rows() {
1569        let mut g = Graph::new(Direction::TopToBottom);
1570        g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
1571        g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
1572        g.edges.push(Edge::new("A", "B", None));
1573
1574        let cfg = LayoutConfig::default();
1575        let pos = layout(&g, &cfg).positions;
1576        assert!(pos["A"].1 < pos["B"].1);
1577    }
1578
1579    #[test]
1580    fn cyclic_graph_terminates() {
1581        let mut g = Graph::new(Direction::LeftToRight);
1582        g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
1583        g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
1584        g.edges.push(Edge::new("A", "B", None));
1585        g.edges.push(Edge::new("B", "A", None));
1586
1587        let cfg = LayoutConfig::default();
1588        let pos = layout(&g, &cfg).positions;
1589        assert_eq!(pos.len(), 2);
1590    }
1591
1592    #[test]
1593    fn single_node_layout() {
1594        let mut g = Graph::new(Direction::LeftToRight);
1595        g.nodes.push(Node::new("A", "Alone", NodeShape::Rectangle));
1596
1597        let cfg = LayoutConfig::default();
1598        let pos = layout(&g, &cfg).positions;
1599        assert_eq!(pos["A"], (0, 0));
1600    }
1601
1602    // ---- Median + transpose crossing-min passes (Phase A.3) ------------
1603
1604    #[test]
1605    fn median_of_sorted_picks_middle() {
1606        assert_eq!(median_of_sorted(&[1.0, 2.0, 3.0]), 2.0);
1607        assert_eq!(median_of_sorted(&[5.0]), 5.0);
1608    }
1609
1610    #[test]
1611    fn median_of_sorted_averages_two_middle_for_even_length() {
1612        assert_eq!(median_of_sorted(&[1.0, 2.0, 3.0, 4.0]), 2.5);
1613        assert_eq!(median_of_sorted(&[1.0, 1.0, 5.0, 5.0]), 3.0);
1614    }
1615
1616    #[test]
1617    fn median_resists_outliers_better_than_barycenter() {
1618        // Demonstrates the algorithmic difference: a single far-out
1619        // neighbour shifts barycenter dramatically but doesn't move
1620        // the median. This is the property median exploits to escape
1621        // crossings barycenter can't.
1622        let xs = [0.0, 1.0, 2.0, 100.0]; // one wild outlier
1623        let median = median_of_sorted(&xs);
1624        let barycenter: f64 = xs.iter().sum::<f64>() / xs.len() as f64;
1625        assert!((median - 1.5).abs() < 0.01); // tight on the cluster
1626        assert!(barycenter > 25.0); // dragged way out by the outlier
1627    }
1628
1629    #[test]
1630    fn transpose_swaps_when_it_reduces_crossings() {
1631        // Construct a deliberate crossing: edges A→C and B→D with
1632        // layer 0 = [A, B] and layer 1 = [D, C]. EITHER swapping
1633        // layer 0 to [B, A] OR layer 1 to [C, D] eliminates the
1634        // crossing — verify by outcome (zero crossings), not by
1635        // which specific swap won.
1636        let mut buckets = vec![
1637            vec!["A".to_string(), "B".to_string()],
1638            vec!["D".to_string(), "C".to_string()],
1639        ];
1640        let edges = vec![
1641            ("A".to_string(), "C".to_string()),
1642            ("B".to_string(), "D".to_string()),
1643        ];
1644        let mut node_layer: HashMap<&str, usize> = HashMap::new();
1645        node_layer.insert("A", 0);
1646        node_layer.insert("B", 0);
1647        node_layer.insert("C", 1);
1648        node_layer.insert("D", 1);
1649
1650        let before = count_crossings(&edges, &node_layer, &buckets);
1651        assert_eq!(before, 1, "scenario should start with 1 crossing");
1652
1653        let improved = transpose_pass(&mut buckets, &edges, &node_layer);
1654        let after = count_crossings(&edges, &node_layer, &buckets);
1655
1656        assert!(improved, "transpose should report improvement");
1657        assert_eq!(after, 0, "crossing should be eliminated by the swap");
1658    }
1659
1660    #[test]
1661    fn transpose_leaves_already_optimal_orderings_alone() {
1662        // [A, B] → [C, D] with edges A→C, B→D has no crossings.
1663        // Transpose should not swap.
1664        let mut buckets = vec![
1665            vec!["A".to_string(), "B".to_string()],
1666            vec!["C".to_string(), "D".to_string()],
1667        ];
1668        let edges = vec![
1669            ("A".to_string(), "C".to_string()),
1670            ("B".to_string(), "D".to_string()),
1671        ];
1672        let mut node_layer: HashMap<&str, usize> = HashMap::new();
1673        node_layer.insert("A", 0);
1674        node_layer.insert("B", 0);
1675        node_layer.insert("C", 1);
1676        node_layer.insert("D", 1);
1677
1678        let improved = transpose_pass(&mut buckets, &edges, &node_layer);
1679        assert!(!improved, "no swap should be reported when already optimal");
1680        assert_eq!(buckets[1], vec!["C".to_string(), "D".to_string()]);
1681    }
1682}