use crate::layout::{intrinsic_size, layout_clustered, layout_sized, text_width, LayoutResult, Placed};
use crate::model::{Direction, EdgeKind, End, Graph, NodeStyle, Shape};
use std::collections::HashMap;
pub(crate) const MARGIN: f64 = 28.0;
const PARALLEL_GAP: f64 = 16.0;
type BoxCS = ((f64, f64), (f64, f64));
type RawBox = Option<(f64, f64, f64, f64)>;
pub(crate) const EDGE_COLOR: &str = "#44507a";
pub(crate) const TEXT_COLOR: &str = "#232840";
pub(crate) const LABEL_BORDER: &str = "#d5d9ec";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SceneNode {
pub id: String,
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
pub shape: Shape,
pub label: String,
pub style: NodeStyle,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SceneEdge {
pub from: String,
pub to: String,
pub bezier: [(f64, f64); 4],
pub waypoints: Vec<(f64, f64)>,
pub kind: EdgeKind,
pub label: Option<(String, (f64, f64), f64)>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SceneCluster {
pub id: String,
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
pub title: String,
pub depth: usize,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Scene {
pub nodes: Vec<SceneNode>,
pub edges: Vec<SceneEdge>,
pub clusters: Vec<SceneCluster>,
pub width: f64,
pub height: f64,
}
impl Scene {
pub fn empty(width: f64, height: f64) -> Scene {
Scene {
nodes: Vec::new(),
edges: Vec::new(),
clusters: Vec::new(),
width,
height,
}
}
}
pub fn scene(g: &Graph) -> Scene {
let sizes: Vec<(f64, f64)> = g.nodes.iter().map(intrinsic_size).collect();
scene_sized(g, &sizes)
}
pub fn scene_sized(g: &Graph, sizes: &[(f64, f64)]) -> Scene {
assert_eq!(
sizes.len(),
g.nodes.len(),
"number of sizes must match number of nodes"
);
if g.subgraphs.is_empty() {
scene_flat(g, sizes)
} else {
scene_clustered(g, sizes)
}
}
fn scene_flat(g: &Graph, sizes: &[(f64, f64)]) -> Scene {
scene_from_layout(g, sizes, layout_sized(g, sizes))
}
fn scene_from_layout(g: &Graph, sizes: &[(f64, f64)], lo: LayoutResult) -> Scene {
let nlayers = lo.nodes.iter().map(|p| p.layer).max().map_or(0, |m| m + 1);
let mut lay_ext = vec![(f64::INFINITY, f64::NEG_INFINITY); nlayers];
for p in &lo.nodes {
let e = &mut lay_ext[p.layer];
e.0 = e.0.min(p.b - p.bsize / 2.0);
e.1 = e.1.max(p.b + p.bsize / 2.0);
}
let offs = parallel_offsets(g);
type AbsEdge = ([(f64, f64); 4], Vec<(f64, f64)>, Option<((f64, f64), f64)>);
let mut abs_edges: Vec<AbsEdge> = Vec::with_capacity(g.edges.len());
for (ei, (e, off)) in g.edges.iter().zip(offs).enumerate() {
let a = &lo.nodes[e.from];
let b = &lo.nodes[e.to];
let lbl_w = e.label.as_ref().map_or(0.0, |l| text_width(l) + 14.0);
let pts = edge_points(
a,
b,
g.nodes[e.from].shape,
g.nodes[e.to].shape,
e.from == e.to,
lo.total_b,
off,
&lay_ext,
lbl_w,
);
let wps: Vec<(f64, f64)> = if lo.edge_paths[ei].is_empty() {
Vec::new()
} else {
let chain = &lo.edge_paths[ei];
let a_bottom = b.layer > a.layer; let p0 = anchor(a, g.nodes[e.from].shape, chain[0], off, a_bottom);
let p3 = anchor(b, g.nodes[e.to].shape, *chain.last().unwrap(), off, !a_bottom);
let mut v = Vec::with_capacity(chain.len() + 2);
v.push(p0);
v.extend(chain.iter().copied());
v.push(p3);
v
};
let label = e.label.as_ref().map(|l| {
let mid = if wps.is_empty() {
cubic_mid(pts[0], pts[1], pts[2], pts[3])
} else {
wps[wps.len() / 2]
};
(mid, text_width(l) + 14.0)
});
abs_edges.push((pts, wps, label));
}
let mut bb = Bbox::new();
for p in &lo.nodes {
bb.add(p.b - p.bsize / 2.0, p.l - p.lsize / 2.0);
bb.add(p.b + p.bsize / 2.0, p.l + p.lsize / 2.0);
}
for (e, (pts, wps, label)) in g.edges.iter().zip(&abs_edges) {
if matches!(e.kind, EdgeKind::Invisible) {
continue;
}
if wps.is_empty() {
for &(bp, lp) in pts {
bb.add(bp, lp);
}
} else {
for &(bp, lp) in wps {
bb.add(bp, lp);
}
}
if let Some((m, w)) = label {
bb.add(m.0 - w / 2.0, m.1 - 10.0);
bb.add(m.0 + w / 2.0, m.1 + 10.0);
}
}
let (minb, maxb, minl, maxl) = bb.finish();
let offb = MARGIN - minb;
let offl = MARGIN - minl;
let total_b = (maxb - minb) + 2.0 * MARGIN;
let total_l = (maxl - minl) + 2.0 * MARGIN;
let dir = g.direction;
let tf = move |p: (f64, f64)| -> (f64, f64) {
let b = p.0 + offb;
let l = p.1 + offl;
match dir {
Direction::TD => (b, l),
Direction::BT => (b, total_l - l),
Direction::LR => (l, b),
Direction::RL => (total_l - l, b),
}
};
let horizontal = matches!(dir, Direction::LR | Direction::RL);
let (width, height) = if horizontal {
(total_l, total_b)
} else {
(total_b, total_l)
};
let nodes = g
.nodes
.iter()
.enumerate()
.map(|(i, n)| {
let (x, y) = tf((lo.nodes[i].b, lo.nodes[i].l));
let (w, h) = sizes[i]; SceneNode {
id: n.id.clone(),
x,
y,
w,
h,
shape: n.shape,
label: n.label.clone(),
style: n.style.clone(),
}
})
.collect();
let edges = g
.edges
.iter()
.zip(abs_edges)
.map(|(e, (pts, wps, label))| SceneEdge {
from: g.nodes[e.from].id.clone(),
to: g.nodes[e.to].id.clone(),
bezier: [tf(pts[0]), tf(pts[1]), tf(pts[2]), tf(pts[3])],
waypoints: wps.iter().map(|&p| tf(p)).collect(),
kind: e.kind,
label: e
.label
.clone()
.zip(label)
.map(|(t, (m, w))| (t, tf(m), w)),
})
.collect();
Scene {
nodes,
edges,
clusters: Vec::new(),
width,
height,
}
}
const SUB_HEADER: f64 = 26.0;
const SUB_PAD: f64 = 14.0;
fn scene_clustered(g: &Graph, sizes: &[(f64, f64)]) -> Scene {
let node_cluster = node_cluster_paths(g);
let lo = layout_clustered(g, sizes, &node_cluster);
let mut sc = scene_from_layout(g, sizes, lo);
sc.clusters = {
let (mut boxes, depth) = cluster_raw_boxes(g, &sc.nodes);
for si in 0..g.subgraphs.len() {
let Some((x, y, w, h)) = boxes[si] else { continue };
let (mut x0, mut x1) = (x, x + w);
for (ge, se) in g.edges.iter().zip(sc.edges.iter()) {
if se.waypoints.is_empty()
|| !(node_cluster[ge.from].contains(&si)
|| node_cluster[ge.to].contains(&si))
{
continue;
}
for &(wx, wy) in &se.waypoints {
if wy >= y && wy <= y + h {
x0 = x0.min(wx - 12.0);
x1 = x1.max(wx + 12.0);
}
}
}
boxes[si] = Some((x0, y, x1 - x0, h));
}
let mut order: Vec<usize> = (0..g.subgraphs.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse(depth[i]));
for &c in &order {
let (Some(p), Some((cx, cy, cw, ch))) = (g.subgraphs[c].parent, boxes[c]) else {
continue;
};
if let Some((px, py, pw, ph)) = boxes[p] {
let nx0 = px.min(cx - SUB_PAD);
let ny0 = py.min(cy - SUB_PAD);
let nx1 = (px + pw).max(cx + cw + SUB_PAD);
let ny1 = (py + ph).max(cy + ch + SUB_PAD);
boxes[p] = Some((nx0, ny0, nx1 - nx0, ny1 - ny0));
}
}
let mut out: Vec<SceneCluster> = (0..g.subgraphs.len())
.filter_map(|i| {
boxes[i].map(|(x, y, w, h)| SceneCluster {
id: g.subgraphs[i].id.clone(),
x,
y,
w,
h,
title: g.subgraphs[i].title.clone(),
depth: depth[i],
})
})
.collect();
out.sort_by_key(|c| c.depth);
out
};
if !g.sub_edges.is_empty() {
let (raw, _) = cluster_raw_boxes(g, &sc.nodes);
let end_placed = |end: End| -> Option<(Placed, Shape)> {
match end {
End::Node(v) => Some((
Placed {
b: sc.nodes[v].x,
l: sc.nodes[v].y,
bsize: sc.nodes[v].w,
lsize: sc.nodes[v].h,
layer: 0,
},
g.nodes[v].shape,
)),
End::Sub(si) => raw.get(si).copied().flatten().map(|(x, y, w, h)| {
(
Placed {
b: x + w / 2.0,
l: y + h / 2.0,
bsize: w,
lsize: h,
layer: 0,
},
Shape::Rect,
)
}),
}
};
let end_id = |end: End| -> String {
match end {
End::Node(v) => g.nodes[v].id.clone(),
End::Sub(si) => g.subgraphs[si].id.clone(),
}
};
for e in &g.sub_edges {
let (Some((pa, sa)), Some((pb, sb))) = (end_placed(e.from), end_placed(e.to)) else {
continue;
};
let pts = free_edge(&pa, &pb, sa, sb, false, 0.0);
let label = e.label.as_ref().map(|l| {
(
l.clone(),
cubic_mid(pts[0], pts[1], pts[2], pts[3]),
text_width(l) + 14.0,
)
});
sc.edges.push(SceneEdge {
from: end_id(e.from),
to: end_id(e.to),
bezier: pts,
waypoints: Vec::new(),
kind: e.kind,
label,
});
}
}
if !sc.clusters.is_empty() {
let mut bb = Bbox::new();
grow_scene(&mut bb, &sc.nodes, &sc.edges, &sc.clusters);
let (minx, maxx, miny, maxy) = bb.finish();
let (dx, dy) = (MARGIN - minx, MARGIN - miny);
if dx != 0.0 || dy != 0.0 {
shift_scene(&mut sc, dx, dy);
}
sc.width = (maxx - minx) + 2.0 * MARGIN;
sc.height = (maxy - miny) + 2.0 * MARGIN;
}
sc
}
fn node_cluster_paths(g: &Graph) -> Vec<Vec<usize>> {
let mut owner: Vec<Option<usize>> = vec![None; g.nodes.len()];
for (si, s) in g.subgraphs.iter().enumerate() {
for &v in &s.nodes {
owner[v] = Some(si);
}
}
(0..g.nodes.len())
.map(|v| {
let mut path = Vec::new();
let mut cur = owner[v];
while let Some(s) = cur {
path.push(s);
cur = g.subgraphs[s].parent;
}
path.reverse(); path
})
.collect()
}
pub(crate) fn flip_scene(sc: &mut Scene, extent: f64, horizontal: bool) {
let f = |p: (f64, f64)| -> (f64, f64) {
if horizontal { (extent - p.0, p.1) } else { (p.0, extent - p.1) }
};
for n in &mut sc.nodes {
let (x, y) = f((n.x, n.y));
n.x = x;
n.y = y;
}
for e in &mut sc.edges {
for p in e.bezier.iter_mut() {
*p = f(*p);
}
for p in e.waypoints.iter_mut() {
*p = f(*p);
}
if let Some((_, m, _)) = &mut e.label {
*m = f(*m);
}
}
for c in &mut sc.clusters {
if horizontal {
c.x = extent - c.x - c.w;
} else {
c.y = extent - c.y - c.h;
}
}
}
pub(crate) fn shift_scene(sc: &mut Scene, dx: f64, dy: f64) {
for n in &mut sc.nodes {
n.x += dx;
n.y += dy;
}
for e in &mut sc.edges {
for p in e.bezier.iter_mut() {
*p = (p.0 + dx, p.1 + dy);
}
for p in e.waypoints.iter_mut() {
*p = (p.0 + dx, p.1 + dy);
}
if let Some((_, m, _)) = &mut e.label {
*m = (m.0 + dx, m.1 + dy);
}
}
for c in &mut sc.clusters {
c.x += dx;
c.y += dy;
}
}
pub fn route(g: &Graph, centers: &[(f64, f64)]) -> Scene {
let sizes: Vec<(f64, f64)> = g.nodes.iter().map(intrinsic_size).collect();
route_sized(g, centers, &sizes)
}
pub fn route_sized(g: &Graph, centers: &[(f64, f64)], sizes: &[(f64, f64)]) -> Scene {
assert_eq!(
centers.len(),
g.nodes.len(),
"number of positions must match number of nodes"
);
assert_eq!(
sizes.len(),
g.nodes.len(),
"number of sizes must match number of nodes"
);
let placed: Vec<Placed> = (0..g.nodes.len())
.map(|i| Placed {
b: centers[i].0,
l: centers[i].1,
bsize: sizes[i].0,
lsize: sizes[i].1,
layer: 0,
})
.collect();
let nodes: Vec<SceneNode> = g
.nodes
.iter()
.enumerate()
.map(|(i, n)| SceneNode {
id: n.id.clone(),
x: centers[i].0,
y: centers[i].1,
w: sizes[i].0,
h: sizes[i].1,
shape: n.shape,
label: n.label.clone(),
style: n.style.clone(),
})
.collect();
let vertical = matches!(g.direction, Direction::TD | Direction::BT);
let mut owner: Vec<Option<usize>> = vec![None; g.nodes.len()];
for (si, s) in g.subgraphs.iter().enumerate() {
for &nn in &s.nodes {
owner[nn] = Some(si);
}
}
let top_of = |v: usize| -> Option<usize> {
let mut s = owner[v]?;
while let Some(p) = g.subgraphs[s].parent {
s = p;
}
Some(s)
};
let (raw_boxes, _) = cluster_raw_boxes(g, &nodes);
let box_of = |v: usize| -> (f64, f64, f64, f64) {
top_of(v)
.and_then(|s| raw_boxes.get(s).copied().flatten())
.unwrap_or((
centers[v].0 - sizes[v].0 / 2.0,
centers[v].1 - sizes[v].1 / 2.0,
sizes[v].0,
sizes[v].1,
))
};
let offs = parallel_offsets(g);
let mut edges = Vec::with_capacity(g.edges.len());
for (e, off) in g.edges.iter().zip(offs) {
let a = &placed[e.from];
let b = &placed[e.to];
let pts = free_edge(
a,
b,
g.nodes[e.from].shape,
g.nodes[e.to].shape,
e.from == e.to,
off,
);
let wps = if e.from != e.to && top_of(e.from) != top_of(e.to) {
cross_cluster_route(
centers[e.from],
sizes[e.from],
box_of(e.from),
centers[e.to],
sizes[e.to],
box_of(e.to),
vertical,
)
} else {
Vec::new()
};
let label = e.label.as_ref().map(|l| {
let mid = if wps.is_empty() {
cubic_mid(pts[0], pts[1], pts[2], pts[3])
} else {
wps[wps.len() / 2]
};
(l.clone(), mid, text_width(l) + 14.0)
});
edges.push(SceneEdge {
from: g.nodes[e.from].id.clone(),
to: g.nodes[e.to].id.clone(),
bezier: pts,
waypoints: wps,
kind: e.kind,
label,
});
}
let clusters = route_clusters(g, &nodes);
if !g.sub_edges.is_empty() {
let (raw, _) = cluster_raw_boxes(g, &nodes);
let mut boxes: HashMap<usize, BoxCS> = HashMap::new();
for (si, b) in raw.iter().enumerate() {
if let Some((x, y, w, h)) = *b {
boxes.insert(si, ((x + w / 2.0, y + h / 2.0), (w, h)));
}
}
let end_placed = |end: End| -> Option<(Placed, Shape)> {
match end {
End::Node(v) => Some((
Placed {
b: centers[v].0,
l: centers[v].1,
bsize: sizes[v].0,
lsize: sizes[v].1,
layer: 0,
},
g.nodes[v].shape,
)),
End::Sub(si) => boxes.get(&si).map(|&((cx, cy), (w, h))| {
(
Placed {
b: cx,
l: cy,
bsize: w,
lsize: h,
layer: 0,
},
Shape::Rect,
)
}),
}
};
let end_id = |end: End| -> String {
match end {
End::Node(v) => g.nodes[v].id.clone(),
End::Sub(si) => g.subgraphs[si].id.clone(),
}
};
for e in &g.sub_edges {
let (Some((pa, sa)), Some((pb, sb))) = (end_placed(e.from), end_placed(e.to)) else {
continue;
};
let pts = free_edge(&pa, &pb, sa, sb, false, 0.0);
let label = e.label.as_ref().map(|l| {
(
l.clone(),
cubic_mid(pts[0], pts[1], pts[2], pts[3]),
text_width(l) + 14.0,
)
});
edges.push(SceneEdge {
from: end_id(e.from),
to: end_id(e.to),
bezier: pts,
waypoints: Vec::new(),
kind: e.kind,
label,
});
}
}
let mut bb = Bbox::new();
grow_scene(&mut bb, &nodes, &edges, &clusters);
let (_, maxx, _, maxy) = bb.finish();
Scene {
nodes,
edges,
clusters,
width: maxx + MARGIN,
height: maxy + MARGIN,
}
}
pub fn route_partial(
g: &Graph,
centers: &[(f64, f64)],
base: &Scene,
base_centers: &[(f64, f64)],
) -> Scene {
let n = g.nodes.len();
if base.nodes.len() != n || base_centers.len() != n || base.edges.len() < g.edges.len() {
return route(g, centers);
}
let sizes: Vec<(f64, f64)> = g.nodes.iter().map(intrinsic_size).collect();
let moved: Vec<bool> = (0..n)
.map(|i| {
(centers[i].0 - base_centers[i].0).abs() > 0.01
|| (centers[i].1 - base_centers[i].1).abs() > 0.01
})
.collect();
if !moved.iter().any(|&m| m) {
return base.clone();
}
let full = route_sized(g, centers, &sizes);
let mut out = full;
for ei in 0..g.edges.len() {
let e = &g.edges[ei];
if !moved[e.from] && !moved[e.to] {
out.edges[ei] = base.edges[ei].clone();
}
}
let mut bb = Bbox::new();
grow_scene(&mut bb, &out.nodes, &out.edges, &out.clusters);
let (_, maxx, _, maxy) = bb.finish();
out.width = (maxx + MARGIN).max(base.width);
out.height = (maxy + MARGIN).max(base.height);
out
}
fn cluster_raw_boxes(
g: &Graph,
nodes: &[SceneNode],
) -> (Vec<RawBox>, Vec<usize>) {
let nsub = g.subgraphs.len();
let mut depth = vec![0usize; nsub];
for i in 0..nsub {
let mut p = g.subgraphs[i].parent;
while let Some(pi) = p {
depth[i] += 1;
p = g.subgraphs[pi].parent;
}
}
let mut order: Vec<usize> = (0..nsub).collect();
order.sort_by_key(|&i| std::cmp::Reverse(depth[i]));
let mut boxes: Vec<RawBox> = vec![None; nsub];
for &i in &order {
let mut bb = Bbox::new();
for &v in &g.subgraphs[i].nodes {
let n = &nodes[v];
bb.add(n.x - n.w / 2.0, n.y - n.h / 2.0);
bb.add(n.x + n.w / 2.0, n.y + n.h / 2.0);
}
for (j, s) in g.subgraphs.iter().enumerate() {
if s.parent == Some(i) {
if let Some((x, y, w, h)) = boxes[j] {
bb.add(x, y);
bb.add(x + w, y + h);
}
}
}
if !bb.0.is_finite() {
continue; }
let (minx, maxx, miny, maxy) = bb.finish();
boxes[i] = Some((
minx - SUB_PAD,
miny - SUB_PAD - SUB_HEADER,
(maxx - minx) + 2.0 * SUB_PAD,
(maxy - miny) + 2.0 * SUB_PAD + SUB_HEADER,
));
}
(boxes, depth)
}
fn route_clusters(g: &Graph, nodes: &[SceneNode]) -> Vec<SceneCluster> {
if g.subgraphs.is_empty() {
return Vec::new();
}
let (boxes, depth) = cluster_raw_boxes(g, nodes);
let mut out: Vec<SceneCluster> = (0..g.subgraphs.len())
.filter_map(|i| {
boxes[i].map(|(x, y, w, h)| SceneCluster {
id: g.subgraphs[i].id.clone(),
x,
y,
w,
h,
title: g.subgraphs[i].title.clone(),
depth: depth[i],
})
})
.collect();
out.sort_by_key(|c| c.depth);
out
}
pub fn box_edge_bezier(
a_center: (f64, f64),
a_size: (f64, f64),
b_center: (f64, f64),
b_size: (f64, f64),
offset: f64,
self_loop: bool,
) -> [(f64, f64); 4] {
let make = |c: (f64, f64), s: (f64, f64)| Placed {
b: c.0,
l: c.1,
bsize: s.0,
lsize: s.1,
layer: 0,
};
free_edge(
&make(a_center, a_size),
&make(b_center, b_size),
Shape::Rect,
Shape::Rect,
self_loop,
offset,
)
}
pub fn to_svg(sc: &Scene) -> String {
to_svg_titled(sc, "Flowchart diagram")
}
pub fn to_svg_titled(sc: &Scene, title: &str) -> String {
let mut bb = Bbox::new();
grow_scene(&mut bb, &sc.nodes, &sc.edges, &sc.clusters);
let (minx, maxx, miny, maxy) = bb.finish();
let tx = MARGIN - minx;
let ty = MARGIN - miny;
let width = (maxx - minx) + 2.0 * MARGIN;
let height = (maxy - miny) + 2.0 * MARGIN;
let t = |p: (f64, f64)| (p.0 + tx, p.1 + ty);
let shape_of: std::collections::BTreeMap<&str, Shape> =
sc.nodes.iter().map(|n| (n.id.as_str(), n.shape)).collect();
let display = |id: &str| -> String {
match shape_of.get(id) {
Some(Shape::StateStart) => "start".to_string(),
Some(Shape::StateEnd) => "end".to_string(),
_ => id.to_string(),
}
};
let mut s = String::new();
svg_open(&mut s, width, height, 14, title);
s.push_str(&format!(
"<defs><marker id=\"arrow\" viewBox=\"0 0 10 10\" refX=\"8.5\" refY=\"5\" \
markerWidth=\"7\" markerHeight=\"7\" orient=\"auto\">\
<path d=\"M 0 1 L 9 5 L 0 9 z\" fill=\"{}\"/></marker></defs>\n",
EDGE_COLOR
));
let mut cluster_titles = String::new();
for c in &sc.clusters {
let (x, y) = t((c.x, c.y));
s.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" rx=\"8\" \
fill=\"#f7f8fd\" stroke=\"#c9cfe8\" stroke-width=\"1.4\"/>\n",
x, y, c.w, c.h
));
let tw = text_width(&c.title) * 12.0 / 14.0; cluster_titles.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"16\" rx=\"4\" \
fill=\"#f7f8fd\" fill-opacity=\"0.9\"/>\n",
x + 6.0,
y + 5.0,
tw + 8.0
));
cluster_titles.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"12\" font-weight=\"bold\" \
fill=\"#6a7086\">{}</text>\n",
x + 10.0,
y + 17.0,
escape(&c.title)
));
}
let mut edge_labels = String::new();
for e in &sc.edges {
if matches!(e.kind, EdgeKind::Invisible) {
continue; }
let (dash, sw) = match e.kind {
EdgeKind::Dotted | EdgeKind::DottedOpen => (" stroke-dasharray=\"5 4\"", 1.7),
EdgeKind::Thick | EdgeKind::ThickOpen => ("", 3.4),
_ => ("", 1.7),
};
let marker = if e.kind.has_arrow() {
" marker-end=\"url(#arrow)\""
} else {
""
};
let path_d = if e.waypoints.len() >= 2 {
let q: Vec<(f64, f64)> = e.waypoints.iter().map(|&p| t(p)).collect();
spline_d(&q)
} else {
let q: Vec<(f64, f64)> = e.bezier.iter().map(|&p| t(p)).collect();
format!(
"M {:.1} {:.1} C {:.1} {:.1}, {:.1} {:.1}, {:.1} {:.1}",
q[0].0, q[0].1, q[1].0, q[1].1, q[2].0, q[2].1, q[3].0, q[3].1
)
};
let edge_title = match &e.label {
Some((text, ..)) => {
format!("{} \u{2192} {}: {}", display(&e.from), display(&e.to), plain_text(text))
}
None => format!("{} \u{2192} {}", display(&e.from), display(&e.to)),
};
s.push_str(&format!(
"<path d=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\"{}{}>\
<title>{}</title></path>\n",
path_d, EDGE_COLOR, sw, dash, marker, escape(&edge_title)
));
if let Some((text, m, w)) = &e.label {
svg_label_box(&mut edge_labels, text, t(*m), *w);
}
}
for n in &sc.nodes {
let (cx, cy) = t((n.x, n.y));
let (w, h) = (n.w, n.h);
let node_title = {
let lbl = plain_text(&n.label);
if !lbl.is_empty() {
lbl
} else {
match n.shape {
Shape::StateStart => "start".to_string(),
Shape::StateEnd => "end".to_string(),
Shape::ForkBar => "fork/join".to_string(),
_ => String::new(),
}
}
};
if node_title.is_empty() {
s.push_str("<g>\n");
} else {
s.push_str(&format!("<g><title>{}</title>\n", escape(&node_title)));
}
let ss = crate::style::shape_style(n.shape);
let style = format!(
"fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\"",
n.style.fill.as_deref().unwrap_or(ss.fill),
n.style.stroke.as_deref().unwrap_or(ss.stroke),
n.style.stroke_width.unwrap_or(1.6)
);
match n.shape {
Shape::Rect | Shape::Rounded | Shape::Stadium => {
let rx = match n.shape {
Shape::Rounded => 9.0,
Shape::Stadium => h / 2.0,
_ => 3.0,
};
s.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" \
rx=\"{:.1}\" {}/>\n",
cx - w / 2.0,
cy - h / 2.0,
w,
h,
rx,
style
));
}
Shape::Circle => {
s.push_str(&format!(
"<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{:.1}\" {}/>\n",
cx,
cy,
w / 2.0,
style
));
}
Shape::Diamond => {
s.push_str(&format!(
"<polygon points=\"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}\" {}/>\n",
cx,
cy - h / 2.0,
cx + w / 2.0,
cy,
cx,
cy + h / 2.0,
cx - w / 2.0,
cy,
style
));
}
Shape::DoubleCircle => {
for r in [w / 2.0, w / 2.0 - 4.0] {
s.push_str(&format!(
"<circle cx=\"{cx:.1}\" cy=\"{cy:.1}\" r=\"{r:.1}\" {style}/>\n"
));
}
}
Shape::Cylinder => {
let (l, r, t, b) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0);
let ry = 8.0_f64.min(h / 4.0); s.push_str(&format!(
"<path d=\"M {l:.1} {ty:.1} A {rx:.1} {ry:.1} 0 0 0 {r:.1} {ty:.1} \
L {r:.1} {by:.1} A {rx:.1} {ry:.1} 0 0 1 {l:.1} {by:.1} Z\" {style}/>\n\
<path d=\"M {l:.1} {ty:.1} A {rx:.1} {ry:.1} 0 0 1 {r:.1} {ty:.1}\" \
fill=\"none\" stroke=\"{stroke}\" stroke-width=\"1.6\"/>\n",
l = l, r = r, ty = t + ry, by = b - ry, rx = w / 2.0, ry = ry,
stroke = n.style.stroke.as_deref().unwrap_or(ss.stroke),
style = style,
));
}
Shape::Subroutine => {
let (l, t) = (cx - w / 2.0, cy - h / 2.0);
s.push_str(&format!(
"<rect x=\"{l:.1}\" y=\"{t:.1}\" width=\"{w:.1}\" height=\"{h:.1}\" rx=\"3\" {style}/>\n\
<line x1=\"{l1:.1}\" y1=\"{t:.1}\" x2=\"{l1:.1}\" y2=\"{b:.1}\" stroke=\"{stroke}\" stroke-width=\"1.6\"/>\n\
<line x1=\"{r1:.1}\" y1=\"{t:.1}\" x2=\"{r1:.1}\" y2=\"{b:.1}\" stroke=\"{stroke}\" stroke-width=\"1.6\"/>\n",
l = l, t = t, w = w, h = h, b = t + h, l1 = l + 8.0, r1 = l + w - 8.0,
stroke = n.style.stroke.as_deref().unwrap_or(ss.stroke), style = style,
));
}
Shape::Hexagon => {
let (l, r, t, b) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0);
let k = 14.0_f64.min(w / 4.0);
s.push_str(&format!(
"<polygon points=\"{a:.1},{cy:.1} {b1:.1},{t:.1} {c:.1},{t:.1} {r:.1},{cy:.1} {c:.1},{b:.1} {b1:.1},{b:.1}\" {style}/>\n",
a = l, b1 = l + k, c = r - k, r = r, t = t, b = b, cy = cy, style = style,
));
}
Shape::Parallelogram | Shape::ParallelogramAlt => {
let (l, r, t, b) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0);
let k = 14.0_f64.min(w / 4.0);
let pts = if matches!(n.shape, Shape::Parallelogram) {
format!("{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", l + k, t, r, t, r - k, b, l, b)
} else {
format!("{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", l, t, r - k, t, r, b, l + k, b)
};
s.push_str(&format!("<polygon points=\"{pts}\" {style}/>\n"));
}
Shape::StateStart => {
s.push_str(&format!(
"<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{:.1}\" {}/>\n",
cx,
cy,
w / 2.0,
style
));
}
Shape::StateEnd => {
s.push_str(&format!(
"<circle cx=\"{cx:.1}\" cy=\"{cy:.1}\" r=\"{r:.1}\" fill=\"#ffffff\" \
stroke=\"{stroke}\" stroke-width=\"1.6\"/>\n\
<circle cx=\"{cx:.1}\" cy=\"{cy:.1}\" r=\"{ri:.1}\" {style}/>\n",
r = w / 2.0,
ri = w / 2.0 - 4.0,
stroke = n.style.stroke.as_deref().unwrap_or(ss.stroke),
));
}
Shape::ForkBar => {
s.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" rx=\"3\" {}/>\n",
cx - w / 2.0,
cy - h / 2.0,
w,
h,
style
));
}
}
svg_text_multiline(
&mut s,
cx,
cy,
n.style.color.as_deref().unwrap_or(TEXT_COLOR),
&n.label,
);
s.push_str("</g>\n");
}
s.push_str(&edge_labels);
s.push_str(&cluster_titles);
s.push_str("</svg>\n");
s
}
pub(crate) struct Bbox(pub f64, pub f64, pub f64, pub f64);
impl Bbox {
pub(crate) fn new() -> Self {
Bbox(
f64::INFINITY,
f64::NEG_INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
)
}
pub(crate) fn add(&mut self, x: f64, y: f64) {
self.0 = self.0.min(x);
self.1 = self.1.max(x);
self.2 = self.2.min(y);
self.3 = self.3.max(y);
}
pub(crate) fn finish(&self) -> (f64, f64, f64, f64) {
if self.0.is_finite() {
(self.0, self.1, self.2, self.3)
} else {
(0.0, 0.0, 0.0, 0.0)
}
}
}
pub(crate) fn grow_scene(bb: &mut Bbox, nodes: &[SceneNode], edges: &[SceneEdge], clusters: &[SceneCluster]) {
for c in clusters {
bb.add(c.x, c.y);
bb.add(c.x + c.w, c.y + c.h);
}
for n in nodes {
bb.add(n.x - n.w / 2.0, n.y - n.h / 2.0);
bb.add(n.x + n.w / 2.0, n.y + n.h / 2.0);
}
for e in edges {
if matches!(e.kind, EdgeKind::Invisible) {
continue;
}
for &(x, y) in &e.bezier {
bb.add(x, y);
}
if let Some((_, m, w)) = &e.label {
bb.add(m.0 - w / 2.0, m.1 - 10.0);
bb.add(m.0 + w / 2.0, m.1 + 10.0);
}
}
}
fn pair(a: usize, b: usize) -> (usize, usize) {
if a <= b {
(a, b)
} else {
(b, a)
}
}
pub(crate) fn parallel_offsets(g: &Graph) -> Vec<f64> {
let mut count: HashMap<(usize, usize), usize> = HashMap::new();
for e in &g.edges {
*count.entry(pair(e.from, e.to)).or_insert(0) += 1;
}
let mut seen: HashMap<(usize, usize), usize> = HashMap::new();
g.edges
.iter()
.map(|e| {
let k = pair(e.from, e.to);
let i = *seen.entry(k).and_modify(|x| *x += 1).or_insert(0);
(i as f64 - (count[&k] as f64 - 1.0) / 2.0) * PARALLEL_GAP
})
.collect()
}
pub(crate) fn anchor(p: &Placed, shape: Shape, other: (f64, f64), off: f64, bottom: bool) -> (f64, f64) {
match shape {
Shape::Diamond
| Shape::Circle
| Shape::DoubleCircle
| Shape::StateStart
| Shape::StateEnd => border(p, shape, (other.0 + off * 4.0, other.1)),
_ => {
let flat = match shape {
Shape::Stadium => p.bsize / 2.0 - p.lsize / 2.0 - 4.0,
_ => p.bsize / 2.0 - 12.0,
}
.max(0.0);
let bias = ((other.0 - p.b) * 0.35 + off).clamp(-flat, flat);
let l = if bottom {
p.l + p.lsize / 2.0
} else {
p.l - p.lsize / 2.0
};
(p.b + bias, l)
}
}
}
#[allow(clippy::too_many_arguments)]
fn edge_points(
a: &Placed,
b: &Placed,
sa: Shape,
sb: Shape,
self_loop: bool,
total_b: f64,
off: f64,
lay_ext: &[(f64, f64)],
label_w: f64,
) -> [(f64, f64); 4] {
if self_loop {
return loop_points(a, off);
}
if b.layer != a.layer {
let down = b.layer > a.layer;
let p0 = anchor(a, sa, (b.b, b.l), off, down);
let p3 = anchor(b, sb, (a.b, a.l), off, !down);
if down {
let dl = ((p3.1 - p0.1) * 0.5).max(20.0);
if b.layer - a.layer > 1 && (a.b - b.b).abs() < 30.0 {
let bow = if a.b >= total_b / 2.0 {
60.0 + off
} else {
-60.0 + off
};
return [
p0,
(p0.0 + bow, p0.1 + dl * 0.6),
(p3.0 + bow, p3.1 - dl * 0.6),
p3,
];
}
return [p0, (p0.0, p0.1 + dl), (p3.0, p3.1 - dl), p3];
}
let (l0, l1) = (b.layer, a.layer);
let mut ext_l = f64::INFINITY;
let mut ext_r = f64::NEG_INFINITY;
for li in l0..=l1 {
ext_l = ext_l.min(lay_ext[li].0);
ext_r = ext_r.max(lay_ext[li].1);
}
let clear = 24.0 + label_w / 2.0;
let ends = p0.0 + p3.0;
let mid = ends / 2.0;
let bt_r = ((4.0 / 3.0) * (ext_r + clear) - ends / 6.0).max(ext_r + 40.0);
let bt_l = ((4.0 / 3.0) * (ext_l - clear) - ends / 6.0).min(ext_l - 40.0);
let bt = (if (bt_r - mid) <= (mid - bt_l) { bt_r } else { bt_l }) + off;
return [p0, (bt, p0.1 - 40.0), (bt, p3.1 + 40.0), p3];
}
let p0 = border(a, sa, (b.b, b.l));
let p3 = border(b, sb, (a.b, a.l));
let drop = a.lsize.max(b.lsize) / 2.0 + 22.0 + off;
[
p0,
(p0.0 * 0.65 + p3.0 * 0.35, a.l + drop),
(p0.0 * 0.35 + p3.0 * 0.65, b.l + drop),
p3,
]
}
fn cross_cluster_route(
a_c: (f64, f64),
a_sz: (f64, f64),
a_box: (f64, f64, f64, f64),
b_c: (f64, f64),
b_sz: (f64, f64),
b_box: (f64, f64, f64, f64),
vertical: bool,
) -> Vec<(f64, f64)> {
const GAP: f64 = 16.0;
if vertical {
let down = b_c.1 >= a_c.1;
let ok = if down {
a_box.1 + a_box.3 < b_box.1
} else {
b_box.1 + b_box.3 < a_box.1
};
if !ok {
return Vec::new();
}
let a_edge = if down { a_c.1 + a_sz.1 / 2.0 } else { a_c.1 - a_sz.1 / 2.0 };
let a_out = if down { a_box.1 + a_box.3 + GAP } else { a_box.1 - GAP };
let b_in = if down { b_box.1 - GAP } else { b_box.1 + b_box.3 + GAP };
let b_edge = if down { b_c.1 - b_sz.1 / 2.0 } else { b_c.1 + b_sz.1 / 2.0 };
vec![(a_c.0, a_edge), (a_c.0, a_out), (b_c.0, b_in), (b_c.0, b_edge)]
} else {
let right = b_c.0 >= a_c.0;
let ok = if right {
a_box.0 + a_box.2 < b_box.0
} else {
b_box.0 + b_box.2 < a_box.0
};
if !ok {
return Vec::new();
}
let a_edge = if right { a_c.0 + a_sz.0 / 2.0 } else { a_c.0 - a_sz.0 / 2.0 };
let a_out = if right { a_box.0 + a_box.2 + GAP } else { a_box.0 - GAP };
let b_in = if right { b_box.0 - GAP } else { b_box.0 + b_box.2 + GAP };
let b_edge = if right { b_c.0 - b_sz.0 / 2.0 } else { b_c.0 + b_sz.0 / 2.0 };
vec![(a_edge, a_c.1), (a_out, a_c.1), (b_in, b_c.1), (b_edge, b_c.1)]
}
}
fn free_edge(
a: &Placed,
b: &Placed,
sa: Shape,
sb: Shape,
self_loop: bool,
off: f64,
) -> [(f64, f64); 4] {
if self_loop {
return loop_points(a, off);
}
let dx = b.b - a.b;
let dy = b.l - a.l;
if dy.abs() >= dx.abs() {
let down = dy >= 0.0;
let p0 = anchor(a, sa, (b.b, b.l), off, down);
let p3 = anchor(b, sb, (a.b, a.l), off, !down);
let s = if down { 1.0 } else { -1.0 };
let dl = (dy.abs() * 0.45).max(24.0);
[
p0,
(p0.0, p0.1 + s * dl),
(p3.0, p3.1 - s * dl),
p3,
]
} else {
let p0 = border(a, sa, (b.b, b.l + off * 4.0));
let p3 = border(b, sb, (a.b, a.l + off * 4.0));
let s = if dx >= 0.0 { 1.0 } else { -1.0 };
let dl = (dx.abs() * 0.45).max(24.0);
[
p0,
(p0.0 + s * dl, p0.1),
(p3.0 - s * dl, p3.1),
p3,
]
}
}
fn loop_points(a: &Placed, off: f64) -> [(f64, f64); 4] {
let r = a.b + a.bsize / 2.0;
let ext = 48.0 + off.abs() * 0.8;
[
(r, a.l - 8.0 + off),
(r + ext, a.l - 28.0 + off),
(r + ext, a.l + 28.0 + off),
(r, a.l + 8.0 + off),
]
}
fn border(p: &Placed, shape: Shape, toward: (f64, f64)) -> (f64, f64) {
let dx = toward.0 - p.b;
let dy = toward.1 - p.l;
if dx.abs() < 1e-6 && dy.abs() < 1e-6 {
return (p.b, p.l);
}
let hw = p.bsize / 2.0;
let hh = p.lsize / 2.0;
let t = match shape {
Shape::Circle | Shape::DoubleCircle | Shape::StateStart | Shape::StateEnd => {
hw / (dx * dx + dy * dy).sqrt()
}
Shape::Diamond => 1.0 / (dx.abs() / hw + dy.abs() / hh),
_ => {
let tx = if dx.abs() > 1e-6 { hw / dx.abs() } else { f64::INFINITY };
let ty = if dy.abs() > 1e-6 { hh / dy.abs() } else { f64::INFINITY };
tx.min(ty)
}
};
(p.b + dx * t, p.l + dy * t)
}
fn cubic_mid(p0: (f64, f64), c1: (f64, f64), c2: (f64, f64), p3: (f64, f64)) -> (f64, f64) {
(
(p0.0 + 3.0 * c1.0 + 3.0 * c2.0 + p3.0) / 8.0,
(p0.1 + 3.0 * c1.1 + 3.0 * c2.1 + p3.1) / 8.0,
)
}
fn spline_d(pts: &[(f64, f64)]) -> String {
let n = pts.len();
let mut d = format!("M {:.1} {:.1}", pts[0].0, pts[0].1);
if n < 3 {
d.push_str(&format!(" L {:.1} {:.1}", pts[n - 1].0, pts[n - 1].1));
return d;
}
d.push_str(&format!(
" L {:.1} {:.1}",
(5.0 * pts[0].0 + pts[1].0) / 6.0,
(5.0 * pts[0].1 + pts[1].1) / 6.0
));
let bez = |d: &mut String, a: (f64, f64), b: (f64, f64), p: (f64, f64)| {
d.push_str(&format!(
" C {:.1} {:.1}, {:.1} {:.1}, {:.1} {:.1}",
(2.0 * a.0 + b.0) / 3.0,
(2.0 * a.1 + b.1) / 3.0,
(a.0 + 2.0 * b.0) / 3.0,
(a.1 + 2.0 * b.1) / 3.0,
(a.0 + 4.0 * b.0 + p.0) / 6.0,
(a.1 + 4.0 * b.1 + p.1) / 6.0
));
};
for i in 2..n {
bez(&mut d, pts[i - 2], pts[i - 1], pts[i]);
}
bez(&mut d, pts[n - 2], pts[n - 1], pts[n - 1]);
d.push_str(&format!(" L {:.1} {:.1}", pts[n - 1].0, pts[n - 1].1));
d
}
pub(crate) fn svg_open(s: &mut String, width: f64, height: f64, font_size: u32, title: &str) {
s.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.0}\" height=\"{h:.0}\" \
viewBox=\"0 0 {w:.0} {h:.0}\" font-family=\"Helvetica, Arial, sans-serif\" \
font-size=\"{fs}\" role=\"img\" aria-label=\"{t}\">\n",
w = width,
h = height,
fs = font_size,
t = escape(title)
));
s.push_str(&format!("<title>{}</title>\n", escape(title)));
s.push_str(&format!(
"<rect width=\"{:.0}\" height=\"{:.0}\" fill=\"#ffffff\"/>\n",
width, height
));
}
pub(crate) fn svg_label_box(s: &mut String, text: &str, center: (f64, f64), box_w: f64) {
s.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"20\" rx=\"4\" \
fill=\"#ffffff\" stroke=\"{}\"/>\n",
center.0 - box_w / 2.0,
center.1 - 10.0,
box_w,
LABEL_BORDER
));
s.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" dy=\"0.33em\" text-anchor=\"middle\" \
fill=\"{}\">{}</text>\n",
center.0,
center.1,
TEXT_COLOR,
rich(text)
));
}
pub(crate) fn svg_text_multiline(s: &mut String, cx: f64, cy: f64, fill: &str, label: &str) {
let lines: Vec<&str> = label.split('\n').collect();
if lines.len() <= 1 {
s.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" dy=\"0.33em\" text-anchor=\"middle\" \
fill=\"{}\">{}</text>\n",
cx,
cy,
fill,
rich(label)
));
return;
}
let lh = crate::layout::LINE_H;
let top = cy - (lines.len() as f64 - 1.0) * lh / 2.0;
s.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" text-anchor=\"middle\" fill=\"{}\">",
cx, top, fill
));
for (i, line) in lines.iter().enumerate() {
s.push_str(&format!(
"<tspan x=\"{:.1}\" dy=\"{}\">{}</tspan>",
cx,
if i == 0 { "0.33em".to_string() } else { format!("{lh:.1}") },
rich(line)
));
}
s.push_str("</text>\n");
}
fn plain_text(label: &str) -> String {
label
.split('\n')
.map(|l| {
crate::layout::spans(l)
.into_iter()
.map(|(t, ..)| t)
.collect::<String>()
})
.collect::<Vec<_>>()
.join(" ")
}
fn rich(line: &str) -> String {
let spans = crate::layout::spans(line);
if spans.len() == 1 && !spans[0].1 && !spans[0].2 {
return escape(&spans[0].0);
}
let mut s = String::new();
for (t, b, i) in spans {
if !b && !i {
s.push_str(&escape(&t));
} else {
s.push_str("<tspan");
if b {
s.push_str(" font-weight=\"bold\"");
}
if i {
s.push_str(" font-style=\"italic\"");
}
s.push('>');
s.push_str(&escape(&t));
s.push_str("</tspan>");
}
}
s
}
pub(crate) fn escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
#[test]
fn scene_and_render_are_consistent() {
let g = parse("flowchart LR\nA --> B{X}\nB -->|y| C").unwrap();
let s = scene(&g);
assert_eq!(s.nodes.len(), 3);
assert_eq!(s.edges.len(), 2);
assert!(s.width > 0.0 && s.height > 0.0);
assert_eq!(to_svg(&s), crate::render::render(&g));
}
#[test]
fn cross_cluster_layout_separates_boxes_and_stays_contained() {
let src = "flowchart TD\n\
subgraph Top\n A\nend\n\
subgraph Bot\n B\nend\n\
A --> B";
let g = parse(src).unwrap();
let s = scene(&g);
assert_eq!(s.clusters.len(), 2);
let top = s.clusters.iter().find(|c| c.title == "Top").unwrap();
let bot = s.clusters.iter().find(|c| c.title == "Bot").unwrap();
assert!(top.y + top.h <= bot.y + 0.5, "Top must sit above Bot");
for p in &s.edges[0].bezier {
assert!(p.0 >= -0.5 && p.0 <= s.width + 0.5);
assert!(p.1 >= -0.5 && p.1 <= s.height + 0.5);
}
let centers: Vec<(f64, f64)> = s.nodes.iter().map(|n| (n.x, n.y)).collect();
let r = route(&g, ¢ers);
assert_eq!(s.edges.len(), r.edges.len());
assert_eq!(s.clusters.len(), r.clusters.len());
let g2 = parse("flowchart TD\nsubgraph S\n A\n B\nend\nA --> B").unwrap();
assert!(scene(&g2).edges[0].waypoints.is_empty());
}
#[test]
fn svg_is_accessible_and_deterministic() {
let svg = crate::render_svg(
"flowchart TD\nA[\"<b>Start</b> here\"] -->|go| B",
)
.unwrap();
assert!(svg.contains("role=\"img\""));
assert!(svg.contains("aria-label=\"Flowchart diagram\""));
assert!(svg.contains("<title>Flowchart diagram</title>"));
assert!(svg.contains("<title>Start here</title>"), "node title, tags stripped");
assert!(svg.contains("<title>A \u{2192} B: go</title>"), "edge title with label");
let st = crate::render_svg("stateDiagram-v2\n[*] --> Idle\nIdle --> [*]").unwrap();
assert!(st.contains("aria-label=\"State diagram\""), "state aria-label");
assert!(st.contains("<title>State diagram</title>"), "state root title");
assert!(!st.contains("__start_"), "no synthesized start id leaked");
assert!(!st.contains("__end_"), "no synthesized end id leaked");
assert!(st.contains("start \u{2192} Idle"), "pseudostate named 'start'");
assert!(!st.contains("<title></title>"), "no empty node titles");
for src in [
include_str!("../examples/demo.mmd"),
include_str!("../examples/advanced.mmd"),
include_str!("../examples/subgraph.mmd"),
include_str!("../examples/er.mmd"),
include_str!("../examples/class.mmd"),
include_str!("../examples/sequence.mmd"),
include_str!("../examples/state.mmd"),
include_str!("../examples/pie.mmd"),
] {
assert_eq!(crate::render_svg(src).unwrap(), crate::render_svg(src).unwrap());
}
}
#[test]
fn scene_carries_stable_identity_and_stays_index_parallel() {
let g = parse(
"flowchart TD\nsubgraph grp [Group]\n B\nend\nA --> B\nA --> grp",
)
.unwrap();
let s = scene(&g);
for (sn, n) in s.nodes.iter().zip(&g.nodes) {
assert_eq!(sn.id, n.id, "scene.nodes index-parallel with graph.nodes");
}
assert_eq!(s.edges[0].from, "A");
assert_eq!(s.edges[0].to, "B");
let sub = s.edges.last().unwrap();
assert_eq!((sub.from.as_str(), sub.to.as_str()), ("A", "grp"));
assert_eq!(s.clusters[0].id, "grp");
let centers: Vec<(f64, f64)> = s.nodes.iter().map(|n| (n.x, n.y)).collect();
let r = route(&g, ¢ers);
assert_eq!(r.nodes[0].id, s.nodes[0].id);
assert_eq!(r.edges[0].from, "A");
let crate::model::Document::Er(er) =
crate::parser::parse_document("erDiagram\nusers ||--o{ posts : has").unwrap()
else {
panic!("er document");
};
let es = crate::er::scene(&er);
assert_eq!(es.scene.nodes[0].id, "users");
assert_eq!(es.scene.edges[0].to, "posts");
}
#[test]
fn route_partial_keeps_unmoved_edges_and_reroutes_moved() {
let src = "flowchart TD\nsubgraph S1\n A\nend\nsubgraph S2\n B\n C\nend\nA-->B\nA-->C\nB-->C";
let g = parse(src).unwrap();
let s0 = scene(&g);
let auto: Vec<(f64, f64)> = s0.nodes.iter().map(|n| (n.x, n.y)).collect();
let same = route_partial(&g, &auto, &s0, &auto);
assert_eq!(same.edges.len(), s0.edges.len());
assert_eq!(same.edges[0].bezier, s0.edges[0].bezier);
assert_eq!(same.edges[0].waypoints, s0.edges[0].waypoints);
let ic = g.node_index("C").unwrap();
let mut dragged = auto.clone();
dragged[ic].0 += 150.0;
dragged[ic].1 += 40.0;
let s1 = route_partial(&g, &dragged, &s0, &auto);
assert_eq!(s1.edges[0].bezier, s0.edges[0].bezier, "A->B unmoved");
assert_eq!(s1.edges[0].waypoints, s0.edges[0].waypoints);
assert_ne!(s1.edges[1].bezier, s0.edges[1].bezier, "A->C re-routed");
let g2 = parse("flowchart TD\nA-->B").unwrap();
let p2: Vec<(f64, f64)> = vec![(40.0, 40.0), (40.0, 160.0)];
let fb = route_partial(&g2, &p2, &s0, &auto);
assert_eq!(fb.nodes.len(), 2);
}
#[test]
fn box_edge_bezier_matches_route_geometry() {
let g = parse("A[Left] --> B[Right]").unwrap();
let s0 = scene(&g);
let centers: Vec<(f64, f64)> = s0.nodes.iter().map(|n| (n.x, n.y)).collect();
let s1 = route(&g, ¢ers);
let a = &s1.nodes[0];
let b = &s1.nodes[1];
let bez = box_edge_bezier((a.x, a.y), (a.w, a.h), (b.x, b.y), (b.w, b.h), 0.0, false);
assert_eq!(bez, s1.edges[0].bezier);
let lp = box_edge_bezier((a.x, a.y), (a.w, a.h), (a.x, a.y), (a.w, a.h), 0.0, true);
assert!(lp[1].0 > a.x + a.w / 2.0, "loop must extend right of the box");
}
fn cluster_contains(c: &SceneCluster, n: &SceneNode) -> bool {
n.x - n.w / 2.0 >= c.x
&& n.y - n.h / 2.0 >= c.y
&& n.x + n.w / 2.0 <= c.x + c.w
&& n.y + n.h / 2.0 <= c.y + c.h
}
const NESTED: &str = "flowchart TD\n\
In[Request] --> A1\n\
subgraph backend [Backend Services]\n\
A1[API] --> W1\n\
subgraph workers\n\
W1[Worker 1] --> W2[Worker 2]\n\
end\n\
end\n\
W2 --> Out[Response]\n";
#[test]
fn subgraph_scene_wraps_members_and_nests() {
let g = parse(NESTED).unwrap();
let s = scene(&g);
assert_eq!(s.clusters.len(), 2);
assert!(s.clusters[0].depth <= s.clusters[1].depth);
let outer = &s.clusters[0];
let inner = &s.clusters[1];
assert_eq!(outer.title, "Backend Services");
assert!(
inner.x >= outer.x
&& inner.y >= outer.y
&& inner.x + inner.w <= outer.x + outer.w
&& inner.y + inner.h <= outer.y + outer.h,
"nested cluster must sit inside its parent"
);
for (i, n) in s.nodes.iter().enumerate() {
let id = g.nodes[i].id.as_str();
match id {
"W1" | "W2" => assert!(cluster_contains(inner, n), "{} in workers", id),
"A1" => assert!(cluster_contains(outer, n), "A1 in backend"),
_ => assert!(!cluster_contains(outer, n), "{} outside backend", id),
}
}
for c in &s.clusters {
assert!(c.x >= 0.0 && c.y >= 0.0);
assert!(c.x + c.w <= s.width && c.y + c.h <= s.height);
}
let svg = to_svg(&s);
assert!(svg.contains("Backend Services") && svg.contains("workers"));
}
#[test]
fn empty_and_nested_empty_subgraphs_stay_finite() {
for src in [
"graph TD\nA[Node1]\nsubgraph outer\nsubgraph inner\nend\nend",
"graph TD\nA --> B\nsubgraph l1[L1]\nsubgraph l2[L2]\nsubgraph l3[L3]\nend\nend\nend",
"flowchart TD\nsubgraph only\nend",
] {
let s = scene(&parse(src).unwrap());
assert!(s.width.is_finite() && s.height.is_finite(), "{}", src);
for n in &s.nodes {
assert!(n.x.is_finite() && n.y.is_finite(), "{}", src);
}
for c in &s.clusters {
assert!(
c.x.is_finite() && c.y.is_finite() && c.w.is_finite() && c.h.is_finite(),
"cluster in {}",
src
);
assert!(c.w >= 0.0 && c.h >= 0.0);
}
let svg = to_svg(&s);
assert!(!svg.contains("NaN") && !svg.contains("inf"), "{}", src);
}
}
#[test]
fn dragging_a_member_pulls_its_cluster_along() {
let g = parse(NESTED).unwrap();
let s0 = scene(&g);
let mut pos: Vec<(f64, f64)> = s0.nodes.iter().map(|n| (n.x, n.y)).collect();
let w2 = g.node_index("W2").unwrap();
pos[w2].0 += 600.0;
let s1 = route(&g, &pos);
let inner = s1.clusters.iter().find(|c| c.title == "workers").unwrap();
assert!(cluster_contains(inner, &s1.nodes[w2]), "cluster must follow the drag");
}
#[test]
fn route_attaches_to_node_borders() {
let g = parse("A[Left] --> B[Right]").unwrap();
let s0 = scene(&g);
let mut pos: Vec<(f64, f64)> = s0.nodes.iter().map(|n| (n.x, n.y)).collect();
pos[1] = (pos[0].0 + 400.0, pos[0].1); let s1 = route(&g, &pos);
let e = s1.edges[0].bezier;
let a = &s1.nodes[0];
let b = &s1.nodes[1];
assert!(
(e[0].0 - (a.x + a.w / 2.0)).abs() < 1.0,
"edge must leave from A's right side"
);
assert!(
(e[3].0 - (b.x - b.w / 2.0)).abs() < 1.0,
"edge must enter at B's left side"
);
}
}