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;
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)]
pub struct SceneNode {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
pub shape: Shape,
pub label: String,
pub style: NodeStyle,
}
#[derive(Debug, Clone)]
pub struct SceneEdge {
pub bezier: [(f64, f64); 4],
pub waypoints: Vec<(f64, f64)>,
pub kind: EdgeKind,
pub label: Option<(String, (f64, f64), f64)>,
}
#[derive(Debug, Clone)]
pub struct SceneCluster {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
pub title: String,
pub depth: usize,
}
#[derive(Debug, Clone)]
pub struct Scene {
pub nodes: Vec<SceneNode>,
pub edges: Vec<SceneEdge>,
pub clusters: Vec<SceneCluster>,
pub width: f64,
pub height: f64,
}
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 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,
);
let wps: Vec<(f64, f64)> = if lo.edge_paths[ei].is_empty() {
Vec::new()
} else {
let mut v = Vec::with_capacity(lo.edge_paths[ei].len() + 2);
v.push(pts[0]);
v.extend(lo.edge_paths[ei].iter().copied());
v.push(pts[3]);
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 {
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 {
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 = route_clusters(g, &sc.nodes);
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,
)
}),
}
};
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 {
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()
}
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 {
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 {
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,
)
}),
}
};
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 {
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,
}
}
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 {
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 {
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 mut s = String::new();
svg_open(&mut s, width, height, 14);
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
));
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
));
s.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
)
};
s.push_str(&format!(
"<path d=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\"{}{}/>\n",
path_d, EDGE_COLOR, sw, dash, marker
));
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 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(&edge_labels);
s.push_str("</svg>\n");
s
}
struct Bbox(f64, f64, f64, f64);
impl Bbox {
fn new() -> Self {
Bbox(
f64::INFINITY,
f64::NEG_INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
)
}
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);
}
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)
}
}
}
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)
}
}
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()
}
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)],
) -> [(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 ends = p0.0 + p3.0;
let mid = ends / 2.0;
let bt_r = ((4.0 / 3.0) * (ext_r + 24.0) - ends / 6.0).max(ext_r + 40.0);
let bt_l = ((4.0 / 3.0) * (ext_l - 24.0) - 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,
)
}
pub(crate) fn catmull_rom(
p0: (f64, f64),
p1: (f64, f64),
p2: (f64, f64),
p3: (f64, f64),
) -> ((f64, f64), (f64, f64)) {
let c1 = (p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0);
let c2 = (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0);
(c1, c2)
}
fn spline_d(pts: &[(f64, f64)]) -> String {
let n = pts.len();
let mut d = format!("M {:.1} {:.1}", pts[0].0, pts[0].1);
for i in 0..n - 1 {
let p0 = pts[i.saturating_sub(1)];
let (p1, p2) = (pts[i], pts[i + 1]);
let p3 = pts[(i + 2).min(n - 1)];
let (c1, c2) = catmull_rom(p0, p1, p2, p3);
d.push_str(&format!(
" C {:.1} {:.1}, {:.1} {:.1}, {:.1} {:.1}",
c1.0, c1.1, c2.0, c2.1, p2.0, p2.1
));
}
d
}
pub(crate) fn svg_open(s: &mut String, width: f64, height: f64, font_size: u32) {
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}\">\n",
w = width,
h = height,
fs = font_size
));
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,
escape(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,
escape(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}") },
escape(line)
));
}
s.push_str("</text>\n");
}
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 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"
);
}
}