#[cfg(feature = "xml")]
pub(crate) mod odf;
#[cfg(feature = "pdf")]
pub(crate) mod pdf;
mod polyline;
#[cfg(all(feature = "svg", feature = "xml"))]
pub(crate) mod svg;
use std::collections::HashMap;
use crate::types::diagram::{DiagramEdge, DiagramGraph, DiagramNode, DiagramShape};
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Rect {
pub x0: f32,
pub y0: f32,
pub x1: f32,
pub y1: f32,
}
impl Rect {
fn width(&self) -> f32 {
self.x1 - self.x0
}
fn height(&self) -> f32 {
self.y1 - self.y0
}
fn area(&self) -> f32 {
self.width() * self.height()
}
fn centre_x(&self) -> f32 {
(self.x0 + self.x1) / 2.0
}
fn contains(&self, x: f32, y: f32) -> bool {
x >= self.x0 && x <= self.x1 && y >= self.y0 && y <= self.y1
}
fn encloses(&self, other: &Rect) -> bool {
self.x0 <= other.x0 && self.y0 <= other.y0 && self.x1 >= other.x1 && self.y1 >= other.y1
}
fn overlap_area(&self, other: &Rect) -> f32 {
let width = (self.x1.min(other.x1) - self.x0.max(other.x0)).max(0.0);
let height = (self.y1.min(other.y1) - self.y0.max(other.y0)).max(0.0);
width * height
}
fn depth_of(&self, x: f32, y: f32) -> f32 {
if !self.contains(x, y) {
return 0.0;
}
(x - self.x0).min(self.x1 - x).min(y - self.y0).min(self.y1 - y)
}
fn distance_to(&self, x: f32, y: f32) -> f32 {
let dx = (self.x0 - x).max(0.0).max(x - self.x1);
let dy = (self.y0 - y).max(0.0).max(y - self.y1);
(dx * dx + dy * dy).sqrt()
}
}
#[derive(Debug, Clone)]
pub(crate) struct Outline {
pub bbox: Rect,
pub shape: DiagramShape,
pub fill: Option<String>,
pub stroke: Option<String>,
pub stroke_width: Option<f32>,
pub dashed: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct Connector {
pub start: (f32, f32),
pub end: (f32, f32),
pub midpoint: (f32, f32),
pub stroke: Option<String>,
pub dashed: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct Label {
pub x: f32,
pub y: f32,
pub text: String,
}
const MAX_OUTLINES: usize = 2_000;
const MAX_CONNECTORS: usize = 5_000;
const MAX_LABELS: usize = 20_000;
const BACKGROUND_AREA_RATIO: f32 = 0.9;
const MIN_NODE_SIDE_RATIO: f32 = 0.02;
const SNAP_RATIO: f32 = 0.02;
const RATIO_FLOOR: f32 = 4.0;
const MIN_NODE_SIDE_CEILING: f32 = 20.0;
const SNAP_CEILING: f32 = 40.0;
const CONCENTRIC_AREA_RATIO: f32 = 0.5;
const CONTAINER_MIN_MEMBERS: usize = 2;
const GRID_MIN_CELLS: usize = 3;
const GRID_CLUSTER_RATIO: f32 = 0.1;
const OVERLAP_RATIO: f32 = 0.1;
const UNLABELLED_DECORATION_RATIO: f32 = 0.25;
const ARROWHEAD_AREA_RATIO: f32 = 0.2;
const ARROWHEAD_MAX_SIDE_RATIO: f32 = 0.05;
const EDGE_LABEL_REACH: f32 = 4.0;
const CAPTION_REACH: f32 = 3.0;
const CAPTION_CEILING: f32 = 48.0;
const GRIDLINE_MIN_COUNT: usize = 3;
const GRIDLINE_AXIS_EPSILON: f32 = 0.75;
const GRIDLINE_SPACING_TOLERANCE_RATIO: f32 = 0.15;
const FRAME_MIN_FLUSH_EDGES: usize = GRIDLINE_MIN_COUNT;
pub(crate) fn assemble(
name: Option<String>,
canvas: (f32, f32),
outlines: Vec<Outline>,
connectors: Vec<Connector>,
labels: Vec<Label>,
) -> Option<DiagramGraph> {
let (canvas_w, canvas_h) = canvas;
if !canvas_w.is_finite() || !canvas_h.is_finite() || canvas_w <= 0.0 || canvas_h <= 0.0 {
return None;
}
let canvas_max = canvas_w.max(canvas_h);
let canvas_area = canvas_w * canvas_h;
let min_side = (canvas_max * MIN_NODE_SIDE_RATIO).clamp(RATIO_FLOOR, MIN_NODE_SIDE_CEILING);
let snap = (canvas_max * SNAP_RATIO).clamp(RATIO_FLOOR, SNAP_CEILING);
let (mut kept, decoration): (Vec<Outline>, Vec<Outline>) = outlines
.into_iter()
.take(MAX_OUTLINES)
.filter(|o| o.bbox.area() < canvas_area * BACKGROUND_AREA_RATIO)
.partition(|o| o.bbox.width() >= min_side && o.bbox.height() >= min_side);
kept.sort_by(|a, b| {
a.bbox
.y0
.total_cmp(&b.bbox.y0)
.then(a.bbox.x0.total_cmp(&b.bbox.x0))
.then(a.bbox.y1.total_cmp(&b.bbox.y1))
.then(a.bbox.x1.total_cmp(&b.bbox.x1))
});
kept.dedup_by(|a, b| a.bbox == b.bbox);
collapse_concentric(&mut kept);
if kept.is_empty() {
return None;
}
let mut labels: Vec<Label> = labels.into_iter().take(MAX_LABELS).collect();
labels.sort_by(|a, b| a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x)));
let mut owners: Vec<Option<usize>> = labels
.iter()
.map(|label| {
kept.iter()
.enumerate()
.filter(|(_, o)| o.bbox.contains(label.x, label.y))
.min_by(|(_, a), (_, b)| a.bbox.area().total_cmp(&b.bbox.area()))
.map(|(i, _)| i)
})
.collect();
let connectors: Vec<Connector> = connectors.into_iter().take(MAX_CONNECTORS).collect();
let is_gridline = find_gridlines(&connectors, snap);
let is_frame = find_frame_lines(&kept, &connectors, snap);
let connectors: Vec<Connector> = connectors
.into_iter()
.zip(is_gridline.into_iter().zip(is_frame))
.filter_map(|(connector, (gridline, frame_line))| (!gridline && !frame_line).then_some(connector))
.collect();
let arrowheads = find_arrowheads(&kept, &connectors, &owners, snap, canvas_max);
let reach: Vec<Rect> = arrowheads
.iter()
.zip(&kept)
.filter(|(is_arrowhead, _)| **is_arrowhead)
.map(|(_, outline)| outline.bbox)
.chain(
decoration
.iter()
.filter(|o| {
connectors.iter().any(|c| {
o.bbox.distance_to(c.start.0, c.start.1) <= snap || o.bbox.distance_to(c.end.0, c.end.1) <= snap
})
})
.map(|o| o.bbox),
)
.collect();
let mut contained: Vec<Vec<&Label>> = vec![Vec::new(); kept.len()];
for (label, owner) in labels.iter().zip(&owners) {
if let Some(index) = owner {
contained[*index].push(label);
}
}
let grids: Vec<bool> = kept
.iter()
.zip(&contained)
.map(|(outline, texts)| holds_a_grid(&outline.bbox, texts))
.collect();
let endpoints: Vec<((f32, f32), f32)> = connectors
.iter()
.flat_map(|c| {
[
(c.start, snap + touching_arrowhead(&reach, c.start, snap).unwrap_or(0.0)),
(c.end, snap + touching_arrowhead(&reach, c.end, snap).unwrap_or(0.0)),
]
})
.collect();
let landings: Vec<Vec<(f32, f32)>> = kept
.iter()
.map(|outline| {
endpoints
.iter()
.filter(|(point, tolerance)| outline.bbox.distance_to(point.0, point.1) <= *tolerance)
.map(|(point, _)| *point)
.collect()
})
.collect();
adopt_captions(&kept, &labels, &arrowheads, &connectors, &mut owners, snap);
let mut owned: Vec<Vec<&Label>> = vec![Vec::new(); kept.len()];
for (label, owner) in labels.iter().zip(&owners) {
if let Some(index) = owner {
owned[*index].push(label);
}
}
let is_container = find_containers(&kept, &arrowheads, &owned, &landings, snap);
let overlapping: Vec<bool> = kept
.iter()
.enumerate()
.map(|(i, outline)| {
kept.iter().enumerate().any(|(j, other)| {
i != j
&& !other.bbox.encloses(&outline.bbox)
&& !outline.bbox.encloses(&other.bbox)
&& outline.bbox.overlap_area(&other.bbox)
> outline.bbox.area().min(other.bbox.area()) * OVERLAP_RATIO
})
})
.collect();
let decoration_cutoff = median_area(
kept.iter()
.enumerate()
.filter(|(i, _)| !arrowheads[*i] && !grids[*i] && !owned[*i].is_empty())
.map(|(_, o)| o.bbox.area()),
)
.map(|median| median * UNLABELLED_DECORATION_RATIO);
let edge_label_reach = snap * EDGE_LABEL_REACH;
let label_backgrounds: Vec<bool> = kept
.iter()
.enumerate()
.map(|(i, outline)| {
!owned[i].is_empty()
&& decoration_cutoff.is_some_and(|cutoff| outline.bbox.area() < cutoff)
&& owned[i].iter().all(|label| {
connectors
.iter()
.any(|c| (label.x - c.midpoint.0).hypot(label.y - c.midpoint.1) <= edge_label_reach)
})
})
.collect();
let node_indices: Vec<usize> = (0..kept.len())
.filter(|i| {
let anonymous_decoration = owned[*i].is_empty()
&& (overlapping[*i] || decoration_cutoff.is_some_and(|cutoff| kept[*i].bbox.area() < cutoff));
!arrowheads[*i] && !grids[*i] && !is_container[*i] && !anonymous_decoration && !label_backgrounds[*i]
})
.collect();
if node_indices.is_empty() {
return None;
}
let mut to_node = vec![usize::MAX; kept.len()];
for (new, old) in node_indices.iter().enumerate() {
to_node[*old] = new;
}
let node_outlines: Vec<&Outline> = node_indices.iter().map(|i| &kept[*i]).collect();
let mut nodes: Vec<DiagramNode> = node_outlines
.iter()
.enumerate()
.map(|(i, o)| DiagramNode {
id: format!("n{i}"),
label: String::new(),
shape: o.shape,
fill: o.fill.clone(),
stroke: o.stroke.clone(),
stroke_width: o.stroke_width,
dashed: o.dashed,
})
.collect();
let mut free_labels: Vec<&Label> = Vec::new();
for (label, owner) in labels.iter().zip(&owners) {
match owner.map(|old| to_node[old]).filter(|new| *new != usize::MAX) {
Some(i) => {
let node_label = &mut nodes[i].label;
if !node_label.is_empty() {
node_label.push('\n');
}
node_label.push_str(&label.text);
}
None => free_labels.push(label),
}
}
let mut edges: Vec<DiagramEdge> = Vec::new();
for connector in connectors {
let head_at_start = touching_arrowhead(&reach, connector.start, snap);
let head_at_end = touching_arrowhead(&reach, connector.end, snap);
let (Some(from), Some(to)) = (
snap_to_outline(&node_outlines, connector.start, snap + head_at_start.unwrap_or(0.0)),
snap_to_outline(&node_outlines, connector.end, snap + head_at_end.unwrap_or(0.0)),
) else {
continue;
};
if from == to
&& node_outlines[from]
.bbox
.contains(connector.midpoint.0, connector.midpoint.1)
{
continue;
}
let (from, to, bidirectional) = match (head_at_start.is_some(), head_at_end.is_some()) {
(true, false) => (to, from, false),
(true, true) => (from, to, true),
_ => (from, to, false),
};
edges.push(DiagramEdge {
from,
to,
bidirectional,
label: nearest_free_label(&free_labels, connector.midpoint, snap * EDGE_LABEL_REACH),
stroke: connector.stroke,
dashed: connector.dashed,
});
}
if edges.is_empty() {
return None;
}
edges.sort_by(|a, b| a.from.cmp(&b.from).then(a.to.cmp(&b.to)));
edges.dedup_by(|a, b| a.from == b.from && a.to == b.to && a.label == b.label);
Some(DiagramGraph { name, nodes, edges })
}
fn median_area(areas: impl Iterator<Item = f32>) -> Option<f32> {
let mut sorted: Vec<f32> = areas.filter(|a| a.is_finite()).collect();
sorted.sort_by(f32::total_cmp);
sorted.get(sorted.len() / 2).copied()
}
fn holds_a_grid(bbox: &Rect, texts: &[&Label]) -> bool {
if texts.len() < GRID_MIN_CELLS {
return false;
}
let rows = cluster_count(texts.iter().map(|t| t.y), bbox.height() * GRID_CLUSTER_RATIO);
let columns = cluster_count(texts.iter().map(|t| t.x), bbox.width() * GRID_CLUSTER_RATIO);
rows > 1 && columns > 1
}
fn cluster_count(values: impl Iterator<Item = f32>, tolerance: f32) -> usize {
let mut sorted: Vec<f32> = values.filter(|v| v.is_finite()).collect();
sorted.sort_by(f32::total_cmp);
let mut clusters = 0;
let mut current = f32::NEG_INFINITY;
for value in sorted {
if clusters == 0 || value - current > tolerance.max(f32::EPSILON) {
clusters += 1;
}
current = value;
}
clusters
}
fn find_containers(
outlines: &[Outline],
arrowheads: &[bool],
owned: &[Vec<&Label>],
landings: &[Vec<(f32, f32)>],
snap: f32,
) -> Vec<bool> {
outlines
.iter()
.enumerate()
.map(|(i, outer)| {
let members = outlines
.iter()
.enumerate()
.filter(|(j, inner)| *j != i && !arrowheads[*j] && outer.bbox.encloses(&inner.bbox));
if owned[i].is_empty() {
return members.count() >= CONTAINER_MIN_MEMBERS;
}
members
.filter(|(j, _)| {
landings
.get(*j)
.is_some_and(|points| points.iter().any(|(x, y)| outer.bbox.depth_of(*x, *y) > snap))
})
.take(CONTAINER_MIN_MEMBERS)
.count()
>= CONTAINER_MIN_MEMBERS
})
.collect()
}
fn collapse_concentric(outlines: &mut Vec<Outline>) {
let mut inner = vec![false; outlines.len()];
let mut merged: Vec<(usize, Outline)> = Vec::new();
for (i, a) in outlines.iter().enumerate() {
for (j, b) in outlines.iter().enumerate() {
if i == j || inner[j] {
continue;
}
let area = a.bbox.area();
if area <= 0.0 || area >= b.bbox.area() {
continue;
}
if b.bbox.encloses(&a.bbox) && area >= b.bbox.area() * CONCENTRIC_AREA_RATIO {
inner[i] = true;
let mut outer = b.clone();
outer.fill = outer.fill.or_else(|| a.fill.clone());
outer.stroke = outer.stroke.or_else(|| a.stroke.clone());
outer.stroke_width = outer.stroke_width.or(a.stroke_width);
outer.dashed |= a.dashed;
merged.push((j, outer));
break;
}
}
}
for (index, outline) in merged {
outlines[index] = outline;
}
let mut keep = inner.iter().map(|i| !i);
outlines.retain(|_| keep.next().unwrap_or(true));
}
fn find_arrowheads(
outlines: &[Outline],
connectors: &[Connector],
label_owners: &[Option<usize>],
snap: f32,
canvas_max: f32,
) -> Vec<bool> {
let max_side = canvas_max * ARROWHEAD_MAX_SIDE_RATIO;
let mut marked = vec![false; outlines.len()];
let Some(largest) = outlines
.iter()
.map(|o| o.bbox.area())
.max_by(|a, b| a.total_cmp(b))
.filter(|a| *a > 0.0)
else {
return marked;
};
let mut labelled = vec![false; outlines.len()];
for owner in label_owners.iter().flatten() {
labelled[*owner] = true;
}
for (index, outline) in outlines.iter().enumerate() {
if outline.bbox.area() > largest * ARROWHEAD_AREA_RATIO {
continue;
}
if outline.bbox.width() > max_side || outline.bbox.height() > max_side {
continue;
}
if labelled[index] {
continue;
}
let touches = connectors.iter().any(|c| {
outline.bbox.distance_to(c.start.0, c.start.1) <= snap || outline.bbox.distance_to(c.end.0, c.end.1) <= snap
});
marked[index] = touches;
}
marked
}
fn find_gridlines(connectors: &[Connector], snap: f32) -> Vec<bool> {
let mut marked = vec![false; connectors.len()];
for horizontal in [true, false] {
let mut groups: HashMap<(String, bool), Vec<usize>> = HashMap::new();
for (index, connector) in connectors.iter().enumerate() {
let axis_aligned = if horizontal {
(connector.start.1 - connector.end.1).abs() <= GRIDLINE_AXIS_EPSILON
} else {
(connector.start.0 - connector.end.0).abs() <= GRIDLINE_AXIS_EPSILON
};
if !axis_aligned {
continue;
}
let key = (connector.stroke.clone().unwrap_or_default(), connector.dashed);
groups.entry(key).or_default().push(index);
}
for indices in groups.values() {
if indices.len() >= GRIDLINE_MIN_COUNT && is_gridline_family(connectors, indices, horizontal, snap) {
for &index in indices {
marked[index] = true;
}
}
}
}
marked
}
fn is_gridline_family(connectors: &[Connector], indices: &[usize], horizontal: bool, snap: f32) -> bool {
let mut axis_positions = Vec::with_capacity(indices.len());
let mut run_starts = Vec::with_capacity(indices.len());
let mut run_ends = Vec::with_capacity(indices.len());
for &index in indices {
let connector = &connectors[index];
let (axis, run_a, run_b) = if horizontal {
(
(connector.start.1 + connector.end.1) / 2.0,
connector.start.0,
connector.end.0,
)
} else {
(
(connector.start.0 + connector.end.0) / 2.0,
connector.start.1,
connector.end.1,
)
};
axis_positions.push(axis);
run_starts.push(run_a.min(run_b));
run_ends.push(run_a.max(run_b));
}
if spread(run_starts.iter().copied()) > snap || spread(run_ends.iter().copied()) > snap {
return false;
}
let mut sorted = axis_positions;
sorted.sort_by(f32::total_cmp);
let gaps: Vec<f32> = sorted.windows(2).map(|pair| pair[1] - pair[0]).collect();
if gaps.iter().any(|gap| *gap <= snap) {
return false;
}
let average = gaps.iter().sum::<f32>() / gaps.len() as f32;
gaps.iter()
.all(|gap| (gap - average).abs() <= average * GRIDLINE_SPACING_TOLERANCE_RATIO)
}
fn find_frame_lines(outlines: &[Outline], connectors: &[Connector], snap: f32) -> Vec<bool> {
connectors
.iter()
.map(|connector| is_frame_line(outlines, connector, snap))
.collect()
}
fn is_frame_line(outlines: &[Outline], connector: &Connector, snap: f32) -> bool {
let horizontal = (connector.start.1 - connector.end.1).abs() <= GRIDLINE_AXIS_EPSILON;
let vertical = (connector.start.0 - connector.end.0).abs() <= GRIDLINE_AXIS_EPSILON;
if !horizontal && !vertical {
return false;
}
let (axis, run_min, run_max) = if horizontal {
(
(connector.start.1 + connector.end.1) / 2.0,
connector.start.0.min(connector.end.0),
connector.start.0.max(connector.end.0),
)
} else {
(
(connector.start.0 + connector.end.0) / 2.0,
connector.start.1.min(connector.end.1),
connector.start.1.max(connector.end.1),
)
};
let flush_edges = outlines
.iter()
.filter(|outline| {
let (near, far, span_min, span_max) = if horizontal {
(outline.bbox.y0, outline.bbox.y1, outline.bbox.x0, outline.bbox.x1)
} else {
(outline.bbox.x0, outline.bbox.x1, outline.bbox.y0, outline.bbox.y1)
};
let on_boundary = (near - axis).abs() <= snap || (far - axis).abs() <= snap;
let overlap = (span_max.min(run_max) - span_min.max(run_min)).max(0.0);
on_boundary && overlap > snap
})
.count();
flush_edges >= FRAME_MIN_FLUSH_EDGES
}
fn spread(values: impl Iterator<Item = f32>) -> f32 {
let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
for value in values {
min = min.min(value);
max = max.max(value);
}
if min.is_finite() && max.is_finite() {
max - min
} else {
0.0
}
}
fn touching_arrowhead(arrowheads: &[Rect], point: (f32, f32), snap: f32) -> Option<f32> {
arrowheads
.iter()
.map(|bbox| (bbox.distance_to(point.0, point.1), bbox))
.filter(|(distance, _)| *distance <= snap)
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, bbox)| bbox.width().hypot(bbox.height()))
}
fn snap_to_outline(outlines: &[&Outline], point: (f32, f32), tolerance: f32) -> Option<usize> {
let (x, y) = point;
if !x.is_finite() || !y.is_finite() {
return None;
}
outlines
.iter()
.enumerate()
.map(|(i, o)| (i, o.bbox.distance_to(x, y), o.bbox.area()))
.filter(|(_, distance, _)| *distance <= tolerance)
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)))
.map(|(i, _, _)| i)
}
fn adopt_captions(
outlines: &[Outline],
labels: &[Label],
arrowheads: &[bool],
connectors: &[Connector],
owners: &mut [Option<usize>],
snap: f32,
) {
let mut labelled = vec![false; outlines.len()];
for owner in owners.iter().flatten() {
labelled[*owner] = true;
}
let reach = (snap * CAPTION_REACH).min(CAPTION_CEILING);
for (label, owner) in labels.iter().zip(owners.iter_mut()) {
if owner.is_some() {
continue;
}
let Some((index, gap)) = outlines
.iter()
.enumerate()
.filter(|(i, outline)| {
!labelled[*i]
&& !arrowheads[*i]
&& label.x >= outline.bbox.x0
&& label.x <= outline.bbox.x1
&& label.y > outline.bbox.y1
&& label.y - outline.bbox.y1 <= reach
})
.min_by(|(_, a), (_, b)| {
(label.y - a.bbox.y1).total_cmp(&(label.y - b.bbox.y1)).then(
(label.x - a.bbox.centre_x())
.abs()
.total_cmp(&(label.x - b.bbox.centre_x()).abs()),
)
})
.map(|(i, outline)| (i, label.y - outline.bbox.y1))
else {
continue;
};
if connectors.iter().any(|connector| {
let dx = label.x - connector.midpoint.0;
let dy = label.y - connector.midpoint.1;
dx.hypot(dy) < gap
}) {
continue;
}
*owner = Some(index);
}
}
fn nearest_free_label(labels: &[&Label], midpoint: (f32, f32), tolerance: f32) -> Option<String> {
labels
.iter()
.map(|l| {
let dx = l.x - midpoint.0;
let dy = l.y - midpoint.1;
(l, (dx * dx + dy * dy).sqrt())
})
.filter(|(_, distance)| *distance <= tolerance)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(l, _)| l.text.clone())
}
#[cfg(test)]
mod tests {
use super::*;
fn outline(x0: f32, y0: f32, x1: f32, y1: f32) -> Outline {
Outline {
bbox: Rect { x0, y0, x1, y1 },
shape: DiagramShape::Box,
fill: None,
stroke: None,
stroke_width: None,
dashed: false,
}
}
fn connector(start: (f32, f32), end: (f32, f32)) -> Connector {
Connector {
start,
end,
midpoint: ((start.0 + end.0) / 2.0, (start.1 + end.1) / 2.0),
stroke: None,
dashed: false,
}
}
fn styled_connector(start: (f32, f32), end: (f32, f32), stroke: &str) -> Connector {
Connector {
stroke: Some(stroke.to_string()),
..connector(start, end)
}
}
fn label(x: f32, y: f32, text: &str) -> Label {
Label {
x,
y,
text: text.to_string(),
}
}
fn nowhere(outlines: usize) -> Vec<Vec<(f32, f32)>> {
vec![Vec::new(); outlines]
}
#[test]
fn a_box_behind_an_edge_label_is_not_a_node() {
let mut background = outline(55.0, 115.0, 75.0, 135.0);
background.fill = Some("#e8e8e8".to_string());
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
background,
outline(0.0, 200.0, 100.0, 250.0),
],
vec![connector((50.0, 50.0), (50.0, 200.0))],
vec![
label(50.0, 25.0, "Start"),
label(65.0, 125.0, "yes"),
label(50.0, 225.0, "End"),
],
)
.expect("graph");
let names: Vec<&str> = graph.nodes.iter().map(|n| n.label.as_str()).collect();
assert_eq!(names, ["Start", "End"]);
assert_eq!(graph.edges.len(), 1);
assert_eq!(graph.edges[0].label.as_deref(), Some("yes"));
}
#[test]
fn a_small_node_away_from_every_connector_keeps_its_label() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
outline(300.0, 300.0, 320.0, 320.0),
outline(0.0, 200.0, 100.0, 250.0),
],
vec![connector((50.0, 50.0), (50.0, 200.0))],
vec![
label(50.0, 25.0, "Start"),
label(50.0, 225.0, "End"),
label(310.0, 310.0, "Aside"),
],
)
.expect("graph");
let names: Vec<&str> = graph.nodes.iter().map(|n| n.label.as_str()).collect();
assert_eq!(names, ["Start", "End", "Aside"]);
}
#[test]
fn a_self_loop_arcing_clear_of_its_node_is_an_edge() {
let mut loop_back = connector((30.0, 200.0), (70.0, 200.0));
loop_back.midpoint = (50.0, 280.0);
let graph = assemble(
None,
(400.0, 400.0),
vec![outline(0.0, 0.0, 100.0, 50.0), outline(0.0, 200.0, 100.0, 250.0)],
vec![connector((50.0, 50.0), (50.0, 200.0)), loop_back],
Vec::new(),
)
.expect("graph");
let self_loop = graph.edges.iter().find(|e| e.from == e.to);
assert!(self_loop.is_some(), "a self-loop is an edge: {:?}", graph.edges);
assert_eq!(self_loop.expect("checked").from, 1);
}
#[test]
fn a_box_holding_a_grid_of_text_is_a_table_not_a_node() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
outline(0.0, 200.0, 100.0, 250.0),
outline(200.0, 0.0, 380.0, 100.0),
],
vec![connector((50.0, 50.0), (50.0, 200.0))],
vec![
label(240.0, 20.0, "Stage"),
label(320.0, 20.0, "Owner"),
label(240.0, 70.0, "Build"),
label(320.0, 70.0, "Ada"),
],
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2, "the table is not a node: {:?}", graph.nodes);
}
#[test]
fn a_wrapped_caption_is_not_a_grid() {
let graph = assemble(
None,
(400.0, 400.0),
vec![outline(0.0, 0.0, 100.0, 50.0), outline(0.0, 200.0, 100.0, 250.0)],
vec![connector((50.0, 50.0), (50.0, 200.0))],
vec![
label(50.0, 210.0, "Release"),
label(50.0, 225.0, "engineer"),
label(50.0, 240.0, "on call"),
],
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.nodes[1].label, "Release\nengineer\non call");
}
#[test]
fn overlapping_anonymous_shapes_are_one_drawing_not_nodes() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
outline(0.0, 200.0, 100.0, 250.0),
outline(200.0, 100.0, 300.0, 200.0),
outline(210.0, 110.0, 310.0, 210.0),
],
vec![connector((50.0, 50.0), (50.0, 200.0))],
vec![label(50.0, 25.0, "from"), label(50.0, 225.0, "to")],
)
.expect("graph");
assert_eq!(
graph.nodes.len(),
2,
"overlapping wedges are not nodes: {:?}",
graph.nodes
);
}
#[test]
fn overlapping_labelled_shapes_are_still_nodes() {
let graph = assemble(
None,
(400.0, 400.0),
vec![outline(0.0, 0.0, 100.0, 100.0), outline(60.0, 60.0, 160.0, 160.0)],
vec![connector((50.0, 50.0), (110.0, 110.0))],
vec![label(20.0, 20.0, "left"), label(140.0, 140.0, "right")],
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2);
}
#[test]
fn a_tiny_unlabelled_shape_beside_labelled_ones_is_decoration() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
outline(0.0, 200.0, 100.0, 250.0),
outline(40.0, 300.0, 52.0, 312.0),
],
vec![
connector((50.0, 50.0), (50.0, 200.0)),
connector((50.0, 250.0), (46.0, 300.0)),
],
vec![label(50.0, 25.0, "from"), label(50.0, 225.0, "to")],
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2, "the dot is decoration: {:?}", graph.nodes);
}
#[test]
fn two_boxes_and_a_line_make_an_edge() {
let graph = assemble(
Some("g".into()),
(400.0, 400.0),
vec![outline(0.0, 0.0, 100.0, 50.0), outline(0.0, 200.0, 100.0, 250.0)],
vec![connector((50.0, 50.0), (50.0, 200.0))],
vec![label(50.0, 25.0, "top"), label(50.0, 225.0, "bottom")],
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.nodes[0].label, "top");
assert_eq!(graph.nodes[1].label, "bottom");
assert_eq!(graph.edges.len(), 1);
assert_eq!((graph.edges[0].from, graph.edges[0].to), (0, 1));
}
#[test]
fn shapes_without_connectors_are_not_a_graph() {
assert!(
assemble(
None,
(400.0, 400.0),
vec![outline(0.0, 0.0, 100.0, 50.0), outline(0.0, 200.0, 100.0, 250.0)],
Vec::new(),
Vec::new(),
)
.is_none()
);
}
#[test]
fn background_panel_is_not_a_node() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 400.0, 400.0),
outline(0.0, 0.0, 100.0, 50.0),
outline(0.0, 200.0, 100.0, 250.0),
],
vec![connector((50.0, 50.0), (50.0, 200.0))],
Vec::new(),
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2);
}
#[test]
fn shapes_no_connector_reaches_are_kept() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
outline(0.0, 100.0, 100.0, 150.0),
outline(0.0, 200.0, 100.0, 250.0),
],
vec![connector((50.0, 50.0), (50.0, 200.0))],
Vec::new(),
)
.expect("graph");
assert_eq!(graph.nodes.len(), 3);
assert_eq!((graph.edges[0].from, graph.edges[0].to), (0, 2));
}
#[test]
fn duplicate_outlines_and_edges_collapse() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
outline(0.0, 0.0, 100.0, 50.0),
outline(0.0, 200.0, 100.0, 250.0),
],
vec![
connector((50.0, 50.0), (50.0, 200.0)),
connector((50.0, 50.0), (50.0, 200.0)),
],
Vec::new(),
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.edges.len(), 1);
}
#[test]
fn text_outside_every_shape_can_label_an_edge() {
let graph = assemble(
None,
(400.0, 400.0),
vec![outline(0.0, 0.0, 100.0, 50.0), outline(0.0, 200.0, 100.0, 250.0)],
vec![connector((50.0, 50.0), (50.0, 200.0))],
vec![label(52.0, 126.0, "yes"), label(390.0, 390.0, "footer")],
)
.expect("graph");
assert_eq!(graph.edges[0].label.as_deref(), Some("yes"));
assert!(graph.nodes.iter().all(|n| n.label.is_empty()));
}
#[test]
fn label_attaches_to_the_innermost_containing_shape() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 200.0, 300.0),
outline(10.0, 10.0, 100.0, 60.0),
outline(10.0, 200.0, 100.0, 250.0),
],
vec![connector((50.0, 60.0), (50.0, 200.0))],
vec![label(50.0, 30.0, "inner")],
)
.expect("graph");
let inner = graph.nodes.iter().find(|n| n.label == "inner");
assert!(inner.is_some(), "label went to the panel instead of the box");
}
fn arrowhead(cx: f32, cy: f32) -> Outline {
outline(cx - 4.0, cy - 5.0, cx + 4.0, cy + 5.0)
}
#[test]
fn an_arrowhead_is_not_a_node_and_sets_direction() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
arrowhead(50.0, 194.0),
outline(0.0, 200.0, 100.0, 250.0),
],
vec![connector((50.0, 50.0), (50.0, 189.0))],
vec![label(50.0, 25.0, "top"), label(50.0, 225.0, "bottom")],
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2, "arrowhead became a node: {:?}", graph.nodes);
assert_eq!(graph.nodes[0].label, "top");
assert_eq!(graph.nodes[1].label, "bottom");
assert_eq!((graph.edges[0].from, graph.edges[0].to), (0, 1));
assert!(!graph.edges[0].bidirectional);
}
#[test]
fn an_arrowhead_at_the_start_reverses_the_edge() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
arrowhead(50.0, 56.0),
outline(0.0, 200.0, 100.0, 250.0),
],
vec![connector((50.0, 61.0), (50.0, 200.0))],
Vec::new(),
)
.expect("graph");
assert_eq!((graph.edges[0].from, graph.edges[0].to), (1, 0));
}
#[test]
fn arrowheads_at_both_ends_are_bidirectional() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
arrowhead(50.0, 56.0),
arrowhead(50.0, 194.0),
outline(0.0, 200.0, 100.0, 250.0),
],
vec![connector((50.0, 61.0), (50.0, 189.0))],
Vec::new(),
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2);
assert!(graph.edges[0].bidirectional);
}
#[test]
fn a_double_border_is_one_node() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 100.0, 50.0),
outline(0.0, 200.0, 100.0, 250.0),
outline(4.0, 204.0, 96.0, 246.0),
],
vec![connector((50.0, 50.0), (50.0, 200.0))],
vec![label(50.0, 225.0, "done")],
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.nodes[1].label, "done");
assert_eq!((graph.edges[0].from, graph.edges[0].to), (0, 1));
}
#[test]
fn a_double_border_keeps_the_styling_of_both_rings() {
let mut outer = outline(0.0, 200.0, 100.0, 250.0);
outer.fill = None;
outer.stroke = Some("#000000".to_string());
let mut inner = outline(4.0, 204.0, 96.0, 246.0);
inner.fill = Some("#fb8072".to_string());
inner.stroke = None;
let graph = assemble(
None,
(400.0, 400.0),
vec![outline(0.0, 0.0, 100.0, 50.0), outer, inner],
vec![connector((50.0, 50.0), (50.0, 200.0))],
Vec::new(),
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.nodes[1].fill.as_deref(), Some("#fb8072"));
assert_eq!(graph.nodes[1].stroke.as_deref(), Some("#000000"));
}
#[test]
fn an_enclosing_panel_is_a_container_not_a_node() {
let graph = assemble(
None,
(400.0, 400.0),
vec![
outline(0.0, 0.0, 200.0, 300.0),
outline(10.0, 10.0, 100.0, 60.0),
outline(10.0, 200.0, 100.0, 250.0),
],
vec![connector((50.0, 60.0), (50.0, 200.0))],
Vec::new(),
)
.expect("graph");
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.edges.len(), 1);
}
#[test]
fn a_container_test_excludes_arrowheads_from_its_member_count() {
let outlines = vec![
outline(0.0, 0.0, 100.0, 100.0),
outline(5.0, 5.0, 15.0, 15.0),
outline(80.0, 80.0, 90.0, 90.0),
];
let arrowheads = vec![false, true, true];
let owned: Vec<Vec<&Label>> = vec![Vec::new(), Vec::new(), Vec::new()];
assert_eq!(
find_containers(&outlines, &arrowheads, &owned, &nowhere(3), 4.0),
vec![false, false, false],
"two arrowheads inside a node's border are not two members grouped by it"
);
}
#[test]
fn a_labelled_enclosure_of_unconnected_detail_is_a_node() {
let outlines = vec![
outline(0.0, 0.0, 100.0, 100.0),
outline(10.0, 10.0, 40.0, 40.0),
outline(60.0, 60.0, 90.0, 90.0),
];
let arrowheads = vec![false, false, false];
let caption = label(50.0, 50.0, "ClassName");
let owned: Vec<Vec<&Label>> = vec![vec![&caption], Vec::new(), Vec::new()];
assert_eq!(
find_containers(&outlines, &arrowheads, &owned, &nowhere(3), 4.0),
vec![false, false, false],
"compartments nothing connects to are interior detail, not grouped members"
);
}
#[test]
fn a_labelled_enclosure_grouping_connected_shapes_is_a_container() {
let outlines = vec![
outline(0.0, 0.0, 100.0, 100.0),
outline(10.0, 10.0, 40.0, 40.0),
outline(60.0, 60.0, 90.0, 90.0),
];
let arrowheads = vec![false, false, false];
let caption = label(50.0, 5.0, "Ingest");
let owned: Vec<Vec<&Label>> = vec![vec![&caption], Vec::new(), Vec::new()];
let landings = vec![Vec::new(), vec![(25.0, 40.0)], vec![(75.0, 60.0)]];
assert_eq!(
find_containers(&outlines, &arrowheads, &owned, &landings, 4.0),
vec![true, false, false],
"a captioned box grouping two connected shapes is a cluster, not a node"
);
}
#[test]
fn a_labelled_enclosure_whose_members_are_touched_only_at_its_rim_is_a_node() {
let outlines = vec![
outline(0.0, 0.0, 100.0, 100.0),
outline(0.0, 10.0, 100.0, 40.0),
outline(0.0, 60.0, 100.0, 90.0),
];
let arrowheads = vec![false, false, false];
let caption = label(50.0, 5.0, "Order");
let owned: Vec<Vec<&Label>> = vec![vec![&caption], Vec::new(), Vec::new()];
let landings = vec![Vec::new(), vec![(0.0, 20.0)], vec![(0.0, 70.0)]];
assert_eq!(
find_containers(&outlines, &arrowheads, &owned, &landings, 4.0),
vec![false, false, false],
"compartments touched on the enclosure's rim are interior detail"
);
}
#[test]
fn a_container_test_still_condemns_an_unlabelled_enclosure_of_two_real_shapes() {
let outlines = vec![
outline(0.0, 0.0, 100.0, 100.0),
outline(10.0, 10.0, 40.0, 40.0),
outline(60.0, 60.0, 90.0, 90.0),
];
let arrowheads = vec![false, false, false];
let owned: Vec<Vec<&Label>> = vec![Vec::new(), Vec::new(), Vec::new()];
assert_eq!(
find_containers(&outlines, &arrowheads, &owned, &nowhere(3), 4.0),
vec![true, false, false]
);
}
#[test]
fn a_stroke_inside_one_shape_is_not_a_self_loop() {
assert!(
assemble(
None,
(400.0, 400.0),
vec![outline(0.0, 0.0, 100.0, 50.0), outline(0.0, 200.0, 100.0, 250.0)],
vec![connector((10.0, 10.0), (90.0, 40.0))],
Vec::new(),
)
.is_none()
);
}
#[test]
fn bar_chart_gridlines_crossing_the_bars_are_not_a_graph() {
let bars = vec![
outline(120.0, 180.0, 220.0, 380.0),
outline(250.0, 240.0, 350.0, 380.0),
outline(380.0, 130.0, 480.0, 380.0),
outline(510.0, 210.0, 610.0, 380.0),
];
let gridlines = vec![
styled_connector((120.0, 300.0), (620.0, 300.0), "#cccccc"),
styled_connector((120.0, 220.0), (620.0, 220.0), "#cccccc"),
styled_connector((120.0, 140.0), (620.0, 140.0), "#cccccc"),
];
assert!(
assemble(None, (700.0, 450.0), bars, gridlines, Vec::new()).is_none(),
"chart gridlines that cross the bars must not read as a graph"
);
}
#[test]
fn gridlines_are_dropped_but_a_genuine_edge_beside_them_survives() {
let bars = vec![
outline(120.0, 180.0, 220.0, 380.0),
outline(250.0, 240.0, 350.0, 380.0),
outline(380.0, 130.0, 480.0, 380.0),
outline(510.0, 210.0, 610.0, 380.0),
];
let extra_nodes = vec![outline(630.0, 0.0, 700.0, 50.0), outline(630.0, 60.0, 700.0, 110.0)];
let gridlines = vec![
styled_connector((120.0, 300.0), (620.0, 300.0), "#cccccc"),
styled_connector((120.0, 220.0), (620.0, 220.0), "#cccccc"),
styled_connector((120.0, 140.0), (620.0, 140.0), "#cccccc"),
];
let real_edge = styled_connector((665.0, 50.0), (665.0, 60.0), "#333333");
let mut connectors = gridlines;
connectors.push(real_edge);
let mut outlines = bars;
outlines.extend(extra_nodes);
let graph = assemble(None, (700.0, 450.0), outlines, connectors, Vec::new()).expect("the real edge survives");
assert_eq!(
graph.edges.len(),
1,
"only the non-gridline connector is an edge: {:?}",
graph.edges
);
assert_eq!(graph.edges[0].stroke.as_deref(), Some("#333333"));
}
#[test]
fn a_straight_vertical_chain_is_not_mistaken_for_gridlines() {
let nodes = vec![
outline(0.0, 0.0, 100.0, 50.0),
outline(0.0, 100.0, 100.0, 150.0),
outline(0.0, 200.0, 100.0, 250.0),
outline(0.0, 300.0, 100.0, 350.0),
];
let links = vec![
styled_connector((50.0, 50.0), (50.0, 100.0), "#000000"),
styled_connector((50.0, 150.0), (50.0, 200.0), "#000000"),
styled_connector((50.0, 250.0), (50.0, 300.0), "#000000"),
];
let graph = assemble(None, (400.0, 400.0), nodes, links, Vec::new()).expect("a real chain is a graph");
let edges: Vec<(usize, usize)> = graph.edges.iter().map(|e| (e.from, e.to)).collect();
assert_eq!(
edges,
vec![(0, 1), (1, 2), (2, 3)],
"a real vertical chain loses no edges"
);
}
}