use std::collections::HashMap;
use unicode_width::UnicodeWidthStr;
use crate::{
layout::{
Grid, SubgraphBounds,
grid::{EdgeLineStyle, arrow, endpoint},
layered::GridPos,
},
types::{BarOrientation, Direction, EdgeEndpoint, EdgeStyle, Graph, Node, NodeShape, NodeStyle},
};
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: 4 + extra_lines,
text_row: 2,
},
NodeShape::DoubleCircle => NodeGeom {
width: inner_w + 4,
height: 5 + extra_lines,
text_row: 2,
},
NodeShape::Bar(BarOrientation::Horizontal) => NodeGeom {
width: 5,
height: 1,
text_row: 0,
},
NodeShape::Bar(BarOrientation::Vertical) => NodeGeom {
width: 1,
height: 5,
text_row: 0,
},
}
}
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 exit_point_back_edge(pos: GridPos, geom: NodeGeom, dir: Direction) -> Attach {
let (c, r) = pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => Attach {
col: c + geom.cx(),
row: r + geom.height, },
Direction::TopToBottom | Direction::BottomToTop => Attach {
col: c + geom.width, row: r + geom.cy(),
},
}
}
fn entry_point_back_edge(pos: GridPos, geom: NodeGeom, dir: Direction) -> Attach {
let (c, r) = pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => Attach {
col: c + geom.cx(),
row: r + geom.height, },
Direction::TopToBottom | Direction::BottomToTop => Attach {
col: c + geom.width, row: r + geom.cy(),
},
}
}
fn tip_char_for_back_edge(dir: Direction) -> char {
match dir {
Direction::LeftToRight | Direction::RightToLeft => arrow::UP,
Direction::TopToBottom | Direction::BottomToTop => arrow::LEFT,
}
}
fn back_edge_border_cells(
pos: GridPos,
geom: NodeGeom,
dir: Direction,
) -> ((usize, usize), (usize, usize)) {
let (c, r) = pos;
match dir {
Direction::LeftToRight | Direction::RightToLeft => {
let col = c + geom.cx();
let border_row = r + geom.height - 1;
let path_row = r + geom.height;
((col, border_row), (col, path_row))
}
Direction::TopToBottom | Direction::BottomToTop => {
let row = r + geom.cy();
let border_col = c + geom.width - 1;
let path_col = c + geom.width;
((border_col, row), (path_col, row))
}
}
}
fn is_back_edge(from_pos: GridPos, to_pos: GridPos, dir: Direction) -> bool {
let (fc, fr) = from_pos;
let (tc, tr) = to_pos;
match dir {
Direction::LeftToRight => tc < fc,
Direction::RightToLeft => tc > fc,
Direction::TopToBottom => tr < fr,
Direction::BottomToTop => tr > fr,
}
}
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;
}
let has_back_edge = graph.edges.iter().any(|e| {
let Some(&fp) = positions.get(&e.from) else {
return false;
};
let Some(&tp) = positions.get(&e.to) else {
return false;
};
is_back_edge(fp, tp, graph.direction)
});
if has_back_edge {
match graph.direction {
Direction::LeftToRight | Direction::RightToLeft => {
max_row += 4;
}
Direction::TopToBottom | Direction::BottomToTop => {
max_col += 4;
}
}
}
(max_col.max(1), max_row.max(1))
}
pub fn render(
graph: &Graph,
positions: &HashMap<String, GridPos>,
sg_bounds: &[SubgraphBounds],
) -> String {
render_inner(graph, positions, sg_bounds, false)
}
pub fn render_color(
graph: &Graph,
positions: &HashMap<String, GridPos>,
sg_bounds: &[SubgraphBounds],
) -> String {
render_inner(graph, positions, sg_bounds, true)
}
fn render_inner(
graph: &Graph,
positions: &HashMap<String, GridPos>,
sg_bounds: &[SubgraphBounds],
with_color: bool,
) -> 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);
}
let mut node_rects: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(graph.nodes.len());
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);
node_rects.push((col, row, geom.width, geom.height));
}
let attach_points = compute_spread_attaches(graph, positions, &geoms);
let mut pending_labels: Vec<(usize, usize, String, Option<crate::types::Rgb>)> = Vec::new();
let mut placed_labels: Vec<(usize, usize, usize, usize)> = Vec::new();
let mut back_edge_border_joins: Vec<(usize, usize, bool)> = Vec::new();
let mut back_edge_path_joins: Vec<(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 edge_is_back = {
let from_pos = positions.get(&edge.from).copied();
let to_pos = positions.get(&edge.to).copied();
match (from_pos, to_pos) {
(Some(fp), Some(tp)) => is_back_edge(fp, tp, graph.direction),
_ => false,
}
};
if edge_is_back
&& let (Some(fp), Some(fg), Some(tp), Some(tg)) = (
positions.get(&edge.from).copied(),
geoms.get(&edge.from).copied(),
positions.get(&edge.to).copied(),
geoms.get(&edge.to).copied(),
)
{
let (sb, sp) = back_edge_border_cells(fp, fg, graph.direction);
let (db, _) = back_edge_border_cells(tp, tg, graph.direction);
back_edge_border_joins.push((sb.0, sb.1, false));
back_edge_border_joins.push((db.0, db.1, true));
back_edge_path_joins.push(sp);
}
let fwd_tip = if edge_is_back {
tip_char_for_back_edge(graph.direction)
} else {
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 edge_is_back {
if horizontal_first { '│' } else { '─' }
} else 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 with_color
&& let Some(es) = graph.edge_styles.get(&edge_idx)
&& let Some(stroke) = es.stroke
{
grid.paint_fg_path(path, stroke);
}
}
if let (Some(lbl), Some(path)) = (&edge.label, &path)
&& let Some((lbl_col, lbl_row)) =
label_position(path, lbl, graph.direction, &mut placed_labels, &node_rects)
{
let lbl_color = if with_color {
graph
.edge_styles
.get(&edge_idx)
.and_then(|es| es.color.or(es.stroke))
} else {
None
};
pending_labels.push((lbl_col, lbl_row, lbl.clone(), lbl_color));
}
}
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);
if with_color
&& let Some(style) = graph.node_styles.get(&node.id).copied()
{
paint_node_colors(&mut grid, pos, geom, style);
}
}
let (border_junction, path_junction) = match graph.direction {
Direction::LeftToRight | Direction::RightToLeft => ('┬', '┴'),
Direction::TopToBottom | Direction::BottomToTop => ('├', '┤'),
};
for (col, row, _is_dest) in &back_edge_border_joins {
grid.set(*col, *row, border_junction);
}
for (col, row) in &back_edge_path_joins {
let current = grid.get(*col, *row);
if current == '─' || current == '│' {
grid.set(*col, *row, path_junction);
}
}
for (lbl_col, lbl_row, lbl, lbl_color) in &pending_labels {
grid.write_text_protected(*lbl_col, *lbl_row, lbl);
if let Some(c) = lbl_color {
let lbl_w = UnicodeWidthStr::width(lbl.as_str());
grid.paint_fg_rect(*lbl_col, *lbl_row, lbl_w, 1, *c);
}
}
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);
}
if with_color {
grid.render_with_colors()
} else {
grid.render()
}
}
fn paint_node_colors(grid: &mut Grid, pos: GridPos, geom: NodeGeom, style: NodeStyle) {
let (col, row) = pos;
let w = geom.width;
let h = geom.height;
if w < 2 || h < 2 {
return;
}
if let Some(stroke) = style.stroke {
for x in col..(col + w) {
grid.set_fg(x, row, stroke);
grid.set_fg(x, row + h - 1, stroke);
}
for y in (row + 1)..(row + h - 1) {
grid.set_fg(col, y, stroke);
grid.set_fg(col + w - 1, y, stroke);
}
}
let inner_col = col + 1;
let inner_row = row + 1;
let inner_w = w - 2;
let inner_h = h - 2;
if let Some(fill) = style.fill {
grid.paint_bg_rect(inner_col, inner_row, inner_w, inner_h, fill);
}
if let Some(text_color) = style.color {
grid.paint_fg_rect(inner_col, inner_row, inner_w, inner_h, text_color);
}
}
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)?;
if is_back_edge(from_pos, to_pos, graph.direction) {
let src = exit_point_back_edge(from_pos, from_geom, graph.direction);
let dst = entry_point_back_edge(to_pos, to_geom, graph.direction);
Some((src, dst))
} else {
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);
}
NodeShape::Bar(BarOrientation::Horizontal) => {
grid.draw_horizontal_bar(col, row, geom.width);
}
NodeShape::Bar(BarOrientation::Vertical) => {
grid.draw_vertical_bar(col, row, 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);
}
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) {
if matches!(node.shape, NodeShape::Bar(_)) {
return;
}
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)>,
node_rects: &[(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;
}
let candidates = candidate_positions(path, dir);
if candidates.is_empty() {
return None;
}
for &(c, r) in &candidates {
if !collides(c, r, lbl_w, placed) && !overlaps_node_interior(c, r, lbl_w, node_rects) {
placed.push((c, r, lbl_w, 1));
return Some((c, r));
}
}
for &(c, r) in &candidates {
if !collides(c, r, lbl_w, placed) {
placed.push((c, r, lbl_w, 1));
return Some((c, r));
}
}
None
}
fn candidate_positions(path: &[(usize, usize)], dir: Direction) -> Vec<(usize, usize)> {
match dir {
Direction::LeftToRight | Direction::RightToLeft => {
let Some((mid_col, seg_row, lo_col, hi_col)) = last_horizontal_segment_with_range(path)
else {
return Vec::new();
};
let third = (hi_col - lo_col) / 3;
let col_anchors = [mid_col, lo_col + third, lo_col + 2 * third];
let row_offsets: [isize; 8] = [-1, 1, -2, 2, -3, 3, -4, 4];
let mut out = Vec::with_capacity(col_anchors.len() * row_offsets.len());
for &c in &col_anchors {
for &dr in &row_offsets {
let r = (seg_row as isize + dr).max(0) as usize;
out.push((c, r));
}
}
out
}
Direction::TopToBottom | Direction::BottomToTop => {
let (seg_col, seg_row) = match last_vertical_segment(path) {
Some(v) => v,
None => return Vec::new(),
};
let col_anchors = [seg_col + 1, seg_col.saturating_sub(1), seg_col + 2];
let row_offsets: [isize; 5] = [0, -1, 1, -2, 2];
let mut out = Vec::with_capacity(col_anchors.len() * row_offsets.len());
for &c in &col_anchors {
for &dr in &row_offsets {
let r = (seg_row as isize + dr).max(0) as usize;
out.push((c, r));
}
}
out
}
}
}
fn last_horizontal_segment_with_range(
path: &[(usize, usize)],
) -> Option<(usize, usize, 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 lo_col = path[start].0.min(path[i].0);
let hi_col = path[start].0.max(path[i].0);
let mid_col = (lo_col + hi_col) / 2;
return Some((mid_col, row, lo_col, hi_col));
}
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
}
fn overlaps_node_interior(
col: usize,
row: usize,
w: usize,
node_rects: &[(usize, usize, usize, usize)],
) -> bool {
for &(nc, nr, nw, nh) in node_rects {
if nw < 2 || nh < 2 {
continue;
}
let int_left = nc + 1;
let int_right = nc + nw - 1; let int_top = nr + 1;
let int_bottom = nr + nh - 1; let row_in_interior = row >= int_top && row < int_bottom;
if !row_in_interior {
continue;
}
let col_overlaps = !(col + w <= int_left || int_right <= col);
if col_overlaps {
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}");
}
fn one_box() -> Vec<(usize, usize, usize, usize)> {
vec![(10, 5, 10, 5)]
}
#[test]
fn label_fully_inside_interior_overlaps() {
assert!(overlaps_node_interior(12, 7, 4, &one_box()));
}
#[test]
fn label_on_top_border_does_not_overlap() {
assert!(!overlaps_node_interior(12, 5, 4, &one_box()));
}
#[test]
fn label_on_bottom_border_does_not_overlap() {
assert!(!overlaps_node_interior(12, 9, 4, &one_box()));
}
#[test]
fn label_above_box_does_not_overlap() {
assert!(!overlaps_node_interior(12, 4, 4, &one_box()));
}
#[test]
fn label_to_the_right_does_not_overlap() {
assert!(!overlaps_node_interior(25, 7, 4, &one_box()));
}
#[test]
fn label_extending_past_right_border_partially_overlaps() {
assert!(overlaps_node_interior(17, 7, 8, &one_box()));
}
#[test]
fn label_extending_into_left_border_partially_overlaps() {
assert!(overlaps_node_interior(5, 7, 8, &one_box()));
}
#[test]
fn label_skipping_over_box_horizontally_does_not_overlap() {
assert!(!overlaps_node_interior(5, 7, 4, &one_box()));
}
#[test]
fn empty_node_rects_never_overlaps() {
assert!(!overlaps_node_interior(0, 0, 100, &[]));
}
#[test]
fn tiny_boxes_have_no_interior() {
let boxes = vec![(10, 10, 1, 1)];
assert!(!overlaps_node_interior(10, 10, 1, &boxes));
}
}