use std::collections::HashMap;
use super::{shapes, Glyph, Label, PlacedCluster, PlacedEdgeLabel, PlacedNode, Size};
use crate::preview::mermaid::flowchart::Direction;
use crate::preview::mermaid::layout::Point;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Routing {
#[default]
Splines,
Orthogonal,
}
impl Routing {
pub fn parse(s: &str) -> Routing {
match s {
"konoma-orthogonal" => Routing::Orthogonal,
_ => Routing::Splines,
}
}
}
pub const PORT_INSET: f64 = super::svg::NODE_STROKE_WIDTH / 2.0 + 1.0;
pub const PORT_SPACING: f64 = 16.0;
pub const PORT_CLEARANCE: f64 = 8.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Side {
Top,
Bottom,
Left,
Right,
}
impl Side {
fn opposite(self) -> Side {
match self {
Side::Top => Side::Bottom,
Side::Bottom => Side::Top,
Side::Left => Side::Right,
Side::Right => Side::Left,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Axis {
Flow,
Cross,
}
fn flow(direction: Direction, p: &Point) -> f64 {
match direction {
Direction::TopToBottom | Direction::BottomToTop => p.y,
Direction::LeftToRight | Direction::RightToLeft => p.x,
}
}
fn cross(direction: Direction, p: &Point) -> f64 {
match direction {
Direction::TopToBottom | Direction::BottomToTop => p.x,
Direction::LeftToRight | Direction::RightToLeft => p.y,
}
}
fn make(direction: Direction, flow_v: f64, cross_v: f64) -> Point {
match direction {
Direction::TopToBottom | Direction::BottomToTop => Point::new(cross_v, flow_v),
Direction::LeftToRight | Direction::RightToLeft => Point::new(flow_v, cross_v),
}
}
fn flow_face(direction: Direction, delta: f64) -> Side {
match direction {
Direction::TopToBottom | Direction::BottomToTop => {
if delta >= 0.0 {
Side::Bottom
} else {
Side::Top
}
}
Direction::LeftToRight | Direction::RightToLeft => {
if delta >= 0.0 {
Side::Right
} else {
Side::Left
}
}
}
}
fn cross_face(direction: Direction, delta: f64) -> Side {
match direction {
Direction::TopToBottom | Direction::BottomToTop => {
if delta >= 0.0 {
Side::Right
} else {
Side::Left
}
}
Direction::LeftToRight | Direction::RightToLeft => {
if delta >= 0.0 {
Side::Bottom
} else {
Side::Top
}
}
}
}
fn axis_of(direction: Direction, side: Side) -> Axis {
let top_bottom_is_flow = matches!(direction, Direction::TopToBottom | Direction::BottomToTop);
match (top_bottom_is_flow, side) {
(true, Side::Top | Side::Bottom) => Axis::Flow,
(true, Side::Left | Side::Right) => Axis::Cross,
(false, Side::Left | Side::Right) => Axis::Flow,
(false, Side::Top | Side::Bottom) => Axis::Cross,
}
}
fn dominant_face(direction: Direction, center: &Point, reference: &Point) -> Side {
let dflow = flow(direction, reference) - flow(direction, center);
let dcross = cross(direction, reference) - cross(direction, center);
if dflow.abs() >= dcross.abs() {
flow_face(direction, dflow)
} else {
cross_face(direction, dcross)
}
}
fn face_center_coord(node: &PlacedNode, side: Side) -> f64 {
match side {
Side::Top | Side::Bottom => node.center.x,
Side::Left | Side::Right => node.center.y,
}
}
fn face_port(node: &PlacedNode, side: Side, inset: f64) -> Point {
port_at(node, side, face_center_coord(node, side), inset)
}
fn port_at(node: &PlacedNode, side: Side, coord: f64, inset: f64) -> Point {
let (l, t, r, b) = node.bounds();
match side {
Side::Top => Point::new(coord, t - inset),
Side::Bottom => Point::new(coord, b + inset),
Side::Left => Point::new(l - inset, coord),
Side::Right => Point::new(r + inset, coord),
}
}
const EPS: f64 = 1e-6;
fn bridge(direction: Direction, a: &Point, b: &Point, leave: Axis, enter: Axis) -> Vec<Point> {
match (leave, enter) {
(Axis::Flow, Axis::Cross) => {
let corner = make(direction, flow(direction, b), cross(direction, a));
vec![corner, b.clone()]
}
(Axis::Cross, Axis::Flow) => {
let corner = make(direction, flow(direction, a), cross(direction, b));
vec![corner, b.clone()]
}
(Axis::Flow, Axis::Flow) => {
if (cross(direction, a) - cross(direction, b)).abs() < EPS {
vec![b.clone()]
} else {
let mid = (flow(direction, a) + flow(direction, b)) / 2.0;
vec![
make(direction, mid, cross(direction, a)),
make(direction, mid, cross(direction, b)),
b.clone(),
]
}
}
(Axis::Cross, Axis::Cross) => {
if (flow(direction, a) - flow(direction, b)).abs() < EPS {
vec![b.clone()]
} else {
let mid = (cross(direction, a) + cross(direction, b)) / 2.0;
vec![
make(direction, flow(direction, a), mid),
make(direction, flow(direction, b), mid),
b.clone(),
]
}
}
}
}
#[derive(Debug, Clone, Copy)]
struct EdgeShape {
reverse: bool,
aligned: bool,
staircase: bool,
source_side: Side,
source_axis: Axis,
target_side: Side,
target_axis: Axis,
}
pub(crate) const COLLISION_MARGIN: f64 = 4.0;
pub(crate) fn segment_crosses_node(a: &Point, b: &Point, node: &PlacedNode) -> bool {
let (l, t, r, bo) = node.bounds();
let (l, t, r, bo) = (
l - COLLISION_MARGIN,
t - COLLISION_MARGIN,
r + COLLISION_MARGIN,
bo + COLLISION_MARGIN,
);
if (a.y - b.y).abs() < EPS {
let y = a.y;
let (x0, x1) = (a.x.min(b.x), a.x.max(b.x));
y >= t && y <= bo && x1 >= l && x0 <= r
} else if (a.x - b.x).abs() < EPS {
let x = a.x;
let (y0, y1) = (a.y.min(b.y), a.y.max(b.y));
x >= l && x <= r && y1 >= t && y0 <= bo
} else {
false
}
}
fn shape_crosses_a_node(
direction: Direction,
source: &PlacedNode,
target: &PlacedNode,
shape: &EdgeShape,
nodes: &[PlacedNode],
) -> bool {
let source_port = face_port(source, shape.source_side, PORT_INSET);
let target_port = face_port(target, shape.target_side, PORT_INSET);
let mut pts = vec![source_port.clone()];
pts.extend(bridge(
direction,
&source_port,
&target_port,
shape.source_axis,
shape.target_axis,
));
for w in pts.windows(2) {
for node in nodes {
if node.id == source.id || node.id == target.id {
continue;
}
if segment_crosses_node(&w[0], &w[1], node) {
return true;
}
}
}
false
}
#[allow(clippy::too_many_arguments)]
fn classify(
direction: Direction,
source: &PlacedNode,
target: &PlacedNode,
raw: &[Point],
source_rank: Option<i32>,
target_rank: Option<i32>,
source_out_degree: usize,
target_in_degree: usize,
nodes: &[PlacedNode],
) -> EdgeShape {
let is_reverse = matches!((source_rank, target_rank), (Some(sr), Some(tr)) if tr <= sr);
if is_reverse {
let mut deduped = raw.to_vec();
super::edges::dedupe(&mut deduped);
let interior: Vec<Point> = if deduped.len() > 2 {
deduped[1..deduped.len() - 1].to_vec()
} else {
Vec::new()
};
let ref_start = interior
.first()
.cloned()
.unwrap_or_else(|| target.center.clone());
let ref_end = interior
.last()
.cloned()
.unwrap_or_else(|| source.center.clone());
let source_side = dominant_face(direction, &source.center, &ref_start);
let target_side = dominant_face(direction, &target.center, &ref_end);
return EdgeShape {
reverse: true,
aligned: false,
staircase: false,
source_side,
source_axis: axis_of(direction, source_side),
target_side,
target_axis: axis_of(direction, target_side),
};
}
let dcross = cross(direction, &target.center) - cross(direction, &source.center);
if dcross.abs() < 0.5 {
let delta = flow(direction, &target.center) - flow(direction, &source.center);
let source_side = flow_face(direction, delta);
let target_side = source_side.opposite();
let mut shape = EdgeShape {
reverse: false,
aligned: true,
staircase: false,
source_side,
source_axis: Axis::Flow,
target_side,
target_axis: Axis::Flow,
};
if shape_crosses_a_node(direction, source, target, &shape, nodes) {
shape.staircase = true;
}
return shape;
}
let branching = source_out_degree > 1 || target_in_degree <= 1;
let (branch_source_side, branch_target_side) = (
cross_face(
direction,
cross(direction, &target.center) - cross(direction, &source.center),
),
flow_face(
direction,
flow(direction, &source.center) - flow(direction, &target.center),
),
);
let (merge_source_side, merge_target_side) = (
flow_face(
direction,
flow(direction, &target.center) - flow(direction, &source.center),
),
cross_face(
direction,
cross(direction, &source.center) - cross(direction, &target.center),
),
);
let (source_side, target_side) = if branching {
(branch_source_side, branch_target_side)
} else {
(merge_source_side, merge_target_side)
};
let mut shape = EdgeShape {
reverse: false,
aligned: false,
staircase: false,
source_side,
source_axis: axis_of(direction, source_side),
target_side,
target_axis: axis_of(direction, target_side),
};
if shape_crosses_a_node(direction, source, target, &shape, nodes) {
let (alt_source_side, alt_target_side) = if branching {
(merge_source_side, merge_target_side)
} else {
(branch_source_side, branch_target_side)
};
let alt = EdgeShape {
reverse: false,
aligned: false,
staircase: false,
source_side: alt_source_side,
source_axis: axis_of(direction, alt_source_side),
target_side: alt_target_side,
target_axis: axis_of(direction, alt_target_side),
};
if shape_crosses_a_node(direction, source, target, &alt, nodes) {
shape = alt;
shape.staircase = true;
} else {
shape = alt;
}
}
shape
}
#[allow(clippy::too_many_arguments)]
fn route_with_ports(
direction: Direction,
shape: &EdgeShape,
source: &PlacedNode,
target: &PlacedNode,
source_coord: f64,
target_coord: f64,
raw: &[Point],
ring: (f64, f64, f64, f64),
nodes: &[PlacedNode],
) -> Vec<Point> {
let source_port = port_at(source, shape.source_side, source_coord, PORT_INSET);
let target_port = port_at(target, shape.target_side, target_coord, PORT_INSET);
let mut points = if shape.staircase || (shape.reverse && source.id == target.id) {
let staircase = route_staircase_with_ports(direction, shape, source_port, target_port, raw);
clear_local_route(staircase, nodes, (source.id.as_str(), target.id.as_str()))
} else if shape.reverse {
let ids = (source.id.as_str(), target.id.as_str());
let blocked = |a: &Point, b: &Point| segment_crosses_any_node(a, b, nodes, ids);
route_perimeter(shape, source_port, target_port, ring, &blocked)
} else {
let mut out = vec![source_port.clone()];
out.extend(bridge(
direction,
&source_port,
&target_port,
shape.source_axis,
shape.target_axis,
));
out
};
super::edges::dedupe(&mut points);
if points.len() < 2 {
points = vec![source.center.clone(), target.center.clone()];
}
points
}
fn route_staircase_with_ports(
direction: Direction,
shape: &EdgeShape,
source_port: Point,
target_port: Point,
raw: &[Point],
) -> Vec<Point> {
let mut deduped = raw.to_vec();
super::edges::dedupe(&mut deduped);
let interior: Vec<Point> = if deduped.len() > 2 {
deduped[1..deduped.len() - 1].to_vec()
} else {
Vec::new()
};
let mut out = vec![source_port.clone()];
let mut prev = source_port;
let mut prev_axis = shape.source_axis;
for w in &interior {
out.extend(bridge(direction, &prev, w, prev_axis, Axis::Flow));
prev = w.clone();
prev_axis = Axis::Flow;
}
out.extend(bridge(
direction,
&prev,
&target_port,
prev_axis,
shape.target_axis,
));
out
}
fn clear_local_route(
mut points: Vec<Point>,
nodes: &[PlacedNode],
ids: (&str, &str),
) -> Vec<Point> {
const MAX_PASSES: usize = 4;
for _ in 0..MAX_PASSES {
let mut fix: Option<(f64, f64, bool)> = None; 'search: for w in points.windows(2) {
let (a, b) = (&w[0], &w[1]);
let horizontal = (a.y - b.y).abs() < EPS;
let vertical = (a.x - b.x).abs() < EPS;
if !horizontal && !vertical {
continue; }
for n in nodes {
if n.id == ids.0 || n.id == ids.1 {
continue;
}
if segment_crosses_node(a, b, n) {
let (l, t, r, bo) = n.bounds();
fix = Some(if horizontal {
let y = a.y;
let new_y = if y <= (t + bo) / 2.0 {
t - COLLISION_MARGIN - 1.0
} else {
bo + COLLISION_MARGIN + 1.0
};
(y, new_y, true)
} else {
let x = a.x;
let new_x = if x <= (l + r) / 2.0 {
l - COLLISION_MARGIN - 1.0
} else {
r + COLLISION_MARGIN + 1.0
};
(x, new_x, false)
});
break 'search;
}
}
}
let Some((old_c, new_c, horizontal)) = fix else {
break;
};
for p in &mut points {
if horizontal && (p.y - old_c).abs() < EPS {
p.y = new_c;
} else if !horizontal && (p.x - old_c).abs() < EPS {
p.x = new_c;
}
}
}
points
}
pub const PERIMETER_MARGIN: f64 = 16.0;
pub const PERIMETER_LANE_SPACING: f64 = 8.0;
fn content_bounds(nodes: &[PlacedNode], clusters: &[PlacedCluster]) -> (f64, f64, f64, f64) {
let mut l = f64::INFINITY;
let mut t = f64::INFINITY;
let mut r = f64::NEG_INFINITY;
let mut b = f64::NEG_INFINITY;
for n in nodes {
let (nl, nt, nr, nb) = n.bounds();
l = l.min(nl);
t = t.min(nt);
r = r.max(nr);
b = b.max(nb);
}
for c in clusters {
let (cl, ct, cr, cb) = c.bounds();
l = l.min(cl);
t = t.min(ct);
r = r.max(cr);
b = b.max(cb);
}
if !l.is_finite() {
return (0.0, 0.0, 0.0, 0.0);
}
(l, t, r, b)
}
fn expand_bounds(bounds: (f64, f64, f64, f64), by: f64) -> (f64, f64, f64, f64) {
(bounds.0 - by, bounds.1 - by, bounds.2 + by, bounds.3 + by)
}
fn ring_touch(side: Side, port: &Point, ring: (f64, f64, f64, f64)) -> Point {
let (l, t, r, b) = ring;
match side {
Side::Top => Point::new(port.x, t),
Side::Bottom => Point::new(port.x, b),
Side::Left => Point::new(l, port.y),
Side::Right => Point::new(r, port.y),
}
}
fn segment_crosses_any_node(
a: &Point,
b: &Point,
nodes: &[PlacedNode],
exclude: (&str, &str),
) -> bool {
nodes
.iter()
.any(|n| n.id != exclude.0 && n.id != exclude.1 && segment_crosses_node(a, b, n))
}
fn safe_ring_exit(
side: Side,
port: &Point,
ring: (f64, f64, f64, f64),
blocked: &dyn Fn(&Point, &Point) -> bool,
) -> Vec<Point> {
let (l, t, r, b) = ring;
let direct = ring_touch(side, port, ring);
let hop = match side {
Side::Top => Point::new(port.x, port.y - PORT_CLEARANCE),
Side::Bottom => Point::new(port.x, port.y + PORT_CLEARANCE),
Side::Left => Point::new(port.x - PORT_CLEARANCE, port.y),
Side::Right => Point::new(port.x + PORT_CLEARANCE, port.y),
};
let l_shape = |corner: f64| -> Vec<Point> {
match side {
Side::Top | Side::Bottom => vec![hop.clone(), Point::new(corner, hop.y)],
Side::Left | Side::Right => vec![hop.clone(), Point::new(hop.x, corner)],
}
};
let (near, far) = match side {
Side::Top | Side::Bottom => {
if (port.x - l) <= (r - port.x) {
(l, r)
} else {
(r, l)
}
}
Side::Left | Side::Right => {
if (port.y - t) <= (b - port.y) {
(t, b)
} else {
(b, t)
}
}
};
let candidates: [Vec<Point>; 3] = [vec![direct.clone()], l_shape(near), l_shape(far)];
for cand in &candidates {
let mut prev = port.clone();
let mut clear = true;
for p in cand {
if blocked(&prev, p) {
clear = false;
break;
}
prev = p.clone();
}
if clear {
return cand.clone();
}
}
vec![direct]
}
fn ring_perimeter_dist(ring: (f64, f64, f64, f64), p: &Point) -> f64 {
let (l, t, r, b) = ring;
let w = r - l;
let h = b - t;
let d_top = (p.y - t).abs();
let d_bottom = (p.y - b).abs();
let d_left = (p.x - l).abs();
let d_right = (p.x - r).abs();
let m = d_top.min(d_bottom).min(d_left).min(d_right);
if m == d_top {
(p.x - l).clamp(0.0, w)
} else if m == d_right {
w + (p.y - t).clamp(0.0, h)
} else if m == d_bottom {
w + h + (r - p.x).clamp(0.0, w)
} else {
w + h + w + (b - p.y).clamp(0.0, h)
}
}
fn ring_corners(ring: (f64, f64, f64, f64)) -> [(f64, Point); 4] {
let (l, t, r, b) = ring;
let w = r - l;
let h = b - t;
[
(0.0, Point::new(l, t)),
(w, Point::new(r, t)),
(w + h, Point::new(r, b)),
(2.0 * w + h, Point::new(l, b)),
]
}
fn corners_between_clockwise(ring: (f64, f64, f64, f64), from: f64, to: f64) -> Vec<Point> {
let w = ring.2 - ring.0;
let h = ring.3 - ring.1;
let perim = 2.0 * (w + h);
if perim <= 0.0 {
return Vec::new();
}
let span = (to - from).rem_euclid(perim);
let mut out: Vec<(f64, Point)> = ring_corners(ring)
.into_iter()
.filter_map(|(dist, p)| {
let rel = (dist - from).rem_euclid(perim);
(rel > EPS && rel < span - EPS).then_some((rel, p))
})
.collect();
out.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
out.into_iter().map(|(_, p)| p).collect()
}
fn ring_path(ring: (f64, f64, f64, f64), a: &Point, b: &Point) -> Vec<Point> {
let w = ring.2 - ring.0;
let h = ring.3 - ring.1;
let perim = 2.0 * (w + h);
if perim <= 0.0 {
return Vec::new();
}
let da = ring_perimeter_dist(ring, a);
let db = ring_perimeter_dist(ring, b);
let cw_span = (db - da).rem_euclid(perim);
let ccw_span = perim - cw_span;
if cw_span <= ccw_span {
corners_between_clockwise(ring, da, db)
} else {
let mut v = corners_between_clockwise(ring, db, da);
v.reverse();
v
}
}
fn route_perimeter(
shape: &EdgeShape,
source_port: Point,
target_port: Point,
ring: (f64, f64, f64, f64),
blocked: &dyn Fn(&Point, &Point) -> bool,
) -> Vec<Point> {
let source_exit = safe_ring_exit(shape.source_side, &source_port, ring, blocked);
let target_exit = safe_ring_exit(shape.target_side, &target_port, ring, blocked);
let source_ring = source_exit
.last()
.cloned()
.unwrap_or_else(|| source_port.clone());
let target_ring = target_exit
.last()
.cloned()
.unwrap_or_else(|| target_port.clone());
let mut out = vec![source_port];
out.extend(source_exit);
out.extend(ring_path(ring, &source_ring, &target_ring));
out.push(target_ring);
out.extend(target_exit.into_iter().rev().skip(1));
out.push(target_port);
out
}
#[allow(clippy::too_many_arguments)]
pub fn route_edge(
direction: Direction,
source: &PlacedNode,
target: &PlacedNode,
raw: &[Point],
source_rank: Option<i32>,
target_rank: Option<i32>,
source_out_degree: usize,
target_in_degree: usize,
) -> Vec<Point> {
let shape = classify(
direction,
source,
target,
raw,
source_rank,
target_rank,
source_out_degree,
target_in_degree,
&[],
);
let source_coord = face_center_coord(source, shape.source_side);
let target_coord = face_center_coord(target, shape.target_side);
let both = [source.clone(), target.clone()];
let ring = expand_bounds(content_bounds(&both, &[]), PERIMETER_MARGIN);
route_with_ports(
direction,
&shape,
source,
target,
source_coord,
target_coord,
raw,
ring,
&both,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FaceEnd {
Source,
Target,
}
struct FaceClaim {
edge_id: String,
end: FaceEnd,
other_cross: f64,
aligned: bool,
}
pub struct EligibleEdge<'a> {
pub id: &'a str,
pub source: &'a str,
pub target: &'a str,
pub raw: &'a [Point],
pub source_rank: Option<i32>,
pub target_rank: Option<i32>,
pub source_out_degree: usize,
pub target_in_degree: usize,
}
pub struct RoutedFlowchart {
pub points: HashMap<String, Vec<Point>>,
pub required_size: HashMap<String, Size>,
}
struct Eviction {
source_coord: HashMap<String, f64>,
target_coord: HashMap<String, f64>,
required_size: HashMap<String, Size>,
}
fn evict(
direction: Direction,
by_id: &HashMap<&str, &PlacedNode>,
edges: &[EligibleEdge],
shapes: &[Option<EdgeShape>],
) -> Eviction {
let mut groups: HashMap<(String, Side), Vec<FaceClaim>> = HashMap::new();
for (edge, shape) in edges.iter().zip(shapes) {
let Some(shape) = shape else { continue };
let (Some(&source), Some(&target)) = (by_id.get(edge.source), by_id.get(edge.target))
else {
continue;
};
groups
.entry((edge.source.to_string(), shape.source_side))
.or_default()
.push(FaceClaim {
edge_id: edge.id.to_string(),
end: FaceEnd::Source,
other_cross: cross(direction, &target.center),
aligned: shape.aligned,
});
groups
.entry((edge.target.to_string(), shape.target_side))
.or_default()
.push(FaceClaim {
edge_id: edge.id.to_string(),
end: FaceEnd::Target,
other_cross: cross(direction, &source.center),
aligned: shape.aligned,
});
}
let mut source_coord: HashMap<String, f64> = HashMap::new();
let mut target_coord: HashMap<String, f64> = HashMap::new();
let mut required_size: HashMap<String, Size> = HashMap::new();
for ((node_id, side), mut claims) in groups {
let Some(&node) = by_id.get(node_id.as_str()) else {
continue;
};
claims.sort_by(|a, b| {
a.other_cross
.partial_cmp(&b.other_cross)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.edge_id.cmp(&b.edge_id))
});
let n = claims.len();
if let Some(aligned_pos) = claims.iter().position(|c| c.aligned) {
let center_idx = (((n - 1) as f64) / 2.0).round() as usize;
if aligned_pos != center_idx {
let claim = claims.remove(aligned_pos);
claims.insert(center_idx, claim);
}
}
for (i, claim) in claims.iter().enumerate() {
let offset = (i as f64 - (n as f64 - 1.0) / 2.0) * PORT_SPACING;
let coord = face_center_coord(node, side) + offset;
match claim.end {
FaceEnd::Source => {
source_coord.insert(claim.edge_id.clone(), coord);
}
FaceEnd::Target => {
target_coord.insert(claim.edge_id.clone(), coord);
}
}
}
let required_flat = if n == 0 {
0.0
} else {
(n as f64 - 1.0) * PORT_SPACING + 2.0 * PORT_CLEARANCE
};
let chamfer_allowance = if node.shape == Glyph::ChamferedRect {
2.0 * shapes::CHAMFER
} else {
0.0
};
let needed = required_flat + chamfer_allowance;
let entry = required_size.entry(node_id).or_insert(Size::new(0.0, 0.0));
match side {
Side::Top | Side::Bottom => entry.w = entry.w.max(needed),
Side::Left | Side::Right => entry.h = entry.h.max(needed),
}
}
Eviction {
source_coord,
target_coord,
required_size,
}
}
fn perimeter_lanes<'a>(
by_id: &HashMap<&str, &PlacedNode>,
edges: &[EligibleEdge<'a>],
shapes: &[Option<EdgeShape>],
) -> HashMap<&'a str, usize> {
let mut ids: Vec<&str> = edges
.iter()
.zip(shapes)
.filter_map(|(e, s)| {
let s = s.as_ref()?;
let (Some(&source), Some(&target)) = (by_id.get(e.source), by_id.get(e.target)) else {
return None;
};
(s.reverse && source.id != target.id).then_some(e.id)
})
.collect();
ids.sort_unstable();
ids.into_iter().enumerate().map(|(i, id)| (id, i)).collect()
}
fn cluster_as_node(c: &PlacedCluster) -> PlacedNode {
PlacedNode {
id: c.id.clone(),
shape: Glyph::default(),
center: c.center.clone(),
size: c.size,
label: Label::measure(""),
panel: None,
series: None,
mark: None,
style: None,
}
}
fn cluster_node_boxes(clusters: &[PlacedCluster]) -> Vec<PlacedNode> {
clusters.iter().map(cluster_as_node).collect()
}
fn build_by_id<'a>(
nodes: &'a [PlacedNode],
cluster_boxes: &'a [PlacedNode],
) -> HashMap<&'a str, &'a PlacedNode> {
let mut by_id: HashMap<&str, &PlacedNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
for cb in cluster_boxes {
by_id.entry(cb.id.as_str()).or_insert(cb);
}
by_id
}
pub fn route_flowchart(
direction: Direction,
nodes: &[PlacedNode],
clusters: &[PlacedCluster],
edges: &[EligibleEdge],
) -> RoutedFlowchart {
let cluster_boxes = cluster_node_boxes(clusters);
let by_id = build_by_id(nodes, &cluster_boxes);
let shapes: Vec<Option<EdgeShape>> = edges
.iter()
.map(|e| {
let (Some(&source), Some(&target)) = (by_id.get(e.source), by_id.get(e.target)) else {
return None;
};
Some(classify(
direction,
source,
target,
e.raw,
e.source_rank,
e.target_rank,
e.source_out_degree,
e.target_in_degree,
nodes,
))
})
.collect();
let eviction = evict(direction, &by_id, edges, &shapes);
let base_bounds = content_bounds(nodes, clusters);
let lane_of = perimeter_lanes(&by_id, edges, &shapes);
let mut points = HashMap::with_capacity(edges.len());
for (edge, shape) in edges.iter().zip(&shapes) {
let Some(shape) = shape else { continue };
let (Some(&source), Some(&target)) = (by_id.get(edge.source), by_id.get(edge.target))
else {
continue;
};
let source_coord = eviction
.source_coord
.get(edge.id)
.copied()
.unwrap_or_else(|| face_center_coord(source, shape.source_side));
let target_coord = eviction
.target_coord
.get(edge.id)
.copied()
.unwrap_or_else(|| face_center_coord(target, shape.target_side));
let ring = match lane_of.get(edge.id) {
Some(&lane) => expand_bounds(
base_bounds,
PERIMETER_MARGIN + lane as f64 * PERIMETER_LANE_SPACING,
),
None => base_bounds,
};
let routed = route_with_ports(
direction,
shape,
source,
target,
source_coord,
target_coord,
edge.raw,
ring,
nodes,
);
points.insert(edge.id.to_string(), routed);
}
RoutedFlowchart {
points,
required_size: eviction.required_size,
}
}
pub const LABEL_CLEARANCE: f64 = 16.0;
pub struct LabelSlot {
pub center: Point,
pub length: f64,
pub is_flow_axis: bool,
pub horizontal: bool,
}
fn segment_is_vertical(a: &Point, b: &Point) -> bool {
(a.x - b.x).abs() < EPS
}
pub fn label_slot(direction: Direction, points: &[Point]) -> Option<LabelSlot> {
if points.len() < 2 {
return points.first().map(|p| LabelSlot {
center: p.clone(),
length: 0.0,
is_flow_axis: false,
horizontal: true,
});
}
let flow_is_vertical = matches!(direction, Direction::TopToBottom | Direction::BottomToTop);
let mut best: Option<(usize, f64, bool)> = None;
for (i, w) in points.windows(2).enumerate() {
let (a, b) = (&w[0], &w[1]);
let vertical = segment_is_vertical(a, b);
let len = (b.x - a.x).hypot(b.y - a.y);
let is_flow = vertical == flow_is_vertical;
let better = match best {
None => true,
Some((_, best_len, best_is_flow)) => {
if is_flow != best_is_flow {
is_flow
} else {
len > best_len
}
}
};
if better {
best = Some((i, len, is_flow));
}
}
let (i, len, is_flow) = best?;
let (a, b) = (&points[i], &points[i + 1]);
Some(LabelSlot {
center: Point::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0),
length: len,
is_flow_axis: is_flow,
horizontal: !segment_is_vertical(a, b),
})
}
pub fn label_min_length(plate_size: Size, horizontal: bool) -> f64 {
(if horizontal {
plate_size.w
} else {
plate_size.h
}) + 2.0 * LABEL_CLEARANCE
}
fn cross_extent(direction: Direction, node: &PlacedNode) -> f64 {
match direction {
Direction::TopToBottom | Direction::BottomToTop => node.size.w / 2.0,
Direction::LeftToRight | Direction::RightToLeft => node.size.h / 2.0,
}
}
#[must_use = "a moved node's self-loop raw waypoints go stale unless this delta is applied back — see this function's own doc"]
pub fn align_straight_lanes(
direction: Direction,
nodes: &mut [PlacedNode],
node_rank: &HashMap<String, i32>,
candidates: &[(String, String)],
) -> HashMap<String, f64> {
if nodes.len() < 2 {
return HashMap::new();
}
let id_index: HashMap<String, usize> = nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.clone(), i))
.collect();
let initial_cross: Vec<f64> = nodes.iter().map(|n| cross(direction, &n.center)).collect();
let mut by_rank: HashMap<i32, Vec<usize>> = HashMap::new();
for (id, &rank) in node_rank {
if let Some(&i) = id_index.get(id) {
by_rank.entry(rank).or_default().push(i);
}
}
for ids in by_rank.values_mut() {
ids.sort_by(|&a, &b| {
cross(direction, &nodes[a].center)
.partial_cmp(&cross(direction, &nodes[b].center))
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| nodes[a].id.cmp(&nodes[b].id))
});
}
let mut used_out: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut used_in: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut next: HashMap<String, String> = HashMap::new();
let mut ranks: Vec<i32> = node_rank.values().copied().collect();
ranks.sort_unstable();
ranks.dedup();
for window in ranks.windows(2) {
let (r, next_r) = (window[0], window[1]);
let mut pair_candidates: Vec<&(String, String)> = candidates
.iter()
.filter(|(s, t)| node_rank.get(s) == Some(&r) && node_rank.get(t) == Some(&next_r))
.collect();
pair_candidates.sort_by(|(s1, t1), (s2, t2)| {
let sc1 = cross(direction, &nodes[id_index[s1]].center);
let sc2 = cross(direction, &nodes[id_index[s2]].center);
sc1.partial_cmp(&sc2)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
let tc1 = cross(direction, &nodes[id_index[t1]].center);
let tc2 = cross(direction, &nodes[id_index[t2]].center);
tc1.partial_cmp(&tc2).unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| s1.cmp(s2))
.then_with(|| t1.cmp(t2))
});
for (s, t) in pair_candidates {
if used_out.contains(s) || used_in.contains(t) {
continue;
}
used_out.insert(s.clone());
used_in.insert(t.clone());
next.insert(s.clone(), t.clone());
}
}
let mut chains: Vec<Vec<String>> = Vec::new();
for s in &used_out {
if used_in.contains(s) {
continue; }
let mut chain = vec![s.clone()];
let mut cur = s.clone();
while let Some(nxt) = next.get(&cur) {
chain.push(nxt.clone());
cur = nxt.clone();
}
chains.push(chain);
}
for chain in &chains {
if chain.len() < 2 {
continue;
}
let avg: f64 = chain
.iter()
.map(|id| cross(direction, &nodes[id_index[id]].center))
.sum::<f64>()
/ chain.len() as f64;
for id in chain {
let i = id_index[id];
let flow_v = flow(direction, &nodes[i].center);
nodes[i].center = make(direction, flow_v, avg);
}
}
for ids in by_rank.values() {
let mut prev_far_edge: Option<f64> = None;
for &i in ids {
let half = cross_extent(direction, &nodes[i]);
let mut c = cross(direction, &nodes[i].center);
if let Some(prev_edge) = prev_far_edge {
let min_c = prev_edge + super::NODE_SEP + half;
if c < min_c {
c = min_c;
let flow_v = flow(direction, &nodes[i].center);
nodes[i].center = make(direction, flow_v, c);
}
}
prev_far_edge = Some(c + half);
}
}
nodes
.iter()
.enumerate()
.filter_map(|(i, n)| {
let delta = cross(direction, &n.center) - initial_cross[i];
(delta.abs() > EPS).then(|| (n.id.clone(), delta))
})
.collect()
}
pub fn shift_cross(direction: Direction, p: &Point, delta: f64) -> Point {
make(direction, flow(direction, p), cross(direction, p) + delta)
}
fn tangent_coord(side: Side, p: &Point) -> f64 {
match side {
Side::Top | Side::Bottom => p.x,
Side::Left | Side::Right => p.y,
}
}
fn face_flat_half_extent(node: &PlacedNode, side: Side) -> f64 {
let full = match side {
Side::Top | Side::Bottom => node.size.w,
Side::Left | Side::Right => node.size.h,
};
let chamfer_allowance = if node.shape == Glyph::ChamferedRect {
2.0 * shapes::CHAMFER
} else {
0.0
};
(full - chamfer_allowance).max(0.0) / 2.0
}
fn ports_on_face(
node_id: &str,
side: Side,
exclude_edge_id: &str,
edges: &[EligibleEdge],
shapes: &[Option<EdgeShape>],
points: &HashMap<String, Vec<Point>>,
) -> Vec<f64> {
let mut out = Vec::new();
for (edge, shape) in edges.iter().zip(shapes) {
if edge.id == exclude_edge_id {
continue;
}
let Some(shape) = shape else { continue };
let Some(pts) = points.get(edge.id) else {
continue;
};
if pts.is_empty() {
continue;
}
if edge.source == node_id && shape.source_side == side {
out.push(tangent_coord(side, &pts[0]));
}
if edge.target == node_id && shape.target_side == side {
out.push(tangent_coord(side, &pts[pts.len() - 1]));
}
}
out
}
fn push_outward(node: &PlacedNode, side: Side, cur: f64, occupied: &[f64]) -> f64 {
let center = face_center_coord(node, side);
let dir = if cur >= center { 1.0 } else { -1.0 };
let half_extent = face_flat_half_extent(node, side);
let mut candidate = cur;
loop {
candidate += dir * PORT_SPACING;
if (candidate - center).abs() > half_extent {
return cur;
}
if occupied.iter().all(|&o| (o - candidate).abs() > EPS) {
return candidate;
}
}
}
fn segment_crosses_plate(a: &Point, b: &Point, plate: &PlacedEdgeLabel) -> bool {
let (l, t, r, bo) = (
plate.center.x - plate.size.w / 2.0,
plate.center.y - plate.size.h / 2.0,
plate.center.x + plate.size.w / 2.0,
plate.center.y + plate.size.h / 2.0,
);
if (a.y - b.y).abs() < EPS {
let y = a.y;
let (x0, x1) = (a.x.min(b.x), a.x.max(b.x));
y >= t && y <= bo && x1 >= l && x0 <= r
} else if (a.x - b.x).abs() < EPS {
let x = a.x;
let (y0, y1) = (a.y.min(b.y), a.y.max(b.y));
x >= l && x <= r && y1 >= t && y0 <= bo
} else {
false
}
}
pub fn separate_coincident_detours(
direction: Direction,
nodes: &[PlacedNode],
clusters: &[PlacedCluster],
edges: &[EligibleEdge],
points: &mut HashMap<String, Vec<Point>>,
) {
let cluster_boxes = cluster_node_boxes(clusters);
let by_id = build_by_id(nodes, &cluster_boxes);
let mut detour_ids: Vec<&str> = edges
.iter()
.filter_map(|e| {
let (Some(&source), Some(&target)) = (by_id.get(e.source), by_id.get(e.target)) else {
return None;
};
let shape = classify(
direction,
source,
target,
e.raw,
e.source_rank,
e.target_rank,
e.source_out_degree,
e.target_in_degree,
nodes,
);
((shape.reverse || shape.staircase) && source.id != target.id).then_some(e.id)
})
.collect();
detour_ids.sort_unstable();
const MAX_PASSES: usize = 4;
for _ in 0..MAX_PASSES {
let mut fix: Option<(&str, f64, f64, bool)> = None; 'search: for (i, &id_a) in detour_ids.iter().enumerate() {
for &id_b in &detour_ids[i + 1..] {
let (Some(ea), Some(eb)) = (
edges.iter().find(|e| e.id == id_a),
edges.iter().find(|e| e.id == id_b),
) else {
continue;
};
if ea.source == eb.source
|| ea.source == eb.target
|| ea.target == eb.source
|| ea.target == eb.target
{
continue; }
let (Some(pa), Some(pb)) = (points.get(id_a), points.get(id_b)) else {
continue;
};
for wa in pa.windows(2) {
for wb in pb.windows(2) {
let vert_a = (wa[0].x - wa[1].x).abs() < EPS;
let vert_b = (wb[0].x - wb[1].x).abs() < EPS;
if vert_a && vert_b && (wa[0].x - wb[0].x).abs() < EPS {
let (y0a, y1a) = (wa[0].y.min(wa[1].y), wa[0].y.max(wa[1].y));
let (y0b, y1b) = (wb[0].y.min(wb[1].y), wb[0].y.max(wb[1].y));
if y0a < y1b - EPS && y0b < y1a - EPS {
fix =
Some((id_b, wb[0].x, wb[0].x + PERIMETER_LANE_SPACING, false));
break 'search;
}
}
let horiz_a = (wa[0].y - wa[1].y).abs() < EPS;
let horiz_b = (wb[0].y - wb[1].y).abs() < EPS;
if horiz_a && horiz_b && (wa[0].y - wb[0].y).abs() < EPS {
let (x0a, x1a) = (wa[0].x.min(wa[1].x), wa[0].x.max(wa[1].x));
let (x0b, x1b) = (wb[0].x.min(wb[1].x), wb[0].x.max(wb[1].x));
if x0a < x1b - EPS && x0b < x1a - EPS {
fix = Some((id_b, wb[0].y, wb[0].y + PERIMETER_LANE_SPACING, true));
break 'search;
}
}
}
}
}
}
let Some((id, old_c, new_c, horizontal)) = fix else {
break;
};
if let Some(pts) = points.get_mut(id) {
for p in pts.iter_mut() {
if horizontal && (p.y - old_c).abs() < EPS {
p.y = new_c;
} else if !horizontal && (p.x - old_c).abs() < EPS {
p.x = new_c;
}
}
}
}
}
pub fn avoid_label_plates(
direction: Direction,
nodes: &[PlacedNode],
clusters: &[PlacedCluster],
edges: &[EligibleEdge],
points: &mut HashMap<String, Vec<Point>>,
plates: &mut HashMap<String, PlacedEdgeLabel>,
) {
let cluster_boxes = cluster_node_boxes(clusters);
let by_id = build_by_id(nodes, &cluster_boxes);
let shapes: Vec<Option<EdgeShape>> = edges
.iter()
.map(|e| {
let (Some(&source), Some(&target)) = (by_id.get(e.source), by_id.get(e.target)) else {
return None;
};
Some(classify(
direction,
source,
target,
e.raw,
e.source_rank,
e.target_rank,
e.source_out_degree,
e.target_in_degree,
nodes,
))
})
.collect();
let base_bounds = content_bounds(nodes, clusters);
let lane_of = perimeter_lanes(&by_id, edges, &shapes);
for (edge, shape) in edges.iter().zip(&shapes) {
let Some(shape) = shape else { continue };
let (Some(&source), Some(&target)) = (by_id.get(edge.source), by_id.get(edge.target))
else {
continue;
};
if shape.reverse && source.id != target.id {
let Some(pts) = points.get(edge.id) else {
continue;
};
if pts.len() < 2 {
continue;
}
let crosses_a_plate = pts.windows(2).any(|w| {
plates
.iter()
.any(|(id, plate)| id != edge.id && segment_crosses_plate(&w[0], &w[1], plate))
});
if !crosses_a_plate {
continue;
}
let Some(&lane) = lane_of.get(edge.id) else {
continue; };
let ring = expand_bounds(
base_bounds,
PERIMETER_MARGIN + lane as f64 * PERIMETER_LANE_SPACING,
);
let source_port = pts[0].clone();
let target_port = pts[pts.len() - 1].clone();
let ids = (edge.source, edge.target);
let blocked = |a: &Point, b: &Point| {
segment_crosses_any_node(a, b, nodes, ids)
|| plates
.iter()
.any(|(id, plate)| id != edge.id && segment_crosses_plate(a, b, plate))
};
let rebuilt = route_perimeter(shape, source_port, target_port, ring, &blocked);
if let Some(plate) = plates.get_mut(edge.id) {
if let Some(slot) = label_slot(direction, &rebuilt) {
plate.center = slot.center;
}
}
points.insert(edge.id.to_string(), rebuilt);
continue;
}
if shape.reverse {
continue; }
let Some(pts) = points.get(edge.id).cloned() else {
continue;
};
if pts.len() < 2 {
continue;
}
let n = pts.len();
let crosses_foreign = |a: &Point, b: &Point| {
plates
.iter()
.any(|(id, plate)| id != edge.id && segment_crosses_plate(a, b, plate))
};
let mut new_source_coord = None;
if crosses_foreign(&pts[0], &pts[1]) {
let cur = tangent_coord(shape.source_side, &pts[0]);
let occupied = ports_on_face(
source.id.as_str(),
shape.source_side,
edge.id,
edges,
&shapes,
points,
);
new_source_coord = Some(push_outward(source, shape.source_side, cur, &occupied));
}
let mut new_target_coord = None;
if crosses_foreign(&pts[n - 2], &pts[n - 1]) {
let cur = tangent_coord(shape.target_side, &pts[n - 1]);
let occupied = ports_on_face(
target.id.as_str(),
shape.target_side,
edge.id,
edges,
&shapes,
points,
);
new_target_coord = Some(push_outward(target, shape.target_side, cur, &occupied));
}
if new_source_coord.is_none() && new_target_coord.is_none() {
continue;
}
let source_coord =
new_source_coord.unwrap_or_else(|| tangent_coord(shape.source_side, &pts[0]));
let target_coord =
new_target_coord.unwrap_or_else(|| tangent_coord(shape.target_side, &pts[n - 1]));
let rebuilt = route_with_ports(
direction,
shape,
source,
target,
source_coord,
target_coord,
edge.raw,
(0.0, 0.0, 0.0, 0.0),
nodes,
);
if let Some(plate) = plates.get_mut(edge.id) {
if let Some(slot) = label_slot(direction, &rebuilt) {
plate.center = slot.center;
}
}
points.insert(edge.id.to_string(), rebuilt);
}
}
pub const CROSSING_GAP: f64 = 12.0;
fn segment_crossing(a: &[Point], b: &[Point]) -> Option<Point> {
let (a0, a1) = (&a[0], &a[1]);
let (b0, b1) = (&b[0], &b[1]);
let a_vert = (a0.x - a1.x).abs() < EPS;
let b_horiz = (b0.y - b1.y).abs() < EPS;
if a_vert && b_horiz {
let x = a0.x;
let (ay0, ay1) = (a0.y.min(a1.y), a0.y.max(a1.y));
let y = b0.y;
let (bx0, bx1) = (b0.x.min(b1.x), b0.x.max(b1.x));
if x > bx0 + EPS && x < bx1 - EPS && y > ay0 + EPS && y < ay1 - EPS {
return Some(Point::new(x, y));
}
return None;
}
let a_horiz = (a0.y - a1.y).abs() < EPS;
let b_vert = (b0.x - b1.x).abs() < EPS;
if a_horiz && b_vert {
return segment_crossing(b, a);
}
None
}
fn gap_around(points: &[Point], seg_idx: usize, cross: &Point) -> (Point, Point) {
let half = CROSSING_GAP / 2.0;
let seg_start = &points[seg_idx];
let to_cross = (cross.x - seg_start.x).hypot(cross.y - seg_start.y);
let s = super::edges::length(&points[..=seg_idx]) + to_cross;
let total = super::edges::length(points);
let lo = (s - half).max(0.0);
let hi = (s + half).min(total);
let g0 = super::edges::point_at_arc_distance(points, lo).unwrap_or_else(|| cross.clone());
let g1 = super::edges::point_at_arc_distance(points, hi).unwrap_or_else(|| cross.clone());
(g0, g1)
}
pub fn insert_crossing_gaps(
direction: Direction,
nodes: &[PlacedNode],
clusters: &[PlacedCluster],
edges: &[EligibleEdge],
points: &HashMap<String, Vec<Point>>,
) -> HashMap<String, Vec<(Point, Point)>> {
let cluster_boxes = cluster_node_boxes(clusters);
let by_id = build_by_id(nodes, &cluster_boxes);
let is_detour: HashMap<&str, bool> = edges
.iter()
.filter_map(|e| {
let (Some(&source), Some(&target)) = (by_id.get(e.source), by_id.get(e.target)) else {
return None;
};
let shape = classify(
direction,
source,
target,
e.raw,
e.source_rank,
e.target_rank,
e.source_out_degree,
e.target_in_degree,
nodes,
);
Some((
e.id,
(shape.reverse || shape.staircase) && source.id != target.id,
))
})
.collect();
let mut gaps: HashMap<String, Vec<(Point, Point)>> = HashMap::new();
for i in 0..edges.len() {
for j in (i + 1)..edges.len() {
let (ei, ej) = (edges[i].id, edges[j].id);
let (Some(pi), Some(pj)) = (points.get(ei), points.get(ej)) else {
continue;
};
let (pi_detour, pj_detour) = (
is_detour.get(ei).copied().unwrap_or(false),
is_detour.get(ej).copied().unwrap_or(false),
);
if !pi_detour && !pj_detour {
continue; }
let cut_on_i = if pi_detour && pj_detour {
ei > ej
} else {
pi_detour
};
let (cut_id, cut_pts, other_pts) = if cut_on_i { (ei, pi, pj) } else { (ej, pj, pi) };
for (seg_idx, wc) in cut_pts.windows(2).enumerate() {
for wo in other_pts.windows(2) {
if let Some(cross) = segment_crossing(wc, wo) {
let (g0, g1) = gap_around(cut_pts, seg_idx, &cross);
gaps.entry(cut_id.to_string()).or_default().push((g0, g1));
}
}
}
}
}
gaps
}
#[cfg(test)]
mod tests {
use super::*;
use crate::preview::mermaid::render::Label;
fn node(id: &str, cx: f64, cy: f64, w: f64, h: f64) -> PlacedNode {
PlacedNode {
id: id.to_string(),
shape: Glyph::default(),
center: Point::new(cx, cy),
size: Size::new(w, h),
label: Label::measure(""),
panel: None,
series: None,
mark: None,
style: None,
}
}
#[test]
fn label_slot_prefers_the_flow_axis_segment_even_when_a_cross_axis_one_is_longer() {
let pts = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 10.0),
Point::new(200.0, 10.0),
];
let slot = label_slot(Direction::TopToBottom, &pts).expect("2+ points must return a slot");
assert!(
slot.is_flow_axis,
"{slot:?}",
slot = (slot.center, slot.length)
);
assert!(
(slot.length - 10.0).abs() < 1e-9,
"must pick the 10px flow-axis leg, not the 200px cross-axis one: {}",
slot.length
);
assert!((slot.center.x - 0.0).abs() < 1e-9 && (slot.center.y - 5.0).abs() < 1e-9);
}
#[test]
fn label_slot_picks_the_longer_of_two_flow_axis_segments() {
let pts = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 30.0),
Point::new(20.0, 30.0),
Point::new(20.0, 130.0),
];
let slot = label_slot(Direction::TopToBottom, &pts).expect("must return a slot");
assert!(slot.is_flow_axis);
assert!((slot.length - 100.0).abs() < 1e-9, "{}", slot.length);
assert!((slot.center.x - 20.0).abs() < 1e-9 && (slot.center.y - 80.0).abs() < 1e-9);
}
#[test]
fn label_slot_falls_back_to_a_cross_axis_segment_when_no_flow_axis_leg_exists() {
let pts = vec![Point::new(0.0, 0.0), Point::new(50.0, 0.0)];
let slot = label_slot(Direction::TopToBottom, &pts).expect("must return a slot");
assert!(!slot.is_flow_axis);
assert!(
slot.horizontal,
"a horizontal-only polyline must report horizontal=true"
);
}
#[test]
fn label_slot_handles_a_single_point_defensively() {
let pts = vec![Point::new(5.0, 5.0)];
let slot = label_slot(Direction::TopToBottom, &pts).expect("a single point is Some");
assert_eq!(slot.length, 0.0);
assert_eq!(slot.center, Point::new(5.0, 5.0));
}
#[test]
fn label_min_length_uses_width_for_a_horizontal_segment_and_height_for_a_vertical_one() {
let plate = Size::new(100.0, 20.0);
assert!((label_min_length(plate, true) - (100.0 + 2.0 * LABEL_CLEARANCE)).abs() < 1e-9);
assert!((label_min_length(plate, false) - (20.0 + 2.0 * LABEL_CLEARANCE)).abs() < 1e-9);
}
#[test]
fn safe_ring_exit_l_shape_stops_at_the_ring_not_the_far_corner() {
let ring = (0.0, 0.0, 600.0, 400.0);
for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
let port = match side {
Side::Top => Point::new(300.0, 50.0),
Side::Bottom => Point::new(300.0, 350.0),
Side::Left => Point::new(50.0, 200.0),
Side::Right => Point::new(550.0, 200.0),
};
let direct = ring_touch(side, &port, ring);
let blocked = |a: &Point, b: &Point| *a == port && *b == direct;
let exit = safe_ring_exit(side, &port, ring, &blocked);
assert_eq!(
exit.len(),
2,
"{side:?}: the L-shaped fallback must be exactly [hop, ring-touch], not a third \
leg out to the far corner: {exit:?}"
);
let touch = &exit[1];
match side {
Side::Top | Side::Bottom => {
assert!(
(touch.y - exit[0].y).abs() < 1e-9,
"{side:?}: the ring touch point must stay at the hop's own y, not travel \
on to direct's ({}): {touch:?}",
direct.y
);
}
Side::Left | Side::Right => {
assert!(
(touch.x - exit[0].x).abs() < 1e-9,
"{side:?}: the ring touch point must stay at the hop's own x, not travel \
on to direct's ({}): {touch:?}",
direct.x
);
}
}
}
}
#[test]
fn perimeter_l_shape_fallback_spans_only_the_ports_own_separation_in_every_direction() {
for direction in [
Direction::TopToBottom,
Direction::BottomToTop,
Direction::LeftToRight,
Direction::RightToLeft,
] {
let (hi, lo, mid) = match direction {
Direction::TopToBottom | Direction::BottomToTop => (
node("hi", 100.0, 0.0, 60.0, 40.0),
node("lo", 100.0, 300.0, 60.0, 40.0),
node("mid", 100.0, 150.0, 60.0, 40.0),
),
Direction::LeftToRight | Direction::RightToLeft => (
node("hi", 0.0, 100.0, 40.0, 60.0),
node("lo", 300.0, 100.0, 40.0, 60.0),
node("mid", 150.0, 100.0, 40.0, 60.0),
),
};
let nodes = vec![hi.clone(), lo.clone(), mid.clone()];
let raw = vec![lo.center.clone(), mid.center.clone(), hi.center.clone()];
let edges = vec![EligibleEdge {
id: "back",
source: "lo",
target: "hi",
raw: &raw,
source_rank: Some(1),
target_rank: Some(0),
source_out_degree: 1,
target_in_degree: 1,
}];
let routed = route_flowchart(direction, &nodes, &[], &edges);
let pts = &routed.points["back"];
for w in pts.windows(2) {
let dx = (w[1].x - w[0].x).abs();
let dy = (w[1].y - w[0].y).abs();
assert!(
dx < 1e-9 || dy < 1e-9,
"{direction:?}: perimeter route must stay axis-parallel: {w:?}"
);
}
let longest = pts
.windows(2)
.map(|w| (w[1].x - w[0].x).hypot(w[1].y - w[0].y))
.fold(0.0_f64, f64::max);
let needed = match direction {
Direction::TopToBottom | Direction::BottomToTop => {
(lo.center.y - hi.center.y).abs()
}
Direction::LeftToRight | Direction::RightToLeft => {
(lo.center.x - hi.center.x).abs()
}
};
assert!(
longest <= needed + 1.0,
"{direction:?}: the ring run is {longest}px, more than the ~{needed}px the two \
ports actually need — the far-corner bug is back: {pts:?}"
);
}
}
#[test]
fn avoid_label_plates_ignores_a_self_loop() {
let a = node("A", 100.0, 100.0, 60.0, 40.0);
let nodes = vec![a];
let raw = vec![
Point::new(130.0, 90.0),
Point::new(160.0, 90.0),
Point::new(160.0, 110.0),
Point::new(130.0, 110.0),
];
let edges = vec![EligibleEdge {
id: "loop",
source: "A",
target: "A",
raw: &raw,
source_rank: Some(0),
target_rank: Some(0),
source_out_degree: 2,
target_in_degree: 2,
}];
let routed = route_flowchart(Direction::TopToBottom, &nodes, &[], &edges);
let mut points = routed.points;
let before = points["loop"].clone();
let mut plates: HashMap<String, PlacedEdgeLabel> = HashMap::new();
plates.insert(
"someone-elses-label".to_string(),
PlacedEdgeLabel {
center: Point::new(100.0, 100.0),
size: Size::new(400.0, 400.0),
label: Label::measure("x"),
},
);
avoid_label_plates(
Direction::TopToBottom,
&nodes,
&[],
&edges,
&mut points,
&mut plates,
);
assert_eq!(points["loop"], before, "a self-loop must never be rebuilt");
}
#[test]
fn avoid_label_plates_reroutes_a_perimeter_edge_off_a_plate_it_can_actually_clear() {
let a = node("A", 100.0, 0.0, 60.0, 40.0);
let d = node("D", 100.0, 200.0, 60.0, 40.0);
let nodes = vec![a, d];
let raw = vec![
Point::new(100.0, 180.0),
Point::new(100.0, 100.0),
Point::new(100.0, 20.0),
];
let edges = vec![EligibleEdge {
id: "da",
source: "D",
target: "A",
raw: &raw,
source_rank: Some(2),
target_rank: Some(0),
source_out_degree: 1,
target_in_degree: 1,
}];
let routed = route_flowchart(Direction::TopToBottom, &nodes, &[], &edges);
let mut points = routed.points;
let mut plates: HashMap<String, PlacedEdgeLabel> = HashMap::new();
plates.insert(
"b-labels-edge".to_string(),
PlacedEdgeLabel {
center: Point::new(100.0, 100.0),
size: Size::new(80.0, 20.0),
label: Label::measure("x"),
},
);
let crosses_plate = |pts: &[Point], plate: &PlacedEdgeLabel| {
pts.windows(2)
.any(|w| segment_crosses_plate(&w[0], &w[1], plate))
};
assert!(
crosses_plate(&points["da"], &plates["b-labels-edge"]),
"fixture must reproduce the bug before the fix runs: {:?}",
points["da"]
);
avoid_label_plates(
Direction::TopToBottom,
&nodes,
&[],
&edges,
&mut points,
&mut plates,
);
assert!(
!crosses_plate(&points["da"], &plates["b-labels-edge"]),
"the rerouted line must actually clear the plate: {:?}",
points["da"]
);
for w in points["da"].windows(2) {
let dx = (w[1].x - w[0].x).abs();
let dy = (w[1].y - w[0].y).abs();
assert!(
dx < 1e-9 || dy < 1e-9,
"rerouted line must stay axis-parallel: {w:?}"
);
}
}
#[test]
fn avoid_label_plates_pushes_a_staircase_forward_edges_port_off_a_plate() {
let a = node("A", 42.6689453125, 30.7, 69.337890625, 45.4);
let b = node("B", 42.6689453125, 137.76666666666668, 69.337890625, 45.4);
let c = node("C", 162.39306640625, 30.7, 70.1103515625, 45.4);
let d = node(
"D",
162.39306640625,
137.76666666666668,
70.1103515625,
45.4,
);
let nodes = vec![a, b, c, d];
let edges = vec![EligibleEdge {
id: "ad",
source: "A",
target: "D",
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 2,
target_in_degree: 2,
}];
let shape = classify(
Direction::LeftToRight,
&nodes[0],
&nodes[3],
&[],
Some(0),
Some(1),
2,
2,
&nodes,
);
assert!(
shape.staircase,
"the fixture must actually reach `staircase` (both the branch and the merge attempt \
must cross `C`'s box): {shape:?}"
);
let source_port = port_at(
&nodes[0],
shape.source_side,
face_center_coord(&nodes[0], shape.source_side),
PORT_INSET,
);
let target_port = port_at(
&nodes[3],
shape.target_side,
face_center_coord(&nodes[3], shape.target_side),
PORT_INSET,
);
let before = bridge(
Direction::LeftToRight,
&source_port,
&target_port,
shape.source_axis,
shape.target_axis,
);
let before: Vec<Point> = std::iter::once(source_port.clone()).chain(before).collect();
let mut points: HashMap<String, Vec<Point>> = HashMap::new();
points.insert("ad".to_string(), before.clone());
let stub_mid = Point::new(
(before[0].x + before[1].x) / 2.0,
(before[0].y + before[1].y) / 2.0,
);
let mut plates: HashMap<String, PlacedEdgeLabel> = HashMap::new();
plates.insert(
"someone-elses-label".to_string(),
PlacedEdgeLabel {
center: stub_mid,
size: Size::new(40.0, 20.0),
label: Label::measure("x"),
},
);
let crosses_plate = |pts: &[Point], plate: &PlacedEdgeLabel| {
pts.windows(2)
.any(|w| segment_crosses_plate(&w[0], &w[1], plate))
};
assert!(
crosses_plate(&before, &plates["someone-elses-label"]),
"fixture must reproduce the crossing before the fix runs: {before:?}"
);
avoid_label_plates(
Direction::LeftToRight,
&nodes,
&[],
&edges,
&mut points,
&mut plates,
);
assert_ne!(
points["ad"], before,
"the staircase edge's port must actually move: {:?}",
points["ad"]
);
assert!(
!crosses_plate(&points["ad"], &plates["someone-elses-label"]),
"the rerouted staircase line must clear the plate: {:?}",
points["ad"]
);
for w in points["ad"].windows(2) {
let dx = (w[1].x - w[0].x).abs();
let dy = (w[1].y - w[0].y).abs();
assert!(
dx < 1e-9 || dy < 1e-9,
"rerouted line must stay axis-parallel: {w:?}"
);
}
}
#[test]
fn dominant_face_at_exactly_45_degrees_prefers_the_flow_face() {
let center = Point::new(0.0, 0.0);
let reference = Point::new(50.0, 50.0); assert_eq!(
dominant_face(Direction::TopToBottom, ¢er, &reference),
Side::Bottom,
"an exact 45° tie must resolve to the flow face (Bottom, since dflow >= 0), not the \
cross face (Right)"
);
assert_eq!(
dominant_face(Direction::LeftToRight, ¢er, &reference),
Side::Right,
"the same 45° tie under LR must resolve to the flow face (Right), not the cross face \
(Bottom)"
);
}
#[test]
fn safe_ring_exit_l_shape_tie_prefers_the_first_side_named_in_the_near_far_pair() {
let ring = (0.0, 0.0, 600.0, 400.0);
let port = Point::new(300.0, 50.0); let direct = ring_touch(Side::Top, &port, ring);
let blocked = |a: &Point, b: &Point| *a == port && *b == direct;
let exit = safe_ring_exit(Side::Top, &port, ring, &blocked);
assert_eq!(exit.len(), 2, "must take the L-shaped fallback: {exit:?}");
assert!(
(exit[1].x - ring.0).abs() < 1e-9,
"an exact near/far tie must resolve to the ring's LEFT side (tried first), not the \
right: {exit:?}"
);
}
#[test]
fn label_slot_keeps_the_first_segment_on_an_exact_length_tie() {
let pts = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 50.0), Point::new(20.0, 50.0), Point::new(20.0, 100.0), ];
let slot = label_slot(Direction::TopToBottom, &pts).expect("must return a slot");
assert!(slot.is_flow_axis);
assert!((slot.length - 50.0).abs() < 1e-9, "{}", slot.length);
assert!(
(slot.center.x - 0.0).abs() < 1e-9 && (slot.center.y - 25.0).abs() < 1e-9,
"an exact-length tie must keep the FIRST segment (centre (0,25)), not the second \
(centre (20,75)): {:?}",
slot.center
);
}
#[test]
fn perimeter_lanes_are_assigned_by_edge_id_not_declaration_order() {
let a = node("A", 0.0, 0.0, 40.0, 30.0);
let b = node("B", 0.0, 200.0, 40.0, 30.0);
let by_id: HashMap<&str, &PlacedNode> = [("A", &a), ("B", &b)].into_iter().collect();
let edges = vec![
EligibleEdge {
id: "b_edge",
source: "B",
target: "A",
raw: &[],
source_rank: Some(2),
target_rank: Some(0),
source_out_degree: 1,
target_in_degree: 1,
},
EligibleEdge {
id: "a_edge",
source: "B",
target: "A",
raw: &[],
source_rank: Some(2),
target_rank: Some(0),
source_out_degree: 1,
target_in_degree: 1,
},
];
let back_shape = EdgeShape {
reverse: true,
aligned: false,
staircase: false,
source_side: Side::Top,
source_axis: Axis::Cross,
target_side: Side::Bottom,
target_axis: Axis::Cross,
};
let shapes = vec![Some(back_shape), Some(back_shape)];
let lanes = perimeter_lanes(&by_id, &edges, &shapes);
assert_eq!(
lanes.get("a_edge").copied(),
Some(0),
"the alphabetically-smaller edge id must get lane 0 regardless of declaration order: \
{lanes:?}"
);
assert_eq!(lanes.get("b_edge").copied(), Some(1), "{lanes:?}");
}
#[test]
fn push_outward_skips_a_coordinate_another_port_already_occupies() {
let a = node("A", 0.0, 0.0, 200.0, 40.0);
let occupied = [-PORT_SPACING, PORT_SPACING];
let pushed = push_outward(&a, Side::Bottom, 0.0, &occupied);
assert!(
occupied.iter().all(|&o| (o - pushed).abs() > EPS),
"pushed port {pushed} must not land on a sibling port {occupied:?}"
);
assert!(pushed > PORT_SPACING, "{pushed}");
}
#[test]
fn push_outward_never_collides_across_a_dense_face() {
let a = node("A", 0.0, 0.0, 400.0, 40.0);
for n in 1..=9usize {
let offsets: Vec<f64> = (0..n)
.map(|i| (i as f64 - (n as f64 - 1.0) / 2.0) * PORT_SPACING)
.collect();
for &cur in &offsets {
let occupied: Vec<f64> = offsets.iter().copied().filter(|&o| o != cur).collect();
let pushed = push_outward(&a, Side::Bottom, cur, &occupied);
assert!(
occupied.iter().all(|&o| (o - pushed).abs() > EPS),
"n={n} cur={cur}: pushed {pushed} collided with {occupied:?}"
);
}
}
}
#[test]
fn push_outward_gives_up_rather_than_leave_the_nodes_flat_run() {
let a = node("A", 0.0, 0.0, 20.0, 40.0); let pushed = push_outward(&a, Side::Bottom, 0.0, &[]);
assert_eq!(pushed, 0.0, "must give up and keep the original coordinate");
}
#[test]
fn ports_on_face_reads_only_the_named_face_excluding_the_edge_itself() {
let a = node("A", 100.0, 100.0, 200.0, 40.0);
let b = node("B", 100.0, 200.0, 60.0, 40.0);
let c = node("C", 100.0, 200.0, 60.0, 40.0);
let d = node("D", 250.0, 100.0, 60.0, 40.0);
let nodes = [a, b, c, d];
let make_edge = |id, target| EligibleEdge {
id,
source: "A",
target,
raw: &[] as &[Point],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 3,
target_in_degree: 1,
};
let edges = vec![
make_edge("ab", "B"),
make_edge("ac", "C"),
make_edge("ad", "D"),
];
let shapes: Vec<Option<EdgeShape>> = vec![
Some(EdgeShape {
reverse: false,
aligned: false,
staircase: false,
source_side: Side::Bottom,
source_axis: Axis::Cross,
target_side: Side::Top,
target_axis: Axis::Cross,
}),
Some(EdgeShape {
reverse: false,
aligned: false,
staircase: false,
source_side: Side::Bottom,
source_axis: Axis::Cross,
target_side: Side::Top,
target_axis: Axis::Cross,
}),
Some(EdgeShape {
reverse: false,
aligned: false,
staircase: false,
source_side: Side::Right,
source_axis: Axis::Flow,
target_side: Side::Left,
target_axis: Axis::Flow,
}),
];
let mut points: HashMap<String, Vec<Point>> = HashMap::new();
points.insert(
"ab".to_string(),
vec![Point::new(84.0, 120.0), Point::new(84.0, 180.0)],
);
points.insert(
"ac".to_string(),
vec![Point::new(116.0, 120.0), Point::new(116.0, 180.0)],
);
points.insert(
"ad".to_string(),
vec![Point::new(200.0, 100.0), Point::new(220.0, 100.0)],
);
let occupied = ports_on_face(&nodes[0].id, Side::Bottom, "ac", &edges, &shapes, &points);
assert_eq!(
occupied,
vec![84.0],
"must see `ab`'s Bottom port, not `ac` (excluded) or `ad` (a different face)"
);
}
#[test]
fn routing_parse_is_permissive() {
assert_eq!(Routing::parse("konoma-orthogonal"), Routing::Orthogonal);
assert_eq!(Routing::parse("splines"), Routing::Splines);
assert_eq!(Routing::parse(""), Routing::Splines);
assert_eq!(Routing::parse("Orthogonal"), Routing::Splines);
assert_eq!(Routing::parse("orthogonal"), Routing::Splines);
assert_eq!(Routing::parse("xyz"), Routing::Splines);
}
#[test]
fn aligned_lr_edge_is_a_straight_two_point_line() {
let a = node("A", 0.0, 100.0, 80.0, 40.0);
let b = node("B", 200.0, 100.0, 80.0, 40.0);
let pts = route_edge(Direction::LeftToRight, &a, &b, &[], Some(0), Some(1), 1, 1);
assert_eq!(pts.len(), 2);
assert!((pts[0].y - pts[1].y).abs() < 1e-9, "flat: {pts:?}");
assert!(pts[0].x > 0.0 && pts[0].x < pts[1].x, "{pts:?}");
}
#[test]
fn branch_lr_edge_bends_once_and_lands_perpendicular() {
let a = node("A", 0.0, 0.0, 80.0, 40.0);
let b = node("B", 200.0, 100.0, 80.0, 40.0);
let pts = route_edge(Direction::LeftToRight, &a, &b, &[], Some(0), Some(1), 2, 1);
assert_eq!(pts.len(), 3, "{pts:?}");
assert!((pts[0].x - pts[1].x).abs() < 1e-9, "{pts:?}");
assert!((pts[1].y - pts[2].y).abs() < 1e-9, "{pts:?}");
}
#[test]
fn merge_lr_edge_bends_once_the_other_way() {
let a = node("A", 0.0, 0.0, 80.0, 40.0);
let b = node("B", 200.0, 100.0, 80.0, 40.0);
let pts = route_edge(Direction::LeftToRight, &a, &b, &[], Some(0), Some(1), 1, 2);
assert_eq!(pts.len(), 3, "{pts:?}");
assert!((pts[0].y - pts[1].y).abs() < 1e-9, "{pts:?}");
assert!((pts[1].x - pts[2].x).abs() < 1e-9, "{pts:?}");
}
#[test]
fn reverse_edge_is_detected_by_rank_and_stays_axis_parallel() {
let a = node("A", 200.0, 0.0, 80.0, 40.0);
let b = node("B", 0.0, 0.0, 80.0, 40.0);
let raw = vec![
Point::new(160.0, 0.0),
Point::new(100.0, -60.0),
Point::new(40.0, 0.0),
];
let pts = route_edge(Direction::LeftToRight, &a, &b, &raw, Some(2), Some(0), 1, 1);
assert!(pts.len() >= 2);
for w in pts.windows(2) {
let dx = (w[1].x - w[0].x).abs();
let dy = (w[1].y - w[0].y).abs();
assert!(dx < 1e-9 || dy < 1e-9, "diagonal segment: {w:?}");
}
}
#[test]
fn all_four_directions_produce_only_axis_parallel_segments() {
for direction in [
Direction::TopToBottom,
Direction::BottomToTop,
Direction::LeftToRight,
Direction::RightToLeft,
] {
let a = node("A", 0.0, 0.0, 80.0, 40.0);
let b = node("B", 150.0, 90.0, 80.0, 40.0);
for (sr, tr, out_d, in_d) in [(0, 1, 1, 1), (0, 1, 2, 1), (0, 1, 1, 2), (1, 0, 1, 1)] {
let pts = route_edge(direction, &a, &b, &[], Some(sr), Some(tr), out_d, in_d);
for w in pts.windows(2) {
let dx = (w[1].x - w[0].x).abs();
let dy = (w[1].y - w[0].y).abs();
assert!(
dx < 1e-9 || dy < 1e-9,
"{direction:?} sr={sr} tr={tr}: diagonal segment {w:?}"
);
}
}
}
}
fn route_all<'a>(
direction: Direction,
nodes: &[PlacedNode],
edges: &[EligibleEdge<'a>],
) -> RoutedFlowchart {
route_flowchart(direction, nodes, &[], edges)
}
fn three_branches_into<'a>(target: &'a str) -> Vec<EligibleEdge<'a>> {
vec![
EligibleEdge {
id: "e1",
source: "X",
target,
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 2,
target_in_degree: 3,
},
EligibleEdge {
id: "e2",
source: "Y",
target,
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 2,
target_in_degree: 3,
},
EligibleEdge {
id: "e3",
source: "Z",
target,
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 2,
target_in_degree: 3,
},
]
}
#[test]
fn three_incoming_edges_get_16px_ports_symmetric_about_the_face_and_clear_of_corners() {
let target = node("T", 500.0, 300.0, 300.0, 60.0);
let x = node("X", 50.0, 50.0, 60.0, 40.0);
let y = node("Y", 950.0, 150.0, 60.0, 40.0);
let z = node("Z", 1400.0, 250.0, 60.0, 40.0);
let nodes = vec![target.clone(), x.clone(), y.clone(), z.clone()];
let edges = three_branches_into("T");
let routed = route_all(Direction::TopToBottom, &nodes, &edges);
let tip_x = routed.points["e1"].last().unwrap().x;
let tip_y = routed.points["e2"].last().unwrap().x;
let tip_z = routed.points["e3"].last().unwrap().x;
let mut xs = [tip_x, tip_y, tip_z];
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!((xs[1] - target.center.x).abs() < 1e-9, "{xs:?}");
assert!((xs[1] - xs[0] - PORT_SPACING).abs() < 1e-9, "{xs:?}");
assert!((xs[2] - xs[1] - PORT_SPACING).abs() < 1e-9, "{xs:?}");
assert!(tip_x < tip_y && tip_y < tip_z, "{tip_x} {tip_y} {tip_z}");
let half_width = target.size.w / 2.0;
for x in xs {
let from_center = (x - target.center.x).abs();
assert!(
half_width - from_center >= PORT_CLEARANCE - 1e-9,
"port at {from_center}px from centre must clear the corner by {PORT_CLEARANCE}px \
(half-width {half_width}): {xs:?}"
);
}
}
#[test]
fn an_aligned_edge_keeps_the_centre_port_and_siblings_move_outward() {
let a = node("A", 300.0, 0.0, 60.0, 40.0);
let b = node("B", 300.0, 200.0, 60.0, 40.0);
let c = node("C", 100.0, 70.0, 60.0, 40.0);
let d = node("D", 500.0, 140.0, 60.0, 40.0);
let nodes = vec![a.clone(), b.clone(), c.clone(), d.clone()];
let edges = vec![
EligibleEdge {
id: "ab",
source: "A",
target: "B",
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 1,
target_in_degree: 3,
},
EligibleEdge {
id: "cb",
source: "C",
target: "B",
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 2,
target_in_degree: 3,
},
EligibleEdge {
id: "db",
source: "D",
target: "B",
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 2,
target_in_degree: 3,
},
];
let routed = route_all(Direction::TopToBottom, &nodes, &edges);
let ab_tip = routed.points["ab"].last().unwrap();
let cb_tip = routed.points["cb"].last().unwrap();
let db_tip = routed.points["db"].last().unwrap();
assert!(
(ab_tip.x - b.center.x).abs() < 1e-9,
"the aligned edge must keep the centre port: {ab_tip:?}"
);
for (name, tip) in [("cb", cb_tip), ("db", db_tip)] {
assert!(
(tip.x - b.center.x).abs() > 1e-9,
"{name}: the branching sibling must not share the centre port: {tip:?}"
);
assert!(
((tip.x - b.center.x).abs() - PORT_SPACING).abs() < 1e-9,
"{name}: the sibling must sit exactly one port-spacing away: {tip:?}"
);
}
}
#[test]
fn a_narrow_node_grows_to_fit_its_ports_and_a_roomy_one_does_not() {
let narrow = node("NARROW", 0.0, 300.0, 20.0, 40.0);
let roomy = node("ROOMY", 500.0, 300.0, 300.0, 40.0);
let x = node("X", -50.0, 50.0, 40.0, 30.0);
let y = node("Y", 225.0, 150.0, 40.0, 30.0);
let z = node("Z", 490.0, 250.0, 40.0, 30.0);
let nodes = vec![
narrow.clone(),
roomy.clone(),
x.clone(),
y.clone(),
z.clone(),
];
let routed_narrow = route_all(
Direction::TopToBottom,
&nodes,
&three_branches_into("NARROW"),
);
let required_w = routed_narrow.required_size["NARROW"].w;
assert!(
(required_w - 48.0).abs() < 1e-9,
"3 ports need (3-1)*16 + 2*8 = 48px of flat run: got {required_w}"
);
let routed_roomy = route_all(
Direction::TopToBottom,
&nodes,
&three_branches_into("ROOMY"),
);
assert!(
!routed_roomy.required_size.contains_key("ROOMY")
|| routed_roomy.required_size["ROOMY"].w <= roomy.size.w,
"a face that already fits its ports must not ask to grow: {:?}",
routed_roomy.required_size.get("ROOMY")
);
}
#[test]
fn chamfered_rect_required_size_adds_the_chamfer_allowance_on_top_of_the_flat_run() {
let mut target = node("T", 300.0, 300.0, 20.0, 40.0);
target.shape = Glyph::ChamferedRect;
let x = node("X", 200.0, 100.0, 40.0, 30.0);
let y = node("Y", 400.0, 100.0, 40.0, 30.0);
let nodes = vec![target.clone(), x.clone(), y.clone()];
let edges = vec![
EligibleEdge {
id: "e1",
source: "X",
target: "T",
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 2,
target_in_degree: 2,
},
EligibleEdge {
id: "e2",
source: "Y",
target: "T",
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 2,
target_in_degree: 2,
},
];
let routed = route_all(Direction::TopToBottom, &nodes, &edges);
let required_w = routed.required_size["T"].w;
assert!(
(required_w - 44.0).abs() < 1e-9,
"expected 32px flat run + 12px chamfer allowance = 44px: got {required_w}"
);
}
#[test]
fn eviction_keeps_every_port_exactly_port_inset_outside_the_node() {
let target = node("T", 500.0, 300.0, 300.0, 60.0);
let x = node("X", 50.0, 50.0, 60.0, 40.0);
let y = node("Y", 950.0, 150.0, 60.0, 40.0);
let z = node("Z", 1400.0, 250.0, 60.0, 40.0);
let nodes = vec![target.clone(), x.clone(), y.clone(), z.clone()];
let edges = three_branches_into("T");
let routed = route_all(Direction::TopToBottom, &nodes, &edges);
let (_, top, _, _) = target.bounds();
for id in ["e1", "e2", "e3"] {
let tip = routed.points[id].last().unwrap();
assert!(
(tip.y - (top - PORT_INSET)).abs() < 1e-9,
"{id}: evicted port must still sit exactly PORT_INSET outside the face: {tip:?}"
);
}
}
fn ranks(pairs: &[(&str, i32)]) -> HashMap<String, i32> {
pairs.iter().map(|(id, r)| (id.to_string(), *r)).collect()
}
fn edge(source: &str, target: &str) -> (String, String) {
(source.to_string(), target.to_string())
}
#[test]
fn a_straight_chain_across_three_ranks_aligns_onto_the_average_cross_coordinate() {
let mut nodes = vec![
node("A", 0.0, 0.0, 40.0, 30.0),
node("B", 50.0, 100.0, 40.0, 30.0),
node("C", 100.0, 200.0, 40.0, 30.0),
];
let node_rank = ranks(&[("A", 0), ("B", 1), ("C", 2)]);
let candidates = [edge("A", "B"), edge("B", "C")];
let _ = align_straight_lanes(Direction::TopToBottom, &mut nodes, &node_rank, &candidates);
for n in &nodes {
assert!(
(n.center.x - 50.0).abs() < 1e-9,
"{}: expected x=50 (the chain's average), got {:?}",
n.id,
n.center
);
}
assert_eq!(nodes[0].center.y, 0.0);
assert_eq!(nodes[1].center.y, 100.0);
assert_eq!(nodes[2].center.y, 200.0);
}
#[test]
fn tie_break_prefers_the_smaller_cross_coordinate() {
let mut nodes = vec![
node("S1", 10.0, 0.0, 40.0, 30.0),
node("S2", 500.0, 0.0, 40.0, 30.0),
node("T", 50.0, 100.0, 40.0, 30.0),
];
let node_rank = ranks(&[("S1", 0), ("S2", 0), ("T", 1)]);
let candidates = [edge("S1", "T"), edge("S2", "T")];
let _ = align_straight_lanes(Direction::TopToBottom, &mut nodes, &node_rank, &candidates);
let by_id: HashMap<&str, &PlacedNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
assert!(
(by_id["T"].center.x - 30.0).abs() < 1e-9,
"T must align with S1 (30), not S2 (275): {:?}",
by_id["T"].center
);
assert_eq!(
by_id["S2"].center.x, 500.0,
"S2 lost the tie and must be left exactly where it started"
);
}
#[test]
fn tie_break_second_key_prefers_the_smaller_target_cross_coordinate_when_sources_tie() {
let mut nodes = vec![
node("S", 100.0, 0.0, 40.0, 30.0),
node("Zed", 20.0, 100.0, 40.0, 30.0),
node("Alef", 500.0, 100.0, 40.0, 30.0),
];
let node_rank = ranks(&[("S", 0), ("Zed", 1), ("Alef", 1)]);
let candidates = [edge("S", "Alef"), edge("S", "Zed")];
let _ = align_straight_lanes(Direction::TopToBottom, &mut nodes, &node_rank, &candidates);
let by_id: HashMap<&str, &PlacedNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
assert!(
(by_id["S"].center.x - 60.0).abs() < 1e-9,
"S must align with Zed (the smaller-cross target), landing at (100+20)/2=60, not \
(100+500)/2=300: {:?}",
by_id["S"].center
);
assert_eq!(
by_id["Alef"].center.x, 500.0,
"Alef lost the second-key tie-break and must be left exactly where it started"
);
}
#[test]
fn overlap_resolution_pushes_the_later_node_without_reordering() {
let mut nodes = vec![
node("A", 0.0, 0.0, 40.0, 30.0),
node("C", 100.0, 0.0, 40.0, 30.0),
node("P", 200.0, 100.0, 40.0, 30.0),
node("Q", 210.0, 100.0, 40.0, 30.0),
];
let node_rank = ranks(&[("A", 0), ("C", 0), ("P", 1), ("Q", 1)]);
let candidates = [edge("A", "P"), edge("C", "Q")];
let _ = align_straight_lanes(Direction::TopToBottom, &mut nodes, &node_rank, &candidates);
let by_id: HashMap<&str, &PlacedNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
assert!(
(by_id["P"].center.x - 100.0).abs() < 1e-9,
"P is first in rank order and never needs pushing: {:?}",
by_id["P"].center
);
assert!(
(by_id["Q"].center.x - 190.0).abs() < 1e-9,
"Q must be pushed to exactly P's far edge (120) + NODE_SEP(50) + Q's half-width(20) \
= 190, not left at its own aligned 155: {:?}",
by_id["Q"].center
);
}
fn crossing_gaps(cut_points: Vec<Point>, other_points: Vec<Point>) -> Vec<(Point, Point)> {
let nodes = vec![
node("P", 0.0, -500.0, 4.0, 4.0),
node("Q", 0.0, 500.0, 4.0, 4.0),
node("M", -1000.0, -1000.0, 4.0, 4.0),
node("N", 1000.0, -1000.0, 4.0, 4.0),
];
let edges = [
EligibleEdge {
id: "cut",
source: "P",
target: "Q",
raw: &[],
source_rank: Some(1),
target_rank: Some(0),
source_out_degree: 1,
target_in_degree: 1,
},
EligibleEdge {
id: "other",
source: "M",
target: "N",
raw: &[],
source_rank: Some(0),
target_rank: Some(1),
source_out_degree: 1,
target_in_degree: 1,
},
];
let mut points = HashMap::new();
points.insert("cut".to_string(), cut_points);
points.insert("other".to_string(), other_points);
let gaps = insert_crossing_gaps(Direction::TopToBottom, &nodes, &[], &edges, &points);
gaps.get("cut").cloned().unwrap_or_default()
}
#[test]
fn crossing_gap_mid_segment_is_the_full_12px() {
let cut = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 20.0),
Point::new(30.0, 20.0),
];
let other = vec![Point::new(-10.0, 10.0), Point::new(10.0, 10.0)];
let gaps = crossing_gaps(cut, other);
assert_eq!(gaps.len(), 1, "{gaps:?}");
let (g0, g1) = &gaps[0];
assert!(
(g0.x - 0.0).abs() < 1e-9 && (g0.y - 4.0).abs() < 1e-9,
"{g0:?}"
);
assert!(
(g1.x - 0.0).abs() < 1e-9 && (g1.y - 16.0).abs() < 1e-9,
"{g1:?}"
);
let width = (g1.x - g0.x).hypot(g1.y - g0.y);
assert!((width - CROSSING_GAP).abs() < 1e-9, "{width}");
}
#[test]
fn crossing_gap_straddling_a_corner_still_totals_12px_by_arc_length() {
let cut = vec![
Point::new(0.0, 0.0),
Point::new(0.0, 20.0),
Point::new(30.0, 20.0),
];
let other = vec![Point::new(-10.0, 17.0), Point::new(10.0, 17.0)];
let gaps = crossing_gaps(cut, other);
assert_eq!(gaps.len(), 1, "{gaps:?}");
let (g0, g1) = &gaps[0];
assert!(
(g0.x - 0.0).abs() < 1e-9 && (g0.y - 11.0).abs() < 1e-9,
"g0 must stay on the vertical leg at arc length 11: {g0:?}"
);
assert!(
(g1.x - 3.0).abs() < 1e-9 && (g1.y - 20.0).abs() < 1e-9,
"g1 must continue 3px past the corner onto the horizontal leg: {g1:?}"
);
let chord = (g1.x - g0.x).hypot(g1.y - g0.y);
assert!(
chord < CROSSING_GAP - 1.0,
"the chord must actually be shorter than CROSSING_GAP for this test to mean anything: \
{chord}"
);
let corner = Point::new(0.0, 20.0);
let arc = crate::preview::mermaid::render::edges::length(&[g0.clone(), corner, g1.clone()]);
assert!((arc - CROSSING_GAP).abs() < 1e-9, "{arc}");
}
#[test]
fn crossing_gap_near_a_port_is_clamped_to_the_room_available() {
let cut = vec![Point::new(0.0, 0.0), Point::new(0.0, 8.0)];
let other = vec![Point::new(-10.0, 3.0), Point::new(10.0, 3.0)];
let gaps = crossing_gaps(cut, other);
assert_eq!(gaps.len(), 1, "{gaps:?}");
let (g0, g1) = &gaps[0];
assert!(
(g0.x - 0.0).abs() < 1e-9 && (g0.y - 0.0).abs() < 1e-9,
"g0 must clamp to the polyline's own start: {g0:?}"
);
assert!(
(g1.x - 0.0).abs() < 1e-9 && (g1.y - 8.0).abs() < 1e-9,
"g1 must clamp to the polyline's own end: {g1:?}"
);
let width = (g1.x - g0.x).hypot(g1.y - g0.y);
assert!(
width < CROSSING_GAP,
"a polyline shorter than CROSSING_GAP can only ever give back less than it: {width}"
);
}
}