use std::collections::HashMap;
use ascii_dag::{Graph as AGraph, LayoutConfig as ALayoutConfig};
use crate::layout::layered::{LayoutConfig, LayoutResult, node_box_height, node_box_width};
use crate::types::{Direction, Graph};
fn register_subgraphs<'g>(
adag: &mut AGraph<'g>,
graph: &'g Graph,
id_to_usize: &HashMap<String, usize>,
) {
let mut queue: std::collections::VecDeque<&str> =
graph.subgraphs.iter().map(|sg| sg.id.as_str()).collect();
let mut all_sg_ids: Vec<String> = Vec::new();
while let Some(id) = queue.pop_front() {
all_sg_ids.push(id.to_owned());
if let Some(sg) = graph.find_subgraph(id) {
for child_id in &sg.subgraph_ids {
queue.push_back(child_id.as_str());
}
}
}
let mut sg_id_map: HashMap<String, usize> = HashMap::with_capacity(all_sg_ids.len());
for sg_id in &all_sg_ids {
if let Some(sg) = graph.find_subgraph(sg_id) {
let adag_sg_id = adag.add_subgraph(&sg.label);
sg_id_map.insert(sg.id.clone(), adag_sg_id);
}
}
for sg_id in &all_sg_ids {
let Some(sg) = graph.find_subgraph(sg_id) else {
continue;
};
let Some(&parent_aid) = sg_id_map.get(&sg.id) else {
continue;
};
let node_aids: Vec<usize> = sg
.node_ids
.iter()
.filter_map(|nid| id_to_usize.get(nid).copied())
.collect();
if !node_aids.is_empty() {
adag.put_nodes(&node_aids)
.inside(parent_aid)
.expect("ascii-dag rejected node placement — id_to_usize mapping inconsistent");
}
let child_aids: Vec<usize> = sg
.subgraph_ids
.iter()
.filter_map(|cid| sg_id_map.get(cid).copied())
.collect();
if !child_aids.is_empty() {
adag.put_subgraphs(&child_aids)
.inside(parent_aid)
.expect("ascii-dag rejected subgraph nesting — sg_id_map inconsistent");
}
}
}
pub fn sugiyama_layout(graph: &Graph, _config: &LayoutConfig) -> LayoutResult {
if graph.nodes.is_empty() {
return LayoutResult::default();
}
let mut id_to_usize: HashMap<String, usize> = HashMap::with_capacity(graph.nodes.len());
let mut usize_to_id: HashMap<usize, String> = HashMap::with_capacity(graph.nodes.len());
for (i, node) in graph.nodes.iter().enumerate() {
let aid = i + 1; id_to_usize.insert(node.id.clone(), aid);
usize_to_id.insert(aid, node.id.clone());
}
let transpose = matches!(
graph.direction,
Direction::LeftToRight | Direction::RightToLeft
);
let mut adag: AGraph = AGraph::new();
for node in &graph.nodes {
let aid = id_to_usize[&node.id];
let our_w = node_box_width(graph, &node.id);
let our_h = node_box_height(graph, &node.id);
let (adag_w, adag_h) = if transpose {
(our_h, our_w)
} else {
(our_w, our_h)
};
adag.add_node_with_size(aid, &node.id, adag_w, adag_h);
}
if !graph.subgraphs.is_empty() {
register_subgraphs(&mut adag, graph, &id_to_usize);
}
for edge in &graph.edges {
let (Some(&from), Some(&to)) = (id_to_usize.get(&edge.from), id_to_usize.get(&edge.to))
else {
continue;
};
adag.add_edge(from, to, edge.label.as_deref());
}
let mut cfg = ALayoutConfig::standard();
cfg.level_spacing = _config.layer_gap;
cfg.node_spacing = _config.node_gap;
cfg.include_dummy_nodes = true;
let ir = adag.compute_layout_with_config(&cfg);
let mut raw_positions: Vec<(String, usize, usize, usize)> =
Vec::with_capacity(ir.nodes().len()); let mut max_x = 0usize;
let mut max_y = 0usize;
for n in ir.nodes() {
if matches!(n.kind, ascii_dag::NodeKind::Dummy) {
continue;
}
let Some(real_id) = usize_to_id.get(&n.id) else {
continue;
};
let (col, row) = if transpose { (n.y, n.x) } else { (n.x, n.y) };
raw_positions.push((real_id.clone(), col, row, n.level));
max_x = max_x.max(col);
max_y = max_y.max(row);
}
const ASCII_DAG_BASELINE_GAP: usize = 3;
let extra_per_layer = _config.layer_gap.saturating_sub(ASCII_DAG_BASELINE_GAP);
let mut positions: HashMap<String, (usize, usize)> =
HashMap::with_capacity(raw_positions.len());
for (id, col, row, level) in raw_positions {
let offset = level * extra_per_layer;
let (col, row) = match graph.direction {
Direction::LeftToRight | Direction::RightToLeft => (col + offset, row),
Direction::TopToBottom | Direction::BottomToTop => (col, row + offset),
};
max_x = max_x.max(col);
max_y = max_y.max(row);
positions.insert(id, (col, row));
}
if matches!(graph.direction, Direction::RightToLeft) {
for (col, _) in positions.values_mut() {
*col = max_x - *col;
}
}
if matches!(graph.direction, Direction::BottomToTop) {
for (_, row) in positions.values_mut() {
*row = max_y - *row;
}
}
LayoutResult { positions }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Direction, Edge, Node, NodeShape};
#[test]
fn empty_graph_returns_empty() {
let g = Graph::new(Direction::TopToBottom);
let out = sugiyama_layout(&g, &LayoutConfig::default());
assert!(out.positions.is_empty());
}
#[test]
fn simple_chain_lr() {
let mut g = Graph::new(Direction::LeftToRight);
g.nodes.push(Node::new("A", "A", NodeShape::Rectangle));
g.nodes.push(Node::new("B", "B", NodeShape::Rectangle));
g.nodes.push(Node::new("C", "C", NodeShape::Rectangle));
g.edges.push(Edge::new("A", "B", None));
g.edges.push(Edge::new("B", "C", None));
let out = sugiyama_layout(&g, &LayoutConfig::default());
assert!(out.positions["A"].0 < out.positions["B"].0);
assert!(out.positions["B"].0 < out.positions["C"].0);
}
#[test]
fn architecture_case_has_4_distinct_layers() {
let src = "graph LR\n App --> DB[(PostgreSQL)]\n App --> Cache[(Redis)]\n App --> Queue[(RabbitMQ)]\n Queue --> Worker[Worker]\n Worker --> DB";
let g = crate::parser::flowchart::parse(src).unwrap();
let out = sugiyama_layout(&g, &LayoutConfig::default());
let app_col = out.positions["App"].0;
let cache_col = out.positions["Cache"].0;
let queue_col = out.positions["Queue"].0;
let worker_col = out.positions["Worker"].0;
let db_col = out.positions["DB"].0;
assert!(
app_col < cache_col,
"App should precede Cache: {app_col} < {cache_col}"
);
assert_eq!(cache_col, queue_col, "Cache and Queue share a layer");
assert!(queue_col < worker_col, "Worker is its own layer");
assert!(worker_col < db_col, "DB is the rightmost layer");
}
#[test]
fn diamond_no_crossings() {
let mut g = Graph::new(Direction::TopToBottom);
for id in ["A", "B", "C", "D"] {
g.nodes.push(Node::new(id, id, NodeShape::Rectangle));
}
g.edges.push(Edge::new("A", "B", None));
g.edges.push(Edge::new("A", "C", None));
g.edges.push(Edge::new("B", "D", None));
g.edges.push(Edge::new("C", "D", None));
let out = sugiyama_layout(&g, &LayoutConfig::default());
assert!(out.positions["A"].1 < out.positions["B"].1);
assert!(out.positions["A"].1 < out.positions["C"].1);
assert!(out.positions["B"].1 < out.positions["D"].1);
assert!(out.positions["C"].1 < out.positions["D"].1);
assert_eq!(out.positions["B"].1, out.positions["C"].1);
}
fn rect(id: &str) -> Node {
Node::new(id, id, NodeShape::Rectangle)
}
#[test]
fn subgraph_register_one_cluster() {
use crate::types::Subgraph;
let mut g = Graph::new(Direction::TopToBottom);
for id in ["X", "Y", "Z"] {
g.nodes.push(rect(id));
}
g.edges.push(Edge::new("X", "Y", None));
g.edges.push(Edge::new("Y", "Z", None));
let mut sg = Subgraph::new("S", "My Cluster");
sg.node_ids = vec!["X".into(), "Y".into(), "Z".into()];
g.subgraphs.push(sg);
let out = sugiyama_layout(&g, &LayoutConfig::default());
assert!(out.positions.contains_key("X"), "X missing from positions");
assert!(out.positions.contains_key("Y"), "Y missing from positions");
assert!(out.positions.contains_key("Z"), "Z missing from positions");
let rx = out.positions["X"].1;
let ry = out.positions["Y"].1;
let rz = out.positions["Z"].1;
assert!(rx < ry, "X should be above Y: row {rx} < {ry}");
assert!(ry < rz, "Y should be above Z: row {ry} < {rz}");
let cx = out.positions["X"].0;
let cy = out.positions["Y"].0;
let cz = out.positions["Z"].0;
assert_eq!(
cx, cy,
"X and Y should share column in single-chain cluster"
);
assert_eq!(
cy, cz,
"Y and Z should share column in single-chain cluster"
);
}
#[test]
fn subgraph_register_two_sibling_clusters() {
use crate::types::Subgraph;
let mut g = Graph::new(Direction::LeftToRight);
for id in ["A1", "A2", "B1", "B2"] {
g.nodes.push(rect(id));
}
g.edges.push(Edge::new("A1", "A2", None));
g.edges.push(Edge::new("B1", "B2", None));
g.edges.push(Edge::new("A2", "B1", None));
let mut sga = Subgraph::new("SGA", "ClusterA");
sga.node_ids = vec!["A1".into(), "A2".into()];
let mut sgb = Subgraph::new("SGB", "ClusterB");
sgb.node_ids = vec!["B1".into(), "B2".into()];
g.subgraphs.push(sga);
g.subgraphs.push(sgb);
let out = sugiyama_layout(&g, &LayoutConfig::default());
for id in ["A1", "A2", "B1", "B2"] {
assert!(
out.positions.contains_key(id),
"{id} missing from positions"
);
}
assert!(
out.positions["A1"].0 < out.positions["A2"].0,
"A1 should be left of A2"
);
assert!(
out.positions["B1"].0 < out.positions["B2"].0,
"B1 should be left of B2"
);
let a_max_col = out.positions["A1"].0.max(out.positions["A2"].0);
let b_min_col = out.positions["B1"].0.min(out.positions["B2"].0);
assert!(
a_max_col < b_min_col,
"Cluster A's rightmost col ({a_max_col}) must be left of \
Cluster B's leftmost col ({b_min_col}) — clusters interleaved"
);
}
#[test]
fn subgraph_register_nested_clusters() {
use crate::types::Subgraph;
let mut g = Graph::new(Direction::TopToBottom);
for id in ["I1", "I2", "O1"] {
g.nodes.push(rect(id));
}
g.edges.push(Edge::new("I1", "I2", None));
g.edges.push(Edge::new("O1", "I1", None));
let mut inner = Subgraph::new("Inner", "Inner");
inner.node_ids = vec!["I1".into(), "I2".into()];
let mut outer = Subgraph::new("Outer", "Outer");
outer.node_ids = vec!["O1".into()];
outer.subgraph_ids = vec!["Inner".into()];
g.subgraphs.push(outer);
g.subgraphs.push(inner);
let out = sugiyama_layout(&g, &LayoutConfig::default());
for id in ["I1", "I2", "O1"] {
assert!(
out.positions.contains_key(id),
"{id} missing from positions"
);
}
let all_rows: Vec<usize> = ["I1", "I2", "O1"]
.iter()
.map(|id| out.positions[*id].1)
.collect();
let inner_rows: Vec<usize> = ["I1", "I2"].iter().map(|id| out.positions[*id].1).collect();
let outer_min = *all_rows.iter().min().unwrap();
let outer_max = *all_rows.iter().max().unwrap();
let inner_min = *inner_rows.iter().min().unwrap();
let inner_max = *inner_rows.iter().max().unwrap();
assert!(
outer_min <= inner_min,
"Outer min row ({outer_min}) must be <= Inner min row ({inner_min})"
);
assert!(
outer_max >= inner_max,
"Outer max row ({outer_max}) must be >= Inner max row ({inner_max})"
);
}
#[test]
fn single_subgraph_lr_sugiyama() {
let src = r#"graph LR
subgraph SG[My Group]
A-->B
end
B-->C"#;
let out = crate::render_with_options(
src,
&crate::RenderOptions {
backend: crate::layout::LayoutBackend::Sugiyama,
..Default::default()
},
)
.unwrap();
assert!(out.contains('A'), "node A missing from Sugiyama render");
assert!(out.contains('B'), "node B missing from Sugiyama render");
assert!(out.contains('C'), "node C missing from Sugiyama render");
assert!(
out.contains("My Group"),
"subgraph label missing from Sugiyama render:\n{out}"
);
insta::assert_snapshot!("single_subgraph_lr_sugiyama", out);
}
#[test]
fn nested_subgraphs_td_sugiyama() {
let src = r#"graph TD
subgraph Outer
subgraph Inner
A-->B
end
B-->C
end
C-->D"#;
let out = crate::render_with_options(
src,
&crate::RenderOptions {
backend: crate::layout::LayoutBackend::Sugiyama,
..Default::default()
},
)
.unwrap();
for node in ["A", "B", "C", "D"] {
assert!(
out.contains(node),
"node {node} missing from Sugiyama render"
);
}
assert!(out.contains("Outer"), "Outer label missing:\n{out}");
assert!(out.contains("Inner"), "Inner label missing:\n{out}");
insta::assert_snapshot!("nested_subgraphs_td_sugiyama", out);
}
}