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};
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);
}
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);
}
}