use std::collections::HashMap;
use ascii_dag::{EdgePath, Graph as AGraph, LayoutConfig as ALayoutConfig};
use crate::layout::layered::{
EdgeWaypoints, 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;
}
}
let mut coord_to_level: HashMap<(usize, usize), usize> =
HashMap::with_capacity(ir.nodes().len());
for n in ir.nodes() {
coord_to_level.insert((n.x, n.y), n.level);
}
let mut edge_waypoints: Vec<EdgeWaypoints> = Vec::new();
for (idx, edge) in graph.edges.iter().enumerate() {
let (Some(&from), Some(&to)) = (id_to_usize.get(&edge.from), id_to_usize.get(&edge.to))
else {
continue;
};
let Some(adag_edge) = ir
.edges()
.iter()
.find(|e| e.from_id == from && e.to_id == to)
else {
continue;
};
if let EdgePath::MultiSegment { waypoints, .. } = &adag_edge.path {
let mut points: Vec<(usize, usize)> = waypoints
.iter()
.map(|&(raw_x, raw_y)| {
let level = coord_to_level.get(&(raw_x, raw_y)).copied().unwrap_or(0);
let level_offset = level * extra_per_layer;
let (col, row) = if transpose {
(raw_y, raw_x)
} else {
(raw_x, raw_y)
};
let (col, row) = match graph.direction {
Direction::LeftToRight | Direction::RightToLeft => {
(col + level_offset, row)
}
Direction::TopToBottom | Direction::BottomToTop => {
(col, row + level_offset)
}
};
(col, row)
})
.collect();
if matches!(graph.direction, Direction::RightToLeft) {
for (col, _) in points.iter_mut() {
*col = max_x.saturating_sub(*col);
}
}
if matches!(graph.direction, Direction::BottomToTop) {
for (_, row) in points.iter_mut() {
*row = max_y.saturating_sub(*row);
}
}
if !points.is_empty() {
edge_waypoints.push(EdgeWaypoints {
edge_idx: idx,
waypoints: points,
});
}
}
}
LayoutResult {
positions,
edge_waypoints,
}
}
#[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());
assert!(out.edge_waypoints.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");
let app_db_idx = g
.edges
.iter()
.position(|e| e.from == "App" && e.to == "DB")
.expect("App→DB edge exists");
let app_db_wp = out
.edge_waypoints
.iter()
.find(|w| w.edge_idx == app_db_idx)
.expect("App→DB has waypoints");
assert!(
!app_db_wp.waypoints.is_empty(),
"App→DB long edge gets at least one dummy waypoint: {:?}",
app_db_wp.waypoints,
);
}
#[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);
}
}