use std::collections::HashMap;
use unicode_width::UnicodeWidthStr;
use crate::{
layout::{
Grid, SubgraphBounds,
grid::{EdgeLineStyle, arrow, endpoint},
layered::GridPos,
},
types::{Direction, EdgeEndpoint, EdgeStyle, Graph, Node, NodeShape},
};
const LABEL_PADDING: usize = 2;
#[derive(Debug, Clone, Copy)]
struct NodeGeom {
pub width: usize,
pub height: usize,
pub text_row: usize,
}
impl NodeGeom {
fn for_node(node: &Node) -> Self {
let label_w = node.label_width();
let inner_w = label_w + LABEL_PADDING * 2;
let extra_lines = node.label_line_count().saturating_sub(1);
match node.shape {
NodeShape::Rectangle | NodeShape::Rounded | NodeShape::Diamond => NodeGeom {
width: inner_w,
height: 3 + extra_lines,
text_row: 1,
},
NodeShape::Circle
| NodeShape::Stadium
| NodeShape::Hexagon
| NodeShape::Asymmetric => NodeGeom {
width: inner_w + 2,
height: 3 + extra_lines,
text_row: 1,
},
NodeShape::Subroutine => NodeGeom {
width: inner_w + 2,
height: 3 + extra_lines,
text_row: 1,
},
NodeShape::Parallelogram | NodeShape::Trapezoid => NodeGeom {
width: inner_w + 2,
height: 3 + extra_lines,
text_row: 1,
},
NodeShape::Cylinder => NodeGeom {
width: inner_w,
height: 5 + extra_lines,
text_row: 2,
},
NodeShape::DoubleCircle => NodeGeom {
width: inner_w + 4,
height: 5 + extra_lines,
text_row: 2,
},
}
}
fn cx(self) -> usize {
self.width / 2
}
fn cy(self) -> usize {
self.height / 2
}
}
#[derive(Debug, Clone, Copy)]
struct Attach {
pub col: usize,
pub row: usize,
}
fn exit_point(pos: GridPos, geom: NodeGeom, dir: Direction) -> Attach {
let (c, r) = pos;
match dir {
Direction::LeftToRight => Attach {
col: c + geom.width, row: r + geom.cy(),
},
Direction::RightToLeft => Attach {
col: c.saturating_sub(1),
row: r + geom.cy(),
},
Direction::TopToBottom => Attach {
col: c + geom.cx(),
row: r + geom.height, },
Direction::BottomToTop => Attach {
col: c + geom.cx(),
row: r.saturating_sub(1),
},
}
}
fn entry_point(pos: GridPos, geom: NodeGeom, dir: Direction) -> Attach {
let (c, r) = pos;
match dir {
Direction::LeftToRight => Attach {
col: c.saturating_sub(1), row: r + geom.cy(),
},
Direction::RightToLeft => Attach {
col: c + geom.width,
row: r + geom.cy(),
},
Direction::TopToBottom => Attach {
col: c + geom.cx(),
row: r.saturating_sub(1),
},
Direction::BottomToTop => Attach {
col: c + geom.cx(),
row: r + geom.height,
},
}
}
fn endpoint_char_back(dir: Direction) -> char {
match dir {
Direction::LeftToRight => arrow::LEFT,
Direction::RightToLeft => arrow::RIGHT,
Direction::TopToBottom => arrow::UP,
Direction::BottomToTop => arrow::DOWN,
}
}
fn tip_char(dir: Direction) -> char {
match dir {
Direction::LeftToRight => arrow::RIGHT,
Direction::RightToLeft => arrow::LEFT,
Direction::TopToBottom => arrow::DOWN,
Direction::BottomToTop => arrow::UP,
}
}
fn grid_size(
graph: &Graph,
positions: &HashMap<String, GridPos>,
geoms: &HashMap<String, NodeGeom>,
sg_bounds: &[SubgraphBounds],
) -> (usize, usize) {
let mut max_col = 0usize;
let mut max_row = 0usize;
for node in &graph.nodes {
if let (Some(&(c, r)), Some(&g)) = (positions.get(&node.id), geoms.get(&node.id)) {
max_col = max_col.max(c + g.width + 4);
max_row = max_row.max(r + g.height + 4);
}
}
for b in sg_bounds {
max_col = max_col.max(b.col + b.width + 4);
max_row = max_row.max(b.row + b.height + 4);
}
let max_label_w = graph
.edges
.iter()
.filter_map(|e| e.label.as_deref())
.map(UnicodeWidthStr::width)
.max()
.unwrap_or(0);
if max_label_w > 0 {
max_col += max_label_w + 2;
max_row += max_label_w + 2;
}
(max_col.max(1), max_row.max(1))
}
pub fn render(
graph: &Graph,
positions: &HashMap<String, GridPos>,
sg_bounds: &[SubgraphBounds],
) -> String {
let geoms: HashMap<String, NodeGeom> = graph
.nodes
.iter()
.map(|n| (n.id.clone(), NodeGeom::for_node(n)))
.collect();
let (width, height) = grid_size(graph, positions, &geoms, sg_bounds);
let mut grid = Grid::new(width, height);
for bounds in sg_bounds.iter().rev() {
draw_subgraph_border(&mut grid, bounds);
}
for node in &graph.nodes {
let Some(&(col, row)) = positions.get(&node.id) else {
continue;
};
let Some(&geom) = geoms.get(&node.id) else {
continue;
};
grid.mark_node_box(col, row, geom.width, geom.height);
}
let attach_points = compute_spread_attaches(graph, positions, &geoms);
let mut pending_labels: Vec<(usize, usize, String)> = Vec::new();
let mut placed_labels: Vec<(usize, usize, usize, usize)> = Vec::new();
for (edge_idx, edge) in graph.edges.iter().enumerate() {
let Some(Some((src, dst))) = attach_points.get(edge_idx) else {
continue;
};
let (src, dst) = (*src, *dst);
let fwd_tip = tip_char(graph.direction);
let horizontal_first = graph.direction.is_horizontal();
let path = grid.route_edge(src.col, src.row, dst.col, dst.row, horizontal_first, fwd_tip);
if let Some(ref path) = path
&& let Some(&(tip_c, tip_r)) = path.last()
&& edge.end != EdgeEndpoint::Arrow
{
grid.unprotect_cell(tip_c, tip_r);
let glyph = match edge.end {
EdgeEndpoint::None => {
if horizontal_first { '─' } else { '│' }
}
EdgeEndpoint::Circle => endpoint::CIRCLE,
EdgeEndpoint::Cross => endpoint::CROSS,
EdgeEndpoint::Arrow => unreachable!(),
};
grid.set(tip_c, tip_r, glyph);
if edge.end != EdgeEndpoint::None {
grid.protect_cell(tip_c, tip_r);
}
}
if let Some(ref path) = path {
let line_style = match edge.style {
EdgeStyle::Solid => EdgeLineStyle::Solid,
EdgeStyle::Dotted => EdgeLineStyle::Dotted,
EdgeStyle::Thick => EdgeLineStyle::Thick,
};
if path.len() > 1 {
grid.overdraw_path_style(&path[..path.len() - 1], line_style);
}
if edge.start == EdgeEndpoint::Arrow && path.len() >= 2 {
let back_tip = endpoint_char_back(graph.direction);
grid.set(src.col, src.row, back_tip);
grid.protect_cell(src.col, src.row);
}
}
if let (Some(lbl), Some(path)) = (&edge.label, &path)
&& let Some((lbl_col, lbl_row)) =
label_position(path, lbl, graph.direction, &mut placed_labels)
{
pending_labels.push((lbl_col, lbl_row, lbl.clone()));
}
}
for node in &graph.nodes {
let Some(&pos) = positions.get(&node.id) else {
continue;
};
let Some(&geom) = geoms.get(&node.id) else {
continue;
};
draw_node_box(&mut grid, node, pos, geom);
}
for (lbl_col, lbl_row, lbl) in &pending_labels {
grid.write_text_protected(*lbl_col, *lbl_row, lbl);
}
for node in &graph.nodes {
let Some(&pos) = positions.get(&node.id) else {
continue;
};
let Some(&geom) = geoms.get(&node.id) else {
continue;
};
draw_label_centred(&mut grid, node, pos, geom);
}
grid.render()
}
fn compute_spread_attaches(
graph: &Graph,
positions: &HashMap<String, GridPos>,
geoms: &HashMap<String, NodeGeom>,
) -> Vec<Option<(Attach, Attach)>> {
let mut pairs: Vec<Option<(Attach, Attach)>> = graph
.edges
.iter()
.map(|edge| {
let from_pos = *positions.get(&edge.from)?;
let to_pos = *positions.get(&edge.to)?;
let from_geom = *geoms.get(&edge.from)?;
let to_geom = *geoms.get(&edge.to)?;
let src = exit_point(from_pos, from_geom, graph.direction);
let dst = entry_point(to_pos, to_geom, graph.direction);
Some((src, dst))
})
.collect();
let mut dst_groups: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
for (i, pair) in pairs.iter().enumerate() {
if let Some((_, dst)) = pair {
dst_groups.entry((dst.col, dst.row)).or_default().push(i);
}
}
for indices in dst_groups.values() {
if indices.len() <= 1 {
continue;
}
let first_edge = &graph.edges[indices[0]];
let Some(&to_pos) = positions.get(&first_edge.to) else {
continue;
};
let Some(&to_geom) = geoms.get(&first_edge.to) else {
continue;
};
spread_destinations(&mut pairs, indices, to_pos, to_geom, graph.direction);
}
let mut src_groups: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
for (i, pair) in pairs.iter().enumerate() {
if let Some((src, _)) = pair {
src_groups.entry((src.col, src.row)).or_default().push(i);
}
}
for indices in src_groups.values() {
if indices.len() <= 1 {
continue;
}
let first_edge = &graph.edges[indices[0]];
let Some(&from_pos) = positions.get(&first_edge.from) else {
continue;
};
let Some(&from_geom) = geoms.get(&first_edge.from) else {
continue;
};
spread_sources(&mut pairs, indices, from_pos, from_geom, graph.direction);
}
pairs
}
fn spread_destinations(
pairs: &mut [Option<(Attach, Attach)>],
indices: &[usize],
to_pos: GridPos,
to_geom: NodeGeom,
dir: Direction,
) {
let n = indices.len();
let (to_col, to_row) = to_pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => {
let min_row = to_row;
let max_row = to_row + to_geom.height.saturating_sub(1);
if max_row < min_row || max_row - min_row + 1 < n {
return;
}
let centre = (to_row + to_geom.cy()) as isize;
let spread_range = (max_row - min_row) as isize;
let step = if n > 1 {
(spread_range / (n as isize - 1)).clamp(1, 2)
} else {
1
};
for (i, &idx) in indices.iter().enumerate() {
let offset = (i as isize - (n as isize - 1) / 2) * step;
let new_row = (centre + offset)
.max(min_row as isize)
.min(max_row as isize) as usize;
if let Some((_, dst)) = &mut pairs[idx] {
dst.row = new_row;
}
}
}
Direction::TopToBottom | Direction::BottomToTop => {
let min_col = to_col;
let max_col = to_col + to_geom.width.saturating_sub(1);
if max_col < min_col || max_col - min_col + 1 < n {
return;
}
let centre = (to_col + to_geom.cx()) as isize;
let spread_range = (max_col - min_col) as isize;
let step = if n > 1 {
(spread_range / (n as isize - 1)).clamp(1, 2)
} else {
1
};
for (i, &idx) in indices.iter().enumerate() {
let offset = (i as isize - (n as isize - 1) / 2) * step;
let new_col = (centre + offset)
.max(min_col as isize)
.min(max_col as isize) as usize;
if let Some((_, dst)) = &mut pairs[idx] {
dst.col = new_col;
}
}
}
}
}
fn spread_sources(
pairs: &mut [Option<(Attach, Attach)>],
indices: &[usize],
from_pos: GridPos,
from_geom: NodeGeom,
dir: Direction,
) {
let n = indices.len();
let (from_col, from_row) = from_pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => {
let min_row = from_row;
let max_row = from_row + from_geom.height.saturating_sub(1);
if min_row > max_row {
return;
}
let available = max_row - min_row + 1;
if available < 2 {
return; }
let centre = (from_row + from_geom.cy()) as isize;
let spread_range = (max_row - min_row) as isize;
let step = if n > 1 {
(spread_range / (n as isize - 1)).clamp(1, 2)
} else {
1
};
for (i, &idx) in indices.iter().enumerate() {
let offset = (i as isize - (n as isize - 1) / 2) * step;
let new_row = (centre + offset)
.max(min_row as isize)
.min(max_row as isize) as usize;
if let Some((src, _)) = &mut pairs[idx] {
src.row = new_row;
}
}
}
Direction::TopToBottom | Direction::BottomToTop => {
let min_col = from_col;
let max_col = from_col + from_geom.width.saturating_sub(1);
if min_col > max_col {
return;
}
let available = max_col - min_col + 1;
if available < 2 {
return;
}
let centre = (from_col + from_geom.cx()) as isize;
let spread_range = (max_col - min_col) as isize;
let step = if n > 1 {
(spread_range / (n as isize - 1)).clamp(1, 2)
} else {
1
};
for (i, &idx) in indices.iter().enumerate() {
let offset = (i as isize - (n as isize - 1) / 2) * step;
let new_col = (centre + offset)
.max(min_col as isize)
.min(max_col as isize) as usize;
if let Some((src, _)) = &mut pairs[idx] {
src.col = new_col;
}
}
}
}
}
fn draw_node_box(grid: &mut Grid, node: &Node, pos: GridPos, geom: NodeGeom) {
let (col, row) = pos;
for y in (row + 1)..(row + geom.height.saturating_sub(1)) {
for x in (col + 1)..(col + geom.width.saturating_sub(1)) {
grid.set(x, y, ' ');
}
}
match node.shape {
NodeShape::Rectangle => {
grid.draw_box(col, row, geom.width, geom.height);
}
NodeShape::Rounded => {
grid.draw_rounded_box(col, row, geom.width, geom.height);
}
NodeShape::Diamond => {
grid.draw_diamond(col, row, geom.width, geom.height);
}
NodeShape::Circle => {
grid.draw_rounded_box(col, row, geom.width, geom.height);
let mid = row + geom.cy();
grid.set(col + 1, mid, '(');
grid.set(col + geom.width - 2, mid, ')');
}
NodeShape::Stadium => {
grid.draw_stadium(col, row, geom.width, geom.height);
}
NodeShape::Subroutine => {
grid.draw_subroutine(col, row, geom.width, geom.height);
}
NodeShape::Cylinder => {
grid.draw_cylinder(col, row, geom.width, geom.height);
}
NodeShape::Hexagon => {
grid.draw_hexagon(col, row, geom.width, geom.height);
}
NodeShape::Asymmetric => {
grid.draw_asymmetric(col, row, geom.width, geom.height);
}
NodeShape::Parallelogram => {
grid.draw_parallelogram(col, row, geom.width, geom.height);
}
NodeShape::Trapezoid => {
grid.draw_trapezoid(col, row, geom.width, geom.height);
}
NodeShape::DoubleCircle => {
grid.draw_double_circle(col, row, geom.width, geom.height);
}
}
}
fn draw_subgraph_border(grid: &mut Grid, bounds: &SubgraphBounds) {
let (col, row, w, h) = (bounds.col, bounds.row, bounds.width, bounds.height);
if w < 2 || h < 2 {
return;
}
grid.draw_rounded_box(col, row, w, h);
for x in col..(col + w) {
grid.protect_cell(x, row);
grid.protect_cell(x, row + h - 1);
}
for y in (row + 1)..(row + h - 1) {
grid.protect_cell(col, y);
grid.protect_cell(col + w - 1, y);
}
for x in col..(col + w) {
grid.mark_obstacle(x, row);
grid.mark_obstacle(x, row + h - 1);
}
for y in (row + 1)..(row + h - 1) {
grid.mark_obstacle(col, y);
grid.mark_obstacle(col + w - 1, y);
}
let label_col = col + 2;
let label_row = row;
let max_label_w = w.saturating_sub(4);
let label = truncate_to_width(&bounds.label, max_label_w);
if !label.is_empty() {
grid.write_text_protected(label_col, label_row, &label);
}
}
fn truncate_to_width(s: &str, max_width: usize) -> String {
let mut out = String::new();
let mut w = 0;
for ch in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if w + cw > max_width {
break;
}
out.push(ch);
w += cw;
}
out
}
fn draw_label_centred(grid: &mut Grid, node: &Node, pos: GridPos, geom: NodeGeom) {
let (col, row) = pos;
let interior_w = geom.width.saturating_sub(2);
for (i, line) in node.label.lines().enumerate() {
let line_w = UnicodeWidthStr::width(line);
let text_col = if line_w <= interior_w {
col + 1 + (interior_w - line_w) / 2
} else {
col + 1
};
grid.write_text(text_col, row + geom.text_row + i, line);
}
}
fn label_position(
path: &[(usize, usize)],
label: &str,
dir: Direction,
placed: &mut Vec<(usize, usize, usize, usize)>,
) -> Option<(usize, usize)> {
if path.len() < 2 {
return None;
}
let lbl_w = UnicodeWidthStr::width(label);
if lbl_w == 0 {
return None;
}
match dir {
Direction::LeftToRight | Direction::RightToLeft => {
let (seg_col, seg_row) = last_horizontal_segment(path)?;
let candidates = [
seg_row.saturating_sub(1),
seg_row + 1,
seg_row.saturating_sub(2),
seg_row + 2,
];
for lbl_row in candidates {
if !collides(seg_col, lbl_row, lbl_w, placed) {
placed.push((seg_col, lbl_row, lbl_w, 1));
return Some((seg_col, lbl_row));
}
}
None
}
Direction::TopToBottom | Direction::BottomToTop => {
let (seg_col, seg_row) = last_vertical_segment(path)?;
let col_candidates = [seg_col + 1, seg_col.saturating_sub(1), seg_col + 2];
let row_offsets: [isize; 5] = [0, -1, 1, -2, 2];
for lbl_col in col_candidates {
for &dr in &row_offsets {
let lbl_row = (seg_row as isize + dr).max(0) as usize;
if !collides(lbl_col, lbl_row, lbl_w, placed) {
placed.push((lbl_col, lbl_row, lbl_w, 1));
return Some((lbl_col, lbl_row));
}
}
}
None
}
}
}
fn last_horizontal_segment(path: &[(usize, usize)]) -> Option<(usize, usize)> {
let n = path.len();
let mut i = n.saturating_sub(2); loop {
let row = path[i].1;
let mut start = i;
while start > 0 && path[start - 1].1 == row {
start -= 1;
}
let run_len = i - start + 1;
if run_len >= 2 {
let mid_col = (path[start].0 + path[i].0) / 2;
return Some((mid_col, row));
}
if i == 0 {
break;
}
i = start.saturating_sub(1);
if i == 0 && path[0].1 != row {
break;
}
}
None
}
fn last_vertical_segment(path: &[(usize, usize)]) -> Option<(usize, usize)> {
let n = path.len();
let mut i = n.saturating_sub(2);
loop {
let col = path[i].0;
let mut start = i;
while start > 0 && path[start - 1].0 == col {
start -= 1;
}
let run_len = i - start + 1;
if run_len >= 2 {
let mid_row = (path[start].1 + path[i].1) / 2;
return Some((col, mid_row));
}
if i == 0 {
break;
}
i = start.saturating_sub(1);
if i == 0 && path[0].0 != col {
break;
}
}
None
}
fn collides(col: usize, row: usize, w: usize, placed: &[(usize, usize, usize, usize)]) -> bool {
for &(pc, pr, pw, ph) in placed {
let row_overlaps = (row >= pr && row < pr + ph) || (pr >= row && pr < row + 1);
if row_overlaps {
let padded_start = col.saturating_sub(1);
let padded_end = col + w + 1;
let no_col_overlap = padded_end <= pc || pc + pw <= padded_start;
if !no_col_overlap {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
layout::layered::{LayoutConfig, layout},
parser,
};
fn render_diagram(src: &str) -> String {
let graph = parser::parse(src).unwrap();
let positions = layout(&graph, &LayoutConfig::default());
let sg_bounds = crate::layout::subgraph::compute_subgraph_bounds(&graph, &positions);
render(&graph, &positions, &sg_bounds)
}
#[test]
fn lr_output_contains_node_labels() {
let out = render_diagram("graph LR\nA[Start] --> B[End]");
assert!(out.contains("Start"), "missing 'Start' in:\n{out}");
assert!(out.contains("End"), "missing 'End' in:\n{out}");
}
#[test]
fn td_output_contains_node_labels() {
let out = render_diagram("graph TD\nA[Top] --> B[Bottom]");
assert!(out.contains("Top"), "missing 'Top' in:\n{out}");
assert!(out.contains("Bottom"), "missing 'Bottom' in:\n{out}");
}
}