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 ¶llel_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 ¶llel_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}