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}
178
179impl Default for GraphStyle {
180    fn default() -> Self {
181        GraphStyle {
182            card: (255, 255, 255),
183            band: (226, 228, 233),
184            muted: (107, 114, 128),
185            heading: (17, 24, 39),
186            ink: (31, 41, 55),
187            variable_fill: (229, 231, 235),
188            observed_fill: (75, 85, 99),
189            plate: (107, 114, 128),
190            class_prefix: "gg-graph-".to_string(),
191        }
192    }
193}
194
195/// A node's mark. A factor graph (Forney style) draws factors as boxes,
196/// variables as circles (filled when observed), equality nodes as small
197/// squares, deterministic (delta) nodes as small filled squares, and
198/// constants as plain text.
199#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
200pub enum NodeShape {
201    /// A box with a label, value, badge and sparkline ([`node_box`]).
202    #[default]
203    Box,
204    Circle,
205    FilledCircle,
206    /// A small square, its label above it.
207    Square,
208    /// A small filled square, its label above it.
209    FilledSquare,
210    /// The label alone.
211    Text,
212}
213
214impl NodeShape {
215    fn name(self) -> &'static str {
216        match self {
217            NodeShape::Box => "box",
218            NodeShape::Circle => "circle",
219            NodeShape::FilledCircle => "filled-circle",
220            NodeShape::Square => "square",
221            NodeShape::FilledSquare => "filled-square",
222            NodeShape::Text => "text",
223        }
224    }
225}
226
227/// A small square's side (px).
228const SQUARE: f64 = 14.0;
229
230/// The size a node of `shape` needs for `label` (a [`NodeShape::Box`] is
231/// [`node_box`] without a value, badge or sparkline). A circle fits its
232/// label on one line, at least 30 px across.
233pub fn node_size(shape: NodeShape, label: &str) -> NodeBox {
234    let one = || vec![label.to_string()];
235    match shape {
236        NodeShape::Box => node_box(label, None, None, false),
237        NodeShape::Circle | NodeShape::FilledCircle => {
238            let d = (text_w(label, LABEL_PX, true) + 14.0).max(30.0);
239            NodeBox {
240                w: d,
241                h: d,
242                lines: one(),
243            }
244        }
245        NodeShape::Square | NodeShape::FilledSquare => NodeBox {
246            w: SQUARE,
247            h: SQUARE,
248            lines: if label.is_empty() { Vec::new() } else { one() },
249        },
250        NodeShape::Text => NodeBox {
251            w: text_w(label, LABEL_PX, false) + 8.0,
252            h: 20.0,
253            lines: one(),
254        },
255    }
256}
257
258/// A plate: a dashed box around a collection of nodes (indices in the
259/// layout's order), labelled at its top left (`x_t`, `t = 1..T`).
260#[derive(Clone, Debug, Default)]
261pub struct Plate {
262    pub label: String,
263    pub nodes: Vec<usize>,
264}
265
266/// A `data-*` attribute: its name (without `data-`) and value; `None` leaves
267/// it off that mark.
268pub type DataAttr = (String, Option<String>);
269
270/// What a node's box shows, and its attributes. Its geometry comes from the
271/// layout ([`PlacedNode`]); its label lines from [`node_box`].
272#[derive(Clone, Debug, Default)]
273pub struct DrawNode {
274    /// The `data-of` group of everything drawn for the node.
275    pub id: String,
276    /// Its mark; anything but a box shows only the label.
277    pub shape: NodeShape,
278    /// The label, wrapped ([`NodeBox::lines`]).
279    pub lines: Vec<String>,
280    /// A value line under the label.
281    pub value: Option<String>,
282    /// A badge row: its text and its dot's (and outline's) colour.
283    pub badge: Option<(String, Rgb)>,
284    /// The box (or circle, square) outline.
285    pub stroke: Rgb,
286    /// A thin bar along the box's left edge.
287    pub accent: Option<Rgb>,
288    /// A sparkline (two values or more) along the bottom, and its colour.
289    pub spark: Option<(Vec<f64>, Rgb)>,
290    pub tooltip: String,
291    /// `data-*` on the box (the hit target); every node lists the same
292    /// names, in the same order.
293    pub attrs: Vec<DataAttr>,
294}
295
296/// An edge's line pattern.
297#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
298pub enum EdgeLine {
299    #[default]
300    Solid,
301    Dashed,
302    /// Round dots (zero-length dashes with round caps).
303    Dots,
304}
305
306/// What an edge looks like, and its attributes. Its points come from the
307/// layout ([`GraphLayout::edges`]); the arrowhead is on its last point,
308/// unless `undirected`.
309#[derive(Clone, Debug, Default)]
310pub struct DrawEdge {
311    pub colour: Rgb,
312    /// Line width (px), see [`flow_width`].
313    pub width: f64,
314    pub line: EdgeLine,
315    /// Extra CSS classes on the path.
316    pub class: Option<String>,
317    pub tooltip: String,
318    /// A label (`\n` breaks lines) on the edge, clear of the boxes.
319    pub label: Option<String>,
320    /// Where along the edge the label goes (0 at its start, 1 at its end),
321    /// e.g. near a factor for an interface name; `None`: the middle, moved
322    /// along the edge until no box covers it.
323    pub label_at: Option<f64>,
324    /// No arrowhead (a factor graph's edges).
325    pub undirected: bool,
326    /// `data-*` on the path; every edge lists the same names, in the same
327    /// order.
328    pub attrs: Vec<DataAttr>,
329    /// `data-*` on the label.
330    pub label_attrs: Vec<DataAttr>,
331}
332
333/// An edge's width (px) for what it carries, `value` of the graph's `max`:
334/// area-true (`sqrt`), 1.4 px to 7 px; 1.6 px without a value; dotted edges
335/// 3–6 px so their dots show. Rounded to 0.1 px.
336pub fn flow_width(value: Option<f64>, max: f64, line: EdgeLine) -> f64 {
337    let width = match value {
338        Some(v) if max > 0.0 => 1.4 + 5.6 * (v.max(0.0) / max).sqrt(),
339        _ => 1.6,
340    };
341    let width = match line {
342        EdgeLine::Dots => width.clamp(3.0, 6.0),
343        _ => width,
344    };
345    (width * 10.0).round() / 10.0
346}
347
348/// Columns built row by row, in px with y down.
349#[derive(Default)]
350struct Frame(Vec<(String, Vec<Value>)>);
351
352impl Frame {
353    fn col(&mut self, name: &str) -> &mut Vec<Value> {
354        let i = match self.0.iter().position(|(n, _)| n == name) {
355            Some(i) => i,
356            None => {
357                self.0.push((name.to_string(), Vec::new()));
358                self.0.len() - 1
359            }
360        };
361        &mut self.0[i].1
362    }
363    fn num(&mut self, name: &str, v: f64) -> &mut Self {
364        self.col(name).push(Value::Float(v));
365        self
366    }
367    fn txt(&mut self, name: &str, v: impl Into<String>) -> &mut Self {
368        self.col(name).push(Value::Str(v.into()));
369        self
370    }
371    fn opt(&mut self, name: &str, v: Option<String>) -> &mut Self {
372        self.col(name).push(v.map_or(Value::Na, Value::Str));
373        self
374    }
375    /// `attrs` as columns `a:<name>`.
376    fn attrs(&mut self, attrs: &[DataAttr]) -> &mut Self {
377        for (n, v) in attrs {
378            self.opt(&format!("a:{n}"), v.clone());
379        }
380        self
381    }
382    fn is_empty(&self) -> bool {
383        self.0.first().is_none_or(|(_, v)| v.is_empty())
384    }
385    /// `aes` plus a `data_attr` per `a:<name>` column, in column order.
386    fn attr_aes(&self, mut aes: Aes) -> Aes {
387        for (n, _) in &self.0 {
388            if let Some(name) = n.strip_prefix("a:") {
389                aes = aes.data_attr(name, n);
390            }
391        }
392        aes
393    }
394    /// The frame's columns, its pixel y (down) flipped to data y (up) on a
395    /// canvas `h` tall: `y` → `h - y`, and a rect's `y0`/`y1` swapped as
396    /// they flip so `y0` stays the low edge.
397    fn take(&mut self, h: f64) -> Vec<(String, Vec<Value>)> {
398        let flip = |v: Vec<Value>| -> Vec<Value> {
399            v.into_iter()
400                .map(|x| match x {
401                    Value::Float(y) => Value::Float(h - y),
402                    other => other,
403                })
404                .collect()
405        };
406        std::mem::take(&mut self.0)
407            .into_iter()
408            .map(|(n, v)| match n.as_str() {
409                "y" => (n, flip(v)),
410                "y0" => ("y1".to_string(), flip(v)),
411                "y1" => ("y0".to_string(), flip(v)),
412                _ => (n, v),
413            })
414            .collect()
415    }
416}
417
418/// Frames keyed by a colour a geom only takes as a fixed parameter (a
419/// rect's stroke), in first-seen order: one layer per colour.
420fn by_colour(v: &mut Vec<(Rgb, Frame)>, c: Rgb) -> &mut Frame {
421    let i = match v.iter().position(|(k, _)| *k == c) {
422        Some(i) => i,
423        None => {
424            v.push((c, Frame::default()));
425            v.len() - 1
426        }
427    };
428    &mut v[i].1
429}
430
431fn hex(c: Rgb) -> String {
432    format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
433}
434
435/// The point a fraction `t` (0..1) of the way along a polyline.
436fn point_at(pts: &[(f64, f64)], t: f64) -> (f64, f64) {
437    let seg = |i: usize| {
438        let (a, b) = (pts[i], pts[i + 1]);
439        ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()
440    };
441    let total: f64 = (0..pts.len().saturating_sub(1)).map(seg).sum();
442    let mut left = total * t;
443    for i in 0..pts.len().saturating_sub(1) {
444        let l = seg(i);
445        if l >= left && l > 0.0 {
446            let t = left / l;
447            let (a, b) = (pts[i], pts[i + 1]);
448            return (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t);
449        }
450        left -= l;
451    }
452    pts.first().copied().unwrap_or((0.0, 0.0))
453}
454
455/// Where an edge's label goes: the midpoint of the edge, else the nearest
456/// point along it where the label's box (11 px lines drawn around `y - 4`)
457/// touches no node box — so a node doesn't cover it.
458fn edge_label_at(pts: &[(f64, f64)], label: &str, nodes: &[PlacedNode]) -> (f64, f64) {
459    let lines = label.split('\n').count();
460    let hw = edge_label_width(label) / 2.0 + 3.0;
461    let hh = lines as f64 * 6.6 + 3.0;
462    let clear = |(x, y): (f64, f64)| {
463        let cy = y - 4.0 - 0.36 * SMALL_PX;
464        nodes
465            .iter()
466            .all(|n| (x - n.cx).abs() >= hw + n.w / 2.0 || (cy - n.cy).abs() >= hh + n.h / 2.0)
467    };
468    let mid = point_at(pts, 0.5);
469    [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74, 0.18, 0.82]
470        .into_iter()
471        .map(|t| point_at(pts, t))
472        .find(|&p| clear(p))
473        .unwrap_or(mid)
474}
475
476/// Text is centred on its y (`dominant-baseline="middle"`); the layout
477/// places lines by their baseline. A cap-height-ish shift between the two.
478fn mid_of_baseline(baseline: f64, px: f64) -> f64 {
479    baseline - 0.36 * px
480}
481
482/// Draw a laid-out graph on a `layout.w` × `layout.h` px canvas: `lanes`
483/// names the layout's bands, `plates` group nodes, `nodes` and `edges` are
484/// in the layout's order.
485/// `theme` is the base (a void theme; the legend is turned off). Render the
486/// result with e.g. `render_svg_native_at(0.0, 0.0, w, h)` at the canvas
487/// size rounded up.
488pub fn draw(
489    layout: &GraphLayout,
490    lanes: &[String],
491    plates: &[Plate],
492    nodes: &[DrawNode],
493    edges: &[DrawEdge],
494    style: &GraphStyle,
495    mut theme: Theme,
496) -> GGPlot {
497    let s = style;
498    let class = |name: &str| format!("{}{name}", s.class_prefix);
499    let (w, h) = (layout.w.ceil().max(1.0), layout.h.ceil().max(1.0));
500    theme.legend_position = crate::theme::LegendPosition::None;
501    let mut plot = GGPlot::new(vec![
502        ("x".to_string(), vec![Value::Float(0.0), Value::Float(w)]),
503        ("y".to_string(), vec![Value::Float(0.0), Value::Float(h)]),
504    ])
505    .theme(theme)
506    // the canvas is the view (a zoom, so a mark reaching past it is clipped
507    // rather than dropped); `Frame::take` flips the layout's y-down pixels
508    .scale_x_continuous(ScaleContinuous::new().with_expand(0.0, 0.0))
509    .scale_y_continuous(ScaleContinuous::new().with_expand(0.0, 0.0))
510    .coord_cartesian_zoom(Some((0.0, w)), Some((0.0, h)))
511    .scale_color_identity()
512    .scale_fill_identity()
513    .scale_linewidth(
514        ScaleLinewidthContinuous::new()
515            .with_limits(0.0, 16.0)
516            .with_range(0.0, 16.0),
517    )
518    .scale_linetype_manual(vec![
519        ("solid", Linetype::Solid),
520        ("dashed", Linetype::Custom(vec![(6.0, 4.0)])),
521        // round caps on a zero-length dash: moving dots
522        ("dots", Linetype::Custom(vec![(0.0, 10.0)])),
523    ]);
524    let rect_aes = || Aes::new().xmin("x0").xmax("x1").ymin("y0").ymax("y1");
525    let inert = [("pointer-events", "none")];
526
527    // swimlanes (under everything): a tinted band per lane, its name on top
528    let (mut even, mut odd, mut names) = (Frame::default(), Frame::default(), Frame::default());
529    for &(l, x0, y0, x1, y1) in &layout.bands {
530        let name = lanes.get(l).cloned().unwrap_or_default();
531        let f = if l % 2 == 0 { &mut even } else { &mut odd };
532        f.num("x0", x0).num("x1", x1).num("y0", y0).num("y1", y1);
533        f.txt("lane", name.clone());
534        names
535            .num("x", x0 + 8.0)
536            .num("y", mid_of_baseline(y0 + 13.0, SMALL_PX));
537        names.txt("label", name);
538    }
539    for (mut f, alpha) in [(even, 0.35), (odd, 0.18)] {
540        if f.is_empty() {
541            continue;
542        }
543        plot = plot
544            .geom_rect_with(
545                GeomRect {
546                    fill: s.band,
547                    alpha,
548                    line_width: 0.0,
549                    ..Default::default()
550                }
551                .with_radius(6.0),
552            )
553            .layer_data(f.take(h))
554            .layer_aes(rect_aes().data_attr("lane", "lane"))
555            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("lane"))]);
556    }
557    if !names.is_empty() {
558        plot = plot
559            .geom_text_with(GeomText {
560                size: SMALL_PX,
561                color: s.muted,
562                hjust: 0.0,
563                fontface: FontFace::Bold,
564                ..Default::default()
565            })
566            .layer_data(names.take(h))
567            .layer_aes(Aes::new().x("x").y("y").label("label"))
568            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("lane-label"))]);
569    }
570
571    // plates (under the edges): a dashed box around each collection
572    let (mut pf, mut pl) = (Frame::default(), Frame::default());
573    for p in plates {
574        let members: Vec<&PlacedNode> = p
575            .nodes
576            .iter()
577            .filter_map(|&i| layout.nodes.get(i))
578            .collect();
579        if members.is_empty() {
580            continue;
581        }
582        let pad = 8.0;
583        let x0 = members
584            .iter()
585            .map(|n| n.cx - n.w / 2.0)
586            .fold(f64::INFINITY, f64::min)
587            - pad;
588        let x1 = members
589            .iter()
590            .map(|n| n.cx + n.w / 2.0)
591            .fold(f64::NEG_INFINITY, f64::max)
592            + pad;
593        let y0 = members
594            .iter()
595            .map(|n| n.cy - n.h / 2.0)
596            .fold(f64::INFINITY, f64::min)
597            - pad
598            - 14.0;
599        let y1 = members
600            .iter()
601            .map(|n| n.cy + n.h / 2.0)
602            .fold(f64::NEG_INFINITY, f64::max)
603            + pad;
604        for (x, y) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] {
605            pf.num("x", x).num("y", y).txt("lt", "dashed");
606            pf.txt("g", p.label.clone()).txt("plate", p.label.clone());
607        }
608        pl.num("x", x0 + 6.0)
609            .num("y", mid_of_baseline(y0 + 13.0, SMALL_PX));
610        pl.txt("label", p.label.clone());
611    }
612    if !pf.is_empty() {
613        plot = plot
614            .geom_path_with(GeomPath::default().color(s.plate).linewidth(1.0))
615            .layer_data(pf.take(h))
616            .layer_aes(
617                Aes::new()
618                    .x("x")
619                    .y("y")
620                    .group("g")
621                    .linetype("lt")
622                    .data_attr("plate", "plate"),
623            )
624            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("plate"))]);
625        plot = plot
626            .geom_text_with(GeomText {
627                size: SMALL_PX,
628                color: s.plate,
629                hjust: 0.0,
630                ..Default::default()
631            })
632            .layer_data(pl.take(h))
633            .layer_aes(Aes::new().x("x").y("y").label("label"))
634            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("plate-label"))]);
635    }
636
637    // edges (under the nodes): one path per edge, arrowed ones first
638    let (mut ef, mut uf, mut lf) = (Frame::default(), Frame::default(), Frame::default());
639    for (k, e) in edges.iter().enumerate() {
640        let Some(route) = layout.edges.get(k) else {
641            continue;
642        };
643        let mut pts = route.clone();
644        if pts.len() < 2 {
645            continue;
646        }
647        // the arrow's tip is on the path's last point and its head is
648        // stroked as wide as the line (its mitred tip reaching about one
649        // line width further): end the path one width short of the box so
650        // the tip lands on its edge.
651        if !e.undirected {
652            let n = pts.len();
653            let ((fx, fy), (tx, ty)) = (pts[n - 2], pts[n - 1]);
654            let len = ((tx - fx).powi(2) + (ty - fy).powi(2)).sqrt().max(1e-6);
655            let pull = e.width.min(len / 2.0);
656            pts[n - 1] = (tx - (tx - fx) / len * pull, ty - (ty - fy) / len * pull);
657        }
658        let ef = if e.undirected { &mut uf } else { &mut ef };
659        let lt = match e.line {
660            EdgeLine::Solid => "solid",
661            EdgeLine::Dashed => "dashed",
662            EdgeLine::Dots => "dots",
663        };
664        for &(x, y) in &pts {
665            ef.num("x", x).num("y", y).txt("g", format!("e{k:04}"));
666            ef.txt("col", hex(e.colour))
667                .num("lw", e.width)
668                .txt("lt", lt);
669            ef.opt("cls", e.class.clone()).txt("tip", e.tooltip.clone());
670            ef.attrs(&e.attrs);
671        }
672        if let Some(l) = &e.label {
673            let (mx, my) = match e.label_at {
674                Some(t) => point_at(route, t.clamp(0.0, 1.0)),
675                None => edge_label_at(route, l, &layout.nodes),
676            };
677            lf.num("x", mx)
678                .num("y", mid_of_baseline(my - 4.0, SMALL_PX));
679            lf.txt("label", l.clone());
680            lf.attrs(&e.label_attrs);
681        }
682    }
683    for (mut f, arrowed) in [(ef, true), (uf, false)] {
684        if f.is_empty() {
685            continue;
686        }
687        let aes = f.attr_aes(
688            Aes::new()
689                .x("x")
690                .y("y")
691                .group("g")
692                .color("col")
693                .linewidth("lw")
694                .linetype("lt")
695                .class("cls")
696                .tooltip("tip"),
697        );
698        let mut geom = GeomPath::default().lineend(LineEnd::Round);
699        if arrowed {
700            // a 7 px + width head, ~29° half-angle
701            geom = geom.arrow(
702                arrow()
703                    .closed()
704                    .angle(29.0)
705                    .length(ARROW + 1.0)
706                    .length_per_linewidth(1.2),
707            );
708        }
709        plot = plot
710            .geom_path_with(geom)
711            .layer_data(f.take(h))
712            .layer_aes(aes)
713            .layer_svg_attr("class", class("edge"));
714    }
715    if !lf.is_empty() {
716        // text directly on the line, a card-coloured halo keeping it legible
717        let aes = lf.attr_aes(Aes::new().x("x").y("y").label("label"));
718        plot = plot
719            .geom_text_with(
720                GeomText {
721                    size: SMALL_PX,
722                    color: s.muted,
723                    ..Default::default()
724                }
725                .bg_colour(s.card)
726                .bg_r(2.0),
727            )
728            .layer_data(lf.take(h))
729            .layer_aes(aes)
730            .layer_svg_attrs(inert)
731            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("edge-label"))]);
732    }
733
734    // nodes in reading order: the box (the hit target, outside any `data-of`
735    // group), then what it shows, grouped per node
736    let mut order: Vec<usize> = (0..nodes.len().min(layout.nodes.len())).collect();
737    order.sort_by_key(|&i| (layout.nodes[i].order, i));
738    let mut boxes: Vec<(Rgb, Frame)> = Vec::new();
739    // shaped nodes, a layer per (shape, corner radius, outline)
740    let mut shaped: Vec<((NodeShape, u64, Rgb), Frame)> = Vec::new();
741    let (mut inside, mut inverse, mut above) =
742        (Frame::default(), Frame::default(), Frame::default());
743    let mut badges: Vec<(Rgb, Frame)> = Vec::new();
744    let (mut accent, mut lines, mut values) =
745        (Frame::default(), Frame::default(), Frame::default());
746    let (mut dots, mut btext, mut spark, mut ends) = (
747        Frame::default(),
748        Frame::default(),
749        Frame::default(),
750        Frame::default(),
751    );
752    for v in order {
753        let nd = &nodes[v];
754        let pn = &layout.nodes[v];
755        let id = nd.id.clone();
756        let (x, y) = (pn.cx - pn.w / 2.0, pn.cy - pn.h / 2.0);
757        if nd.shape != NodeShape::Box {
758            let r = match nd.shape {
759                NodeShape::Circle | NodeShape::FilledCircle => pn.w.min(pn.h) / 2.0,
760                NodeShape::Square | NodeShape::FilledSquare => 1.5,
761                _ => 0.0,
762            };
763            let (fill, stroke) = match nd.shape {
764                NodeShape::Circle => (s.variable_fill, nd.stroke),
765                NodeShape::FilledCircle | NodeShape::FilledSquare => {
766                    (s.observed_fill, s.observed_fill)
767                }
768                NodeShape::Square => (s.card, nd.stroke),
769                _ => (s.card, s.card),
770            };
771            let key = (nd.shape, r.to_bits(), stroke);
772            let i = match shaped.iter().position(|(k, _)| *k == key) {
773                Some(i) => i,
774                None => {
775                    shaped.push((key, Frame::default()));
776                    shaped.len() - 1
777                }
778            };
779            let f = &mut shaped[i].1;
780            f.num("x0", x)
781                .num("x1", x + pn.w)
782                .num("y0", y)
783                .num("y1", y + pn.h);
784            f.txt("fill", hex(fill));
785            f.txt("tip", nd.tooltip.clone()).attrs(&nd.attrs);
786            let label = nd.lines.join(" ");
787            if label.is_empty() {
788                continue;
789            }
790            let target = match nd.shape {
791                NodeShape::FilledCircle => &mut inverse,
792                NodeShape::Square | NodeShape::FilledSquare => &mut above,
793                _ => &mut inside,
794            };
795            let ly = match nd.shape {
796                NodeShape::Square | NodeShape::FilledSquare => y - 4.0 - 0.36 * SMALL_PX,
797                _ => pn.cy,
798            };
799            target.num("x", pn.cx).num("y", ly);
800            target.txt("label", label).txt("of", id.clone());
801            continue;
802        }
803        let f = by_colour(&mut boxes, nd.stroke);
804        f.num("x0", x)
805            .num("x1", x + pn.w)
806            .num("y0", y)
807            .num("y1", y + pn.h);
808        f.txt("tip", nd.tooltip.clone()).attrs(&nd.attrs);
809
810        if let Some(a) = nd.accent {
811            accent.num("x0", x + 1.5).num("x1", x + 4.5);
812            accent
813                .num("y0", y + 6.0)
814                .num("y1", y + 6.0 + (pn.h - 12.0).max(4.0));
815            accent.txt("fill", hex(a)).txt("of", id.clone());
816        }
817        let mut base = y + BOX_PAD_Y + 12.0;
818        for line in &nd.lines {
819            lines
820                .num("x", pn.cx)
821                .num("y", mid_of_baseline(base, LABEL_PX));
822            lines.txt("label", line.clone()).txt("of", id.clone());
823            base += LINE_H;
824        }
825        if let Some(fv) = &nd.value {
826            values
827                .num("x", pn.cx)
828                .num("y", mid_of_baseline(base, VALUE_PX));
829            values.txt("label", fv.clone()).txt("of", id.clone());
830            base += LINE_H;
831        }
832        if let Some((b, dot)) = &nd.badge {
833            let bw = text_w(b, BADGE_PX, false) + 20.0;
834            let (bx, by) = (pn.cx - bw / 2.0, base - 9.0);
835            let f = by_colour(&mut badges, *dot);
836            f.num("x0", bx)
837                .num("x1", bx + bw)
838                .num("y0", by)
839                .num("y1", by + 16.0);
840            f.txt("of", id.clone());
841            dots.num("x", bx + 9.0).num("y", by + 8.0);
842            dots.txt("col", hex(*dot)).txt("of", id.clone());
843            btext.num("x", bx + 15.0).num("y", by + 8.0);
844            btext.txt("label", b.clone()).txt("of", id.clone());
845            base += 20.0;
846        }
847        // a mini trend along the bottom of the box
848        if let Some((values, colour)) = nd.spark.as_ref().filter(|(v, _)| v.len() >= 2) {
849            let (lo, hi) = values
850                .iter()
851                .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &v| {
852                    (a.min(v), b.max(v))
853                });
854            let rng = if hi > lo { hi - lo } else { 1.0 };
855            let (sx0, sw) = (x + BOX_PAD_X, pn.w - 2.0 * BOX_PAD_X);
856            let (top, sh) = (base - 8.0, SPARK_H - 8.0);
857            let last = values.len() - 1;
858            let col = hex(*colour);
859            for (i, &sv) in values.iter().enumerate() {
860                let (px, py) = (
861                    sx0 + sw * i as f64 / last as f64,
862                    top + sh - (sv - lo) / rng * sh,
863                );
864                spark.num("x", px).num("y", py).txt("g", id.clone());
865                spark.txt("col", col.clone()).txt("of", id.clone());
866                if i == last {
867                    ends.num("x", px).num("y", py);
868                    ends.txt("col", col.clone()).txt("of", id.clone());
869                }
870            }
871        }
872    }
873    for (stroke, mut f) in boxes {
874        let aes = f.attr_aes(rect_aes().tooltip("tip"));
875        plot = plot
876            .geom_rect_with(
877                GeomRect {
878                    fill: s.card,
879                    color: stroke,
880                    alpha: 1.0,
881                    line_width: 1.2,
882                    ..Default::default()
883                }
884                .with_radius(8.0),
885            )
886            .layer_data(f.take(h))
887            .layer_aes(aes)
888            .layer_svg_attr("class", class("node"));
889    }
890    for ((shape, r, stroke), mut f) in shaped {
891        let aes = f.attr_aes(rect_aes().fill("fill").tooltip("tip"));
892        let cls = format!(
893            "{} {}",
894            class("node"),
895            class(&format!("node-{}", shape.name()))
896        );
897        plot = plot
898            .geom_rect_with(
899                GeomRect {
900                    color: stroke,
901                    alpha: 1.0,
902                    line_width: if shape == NodeShape::Text { 0.0 } else { 1.2 },
903                    ..Default::default()
904                }
905                .with_radius(f64::from_bits(r)),
906            )
907            .layer_data(f.take(h))
908            .layer_aes(aes)
909            .layer_svg_attr("class", cls);
910    }
911    // everything below: dims / lights with its node (`data-of`), inert
912    let of = |a: Aes| a.group_wrap("of", "of");
913    if !accent.is_empty() {
914        plot = plot
915            .geom_rect_with(
916                GeomRect {
917                    alpha: 1.0,
918                    line_width: 0.0,
919                    ..Default::default()
920                }
921                .with_radius(1.5),
922            )
923            .layer_data(accent.take(h))
924            .layer_aes(of(rect_aes().fill("fill")))
925            .decorative()
926            .layer_svg_attrs(inert);
927    }
928    for (mut f, size, colour, cls) in [
929        (lines, LABEL_PX, s.heading, class("node-label")),
930        (values, VALUE_PX, s.ink, class("node-value")),
931        (inside, LABEL_PX, s.heading, class("node-label")),
932        (inverse, LABEL_PX, s.card, class("node-label")),
933        (above, SMALL_PX, s.muted, class("node-label")),
934    ] {
935        if f.is_empty() {
936            continue;
937        }
938        plot = plot
939            .geom_text_with(GeomText {
940                size,
941                color: colour,
942                fontface: FontFace::Bold,
943                ..Default::default()
944            })
945            .layer_data(f.take(h))
946            .layer_aes(of(Aes::new().x("x").y("y").label("label")))
947            .layer_svg_attrs(inert)
948            .layer_svg_attrs([("aria-hidden", "true"), ("class", cls.as_str())]);
949    }
950    for (dot, mut f) in badges {
951        plot = plot
952            .geom_rect_with(
953                GeomRect {
954                    fill: s.card,
955                    color: dot,
956                    alpha: 0.0,
957                    line_width: 1.0,
958                    ..Default::default()
959                }
960                .with_radius(8.0),
961            )
962            .layer_data(f.take(h))
963            .layer_aes(of(rect_aes()))
964            .decorative()
965            .layer_svg_attrs(inert);
966    }
967    for (mut f, size) in [(dots, 3.0), (ends, 2.2)] {
968        if f.is_empty() {
969            continue;
970        }
971        plot = plot
972            .geom_point_with(GeomPoint {
973                size,
974                ..Default::default()
975            })
976            .layer_data(f.take(h))
977            .layer_aes(of(Aes::new().x("x").y("y").color("col")))
978            .decorative()
979            .layer_svg_attrs(inert);
980    }
981    if !btext.is_empty() {
982        plot = plot
983            .geom_text_with(GeomText {
984                size: BADGE_PX,
985                color: s.ink,
986                hjust: 0.0,
987                ..Default::default()
988            })
989            .layer_data(btext.take(h))
990            .layer_aes(of(Aes::new().x("x").y("y").label("label")))
991            .layer_svg_attrs(inert)
992            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("node-badge"))]);
993    }
994    if !spark.is_empty() {
995        plot = plot
996            .geom_path_with(GeomPath::default().linewidth(1.4))
997            .layer_data(spark.take(h))
998            .layer_aes(of(Aes::new().x("x").y("y").group("g").color("col")))
999            .decorative()
1000            .layer_svg_attrs(inert)
1001            .layer_svg_attr("class", class("node-spark"));
1002    }
1003    plot
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009    use crate::graph_layout::{layered, GraphEdge, GraphNode, LayeredParams};
1010
1011    #[test]
1012    fn wraps_long_labels_in_three_lines() {
1013        let l = wrap(
1014            "Recommend replenishment orders for every store and every week of the coming year",
1015            152.0,
1016        );
1017        assert_eq!(l.len(), 3);
1018        assert!(l[2].ends_with('…'));
1019        assert_eq!(wrap("Ingest", 152.0), ["Ingest"]);
1020    }
1021
1022    /// No ellipsis while the label fits; breaks at word boundaries and
1023    /// after `/` / `-`, never mid-word when there is a separator.
1024    #[test]
1025    fn wraps_at_word_boundaries() {
1026        let max = BOX_MAX_W - 2.0 * BOX_PAD_X;
1027        for label in [
1028            "duckdb/community-extensions (descriptions)",
1029            "Locked DuckDB server (anofox-visualization)",
1030            "Parquet snapshot (downloads, extensions)",
1031            "fetch.sh: download, file by ISO week, merge",
1032        ] {
1033            let l = wrap(label, max);
1034            assert!(l.len() <= MAX_LINES, "{l:?}");
1035            assert!(!l.iter().any(|x| x.contains('…')), "{label}: {l:?}");
1036            // every line ends at a space or after a separator
1037            let joined: String = l.concat();
1038            assert_eq!(joined.replace(' ', ""), label.replace(' ', ""), "{l:?}");
1039            for w in l.windows(2) {
1040                let end = w[0].chars().last().unwrap();
1041                let next = w[1].chars().next().unwrap();
1042                let at_space = label.contains(&format!(
1043                    "{} {}",
1044                    w[0].split(' ').next_back().unwrap(),
1045                    w[1].split(' ').next().unwrap()
1046                ));
1047                assert!(
1048                    at_space || matches!(end, '/' | '-' | '_' | '.' | ','),
1049                    "{l:?} breaks at {end}{next}"
1050                );
1051            }
1052        }
1053        assert_eq!(
1054            wrap("duckdb/community-extensions (descriptions)", max),
1055            ["duckdb/community-", "extensions", "(descriptions)"]
1056        );
1057    }
1058
1059    #[test]
1060    fn node_box_grows_with_its_contents() {
1061        let plain = node_box("Ingest", None, None, false);
1062        assert_eq!((plain.w, plain.h), (BOX_MIN_W, 2.0 * BOX_PAD_Y + LINE_H));
1063        let full = node_box("Ingest", Some("1,240"), Some("late"), true);
1064        assert_eq!(full.h, plain.h + LINE_H + 20.0 + SPARK_H);
1065        assert!(full.w >= 110.0 + 2.0 * BOX_PAD_X);
1066        let long = node_box(&"word ".repeat(40), None, None, false);
1067        assert!(long.w <= BOX_MAX_W && long.lines.len() == MAX_LINES);
1068    }
1069
1070    /// An edge label a node box would cover moves along the edge.
1071    #[test]
1072    fn edge_label_keeps_clear_of_nodes() {
1073        let node = |cx: f64, w: f64| PlacedNode {
1074            cx,
1075            cy: 0.0,
1076            w,
1077            h: 40.0,
1078            layer: 0,
1079            order: 0,
1080        };
1081        let pts = [(0.0, 0.0), (300.0, 0.0)];
1082        // nothing in the way: the midpoint
1083        assert_eq!(edge_label_at(&pts, "spikes flagged", &[]), (150.0, 0.0));
1084        // a box over the middle: somewhere else along the edge, clear of it
1085        let nodes = [node(150.0, 60.0)];
1086        let (x, _) = edge_label_at(&pts, "spikes flagged", &nodes);
1087        let hw = edge_label_width("spikes flagged") / 2.0 + 3.0;
1088        assert!((x - 150.0).abs() >= hw + 30.0, "{x}");
1089        assert_eq!(point_at(&pts, 0.25), (75.0, 0.0));
1090    }
1091
1092    #[test]
1093    fn flow_width_is_area_true_and_rounded() {
1094        assert_eq!(flow_width(None, 10.0, EdgeLine::Solid), 1.6);
1095        assert_eq!(flow_width(Some(10.0), 10.0, EdgeLine::Solid), 7.0);
1096        assert_eq!(flow_width(Some(2.5), 10.0, EdgeLine::Solid), 4.2);
1097        assert_eq!(flow_width(None, 10.0, EdgeLine::Dots), 3.0);
1098    }
1099
1100    /// Two lanes, three boxes, a labelled edge: the marks carry the
1101    /// caller's attributes and classes, a node's contents sit in its
1102    /// `data-of` group, and the box itself (the hit target) stays outside.
1103    #[test]
1104    fn draws_marks_with_attributes_and_groups() {
1105        let labels = ["Ingest", "Model", "Publish"];
1106        let boxes: Vec<NodeBox> = labels
1107            .iter()
1108            .enumerate()
1109            .map(|(i, l)| node_box(l, (i == 1).then_some("42"), None, i == 2))
1110            .collect();
1111        let gnodes: Vec<GraphNode> = boxes
1112            .iter()
1113            .enumerate()
1114            .map(|(i, b)| GraphNode {
1115                w: b.w,
1116                h: b.h,
1117                lane: Some(i % 2),
1118                ..Default::default()
1119            })
1120            .collect();
1121        let gedges = [
1122            GraphEdge {
1123                from: 0,
1124                to: 1,
1125                label_w: Some(edge_label_width("rows")),
1126            },
1127            GraphEdge {
1128                from: 1,
1129                to: 2,
1130                label_w: None,
1131            },
1132        ];
1133        let params = LayeredParams {
1134            lanes: 2,
1135            ..Default::default()
1136        };
1137        let l = layered(&gnodes, &gedges, &params);
1138        let nodes: Vec<DrawNode> = labels
1139            .iter()
1140            .zip(&boxes)
1141            .enumerate()
1142            .map(|(i, (id, b))| DrawNode {
1143                id: id.to_string(),
1144                lines: b.lines.clone(),
1145                value: (i == 1).then(|| "42".to_string()),
1146                stroke: (90, 90, 90),
1147                accent: (i == 0).then_some((200, 0, 0)),
1148                spark: (i == 2).then(|| (vec![1.0, 3.0, 2.0], (0, 0, 200))),
1149                tooltip: format!("{id}!"),
1150                attrs: vec![
1151                    ("node".to_string(), Some(id.to_string())),
1152                    ("value".to_string(), (i == 1).then(|| "42".to_string())),
1153                ],
1154                ..Default::default()
1155            })
1156            .collect();
1157        let edges: Vec<DrawEdge> = [(0, 1), (1, 2)]
1158            .iter()
1159            .map(|&(a, b)| DrawEdge {
1160                colour: (100, 100, 100),
1161                width: flow_width(None, 0.0, EdgeLine::Solid),
1162                line: if a == 0 {
1163                    EdgeLine::Solid
1164                } else {
1165                    EdgeLine::Dashed
1166                },
1167                class: (a == 1).then(|| "flowing".to_string()),
1168                tooltip: format!("{} → {}", labels[a], labels[b]),
1169                label: (a == 0).then(|| "rows".to_string()),
1170                attrs: vec![("edge".to_string(), Some(format!("{a}>{b}")))],
1171                label_attrs: vec![("from".to_string(), Some(labels[a].to_string()))],
1172                ..Default::default()
1173            })
1174            .collect();
1175        let lanes = ["Left".to_string(), "Right".to_string()];
1176        let svg = draw(
1177            &l,
1178            &lanes,
1179            &[],
1180            &nodes,
1181            &edges,
1182            &GraphStyle::default(),
1183            crate::theme::presets::theme_void(),
1184        )
1185        .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1186        .unwrap();
1187        // start tags, each with whether it sits inside a `data-of` group
1188        let mut tags: Vec<(&str, bool)> = Vec::new();
1189        let mut stack: Vec<bool> = Vec::new();
1190        for part in svg.split('<').skip(1) {
1191            let tag = &part[..part.find('>').unwrap()];
1192            if tag.starts_with('/') {
1193                stack.pop();
1194                continue;
1195            }
1196            let inside = stack.iter().any(|&g| g);
1197            tags.push((tag, inside));
1198            if !tag.ends_with('/') {
1199                stack.push(tag.contains("data-of="));
1200            }
1201        }
1202        let attr = |tag: &str, name: &str| {
1203            let key = format!(" {name}=\"");
1204            tag.find(&key).map(|i| {
1205                let rest = &tag[i + key.len()..];
1206                rest[..rest.find('"').unwrap()].to_string()
1207            })
1208        };
1209        // boxes: one per node, outside the groups, an NA attribute left off
1210        let boxes: Vec<_> = tags
1211            .iter()
1212            .filter(|(t, _)| t.contains("data-node="))
1213            .collect();
1214        assert_eq!(boxes.len(), 3);
1215        for (b, inside) in &boxes {
1216            assert!(!inside, "{b}");
1217            let id = attr(b, "data-node").unwrap();
1218            assert_eq!(attr(b, "data-value").is_some(), id == "Model", "{id}");
1219        }
1220        // edges: the caller's class on a path; the label's attributes
1221        assert!(tags.iter().any(|(t, _)| t.contains("data-edge=\"1&gt;2\"")
1222            && attr(t, "class").is_some_and(|c| c.contains("flowing"))));
1223        assert!(tags
1224            .iter()
1225            .any(|(t, _)| attr(t, "data-from").as_deref() == Some("Ingest")));
1226        assert!(svg.contains(" gg-graph-node\"") && svg.contains("gg-graph-edge-label"));
1227        // node contents grouped by node; lanes drawn with their names
1228        for id in labels {
1229            assert!(svg.contains(&format!("data-of=\"{id}\"")), "{id}");
1230        }
1231        assert!(svg.contains("data-lane=\"Right\""));
1232        assert!(svg.contains(">Left<") && svg.contains(">42<"));
1233    }
1234
1235    /// A coin toss as a Forney-style factor graph: a Beta prior box, the
1236    /// parameter's circle, an equality node, Bernoulli boxes and observed
1237    /// circles in a plate; undirected edges with interface names near the
1238    /// factor.
1239    #[test]
1240    fn draws_a_factor_graph_with_shapes_and_a_plate() {
1241        let n = 3;
1242        // (label, shape)
1243        let mut spec = vec![
1244            ("Beta", NodeShape::Box),
1245            ("θ", NodeShape::Circle),
1246            ("=", NodeShape::Square),
1247        ];
1248        for _ in 0..n {
1249            spec.push(("Ber", NodeShape::Box));
1250        }
1251        for _ in 0..n {
1252            spec.push(("y", NodeShape::FilledCircle));
1253        }
1254        spec.push(("2.0", NodeShape::Text));
1255        let sizes: Vec<NodeBox> = spec.iter().map(|(l, sh)| node_size(*sh, l)).collect();
1256        // (from, to, interface)
1257        let mut links = vec![(0, 1, "out"), (1, 2, ""), (9, 0, "a")];
1258        for i in 0..n {
1259            links.push((2, 3 + i, "p"));
1260            links.push((3 + i, 3 + n + i, "out"));
1261        }
1262        let gnodes: Vec<GraphNode> = sizes
1263            .iter()
1264            .map(|b| GraphNode {
1265                w: b.w,
1266                h: b.h,
1267                ..Default::default()
1268            })
1269            .collect();
1270        let gedges: Vec<GraphEdge> = links
1271            .iter()
1272            .map(|&(a, b, _)| GraphEdge {
1273                from: a,
1274                to: b,
1275                label_w: None,
1276            })
1277            .collect();
1278        // a plate reaches past its nodes: room for it in the margin
1279        let params = LayeredParams {
1280            margin: 32.0,
1281            ..Default::default()
1282        };
1283        let l = layered(&gnodes, &gedges, &params);
1284        let nodes: Vec<DrawNode> = spec
1285            .iter()
1286            .zip(&sizes)
1287            .enumerate()
1288            .map(|(i, ((_, shape), b))| DrawNode {
1289                id: format!("n{i}"),
1290                shape: *shape,
1291                lines: b.lines.clone(),
1292                stroke: (60, 60, 60),
1293                tooltip: b.lines.join(" "),
1294                attrs: vec![("node".to_string(), Some(format!("n{i}")))],
1295                ..Default::default()
1296            })
1297            .collect();
1298        let edges: Vec<DrawEdge> = links
1299            .iter()
1300            .map(|&(a, b, iface)| DrawEdge {
1301                colour: (90, 90, 90),
1302                width: 1.2,
1303                undirected: true,
1304                label: (!iface.is_empty()).then(|| iface.to_string()),
1305                // the interface name near the factor
1306                label_at: Some(if spec[b].1 == NodeShape::Box {
1307                    0.8
1308                } else {
1309                    0.2
1310                }),
1311                attrs: vec![("edge".to_string(), Some(format!("{a}>{b}")))],
1312                ..Default::default()
1313            })
1314            .collect();
1315        let plates = [Plate {
1316            label: "i = 1..3".to_string(),
1317            nodes: (3..3 + 2 * n).collect(),
1318        }];
1319        let style = GraphStyle::default();
1320        let svg = draw(
1321            &l,
1322            &[],
1323            &plates,
1324            &nodes,
1325            &edges,
1326            &style,
1327            crate::theme::presets::theme_void(),
1328        )
1329        .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1330        .unwrap();
1331        // every node is a mark, shapes classed, undirected: no arrowheads
1332        assert_eq!(svg.matches("data-node=").count(), spec.len());
1333        for shape in ["circle", "filled-circle", "square", "text"] {
1334            assert!(svg.contains(&format!("gg-graph-node-{shape}")), "{shape}");
1335        }
1336        assert!(
1337            !svg.contains("<polygon"),
1338            "an undirected edge has no arrowhead"
1339        );
1340        assert_eq!(svg.matches("data-edge=").count(), links.len());
1341        // the plate, and its label
1342        assert!(svg.contains("data-plate=\"i = 1..3\"") && svg.contains(">i = 1..3<"));
1343        // the observed circles' dark fill
1344        assert!(svg.contains(&format!(
1345            "fill=\"{}\"",
1346            hex(style.observed_fill).to_uppercase()
1347        )));
1348        // no two nodes overlap
1349        for (i, a) in l.nodes.iter().enumerate() {
1350            for b in &l.nodes[i + 1..] {
1351                let apart = (a.cx - b.cx).abs() >= (a.w + b.w) / 2.0
1352                    || (a.cy - b.cy).abs() >= (a.h + b.h) / 2.0;
1353                assert!(apart, "overlap");
1354            }
1355        }
1356        // the circle is round: rx is half its width
1357        let c = &l.nodes[1];
1358        assert!(svg.contains(&format!("rx=\"{:.2}\"", c.w / 2.0)), "{}", c.w);
1359    }
1360
1361    /// An HMM over five slices with learned transition (A) and observation
1362    /// (B) matrices, in bands param / state / obs: the state chain is one
1363    /// straight row, each observation below its state.
1364    #[test]
1365    fn banded_hmm_keeps_the_state_chain_straight() {
1366        use crate::graph_layout::{banded, BandedParams};
1367        let t_n = 5;
1368        // (label, shape, band)
1369        let mut spec: Vec<(String, NodeShape, usize)> = vec![
1370            ("Dir".into(), NodeShape::Box, 0),   // 0: prior on A
1371            ("A".into(), NodeShape::Square, 0),  // 1: = of A
1372            ("Dir".into(), NodeShape::Box, 0),   // 2: prior on B
1373            ("B".into(), NodeShape::Square, 0),  // 3: = of B
1374            ("Cat".into(), NodeShape::Box, 1),   // 4: initial state prior
1375            ("s₀".into(), NodeShape::Circle, 1), // 5
1376        ];
1377        let mut links: Vec<(usize, usize, &str)> =
1378            vec![(0, 1, "out"), (2, 3, "out"), (4, 5, "out")];
1379        let mut prev = 5;
1380        for t in 1..=t_n {
1381            let tr = spec.len();
1382            spec.push(("T".into(), NodeShape::Box, 1));
1383            let eq = spec.len();
1384            spec.push((format!("s{t}"), NodeShape::Square, 1));
1385            let ob = spec.len();
1386            spec.push(("T".into(), NodeShape::Box, 2));
1387            let x = spec.len();
1388            spec.push((format!("x{t}"), NodeShape::FilledCircle, 2));
1389            links.extend([
1390                (prev, tr, "in"),
1391                (1, tr, "a"),
1392                (tr, eq, "out"),
1393                (eq, ob, "in"),
1394                (3, ob, "a"),
1395                (ob, x, "out"),
1396            ]);
1397            prev = eq;
1398        }
1399        let sizes: Vec<NodeBox> = spec.iter().map(|(l, sh, _)| node_size(*sh, l)).collect();
1400        let gnodes: Vec<GraphNode> = spec
1401            .iter()
1402            .zip(&sizes)
1403            .map(|((_, _, b), s)| GraphNode {
1404                w: s.w,
1405                h: s.h,
1406                lane: Some(*b),
1407                ..Default::default()
1408            })
1409            .collect();
1410        let gedges: Vec<GraphEdge> = links
1411            .iter()
1412            .map(|&(a, b, _)| GraphEdge {
1413                from: a,
1414                to: b,
1415                label_w: None,
1416            })
1417            .collect();
1418        let l = banded(
1419            &gnodes,
1420            &gedges,
1421            &BandedParams {
1422                bands: 3,
1423                margin: 32.0,
1424                ..Default::default()
1425            },
1426        );
1427        // the chain: every transition and state node on one row
1428        let chain: Vec<usize> = (0..spec.len())
1429            .filter(|&i| spec[i].2 == 1 && i != 4)
1430            .collect();
1431        let y = l.nodes[chain[0]].cy;
1432        for &i in &chain {
1433            assert_eq!(l.nodes[i].cy, y, "{} off the row", spec[i].0);
1434        }
1435        // each observation factor straight below its state's = node
1436        for t in 0..t_n {
1437            let (eq, ob) = (6 + 4 * t + 1, 6 + 4 * t + 2);
1438            assert_eq!(l.nodes[eq].cx, l.nodes[ob].cx, "slice {t}");
1439            assert!(l.nodes[ob].cy > y);
1440        }
1441        // nothing overlaps
1442        for (i, a) in l.nodes.iter().enumerate() {
1443            for b in &l.nodes[i + 1..] {
1444                assert!(
1445                    (a.cx - b.cx).abs() >= (a.w + b.w) / 2.0
1446                        || (a.cy - b.cy).abs() >= (a.h + b.h) / 2.0
1447                );
1448            }
1449        }
1450        let nodes: Vec<DrawNode> = spec
1451            .iter()
1452            .zip(&sizes)
1453            .enumerate()
1454            .map(|(i, ((_, shape, _), b))| DrawNode {
1455                id: format!("n{i}"),
1456                shape: *shape,
1457                lines: b.lines.clone(),
1458                stroke: (60, 60, 60),
1459                ..Default::default()
1460            })
1461            .collect();
1462        let edges: Vec<DrawEdge> = links
1463            .iter()
1464            .map(|&(_, b, iface)| DrawEdge {
1465                colour: (90, 90, 90),
1466                width: 1.2,
1467                undirected: true,
1468                label: Some(iface.to_string()),
1469                label_at: Some(if spec[b].1 == NodeShape::Box {
1470                    0.8
1471                } else {
1472                    0.2
1473                }),
1474                ..Default::default()
1475            })
1476            .collect();
1477        let lanes = ["param".to_string(), "state".to_string(), "obs".to_string()];
1478        let svg = draw(
1479            &l,
1480            &lanes,
1481            &[],
1482            &nodes,
1483            &edges,
1484            &GraphStyle::default(),
1485            crate::theme::presets::theme_void(),
1486        )
1487        .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1488        .unwrap();
1489        if let Ok(path) = std::env::var("GGPLOT_GRAPH_SVG") {
1490            std::fs::write(path, &svg).unwrap();
1491        }
1492        assert_eq!(svg.matches("data-lane=").count(), 3);
1493    }
1494}