Skip to main content

ggplot_rs/
graph.rs

1//! Drawing a laid-out graph of boxes: swimlane bands, edges (polylines with
2//! an arrowhead, a linetype, a width and an optional label placed clear of
3//! the boxes) and node boxes (a wrapped label, an optional value line,
4//! status badge and sparkline, and an accent bar), as one [`GGPlot`] of
5//! ordinary layers.
6//!
7//! Works with [`graph_layout`](crate::graph_layout): size the boxes with
8//! [`node_box`], lay them out with [`layered`](crate::graph_layout::layered),
9//! then [`draw`] the result. What each mark says and what colour it takes
10//! is the caller's (the [`DrawNode`] / [`DrawEdge`] fields); where things go
11//! inside a box and along an edge is decided here. Every mark carries the
12//! caller's `data-*` attributes, and what belongs to one node is wrapped in
13//! `<g data-of="<id>">`, so a browser can address a node as one element.
14//! Moved here from anofox-visualization's `::DIAGRAM`
15//! (sipemu/ggplot-rs#497, DataZooDE/anofox-visualization#1153).
16
17use crate::geom::arrow::arrow;
18use crate::graph_layout::{GraphLayout, PlacedNode};
19use crate::prelude::*;
20use crate::render::backend::{LineEnd, Linetype};
21use crate::scale::linewidth::ScaleLinewidthContinuous;
22
23type Rgb = (u8, u8, u8);
24
25/// Box geometry (px).
26const LABEL_PX: f64 = 13.0;
27const VALUE_PX: f64 = 12.0;
28const BADGE_PX: f64 = 11.0;
29/// An edge label's and a lane name's text size.
30const SMALL_PX: f64 = 11.0;
31const LINE_H: f64 = 16.0;
32const BOX_PAD_X: f64 = 12.0;
33const BOX_PAD_Y: f64 = 9.0;
34const BOX_MIN_W: f64 = 92.0;
35const BOX_MAX_W: f64 = 208.0;
36/// A sparkline's strip in a box.
37const SPARK_H: f64 = 22.0;
38/// The arrowhead's length (px) at zero line width; the layout's
39/// [`LayeredParams::arrow`](crate::graph_layout::LayeredParams::arrow).
40const ARROW: f64 = 7.0;
41/// A node label's line limit; only beyond it is the last line cut with "…".
42const MAX_LINES: usize = 3;
43
44/// Approximate rendered width of `s` at `px` (proportional sans-serif).
45fn text_w(s: &str, px: f64, bold: bool) -> f64 {
46    let per = if bold { 0.6 } else { 0.56 };
47    s.chars().count() as f64 * px * per
48}
49
50/// The width (px) of an edge label as [`draw`] sets it (its widest line):
51/// the layout's [`GraphEdge::label_w`](crate::graph_layout::GraphEdge::label_w).
52pub fn edge_label_width(label: &str) -> f64 {
53    label
54        .split('\n')
55        .map(|l| text_w(l, SMALL_PX, false))
56        .fold(0.0, f64::max)
57}
58
59/// `s` wrapped into at most [`MAX_LINES`] lines of `max` px (the last cut
60/// with "…" when the label doesn't fit). Breaks at spaces; a word longer
61/// than a line breaks after a `/`, `-`, `_`, `.` or `,` in it
62/// (`duckdb/community-` + `extensions`, not `community-ex` + `tensions`),
63/// and only mid-word when it has none.
64fn wrap(s: &str, max: f64) -> Vec<String> {
65    let cap = ((max / (LABEL_PX * 0.6)).floor() as usize).max(4);
66    // (piece, glued to the previous one) — a long word's pieces are glued
67    let mut pieces: Vec<(String, bool)> = Vec::new();
68    for word in s.split_whitespace() {
69        let chars: Vec<char> = word.chars().collect();
70        let mut start = 0;
71        let mut first = true;
72        while chars.len() - start > cap {
73            let window = &chars[start..start + cap];
74            let cut = window
75                .iter()
76                .rposition(|c| matches!(c, '/' | '-' | '_' | '.' | ','))
77                .filter(|&i| i + 1 >= cap / 3)
78                .map(|i| i + 1)
79                .unwrap_or(cap);
80            pieces.push((chars[start..start + cut].iter().collect(), !first));
81            first = false;
82            start += cut;
83        }
84        pieces.push((chars[start..].iter().collect(), !first));
85    }
86    let mut lines: Vec<String> = Vec::new();
87    let mut cur = String::new();
88    for (piece, glued) in pieces {
89        let sep = if glued || cur.is_empty() { 0 } else { 1 };
90        if !cur.is_empty() && cur.chars().count() + sep + piece.chars().count() > cap {
91            lines.push(std::mem::take(&mut cur));
92        } else if sep == 1 {
93            cur.push(' ');
94        }
95        cur.push_str(&piece);
96    }
97    if !cur.is_empty() {
98        lines.push(cur);
99    }
100    if lines.is_empty() {
101        lines.push(String::new());
102    }
103    if lines.len() > MAX_LINES {
104        let rest = lines[MAX_LINES - 1..].join(" ");
105        let mut last: String = rest.chars().take(cap.saturating_sub(1)).collect();
106        last = last.trim_end().to_string();
107        last.push('…');
108        lines.truncate(MAX_LINES - 1);
109        lines.push(last);
110    }
111    lines
112}
113
114/// A node box's size and its label wrapped to fit.
115#[derive(Clone, Debug, PartialEq)]
116pub struct NodeBox {
117    pub w: f64,
118    pub h: f64,
119    pub lines: Vec<String>,
120}
121
122/// The box a node needs: its label wrapped onto at most three lines, plus a
123/// line for `value`, a badge row for `badge` and a sparkline strip when
124/// `spark` (as [`draw`] fills it).
125pub fn node_box(label: &str, value: Option<&str>, badge: Option<&str>, spark: bool) -> NodeBox {
126    let lines = wrap(label, BOX_MAX_W - 2.0 * BOX_PAD_X);
127    let mut w = lines
128        .iter()
129        .map(|l| text_w(l, LABEL_PX, true))
130        .fold(0.0, f64::max);
131    let mut h = 2.0 * BOX_PAD_Y + LINE_H * lines.len() as f64;
132    if let Some(v) = value {
133        w = w.max(text_w(v, VALUE_PX, true));
134        h += LINE_H;
135    }
136    if let Some(b) = badge {
137        w = w.max(text_w(b, BADGE_PX, false) + 22.0);
138        h += 20.0;
139    }
140    if spark {
141        w = w.max(110.0);
142        h += SPARK_H;
143    }
144    NodeBox {
145        w: (w + 2.0 * BOX_PAD_X).clamp(BOX_MIN_W, BOX_MAX_W),
146        h,
147        lines,
148    }
149}
150
151/// The colours and class names of a drawing.
152#[derive(Clone, Debug)]
153pub struct GraphStyle {
154    /// Box and badge fill, and the halo behind edge labels.
155    pub card: Rgb,
156    /// Swimlane band tint.
157    pub band: Rgb,
158    /// Lane names and edge labels.
159    pub muted: Rgb,
160    /// Node labels.
161    pub heading: Rgb,
162    /// Node values and badge text.
163    pub ink: Rgb,
164    /// A variable's circle ([`NodeShape::Circle`]).
165    pub variable_fill: Rgb,
166    /// An observed variable's circle ([`NodeShape::FilledCircle`]) and a
167    /// delta node's square; their labels are drawn in `card`.
168    pub observed_fill: Rgb,
169    /// A plate's dashed outline and its label.
170    pub plate: Rgb,
171    /// Prefix of the layer classes (`<prefix>node`, `<prefix>edge`,
172    /// `<prefix>edge-label`, `<prefix>lane`, `<prefix>lane-label`,
173    /// `<prefix>node-label`, `<prefix>node-value`, `<prefix>node-badge`,
174    /// `<prefix>node-spark`, `<prefix>plate`, `<prefix>plate-label`; a
175    /// shaped node's mark also has `<prefix>node-<shape>`).
176    pub class_prefix: String,
177    /// A factor graph's math font: when set, the labels of shaped nodes
178    /// (variables, observations, equality nodes, glyph factors) are set in
179    /// this family, italic and regular weight — `θ`, `x₁`, `𝒩` read as
180    /// mathematics. `None` keeps the bold sans labels.
181    pub math_font: Option<String>,
182    /// A glyph factor's fill ([`NodeShape::Glyph`]); `None` is `card`.
183    pub glyph_fill: Option<Rgb>,
184    /// A glyph factor's glyph colour; `None` is `heading`.
185    pub glyph_ink: Option<Rgb>,
186}
187
188impl Default for GraphStyle {
189    fn default() -> Self {
190        GraphStyle {
191            card: (255, 255, 255),
192            band: (226, 228, 233),
193            muted: (107, 114, 128),
194            heading: (17, 24, 39),
195            ink: (31, 41, 55),
196            variable_fill: (229, 231, 235),
197            observed_fill: (75, 85, 99),
198            plate: (107, 114, 128),
199            class_prefix: "gg-graph-".to_string(),
200            math_font: None,
201            glyph_fill: None,
202            glyph_ink: None,
203        }
204    }
205}
206
207/// A node's mark. A factor graph (Forney style) draws factors as boxes,
208/// variables as circles (filled when observed), equality nodes as small
209/// squares, deterministic (delta) nodes as small filled squares, and
210/// constants as plain text.
211#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
212pub enum NodeShape {
213    /// A box with a label, value, badge and sparkline ([`node_box`]).
214    #[default]
215    Box,
216    Circle,
217    FilledCircle,
218    /// A small square, its label above it.
219    Square,
220    /// A small filled square, its label above it.
221    FilledSquare,
222    /// The label alone.
223    Text,
224    /// A small square box with a short glyph centred in it (a factor drawn
225    /// as `𝒩`, `Γ`, `Ber`, …): uniform, so a factor graph stays compact.
226    Glyph,
227    /// A small filled square at the end of an edge, its label beside it (to
228    /// the right): an observation in Forney style, where data enters.
229    Terminal,
230}
231
232impl NodeShape {
233    fn name(self) -> &'static str {
234        match self {
235            NodeShape::Box => "box",
236            NodeShape::Circle => "circle",
237            NodeShape::FilledCircle => "filled-circle",
238            NodeShape::Square => "square",
239            NodeShape::FilledSquare => "filled-square",
240            NodeShape::Text => "text",
241            NodeShape::Glyph => "glyph",
242            NodeShape::Terminal => "terminal",
243        }
244    }
245}
246
247/// How far from its end an edge label set near that end sits, at most (px).
248const LABEL_REACH: f64 = 22.0;
249/// A belief curve's size over its node (px).
250pub const BELIEF_W: f64 = 46.0;
251pub const BELIEF_H: f64 = 16.0;
252/// A small square's side (px).
253const SQUARE: f64 = 14.0;
254/// A terminal's side (px).
255const TERMINAL: f64 = 11.0;
256/// A glyph factor's side (px) and its glyph's size.
257const GLYPH_SIDE: f64 = 34.0;
258const GLYPH_PX: f64 = 15.0;
259
260/// The size a node of `shape` needs for `label` (a [`NodeShape::Box`] is
261/// [`node_box`] without a value, badge or sparkline). A circle fits its
262/// label on one line, at least 30 px across.
263pub fn node_size(shape: NodeShape, label: &str) -> NodeBox {
264    let one = || vec![label.to_string()];
265    match shape {
266        NodeShape::Box => node_box(label, None, None, false),
267        NodeShape::Circle | NodeShape::FilledCircle => {
268            let d = (text_w(label, LABEL_PX, true) + 14.0).max(30.0);
269            NodeBox {
270                w: d,
271                h: d,
272                lines: one(),
273            }
274        }
275        NodeShape::Square | NodeShape::FilledSquare => NodeBox {
276            w: SQUARE,
277            h: SQUARE,
278            lines: if label.is_empty() { Vec::new() } else { one() },
279        },
280        NodeShape::Terminal => NodeBox {
281            w: TERMINAL,
282            h: TERMINAL,
283            lines: if label.is_empty() { Vec::new() } else { one() },
284        },
285        NodeShape::Text => NodeBox {
286            w: text_w(label, LABEL_PX, false) + 8.0,
287            h: 20.0,
288            lines: one(),
289        },
290        NodeShape::Glyph => {
291            let side = (text_w(label, GLYPH_PX, true) + 14.0).max(GLYPH_SIDE);
292            NodeBox {
293                w: side,
294                h: GLYPH_SIDE,
295                lines: one(),
296            }
297        }
298    }
299}
300
301/// A plate: a dashed box around a collection of nodes (indices in the
302/// layout's order), labelled at its top left (`x_t`, `t = 1..T`).
303#[derive(Clone, Debug, Default)]
304pub struct Plate {
305    pub label: String,
306    pub nodes: Vec<usize>,
307}
308
309/// A `data-*` attribute: its name (without `data-`) and value; `None` leaves
310/// it off that mark.
311pub type DataAttr = (String, Option<String>);
312
313/// What a node's box shows, and its attributes. Its geometry comes from the
314/// layout ([`PlacedNode`]); its label lines from [`node_box`].
315#[derive(Clone, Debug, Default)]
316pub struct DrawNode {
317    /// The `data-of` group of everything drawn for the node.
318    pub id: String,
319    /// Its mark; anything but a box shows only the label.
320    pub shape: NodeShape,
321    /// The label, wrapped ([`NodeBox::lines`]).
322    pub lines: Vec<String>,
323    /// A value line under the label.
324    pub value: Option<String>,
325    /// A badge row: its text and its dot's (and outline's) colour.
326    pub badge: Option<(String, Rgb)>,
327    /// The box (or circle, square) outline.
328    pub stroke: Rgb,
329    /// A thin bar along the box's left edge.
330    pub accent: Option<Rgb>,
331    /// A sparkline (two values or more) along the bottom, and its colour.
332    pub spark: Option<(Vec<f64>, Rgb)>,
333    /// A belief curve over the node (a factor graph variable's density):
334    /// heights 0..=1 at evenly spaced points (three or more), and its colour.
335    pub belief: Option<(Vec<f64>, Rgb)>,
336    /// The prior behind the belief curve: heights on the same scale and
337    /// span as [`DrawNode::belief`], drawn as a faint dashed outline, and
338    /// its colour.
339    pub prior: Option<(Vec<f64>, Rgb)>,
340    pub tooltip: String,
341    /// `data-*` on the box (the hit target); every node lists the same
342    /// names, in the same order.
343    pub attrs: Vec<DataAttr>,
344}
345
346/// An edge's line pattern.
347#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
348pub enum EdgeLine {
349    #[default]
350    Solid,
351    Dashed,
352    /// Round dots (zero-length dashes with round caps).
353    Dots,
354}
355
356/// What an edge looks like, and its attributes. Its points come from the
357/// layout ([`GraphLayout::edges`]); the arrowhead is on its last point,
358/// unless `undirected`.
359#[derive(Clone, Debug, Default)]
360pub struct DrawEdge {
361    pub colour: Rgb,
362    /// Line width (px), see [`flow_width`].
363    pub width: f64,
364    pub line: EdgeLine,
365    /// Extra CSS classes on the path.
366    pub class: Option<String>,
367    pub tooltip: String,
368    /// A label (`\n` breaks lines) on the edge, clear of the boxes.
369    pub label: Option<String>,
370    /// Where along the edge the label goes (0 at its start, 1 at its end),
371    /// e.g. near a factor for an interface name; `None`: the middle, moved
372    /// along the edge until no box covers it.
373    pub label_at: Option<f64>,
374    /// No arrowhead (a factor graph's edges).
375    pub undirected: bool,
376    /// The label names a variable (Forney style: the edge is the variable):
377    /// set in [`GraphStyle::math_font`] when there is one, in the heading
378    /// colour.
379    pub label_math: bool,
380    /// `data-*` on the path; every edge lists the same names, in the same
381    /// order.
382    pub attrs: Vec<DataAttr>,
383    /// `data-*` on the label.
384    pub label_attrs: Vec<DataAttr>,
385}
386
387/// An edge's width (px) for what it carries, `value` of the graph's `max`:
388/// area-true (`sqrt`), 1.4 px to 7 px; 1.6 px without a value; dotted edges
389/// 3–6 px so their dots show. Rounded to 0.1 px.
390pub fn flow_width(value: Option<f64>, max: f64, line: EdgeLine) -> f64 {
391    let width = match value {
392        Some(v) if max > 0.0 => 1.4 + 5.6 * (v.max(0.0) / max).sqrt(),
393        _ => 1.6,
394    };
395    let width = match line {
396        EdgeLine::Dots => width.clamp(3.0, 6.0),
397        _ => width,
398    };
399    (width * 10.0).round() / 10.0
400}
401
402/// Columns built row by row, in px with y down.
403#[derive(Default)]
404struct Frame(Vec<(String, Vec<Value>)>);
405
406impl Frame {
407    fn col(&mut self, name: &str) -> &mut Vec<Value> {
408        let i = match self.0.iter().position(|(n, _)| n == name) {
409            Some(i) => i,
410            None => {
411                self.0.push((name.to_string(), Vec::new()));
412                self.0.len() - 1
413            }
414        };
415        &mut self.0[i].1
416    }
417    fn num(&mut self, name: &str, v: f64) -> &mut Self {
418        self.col(name).push(Value::Float(v));
419        self
420    }
421    fn txt(&mut self, name: &str, v: impl Into<String>) -> &mut Self {
422        self.col(name).push(Value::Str(v.into()));
423        self
424    }
425    fn opt(&mut self, name: &str, v: Option<String>) -> &mut Self {
426        self.col(name).push(v.map_or(Value::Na, Value::Str));
427        self
428    }
429    /// `attrs` as columns `a:<name>`.
430    fn attrs(&mut self, attrs: &[DataAttr]) -> &mut Self {
431        for (n, v) in attrs {
432            self.opt(&format!("a:{n}"), v.clone());
433        }
434        self
435    }
436    fn is_empty(&self) -> bool {
437        self.0.first().is_none_or(|(_, v)| v.is_empty())
438    }
439    /// `aes` plus a `data_attr` per `a:<name>` column, in column order.
440    fn attr_aes(&self, mut aes: Aes) -> Aes {
441        for (n, _) in &self.0 {
442            if let Some(name) = n.strip_prefix("a:") {
443                aes = aes.data_attr(name, n);
444            }
445        }
446        aes
447    }
448    /// The frame's columns, its pixel y (down) flipped to data y (up) on a
449    /// canvas `h` tall: `y` → `h - y`, and a rect's `y0`/`y1` swapped as
450    /// they flip so `y0` stays the low edge.
451    fn take(&mut self, h: f64) -> Vec<(String, Vec<Value>)> {
452        let flip = |v: Vec<Value>| -> Vec<Value> {
453            v.into_iter()
454                .map(|x| match x {
455                    Value::Float(y) => Value::Float(h - y),
456                    other => other,
457                })
458                .collect()
459        };
460        std::mem::take(&mut self.0)
461            .into_iter()
462            .map(|(n, v)| match n.as_str() {
463                "y" => (n, flip(v)),
464                "y0" => ("y1".to_string(), flip(v)),
465                "y1" => ("y0".to_string(), flip(v)),
466                _ => (n, v),
467            })
468            .collect()
469    }
470}
471
472/// Frames keyed by a colour a geom only takes as a fixed parameter (a
473/// rect's stroke), in first-seen order: one layer per colour.
474fn by_colour(v: &mut Vec<(Rgb, Frame)>, c: Rgb) -> &mut Frame {
475    let i = match v.iter().position(|(k, _)| *k == c) {
476        Some(i) => i,
477        None => {
478            v.push((c, Frame::default()));
479            v.len() - 1
480        }
481    };
482    &mut v[i].1
483}
484
485fn hex(c: Rgb) -> String {
486    format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
487}
488
489/// The point a fraction `t` (0..1) of the way along a polyline.
490fn point_at(pts: &[(f64, f64)], t: f64) -> (f64, f64) {
491    let seg = |i: usize| {
492        let (a, b) = (pts[i], pts[i + 1]);
493        ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()
494    };
495    let total: f64 = (0..pts.len().saturating_sub(1)).map(seg).sum();
496    let mut left = total * t;
497    for i in 0..pts.len().saturating_sub(1) {
498        let l = seg(i);
499        if l >= left && l > 0.0 {
500            let t = left / l;
501            let (a, b) = (pts[i], pts[i + 1]);
502            return (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t);
503        }
504        left -= l;
505    }
506    pts.first().copied().unwrap_or((0.0, 0.0))
507}
508
509/// Where an edge's label goes: the midpoint of the edge, else the nearest
510/// point along it where the label's box (11 px lines drawn around `y - 4`)
511/// touches no node box — so a node doesn't cover it.
512fn edge_label_at(pts: &[(f64, f64)], label: &str, nodes: &[PlacedNode]) -> (f64, f64) {
513    let lines = label.split('\n').count();
514    let hw = edge_label_width(label) / 2.0 + 3.0;
515    let hh = lines as f64 * 6.6 + 3.0;
516    let clear = |(x, y): (f64, f64)| {
517        let cy = y - 4.0 - 0.36 * SMALL_PX;
518        nodes
519            .iter()
520            .all(|n| (x - n.cx).abs() >= hw + n.w / 2.0 || (cy - n.cy).abs() >= hh + n.h / 2.0)
521    };
522    let mid = point_at(pts, 0.5);
523    [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74, 0.18, 0.82]
524        .into_iter()
525        .map(|t| point_at(pts, t))
526        .find(|&p| clear(p))
527        .unwrap_or(mid)
528}
529
530/// Text is centred on its y (`dominant-baseline="middle"`); the layout
531/// places lines by their baseline. A cap-height-ish shift between the two.
532fn mid_of_baseline(baseline: f64, px: f64) -> f64 {
533    baseline - 0.36 * px
534}
535
536/// Draw a laid-out graph on a `layout.w` × `layout.h` px canvas: `lanes`
537/// names the layout's bands, `plates` group nodes, `nodes` and `edges` are
538/// in the layout's order.
539/// `theme` is the base (a void theme; the legend is turned off). Render the
540/// result with e.g. `render_svg_native_at(0.0, 0.0, w, h)` at the canvas
541/// size rounded up.
542pub fn draw(
543    layout: &GraphLayout,
544    lanes: &[String],
545    plates: &[Plate],
546    nodes: &[DrawNode],
547    edges: &[DrawEdge],
548    style: &GraphStyle,
549    mut theme: Theme,
550) -> GGPlot {
551    let s = style;
552    let class = |name: &str| format!("{}{name}", s.class_prefix);
553    let (w, h) = (layout.w.ceil().max(1.0), layout.h.ceil().max(1.0));
554    theme.legend_position = crate::theme::LegendPosition::None;
555    let mut plot = GGPlot::new(vec![
556        ("x".to_string(), vec![Value::Float(0.0), Value::Float(w)]),
557        ("y".to_string(), vec![Value::Float(0.0), Value::Float(h)]),
558    ])
559    .theme(theme)
560    // the canvas is the view (a zoom, so a mark reaching past it is clipped
561    // rather than dropped); `Frame::take` flips the layout's y-down pixels
562    .scale_x_continuous(ScaleContinuous::new().with_expand(0.0, 0.0))
563    .scale_y_continuous(ScaleContinuous::new().with_expand(0.0, 0.0))
564    .coord_cartesian_zoom(Some((0.0, w)), Some((0.0, h)))
565    .scale_color_identity()
566    .scale_fill_identity()
567    .scale_linewidth(
568        ScaleLinewidthContinuous::new()
569            .with_limits(0.0, 16.0)
570            .with_range(0.0, 16.0),
571    )
572    .scale_linetype_manual(vec![
573        ("solid", Linetype::Solid),
574        ("dashed", Linetype::Custom(vec![(6.0, 4.0)])),
575        // round caps on a zero-length dash: moving dots
576        ("dots", Linetype::Custom(vec![(0.0, 10.0)])),
577    ]);
578    let rect_aes = || Aes::new().xmin("x0").xmax("x1").ymin("y0").ymax("y1");
579    let inert = [("pointer-events", "none")];
580
581    // swimlanes (under everything): a tinted band per lane, its name on top
582    let (mut even, mut odd, mut names) = (Frame::default(), Frame::default(), Frame::default());
583    for &(l, x0, y0, x1, y1) in &layout.bands {
584        let name = lanes.get(l).cloned().unwrap_or_default();
585        let f = if l % 2 == 0 { &mut even } else { &mut odd };
586        f.num("x0", x0).num("x1", x1).num("y0", y0).num("y1", y1);
587        f.txt("lane", name.clone());
588        names
589            .num("x", x0 + 8.0)
590            .num("y", mid_of_baseline(y0 + 13.0, SMALL_PX));
591        names.txt("label", name);
592    }
593    for (mut f, alpha) in [(even, 0.35), (odd, 0.18)] {
594        if f.is_empty() {
595            continue;
596        }
597        plot = plot
598            .geom_rect_with(
599                GeomRect {
600                    fill: s.band,
601                    alpha,
602                    line_width: 0.0,
603                    ..Default::default()
604                }
605                .with_radius(6.0),
606            )
607            .layer_data(f.take(h))
608            .layer_aes(rect_aes().data_attr("lane", "lane"))
609            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("lane"))]);
610    }
611    if !names.is_empty() {
612        plot = plot
613            .geom_text_with(GeomText {
614                size: SMALL_PX,
615                color: s.muted,
616                hjust: 0.0,
617                fontface: FontFace::Bold,
618                ..Default::default()
619            })
620            .layer_data(names.take(h))
621            .layer_aes(Aes::new().x("x").y("y").label("label"))
622            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("lane-label"))]);
623    }
624
625    // plates (under the edges): a dashed box around each collection
626    let (mut pf, mut pl) = (Frame::default(), Frame::default());
627    for p in plates {
628        let members: Vec<&PlacedNode> = p
629            .nodes
630            .iter()
631            .filter_map(|&i| layout.nodes.get(i))
632            .collect();
633        if members.is_empty() {
634            continue;
635        }
636        let pad = 8.0;
637        let x0 = members
638            .iter()
639            .map(|n| n.cx - n.w / 2.0)
640            .fold(f64::INFINITY, f64::min)
641            - pad;
642        let x1 = members
643            .iter()
644            .map(|n| n.cx + n.w / 2.0)
645            .fold(f64::NEG_INFINITY, f64::max)
646            + pad;
647        let y0 = members
648            .iter()
649            .map(|n| n.cy - n.h / 2.0)
650            .fold(f64::INFINITY, f64::min)
651            - pad
652            - 14.0;
653        let y1 = members
654            .iter()
655            .map(|n| n.cy + n.h / 2.0)
656            .fold(f64::NEG_INFINITY, f64::max)
657            + pad;
658        for (x, y) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] {
659            pf.num("x", x).num("y", y).txt("lt", "dashed");
660            pf.txt("g", p.label.clone()).txt("plate", p.label.clone());
661        }
662        pl.num("x", x0 + 6.0)
663            .num("y", mid_of_baseline(y0 + 13.0, SMALL_PX));
664        pl.txt("label", p.label.clone());
665    }
666    if !pf.is_empty() {
667        plot = plot
668            .geom_path_with(GeomPath::default().color(s.plate).linewidth(1.0))
669            .layer_data(pf.take(h))
670            .layer_aes(
671                Aes::new()
672                    .x("x")
673                    .y("y")
674                    .group("g")
675                    .linetype("lt")
676                    .data_attr("plate", "plate"),
677            )
678            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("plate"))]);
679        plot = plot
680            .geom_text_with(GeomText {
681                size: SMALL_PX,
682                color: s.plate,
683                hjust: 0.0,
684                ..Default::default()
685            })
686            .layer_data(pl.take(h))
687            .layer_aes(Aes::new().x("x").y("y").label("label"))
688            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("plate-label"))]);
689    }
690
691    // edges (under the nodes): one path per edge, arrowed ones first
692    let (mut ef, mut uf, mut lf) = (Frame::default(), Frame::default(), Frame::default());
693    // a variable's name on its edge (Forney style), in the math font
694    let mut lmf = Frame::default();
695    for (k, e) in edges.iter().enumerate() {
696        let Some(route) = layout.edges.get(k) else {
697            continue;
698        };
699        let mut pts = route.clone();
700        if pts.len() < 2 {
701            continue;
702        }
703        // the arrow's tip is on the path's last point and its head is
704        // stroked as wide as the line (its mitred tip reaching about one
705        // line width further): end the path one width short of the box so
706        // the tip lands on its edge.
707        if !e.undirected {
708            let n = pts.len();
709            let ((fx, fy), (tx, ty)) = (pts[n - 2], pts[n - 1]);
710            let len = ((tx - fx).powi(2) + (ty - fy).powi(2)).sqrt().max(1e-6);
711            let pull = e.width.min(len / 2.0);
712            pts[n - 1] = (tx - (tx - fx) / len * pull, ty - (ty - fy) / len * pull);
713        }
714        let ef = if e.undirected { &mut uf } else { &mut ef };
715        let lt = match e.line {
716            EdgeLine::Solid => "solid",
717            EdgeLine::Dashed => "dashed",
718            EdgeLine::Dots => "dots",
719        };
720        for &(x, y) in &pts {
721            ef.num("x", x).num("y", y).txt("g", format!("e{k:04}"));
722            ef.txt("col", hex(e.colour))
723                .num("lw", e.width)
724                .txt("lt", lt);
725            ef.opt("cls", e.class.clone()).txt("tip", e.tooltip.clone());
726            ef.attrs(&e.attrs);
727        }
728        // a bus-routed edge's label is left to its tooltip: on the shared
729        // lane or at a shared entry it would only clutter
730        let routed = layout.routed.get(k).copied().unwrap_or(false);
731        if let Some(l) = e.label.as_ref().filter(|_| !routed) {
732            let (mx, my) = match e.label_at {
733                // near an end: at most LABEL_REACH px from it
734                Some(t) => {
735                    let t = t.clamp(0.0, 1.0);
736                    let len: f64 = route
737                        .windows(2)
738                        .map(|w| ((w[1].0 - w[0].0).powi(2) + (w[1].1 - w[0].1).powi(2)).sqrt())
739                        .sum();
740                    let reach = |f: f64| {
741                        if len > 0.0 {
742                            (len * f).min(LABEL_REACH) / len
743                        } else {
744                            f
745                        }
746                    };
747                    point_at(
748                        route,
749                        if t >= 0.5 {
750                            1.0 - reach(1.0 - t)
751                        } else {
752                            reach(t)
753                        },
754                    )
755                }
756                None => edge_label_at(route, l, &layout.nodes),
757            };
758            let f = if e.label_math && s.math_font.is_some() {
759                &mut lmf
760            } else {
761                &mut lf
762            };
763            f.num("x", mx).num("y", mid_of_baseline(my - 4.0, SMALL_PX));
764            f.txt("label", l.clone());
765            f.attrs(&e.label_attrs);
766        }
767    }
768    for (mut f, arrowed) in [(ef, true), (uf, false)] {
769        if f.is_empty() {
770            continue;
771        }
772        let aes = f.attr_aes(
773            Aes::new()
774                .x("x")
775                .y("y")
776                .group("g")
777                .color("col")
778                .linewidth("lw")
779                .linetype("lt")
780                .class("cls")
781                .tooltip("tip"),
782        );
783        let mut geom = GeomPath::default().lineend(LineEnd::Round);
784        if arrowed {
785            // a 7 px + width head, ~29° half-angle
786            geom = geom.arrow(
787                arrow()
788                    .closed()
789                    .angle(29.0)
790                    .length(ARROW + 1.0)
791                    .length_per_linewidth(1.2),
792            );
793        }
794        plot = plot
795            .geom_path_with(geom)
796            .layer_data(f.take(h))
797            .layer_aes(aes)
798            .layer_svg_attr("class", class("edge"));
799    }
800    if !lmf.is_empty() {
801        let aes = lmf.attr_aes(Aes::new().x("x").y("y").label("label"));
802        plot = plot
803            .geom_text_with(
804                GeomText {
805                    size: LABEL_PX + 1.0,
806                    color: s.heading,
807                    fontface: FontFace::Italic,
808                    fontfamily: s.math_font.clone().unwrap_or_default(),
809                    ..Default::default()
810                }
811                .bg_colour(s.card)
812                .bg_r(2.0),
813            )
814            .layer_data(lmf.take(h))
815            .layer_aes(aes)
816            .layer_svg_attrs(inert)
817            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("edge-label"))]);
818    }
819    if !lf.is_empty() {
820        // text directly on the line, a card-coloured halo keeping it legible
821        let aes = lf.attr_aes(Aes::new().x("x").y("y").label("label"));
822        plot = plot
823            .geom_text_with(
824                GeomText {
825                    size: SMALL_PX,
826                    color: s.muted,
827                    ..Default::default()
828                }
829                .bg_colour(s.card)
830                .bg_r(2.0),
831            )
832            .layer_data(lf.take(h))
833            .layer_aes(aes)
834            .layer_svg_attrs(inert)
835            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("edge-label"))]);
836    }
837
838    // nodes in reading order: the box (the hit target, outside any `data-of`
839    // group), then what it shows, grouped per node
840    let mut order: Vec<usize> = (0..nodes.len().min(layout.nodes.len())).collect();
841    order.sort_by_key(|&i| (layout.nodes[i].order, i));
842    let mut boxes: Vec<(Rgb, Frame)> = Vec::new();
843    let mut beliefs = Frame::default();
844    let mut priors = Frame::default();
845    // shaped nodes, a layer per (shape, corner radius, outline)
846    let mut shaped: Vec<((NodeShape, u64, Rgb), Frame)> = Vec::new();
847    let (mut inside, mut inverse, mut above, mut glyphs, mut texts, mut beside) = (
848        Frame::default(),
849        Frame::default(),
850        Frame::default(),
851        Frame::default(),
852        Frame::default(),
853        Frame::default(),
854    );
855    let mut badges: Vec<(Rgb, Frame)> = Vec::new();
856    let (mut accent, mut lines, mut values) =
857        (Frame::default(), Frame::default(), Frame::default());
858    let (mut dots, mut btext, mut spark, mut ends) = (
859        Frame::default(),
860        Frame::default(),
861        Frame::default(),
862        Frame::default(),
863    );
864    for v in order {
865        let nd = &nodes[v];
866        let pn = &layout.nodes[v];
867        let id = nd.id.clone();
868        let (x, y) = (pn.cx - pn.w / 2.0, pn.cy - pn.h / 2.0);
869        // the belief curve sits over the node (over a square's label)
870        let squared = matches!(nd.shape, NodeShape::Square | NodeShape::FilledSquare);
871        let base = y - if squared { 17.0 } else { 3.0 };
872        let x0 = pn.cx - BELIEF_W / 2.0;
873        let curve = |hs: &[f64]| -> Vec<(f64, f64)> {
874            let last = (hs.len() - 1) as f64;
875            hs.iter()
876                .enumerate()
877                .map(|(i, &h)| {
878                    (
879                        x0 + BELIEF_W * i as f64 / last,
880                        base - h.clamp(0.0, 1.0) * BELIEF_H,
881                    )
882                })
883                .collect()
884        };
885        // the prior: a faint outline behind the belief
886        if let Some((hs, col)) = nd.prior.as_ref().filter(|(h, _)| h.len() >= 3) {
887            for (px, py) in curve(hs) {
888                priors.num("x", px).num("y", py).txt("g", id.clone());
889                priors.txt("col", hex(*col)).txt("lt", "dashed");
890                priors.txt("of", id.clone());
891            }
892        }
893        if let Some((hs, col)) = nd.belief.as_ref().filter(|(h, _)| h.len() >= 3) {
894            let mut pts = curve(hs);
895            pts.push((x0 + BELIEF_W, base));
896            pts.push((x0, base));
897            for (px, py) in pts {
898                beliefs.num("x", px).num("y", py).txt("g", id.clone());
899                beliefs.txt("fill", hex(*col)).txt("of", id.clone());
900            }
901        }
902        if nd.shape != NodeShape::Box {
903            let r = match nd.shape {
904                NodeShape::Circle | NodeShape::FilledCircle => pn.w.min(pn.h) / 2.0,
905                NodeShape::Square | NodeShape::FilledSquare | NodeShape::Terminal => 1.5,
906                NodeShape::Glyph => 4.0,
907                _ => 0.0,
908            };
909            let (fill, stroke) = match nd.shape {
910                NodeShape::Circle => (s.variable_fill, nd.stroke),
911                NodeShape::FilledCircle | NodeShape::FilledSquare | NodeShape::Terminal => {
912                    (s.observed_fill, s.observed_fill)
913                }
914                NodeShape::Square => (s.card, nd.stroke),
915                NodeShape::Glyph => (s.glyph_fill.unwrap_or(s.card), nd.stroke),
916                _ => (s.card, s.card),
917            };
918            let key = (nd.shape, r.to_bits(), stroke);
919            let i = match shaped.iter().position(|(k, _)| *k == key) {
920                Some(i) => i,
921                None => {
922                    shaped.push((key, Frame::default()));
923                    shaped.len() - 1
924                }
925            };
926            let f = &mut shaped[i].1;
927            f.num("x0", x)
928                .num("x1", x + pn.w)
929                .num("y0", y)
930                .num("y1", y + pn.h);
931            f.txt("fill", hex(fill));
932            f.txt("tip", nd.tooltip.clone()).attrs(&nd.attrs);
933            let label = nd.lines.join(" ");
934            if label.is_empty() {
935                continue;
936            }
937            let target = match nd.shape {
938                NodeShape::Glyph => &mut glyphs,
939                NodeShape::FilledCircle => &mut inverse,
940                NodeShape::Square | NodeShape::FilledSquare => &mut above,
941                // a constant is a number: upright, never the math italic
942                NodeShape::Text => &mut texts,
943                NodeShape::Terminal => &mut beside,
944                _ => &mut inside,
945            };
946            let ly = match nd.shape {
947                NodeShape::Square | NodeShape::FilledSquare => y - 4.0 - 0.36 * SMALL_PX,
948                _ => pn.cy,
949            };
950            // a terminal's label starts just right of it
951            let lx = match nd.shape {
952                NodeShape::Terminal => x + pn.w + 5.0,
953                _ => pn.cx,
954            };
955            target.num("x", lx).num("y", ly);
956            target.txt("label", label).txt("of", id.clone());
957            continue;
958        }
959        let f = by_colour(&mut boxes, nd.stroke);
960        f.num("x0", x)
961            .num("x1", x + pn.w)
962            .num("y0", y)
963            .num("y1", y + pn.h);
964        f.txt("tip", nd.tooltip.clone()).attrs(&nd.attrs);
965
966        if let Some(a) = nd.accent {
967            accent.num("x0", x + 1.5).num("x1", x + 4.5);
968            accent
969                .num("y0", y + 6.0)
970                .num("y1", y + 6.0 + (pn.h - 12.0).max(4.0));
971            accent.txt("fill", hex(a)).txt("of", id.clone());
972        }
973        let mut base = y + BOX_PAD_Y + 12.0;
974        for line in &nd.lines {
975            lines
976                .num("x", pn.cx)
977                .num("y", mid_of_baseline(base, LABEL_PX));
978            lines.txt("label", line.clone()).txt("of", id.clone());
979            base += LINE_H;
980        }
981        if let Some(fv) = &nd.value {
982            values
983                .num("x", pn.cx)
984                .num("y", mid_of_baseline(base, VALUE_PX));
985            values.txt("label", fv.clone()).txt("of", id.clone());
986            base += LINE_H;
987        }
988        if let Some((b, dot)) = &nd.badge {
989            let bw = text_w(b, BADGE_PX, false) + 20.0;
990            let (bx, by) = (pn.cx - bw / 2.0, base - 9.0);
991            let f = by_colour(&mut badges, *dot);
992            f.num("x0", bx)
993                .num("x1", bx + bw)
994                .num("y0", by)
995                .num("y1", by + 16.0);
996            f.txt("of", id.clone());
997            dots.num("x", bx + 9.0).num("y", by + 8.0);
998            dots.txt("col", hex(*dot)).txt("of", id.clone());
999            btext.num("x", bx + 15.0).num("y", by + 8.0);
1000            btext.txt("label", b.clone()).txt("of", id.clone());
1001            base += 20.0;
1002        }
1003        // a mini trend along the bottom of the box
1004        if let Some((values, colour)) = nd.spark.as_ref().filter(|(v, _)| v.len() >= 2) {
1005            let (lo, hi) = values
1006                .iter()
1007                .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &v| {
1008                    (a.min(v), b.max(v))
1009                });
1010            let rng = if hi > lo { hi - lo } else { 1.0 };
1011            let (sx0, sw) = (x + BOX_PAD_X, pn.w - 2.0 * BOX_PAD_X);
1012            let (top, sh) = (base - 8.0, SPARK_H - 8.0);
1013            let last = values.len() - 1;
1014            let col = hex(*colour);
1015            for (i, &sv) in values.iter().enumerate() {
1016                let (px, py) = (
1017                    sx0 + sw * i as f64 / last as f64,
1018                    top + sh - (sv - lo) / rng * sh,
1019                );
1020                spark.num("x", px).num("y", py).txt("g", id.clone());
1021                spark.txt("col", col.clone()).txt("of", id.clone());
1022                if i == last {
1023                    ends.num("x", px).num("y", py);
1024                    ends.txt("col", col.clone()).txt("of", id.clone());
1025                }
1026            }
1027        }
1028    }
1029    for (stroke, mut f) in boxes {
1030        let aes = f.attr_aes(rect_aes().tooltip("tip"));
1031        plot = plot
1032            .geom_rect_with(
1033                GeomRect {
1034                    fill: s.card,
1035                    color: stroke,
1036                    alpha: 1.0,
1037                    line_width: 1.2,
1038                    ..Default::default()
1039                }
1040                .with_radius(8.0),
1041            )
1042            .layer_data(f.take(h))
1043            .layer_aes(aes)
1044            .layer_svg_attr("class", class("node"));
1045    }
1046    for ((shape, r, stroke), mut f) in shaped {
1047        let aes = f.attr_aes(rect_aes().fill("fill").tooltip("tip"));
1048        let cls = format!(
1049            "{} {}",
1050            class("node"),
1051            class(&format!("node-{}", shape.name()))
1052        );
1053        plot = plot
1054            .geom_rect_with(
1055                GeomRect {
1056                    color: stroke,
1057                    alpha: 1.0,
1058                    line_width: if shape == NodeShape::Text { 0.0 } else { 1.2 },
1059                    ..Default::default()
1060                }
1061                .with_radius(f64::from_bits(r)),
1062            )
1063            .layer_data(f.take(h))
1064            .layer_aes(aes)
1065            .layer_svg_attr("class", cls);
1066    }
1067    if !priors.is_empty() {
1068        plot = plot
1069            .geom_path_with(GeomPath {
1070                alpha: 0.7,
1071                ..GeomPath::default().linewidth(1.0)
1072            })
1073            .layer_data(priors.take(h))
1074            .layer_aes(
1075                Aes::new()
1076                    .x("x")
1077                    .y("y")
1078                    .group("g")
1079                    .color("col")
1080                    .linetype("lt")
1081                    .group_wrap("of", "of"),
1082            )
1083            .decorative()
1084            .layer_svg_attrs(inert)
1085            .layer_svg_attr("class", class("node-prior"));
1086    }
1087    if !beliefs.is_empty() {
1088        plot = plot
1089            .geom_polygon_with(GeomPolygon {
1090                alpha: 0.45,
1091                line_width: 0.0,
1092                ..Default::default()
1093            })
1094            .layer_data(beliefs.take(h))
1095            .layer_aes(
1096                Aes::new()
1097                    .x("x")
1098                    .y("y")
1099                    .group("g")
1100                    .fill("fill")
1101                    .group_wrap("of", "of"),
1102            )
1103            .decorative()
1104            .layer_svg_attrs(inert)
1105            .layer_svg_attr("class", class("node-belief"));
1106    }
1107    // everything below: dims / lights with its node (`data-of`), inert
1108    let of = |a: Aes| a.group_wrap("of", "of");
1109    if !accent.is_empty() {
1110        plot = plot
1111            .geom_rect_with(
1112                GeomRect {
1113                    alpha: 1.0,
1114                    line_width: 0.0,
1115                    ..Default::default()
1116                }
1117                .with_radius(1.5),
1118            )
1119            .layer_data(accent.take(h))
1120            .layer_aes(of(rect_aes().fill("fill")))
1121            .decorative()
1122            .layer_svg_attrs(inert);
1123    }
1124    for (mut f, size, colour, cls, math, hjust) in [
1125        (lines, LABEL_PX, s.heading, class("node-label"), false, 0.5),
1126        (values, VALUE_PX, s.ink, class("node-value"), false, 0.5),
1127        (inside, LABEL_PX, s.heading, class("node-label"), true, 0.5),
1128        (texts, LABEL_PX, s.heading, class("node-label"), false, 0.5),
1129        (inverse, LABEL_PX, s.card, class("node-label"), true, 0.5),
1130        (above, SMALL_PX, s.muted, class("node-label"), true, 0.5),
1131        (beside, LABEL_PX, s.heading, class("node-label"), true, 0.0),
1132        (
1133            glyphs,
1134            GLYPH_PX,
1135            s.glyph_ink.unwrap_or(s.heading),
1136            class("node-label"),
1137            true,
1138            0.5,
1139        ),
1140    ] {
1141        if f.is_empty() {
1142            continue;
1143        }
1144        // a factor graph's labels as mathematics: the math font, italic
1145        let (fontfamily, fontface, size) = match s.math_font.as_ref().filter(|_| math) {
1146            Some(family) => (family.clone(), FontFace::Italic, size + 2.0),
1147            None => (String::new(), FontFace::Bold, size),
1148        };
1149        plot = plot
1150            .geom_text_with(GeomText {
1151                size,
1152                color: colour,
1153                fontface,
1154                fontfamily,
1155                hjust,
1156                ..Default::default()
1157            })
1158            .layer_data(f.take(h))
1159            .layer_aes(of(Aes::new().x("x").y("y").label("label")))
1160            .layer_svg_attrs(inert)
1161            .layer_svg_attrs([("aria-hidden", "true"), ("class", cls.as_str())]);
1162    }
1163    for (dot, mut f) in badges {
1164        plot = plot
1165            .geom_rect_with(
1166                GeomRect {
1167                    fill: s.card,
1168                    color: dot,
1169                    alpha: 0.0,
1170                    line_width: 1.0,
1171                    ..Default::default()
1172                }
1173                .with_radius(8.0),
1174            )
1175            .layer_data(f.take(h))
1176            .layer_aes(of(rect_aes()))
1177            .decorative()
1178            .layer_svg_attrs(inert);
1179    }
1180    for (mut f, size) in [(dots, 3.0), (ends, 2.2)] {
1181        if f.is_empty() {
1182            continue;
1183        }
1184        plot = plot
1185            .geom_point_with(GeomPoint {
1186                size,
1187                ..Default::default()
1188            })
1189            .layer_data(f.take(h))
1190            .layer_aes(of(Aes::new().x("x").y("y").color("col")))
1191            .decorative()
1192            .layer_svg_attrs(inert);
1193    }
1194    if !btext.is_empty() {
1195        plot = plot
1196            .geom_text_with(GeomText {
1197                size: BADGE_PX,
1198                color: s.ink,
1199                hjust: 0.0,
1200                ..Default::default()
1201            })
1202            .layer_data(btext.take(h))
1203            .layer_aes(of(Aes::new().x("x").y("y").label("label")))
1204            .layer_svg_attrs(inert)
1205            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("node-badge"))]);
1206    }
1207    if !spark.is_empty() {
1208        plot = plot
1209            .geom_path_with(GeomPath::default().linewidth(1.4))
1210            .layer_data(spark.take(h))
1211            .layer_aes(of(Aes::new().x("x").y("y").group("g").color("col")))
1212            .decorative()
1213            .layer_svg_attrs(inert)
1214            .layer_svg_attr("class", class("node-spark"));
1215    }
1216    plot
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222    use crate::graph_layout::{layered, GraphEdge, GraphNode, LayeredParams};
1223
1224    #[test]
1225    fn wraps_long_labels_in_three_lines() {
1226        let l = wrap(
1227            "Recommend replenishment orders for every store and every week of the coming year",
1228            152.0,
1229        );
1230        assert_eq!(l.len(), 3);
1231        assert!(l[2].ends_with('…'));
1232        assert_eq!(wrap("Ingest", 152.0), ["Ingest"]);
1233    }
1234
1235    /// No ellipsis while the label fits; breaks at word boundaries and
1236    /// after `/` / `-`, never mid-word when there is a separator.
1237    #[test]
1238    fn wraps_at_word_boundaries() {
1239        let max = BOX_MAX_W - 2.0 * BOX_PAD_X;
1240        for label in [
1241            "duckdb/community-extensions (descriptions)",
1242            "Locked DuckDB server (anofox-visualization)",
1243            "Parquet snapshot (downloads, extensions)",
1244            "fetch.sh: download, file by ISO week, merge",
1245        ] {
1246            let l = wrap(label, max);
1247            assert!(l.len() <= MAX_LINES, "{l:?}");
1248            assert!(!l.iter().any(|x| x.contains('…')), "{label}: {l:?}");
1249            // every line ends at a space or after a separator
1250            let joined: String = l.concat();
1251            assert_eq!(joined.replace(' ', ""), label.replace(' ', ""), "{l:?}");
1252            for w in l.windows(2) {
1253                let end = w[0].chars().last().unwrap();
1254                let next = w[1].chars().next().unwrap();
1255                let at_space = label.contains(&format!(
1256                    "{} {}",
1257                    w[0].split(' ').next_back().unwrap(),
1258                    w[1].split(' ').next().unwrap()
1259                ));
1260                assert!(
1261                    at_space || matches!(end, '/' | '-' | '_' | '.' | ','),
1262                    "{l:?} breaks at {end}{next}"
1263                );
1264            }
1265        }
1266        assert_eq!(
1267            wrap("duckdb/community-extensions (descriptions)", max),
1268            ["duckdb/community-", "extensions", "(descriptions)"]
1269        );
1270    }
1271
1272    #[test]
1273    fn node_box_grows_with_its_contents() {
1274        let plain = node_box("Ingest", None, None, false);
1275        assert_eq!((plain.w, plain.h), (BOX_MIN_W, 2.0 * BOX_PAD_Y + LINE_H));
1276        let full = node_box("Ingest", Some("1,240"), Some("late"), true);
1277        assert_eq!(full.h, plain.h + LINE_H + 20.0 + SPARK_H);
1278        assert!(full.w >= 110.0 + 2.0 * BOX_PAD_X);
1279        let long = node_box(&"word ".repeat(40), None, None, false);
1280        assert!(long.w <= BOX_MAX_W && long.lines.len() == MAX_LINES);
1281    }
1282
1283    /// An edge label a node box would cover moves along the edge.
1284    #[test]
1285    fn edge_label_keeps_clear_of_nodes() {
1286        let node = |cx: f64, w: f64| PlacedNode {
1287            cx,
1288            cy: 0.0,
1289            w,
1290            h: 40.0,
1291            layer: 0,
1292            order: 0,
1293        };
1294        let pts = [(0.0, 0.0), (300.0, 0.0)];
1295        // nothing in the way: the midpoint
1296        assert_eq!(edge_label_at(&pts, "spikes flagged", &[]), (150.0, 0.0));
1297        // a box over the middle: somewhere else along the edge, clear of it
1298        let nodes = [node(150.0, 60.0)];
1299        let (x, _) = edge_label_at(&pts, "spikes flagged", &nodes);
1300        let hw = edge_label_width("spikes flagged") / 2.0 + 3.0;
1301        assert!((x - 150.0).abs() >= hw + 30.0, "{x}");
1302        assert_eq!(point_at(&pts, 0.25), (75.0, 0.0));
1303    }
1304
1305    #[test]
1306    fn flow_width_is_area_true_and_rounded() {
1307        assert_eq!(flow_width(None, 10.0, EdgeLine::Solid), 1.6);
1308        assert_eq!(flow_width(Some(10.0), 10.0, EdgeLine::Solid), 7.0);
1309        assert_eq!(flow_width(Some(2.5), 10.0, EdgeLine::Solid), 4.2);
1310        assert_eq!(flow_width(None, 10.0, EdgeLine::Dots), 3.0);
1311    }
1312
1313    /// Two lanes, three boxes, a labelled edge: the marks carry the
1314    /// caller's attributes and classes, a node's contents sit in its
1315    /// `data-of` group, and the box itself (the hit target) stays outside.
1316    #[test]
1317    fn draws_marks_with_attributes_and_groups() {
1318        let labels = ["Ingest", "Model", "Publish"];
1319        let boxes: Vec<NodeBox> = labels
1320            .iter()
1321            .enumerate()
1322            .map(|(i, l)| node_box(l, (i == 1).then_some("42"), None, i == 2))
1323            .collect();
1324        let gnodes: Vec<GraphNode> = boxes
1325            .iter()
1326            .enumerate()
1327            .map(|(i, b)| GraphNode {
1328                w: b.w,
1329                h: b.h,
1330                lane: Some(i % 2),
1331                ..Default::default()
1332            })
1333            .collect();
1334        let gedges = [
1335            GraphEdge {
1336                from: 0,
1337                to: 1,
1338                label_w: Some(edge_label_width("rows")),
1339            },
1340            GraphEdge {
1341                from: 1,
1342                to: 2,
1343                label_w: None,
1344            },
1345        ];
1346        let params = LayeredParams {
1347            lanes: 2,
1348            ..Default::default()
1349        };
1350        let l = layered(&gnodes, &gedges, &params);
1351        let nodes: Vec<DrawNode> = labels
1352            .iter()
1353            .zip(&boxes)
1354            .enumerate()
1355            .map(|(i, (id, b))| DrawNode {
1356                id: id.to_string(),
1357                lines: b.lines.clone(),
1358                value: (i == 1).then(|| "42".to_string()),
1359                stroke: (90, 90, 90),
1360                accent: (i == 0).then_some((200, 0, 0)),
1361                spark: (i == 2).then(|| (vec![1.0, 3.0, 2.0], (0, 0, 200))),
1362                tooltip: format!("{id}!"),
1363                attrs: vec![
1364                    ("node".to_string(), Some(id.to_string())),
1365                    ("value".to_string(), (i == 1).then(|| "42".to_string())),
1366                ],
1367                ..Default::default()
1368            })
1369            .collect();
1370        let edges: Vec<DrawEdge> = [(0, 1), (1, 2)]
1371            .iter()
1372            .map(|&(a, b)| DrawEdge {
1373                colour: (100, 100, 100),
1374                width: flow_width(None, 0.0, EdgeLine::Solid),
1375                line: if a == 0 {
1376                    EdgeLine::Solid
1377                } else {
1378                    EdgeLine::Dashed
1379                },
1380                class: (a == 1).then(|| "flowing".to_string()),
1381                tooltip: format!("{} → {}", labels[a], labels[b]),
1382                label: (a == 0).then(|| "rows".to_string()),
1383                attrs: vec![("edge".to_string(), Some(format!("{a}>{b}")))],
1384                label_attrs: vec![("from".to_string(), Some(labels[a].to_string()))],
1385                ..Default::default()
1386            })
1387            .collect();
1388        let lanes = ["Left".to_string(), "Right".to_string()];
1389        let svg = draw(
1390            &l,
1391            &lanes,
1392            &[],
1393            &nodes,
1394            &edges,
1395            &GraphStyle::default(),
1396            crate::theme::presets::theme_void(),
1397        )
1398        .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1399        .unwrap();
1400        // start tags, each with whether it sits inside a `data-of` group
1401        let mut tags: Vec<(&str, bool)> = Vec::new();
1402        let mut stack: Vec<bool> = Vec::new();
1403        for part in svg.split('<').skip(1) {
1404            let tag = &part[..part.find('>').unwrap()];
1405            if tag.starts_with('/') {
1406                stack.pop();
1407                continue;
1408            }
1409            let inside = stack.iter().any(|&g| g);
1410            tags.push((tag, inside));
1411            if !tag.ends_with('/') {
1412                stack.push(tag.contains("data-of="));
1413            }
1414        }
1415        let attr = |tag: &str, name: &str| {
1416            let key = format!(" {name}=\"");
1417            tag.find(&key).map(|i| {
1418                let rest = &tag[i + key.len()..];
1419                rest[..rest.find('"').unwrap()].to_string()
1420            })
1421        };
1422        // boxes: one per node, outside the groups, an NA attribute left off
1423        let boxes: Vec<_> = tags
1424            .iter()
1425            .filter(|(t, _)| t.contains("data-node="))
1426            .collect();
1427        assert_eq!(boxes.len(), 3);
1428        for (b, inside) in &boxes {
1429            assert!(!inside, "{b}");
1430            let id = attr(b, "data-node").unwrap();
1431            assert_eq!(attr(b, "data-value").is_some(), id == "Model", "{id}");
1432        }
1433        // edges: the caller's class on a path; the label's attributes
1434        assert!(tags.iter().any(|(t, _)| t.contains("data-edge=\"1&gt;2\"")
1435            && attr(t, "class").is_some_and(|c| c.contains("flowing"))));
1436        assert!(tags
1437            .iter()
1438            .any(|(t, _)| attr(t, "data-from").as_deref() == Some("Ingest")));
1439        assert!(svg.contains(" gg-graph-node\"") && svg.contains("gg-graph-edge-label"));
1440        // node contents grouped by node; lanes drawn with their names
1441        for id in labels {
1442            assert!(svg.contains(&format!("data-of=\"{id}\"")), "{id}");
1443        }
1444        assert!(svg.contains("data-lane=\"Right\""));
1445        assert!(svg.contains(">Left<") && svg.contains(">42<"));
1446    }
1447
1448    /// Forney style (#1172): an observation as a terminal square, its label
1449    /// beside it; a variable as a labelled edge, its name in the math font.
1450    #[test]
1451    fn forney_terminals_and_variable_edges() {
1452        use crate::graph_layout::{layered, GraphEdge, GraphNode, LayeredParams};
1453        let spec = [
1454            ("Cat", NodeShape::Glyph),
1455            ("T", NodeShape::Glyph),
1456            ("x₁", NodeShape::Terminal),
1457        ];
1458        let sizes: Vec<NodeBox> = spec.iter().map(|(l, sh)| node_size(*sh, l)).collect();
1459        assert_eq!((sizes[2].w, sizes[2].h), (TERMINAL, TERMINAL));
1460        let gnodes: Vec<GraphNode> = sizes
1461            .iter()
1462            .map(|b| GraphNode {
1463                w: b.w,
1464                h: b.h,
1465                ..Default::default()
1466            })
1467            .collect();
1468        let gedges = vec![
1469            GraphEdge {
1470                from: 0,
1471                to: 1,
1472                label_w: None,
1473            },
1474            GraphEdge {
1475                from: 1,
1476                to: 2,
1477                label_w: None,
1478            },
1479        ];
1480        let l = layered(&gnodes, &gedges, &LayeredParams::default());
1481        let nodes: Vec<DrawNode> = spec
1482            .iter()
1483            .zip(&sizes)
1484            .enumerate()
1485            .map(|(i, ((_, shape), b))| DrawNode {
1486                id: format!("n{i}"),
1487                shape: *shape,
1488                lines: b.lines.clone(),
1489                stroke: (13, 148, 136),
1490                tooltip: b.lines.join(" "),
1491                attrs: vec![("node".to_string(), Some(format!("n{i}")))],
1492                ..Default::default()
1493            })
1494            .collect();
1495        let edges = vec![
1496            // the variable s₀ between the two factors
1497            DrawEdge {
1498                colour: (13, 148, 136),
1499                width: 1.2,
1500                undirected: true,
1501                label: Some("s₀".to_string()),
1502                label_math: true,
1503                ..Default::default()
1504            },
1505            DrawEdge {
1506                colour: (13, 148, 136),
1507                width: 1.2,
1508                undirected: true,
1509                ..Default::default()
1510            },
1511        ];
1512        let render = |style: &GraphStyle| {
1513            draw(
1514                &l,
1515                &[],
1516                &[],
1517                &nodes,
1518                &edges,
1519                style,
1520                crate::theme::presets::theme_void(),
1521            )
1522            .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32 + 40, l.h.ceil() as u32)
1523            .unwrap()
1524        };
1525        let math = GraphStyle {
1526            math_font: Some("STIX Two Math, serif".to_string()),
1527            ..GraphStyle::default()
1528        };
1529        let svg = render(&math);
1530        let el = |text: &str| {
1531            let at = svg
1532                .find(&format!(">{text}</text>"))
1533                .unwrap_or_else(|| panic!("{text}"));
1534            svg[svg[..at].rfind('<').unwrap()..at].to_string()
1535        };
1536        // the terminal: a filled square, its label starting beside it
1537        assert!(svg.contains("gg-graph-node-terminal"), "terminal mark");
1538        let x1 = el("x₁");
1539        assert!(
1540            x1.contains("text-anchor=\"start\"") && x1.contains("italic"),
1541            "{x1}"
1542        );
1543        let t = &l.nodes[2];
1544        let lx: f64 = x1
1545            .split("x=\"")
1546            .nth(1)
1547            .unwrap()
1548            .split('"')
1549            .next()
1550            .unwrap()
1551            .parse()
1552            .unwrap();
1553        assert!(
1554            lx > t.cx + t.w / 2.0,
1555            "the label right of the square: {lx} vs {}",
1556            t.cx
1557        );
1558        // the variable's edge label in the math font
1559        let s0 = el("s₀");
1560        assert!(
1561            s0.contains("STIX Two Math") && s0.contains("italic"),
1562            "{s0}"
1563        );
1564        // without a math font: a plain edge label
1565        let plain = render(&GraphStyle::default());
1566        let at = plain.find(">s₀</text>").unwrap();
1567        assert!(!plain[plain[..at].rfind('<').unwrap()..at].contains("italic"));
1568    }
1569
1570    /// A coin toss as a Forney-style factor graph: a Beta prior box, the
1571    /// parameter's circle, an equality node, Bernoulli boxes and observed
1572    /// circles in a plate; undirected edges with interface names near the
1573    /// factor.
1574    #[test]
1575    fn draws_a_factor_graph_with_shapes_and_a_plate() {
1576        let n = 3;
1577        // (label, shape)
1578        let mut spec = vec![
1579            ("Beta", NodeShape::Box),
1580            ("θ", NodeShape::Circle),
1581            ("=", NodeShape::Square),
1582        ];
1583        for _ in 0..n {
1584            spec.push(("Ber", NodeShape::Box));
1585        }
1586        for _ in 0..n {
1587            spec.push(("y", NodeShape::FilledCircle));
1588        }
1589        spec.push(("2.0", NodeShape::Text));
1590        let sizes: Vec<NodeBox> = spec.iter().map(|(l, sh)| node_size(*sh, l)).collect();
1591        // (from, to, interface)
1592        let mut links = vec![(0, 1, "out"), (1, 2, ""), (9, 0, "a")];
1593        for i in 0..n {
1594            links.push((2, 3 + i, "p"));
1595            links.push((3 + i, 3 + n + i, "out"));
1596        }
1597        let gnodes: Vec<GraphNode> = sizes
1598            .iter()
1599            .map(|b| GraphNode {
1600                w: b.w,
1601                h: b.h,
1602                ..Default::default()
1603            })
1604            .collect();
1605        let gedges: Vec<GraphEdge> = links
1606            .iter()
1607            .map(|&(a, b, _)| GraphEdge {
1608                from: a,
1609                to: b,
1610                label_w: None,
1611            })
1612            .collect();
1613        // a plate reaches past its nodes: room for it in the margin
1614        let params = LayeredParams {
1615            margin: 32.0,
1616            ..Default::default()
1617        };
1618        let l = layered(&gnodes, &gedges, &params);
1619        let nodes: Vec<DrawNode> = spec
1620            .iter()
1621            .zip(&sizes)
1622            .enumerate()
1623            .map(|(i, ((_, shape), b))| DrawNode {
1624                id: format!("n{i}"),
1625                shape: *shape,
1626                lines: b.lines.clone(),
1627                stroke: (60, 60, 60),
1628                tooltip: b.lines.join(" "),
1629                attrs: vec![("node".to_string(), Some(format!("n{i}")))],
1630                // θ's belief curve
1631                belief: (i == 1).then(|| (vec![0.0, 0.3, 1.0, 0.3, 0.0], (120, 80, 200))),
1632                // and its prior, wider
1633                prior: (i == 1).then(|| (vec![0.2, 0.4, 0.5, 0.4, 0.2], (150, 150, 150))),
1634                ..Default::default()
1635            })
1636            .collect();
1637        let edges: Vec<DrawEdge> = links
1638            .iter()
1639            .map(|&(a, b, iface)| DrawEdge {
1640                colour: (90, 90, 90),
1641                width: 1.2,
1642                undirected: true,
1643                label: (!iface.is_empty()).then(|| iface.to_string()),
1644                // the interface name near the factor
1645                label_at: Some(if spec[b].1 == NodeShape::Box {
1646                    0.8
1647                } else {
1648                    0.2
1649                }),
1650                attrs: vec![("edge".to_string(), Some(format!("{a}>{b}")))],
1651                ..Default::default()
1652            })
1653            .collect();
1654        let plates = [Plate {
1655            label: "i = 1..3".to_string(),
1656            nodes: (3..3 + 2 * n).collect(),
1657        }];
1658        let style = GraphStyle::default();
1659        let svg = draw(
1660            &l,
1661            &[],
1662            &plates,
1663            &nodes,
1664            &edges,
1665            &style,
1666            crate::theme::presets::theme_void(),
1667        )
1668        .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1669        .unwrap();
1670        // every node is a mark, shapes classed, undirected: no arrowheads
1671        assert_eq!(svg.matches("data-node=").count(), spec.len());
1672        for shape in ["circle", "filled-circle", "square", "text"] {
1673            assert!(svg.contains(&format!("gg-graph-node-{shape}")), "{shape}");
1674        }
1675        assert!(
1676            !svg.contains("data-part=\"arrow\""),
1677            "an undirected edge has no arrowhead"
1678        );
1679        assert_eq!(svg.matches("data-edge=").count(), links.len());
1680        // θ's belief curve: a filled shape over its circle, in its group
1681        assert!(svg.contains("gg-graph-node-belief"), "belief layer");
1682        assert!(svg.contains("fill=\"#7850C8\""), "belief colour");
1683        // its prior: a dashed outline, drawn before (under) the belief
1684        let (pr, be) = (
1685            svg.find("gg-graph-node-prior").expect("prior layer"),
1686            svg.find("gg-graph-node-belief").unwrap(),
1687        );
1688        assert!(pr < be, "the prior under the belief");
1689        let mark = &svg[svg[..pr].rfind('<').unwrap()..pr];
1690        assert!(mark.contains("stroke-dasharray"), "dashed prior: {mark}");
1691        // no math font: bold sans labels
1692        assert!(!svg.contains("font-style=\"italic\""), "plain labels");
1693        // a math font: the shaped nodes' labels in it, italic; glyph colours
1694        let math = GraphStyle {
1695            math_font: Some("STIX Two Math, serif".to_string()),
1696            glyph_fill: Some((240, 247, 246)),
1697            glyph_ink: Some((13, 148, 136)),
1698            ..style.clone()
1699        };
1700        let msvg = draw(
1701            &l,
1702            &[],
1703            &plates,
1704            &nodes,
1705            &edges,
1706            &math,
1707            crate::theme::presets::theme_void(),
1708        )
1709        .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1710        .unwrap();
1711        assert!(msvg.contains("STIX Two Math"), "math font");
1712        assert!(msvg.contains("font-style=\"italic\""), "italic labels");
1713        // …but a constant stays upright
1714        let two = msvg.find(">2.0<").expect("the constant 2.0");
1715        let el = &msvg[msvg[..two].rfind('<').unwrap()..two];
1716        assert!(!el.contains("italic"), "upright constant: {el}");
1717        // the plate, and its label
1718        assert!(svg.contains("data-plate=\"i = 1..3\"") && svg.contains(">i = 1..3<"));
1719        // the observed circles' dark fill
1720        assert!(svg.contains(&format!(
1721            "fill=\"{}\"",
1722            hex(style.observed_fill).to_uppercase()
1723        )));
1724        // no two nodes overlap
1725        for (i, a) in l.nodes.iter().enumerate() {
1726            for b in &l.nodes[i + 1..] {
1727                let apart = (a.cx - b.cx).abs() >= (a.w + b.w) / 2.0
1728                    || (a.cy - b.cy).abs() >= (a.h + b.h) / 2.0;
1729                assert!(apart, "overlap");
1730            }
1731        }
1732        // the circle is round: rx is half its width
1733        let c = &l.nodes[1];
1734        assert!(svg.contains(&format!("rx=\"{:.2}\"", c.w / 2.0)), "{}", c.w);
1735    }
1736
1737    /// An HMM over five slices with learned transition (A) and observation
1738    /// (B) matrices, in bands param / state / obs: the state chain is one
1739    /// straight row, each observation below its state.
1740    #[test]
1741    fn banded_hmm_keeps_the_state_chain_straight() {
1742        use crate::graph_layout::{banded, BandedParams};
1743        let t_n = 5;
1744        // (label, shape, band)
1745        let mut spec: Vec<(String, NodeShape, usize)> = vec![
1746            ("Dir".into(), NodeShape::Glyph, 0), // 0: prior on A
1747            ("A".into(), NodeShape::Square, 0),  // 1: = of A
1748            ("Dir".into(), NodeShape::Glyph, 0), // 2: prior on B
1749            ("B".into(), NodeShape::Square, 0),  // 3: = of B
1750            ("Cat".into(), NodeShape::Glyph, 1), // 4: initial state prior
1751            ("s₀".into(), NodeShape::Circle, 1), // 5
1752        ];
1753        let mut links: Vec<(usize, usize, &str)> =
1754            vec![(0, 1, "out"), (2, 3, "out"), (4, 5, "out")];
1755        let mut prev = 5;
1756        for t in 1..=t_n {
1757            let tr = spec.len();
1758            spec.push(("T".into(), NodeShape::Glyph, 1));
1759            let eq = spec.len();
1760            spec.push((format!("s{t}"), NodeShape::Square, 1));
1761            let ob = spec.len();
1762            spec.push(("T".into(), NodeShape::Glyph, 2));
1763            let x = spec.len();
1764            spec.push((format!("x{t}"), NodeShape::FilledCircle, 2));
1765            links.extend([
1766                (prev, tr, "in"),
1767                (1, tr, "a"),
1768                (tr, eq, "out"),
1769                (eq, ob, "in"),
1770                (3, ob, "a"),
1771                (ob, x, "out"),
1772            ]);
1773            prev = eq;
1774        }
1775        let sizes: Vec<NodeBox> = spec.iter().map(|(l, sh, _)| node_size(*sh, l)).collect();
1776        let gnodes: Vec<GraphNode> = spec
1777            .iter()
1778            .zip(&sizes)
1779            .map(|((_, _, b), s)| GraphNode {
1780                w: s.w,
1781                h: s.h,
1782                lane: Some(*b),
1783                ..Default::default()
1784            })
1785            .collect();
1786        let gedges: Vec<GraphEdge> = links
1787            .iter()
1788            .map(|&(a, b, _)| GraphEdge {
1789                from: a,
1790                to: b,
1791                label_w: None,
1792            })
1793            .collect();
1794        let l = banded(
1795            &gnodes,
1796            &gedges,
1797            &BandedParams {
1798                bands: 3,
1799                margin: 32.0,
1800                ..Default::default()
1801            },
1802        );
1803        // the chain: every transition and state node on one row
1804        let chain: Vec<usize> = (0..spec.len())
1805            .filter(|&i| spec[i].2 == 1 && i != 4)
1806            .collect();
1807        let y = l.nodes[chain[0]].cy;
1808        for &i in &chain {
1809            assert_eq!(l.nodes[i].cy, y, "{} off the row", spec[i].0);
1810        }
1811        // each observation factor straight below its state's = node
1812        for t in 0..t_n {
1813            let (eq, ob) = (6 + 4 * t + 1, 6 + 4 * t + 2);
1814            assert_eq!(l.nodes[eq].cx, l.nodes[ob].cx, "slice {t}");
1815            assert!(l.nodes[ob].cy > y);
1816        }
1817        // no edge passes through a box other than its own two ends
1818        let crosses = |(x0, y0): (f64, f64), (x1, y1): (f64, f64), n: &PlacedNode| {
1819            // sampled along the segment, the box shrunk by a pixel
1820            (0..=40).any(|k| {
1821                let t = k as f64 / 40.0;
1822                let (x, y) = (x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
1823                (x - n.cx).abs() < n.w / 2.0 - 1.0 && (y - n.cy).abs() < n.h / 2.0 - 1.0
1824            })
1825        };
1826        for (k, &(a, b, _)) in links.iter().enumerate() {
1827            for w in l.edges[k].windows(2) {
1828                for (i, n) in l.nodes.iter().enumerate() {
1829                    if i != a && i != b {
1830                        assert!(
1831                            !crosses(w[0], w[1], n),
1832                            "edge {a}->{b} crosses {}",
1833                            spec[i].0
1834                        );
1835                    }
1836                }
1837            }
1838        }
1839        // nothing overlaps
1840        for (i, a) in l.nodes.iter().enumerate() {
1841            for b in &l.nodes[i + 1..] {
1842                assert!(
1843                    (a.cx - b.cx).abs() >= (a.w + b.w) / 2.0
1844                        || (a.cy - b.cy).abs() >= (a.h + b.h) / 2.0
1845                );
1846            }
1847        }
1848        let nodes: Vec<DrawNode> = spec
1849            .iter()
1850            .zip(&sizes)
1851            .enumerate()
1852            .map(|(i, ((_, shape, _), b))| DrawNode {
1853                id: format!("n{i}"),
1854                shape: *shape,
1855                lines: b.lines.clone(),
1856                stroke: (60, 60, 60),
1857                ..Default::default()
1858            })
1859            .collect();
1860        let edges: Vec<DrawEdge> = links
1861            .iter()
1862            .map(|&(_, b, iface)| DrawEdge {
1863                colour: (90, 90, 90),
1864                width: 1.2,
1865                undirected: true,
1866                label: Some(iface.to_string()),
1867                label_at: Some(if spec[b].1 == NodeShape::Glyph {
1868                    0.8
1869                } else {
1870                    0.2
1871                }),
1872                ..Default::default()
1873            })
1874            .collect();
1875        let lanes = ["param".to_string(), "state".to_string(), "obs".to_string()];
1876        let svg = draw(
1877            &l,
1878            &lanes,
1879            &[],
1880            &nodes,
1881            &edges,
1882            &GraphStyle::default(),
1883            crate::theme::presets::theme_void(),
1884        )
1885        .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1886        .unwrap();
1887        if let Ok(path) = std::env::var("GGPLOT_GRAPH_SVG") {
1888            std::fs::write(path, &svg).unwrap();
1889        }
1890        assert_eq!(svg.matches("data-lane=").count(), 3);
1891    }
1892}