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    /// Prefix of the layer classes (`<prefix>node`, `<prefix>edge`,
165    /// `<prefix>edge-label`, `<prefix>lane`, `<prefix>lane-label`,
166    /// `<prefix>node-label`, `<prefix>node-value`, `<prefix>node-badge`,
167    /// `<prefix>node-spark`).
168    pub class_prefix: String,
169}
170
171impl Default for GraphStyle {
172    fn default() -> Self {
173        GraphStyle {
174            card: (255, 255, 255),
175            band: (226, 228, 233),
176            muted: (107, 114, 128),
177            heading: (17, 24, 39),
178            ink: (31, 41, 55),
179            class_prefix: "gg-graph-".to_string(),
180        }
181    }
182}
183
184/// A `data-*` attribute: its name (without `data-`) and value; `None` leaves
185/// it off that mark.
186pub type DataAttr = (String, Option<String>);
187
188/// What a node's box shows, and its attributes. Its geometry comes from the
189/// layout ([`PlacedNode`]); its label lines from [`node_box`].
190#[derive(Clone, Debug, Default)]
191pub struct DrawNode {
192    /// The `data-of` group of everything drawn for the node.
193    pub id: String,
194    /// The label, wrapped ([`NodeBox::lines`]).
195    pub lines: Vec<String>,
196    /// A value line under the label.
197    pub value: Option<String>,
198    /// A badge row: its text and its dot's (and outline's) colour.
199    pub badge: Option<(String, Rgb)>,
200    /// The box outline.
201    pub stroke: Rgb,
202    /// A thin bar along the box's left edge.
203    pub accent: Option<Rgb>,
204    /// A sparkline (two values or more) along the bottom, and its colour.
205    pub spark: Option<(Vec<f64>, Rgb)>,
206    pub tooltip: String,
207    /// `data-*` on the box (the hit target); every node lists the same
208    /// names, in the same order.
209    pub attrs: Vec<DataAttr>,
210}
211
212/// An edge's line pattern.
213#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub enum EdgeLine {
215    #[default]
216    Solid,
217    Dashed,
218    /// Round dots (zero-length dashes with round caps).
219    Dots,
220}
221
222/// What an edge looks like, and its attributes. Its points come from the
223/// layout ([`GraphLayout::edges`]); the arrowhead is on its last point.
224#[derive(Clone, Debug, Default)]
225pub struct DrawEdge {
226    pub colour: Rgb,
227    /// Line width (px), see [`flow_width`].
228    pub width: f64,
229    pub line: EdgeLine,
230    /// Extra CSS classes on the path.
231    pub class: Option<String>,
232    pub tooltip: String,
233    /// A label (`\n` breaks lines) on the edge, clear of the boxes.
234    pub label: Option<String>,
235    /// `data-*` on the path; every edge lists the same names, in the same
236    /// order.
237    pub attrs: Vec<DataAttr>,
238    /// `data-*` on the label.
239    pub label_attrs: Vec<DataAttr>,
240}
241
242/// An edge's width (px) for what it carries, `value` of the graph's `max`:
243/// area-true (`sqrt`), 1.4 px to 7 px; 1.6 px without a value; dotted edges
244/// 3–6 px so their dots show. Rounded to 0.1 px.
245pub fn flow_width(value: Option<f64>, max: f64, line: EdgeLine) -> f64 {
246    let width = match value {
247        Some(v) if max > 0.0 => 1.4 + 5.6 * (v.max(0.0) / max).sqrt(),
248        _ => 1.6,
249    };
250    let width = match line {
251        EdgeLine::Dots => width.clamp(3.0, 6.0),
252        _ => width,
253    };
254    (width * 10.0).round() / 10.0
255}
256
257/// Columns built row by row, in px with y down.
258#[derive(Default)]
259struct Frame(Vec<(String, Vec<Value>)>);
260
261impl Frame {
262    fn col(&mut self, name: &str) -> &mut Vec<Value> {
263        let i = match self.0.iter().position(|(n, _)| n == name) {
264            Some(i) => i,
265            None => {
266                self.0.push((name.to_string(), Vec::new()));
267                self.0.len() - 1
268            }
269        };
270        &mut self.0[i].1
271    }
272    fn num(&mut self, name: &str, v: f64) -> &mut Self {
273        self.col(name).push(Value::Float(v));
274        self
275    }
276    fn txt(&mut self, name: &str, v: impl Into<String>) -> &mut Self {
277        self.col(name).push(Value::Str(v.into()));
278        self
279    }
280    fn opt(&mut self, name: &str, v: Option<String>) -> &mut Self {
281        self.col(name).push(v.map_or(Value::Na, Value::Str));
282        self
283    }
284    /// `attrs` as columns `a:<name>`.
285    fn attrs(&mut self, attrs: &[DataAttr]) -> &mut Self {
286        for (n, v) in attrs {
287            self.opt(&format!("a:{n}"), v.clone());
288        }
289        self
290    }
291    fn is_empty(&self) -> bool {
292        self.0.first().is_none_or(|(_, v)| v.is_empty())
293    }
294    /// `aes` plus a `data_attr` per `a:<name>` column, in column order.
295    fn attr_aes(&self, mut aes: Aes) -> Aes {
296        for (n, _) in &self.0 {
297            if let Some(name) = n.strip_prefix("a:") {
298                aes = aes.data_attr(name, n);
299            }
300        }
301        aes
302    }
303    /// The frame's columns, its pixel y (down) flipped to data y (up) on a
304    /// canvas `h` tall: `y` → `h - y`, and a rect's `y0`/`y1` swapped as
305    /// they flip so `y0` stays the low edge.
306    fn take(&mut self, h: f64) -> Vec<(String, Vec<Value>)> {
307        let flip = |v: Vec<Value>| -> Vec<Value> {
308            v.into_iter()
309                .map(|x| match x {
310                    Value::Float(y) => Value::Float(h - y),
311                    other => other,
312                })
313                .collect()
314        };
315        std::mem::take(&mut self.0)
316            .into_iter()
317            .map(|(n, v)| match n.as_str() {
318                "y" => (n, flip(v)),
319                "y0" => ("y1".to_string(), flip(v)),
320                "y1" => ("y0".to_string(), flip(v)),
321                _ => (n, v),
322            })
323            .collect()
324    }
325}
326
327/// Frames keyed by a colour a geom only takes as a fixed parameter (a
328/// rect's stroke), in first-seen order: one layer per colour.
329fn by_colour(v: &mut Vec<(Rgb, Frame)>, c: Rgb) -> &mut Frame {
330    let i = match v.iter().position(|(k, _)| *k == c) {
331        Some(i) => i,
332        None => {
333            v.push((c, Frame::default()));
334            v.len() - 1
335        }
336    };
337    &mut v[i].1
338}
339
340fn hex(c: Rgb) -> String {
341    format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
342}
343
344/// The point a fraction `t` (0..1) of the way along a polyline.
345fn point_at(pts: &[(f64, f64)], t: f64) -> (f64, f64) {
346    let seg = |i: usize| {
347        let (a, b) = (pts[i], pts[i + 1]);
348        ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()
349    };
350    let total: f64 = (0..pts.len().saturating_sub(1)).map(seg).sum();
351    let mut left = total * t;
352    for i in 0..pts.len().saturating_sub(1) {
353        let l = seg(i);
354        if l >= left && l > 0.0 {
355            let t = left / l;
356            let (a, b) = (pts[i], pts[i + 1]);
357            return (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t);
358        }
359        left -= l;
360    }
361    pts.first().copied().unwrap_or((0.0, 0.0))
362}
363
364/// Where an edge's label goes: the midpoint of the edge, else the nearest
365/// point along it where the label's box (11 px lines drawn around `y - 4`)
366/// touches no node box — so a node doesn't cover it.
367fn edge_label_at(pts: &[(f64, f64)], label: &str, nodes: &[PlacedNode]) -> (f64, f64) {
368    let lines = label.split('\n').count();
369    let hw = edge_label_width(label) / 2.0 + 3.0;
370    let hh = lines as f64 * 6.6 + 3.0;
371    let clear = |(x, y): (f64, f64)| {
372        let cy = y - 4.0 - 0.36 * SMALL_PX;
373        nodes
374            .iter()
375            .all(|n| (x - n.cx).abs() >= hw + n.w / 2.0 || (cy - n.cy).abs() >= hh + n.h / 2.0)
376    };
377    let mid = point_at(pts, 0.5);
378    [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74, 0.18, 0.82]
379        .into_iter()
380        .map(|t| point_at(pts, t))
381        .find(|&p| clear(p))
382        .unwrap_or(mid)
383}
384
385/// Text is centred on its y (`dominant-baseline="middle"`); the layout
386/// places lines by their baseline. A cap-height-ish shift between the two.
387fn mid_of_baseline(baseline: f64, px: f64) -> f64 {
388    baseline - 0.36 * px
389}
390
391/// Draw a laid-out graph on a `layout.w` × `layout.h` px canvas: `lanes`
392/// names the layout's bands, `nodes` and `edges` are in the layout's order.
393/// `theme` is the base (a void theme; the legend is turned off). Render the
394/// result with e.g. `render_svg_native_at(0.0, 0.0, w, h)` at the canvas
395/// size rounded up.
396pub fn draw(
397    layout: &GraphLayout,
398    lanes: &[String],
399    nodes: &[DrawNode],
400    edges: &[DrawEdge],
401    style: &GraphStyle,
402    mut theme: Theme,
403) -> GGPlot {
404    let s = style;
405    let class = |name: &str| format!("{}{name}", s.class_prefix);
406    let (w, h) = (layout.w.ceil().max(1.0), layout.h.ceil().max(1.0));
407    theme.legend_position = crate::theme::LegendPosition::None;
408    let mut plot = GGPlot::new(vec![
409        ("x".to_string(), vec![Value::Float(0.0), Value::Float(w)]),
410        ("y".to_string(), vec![Value::Float(0.0), Value::Float(h)]),
411    ])
412    .theme(theme)
413    // the canvas is the view (a zoom, so a mark reaching past it is clipped
414    // rather than dropped); `Frame::take` flips the layout's y-down pixels
415    .scale_x_continuous(ScaleContinuous::new().with_expand(0.0, 0.0))
416    .scale_y_continuous(ScaleContinuous::new().with_expand(0.0, 0.0))
417    .coord_cartesian_zoom(Some((0.0, w)), Some((0.0, h)))
418    .scale_color_identity()
419    .scale_fill_identity()
420    .scale_linewidth(
421        ScaleLinewidthContinuous::new()
422            .with_limits(0.0, 16.0)
423            .with_range(0.0, 16.0),
424    )
425    .scale_linetype_manual(vec![
426        ("solid", Linetype::Solid),
427        ("dashed", Linetype::Custom(vec![(6.0, 4.0)])),
428        // round caps on a zero-length dash: moving dots
429        ("dots", Linetype::Custom(vec![(0.0, 10.0)])),
430    ]);
431    let rect_aes = || Aes::new().xmin("x0").xmax("x1").ymin("y0").ymax("y1");
432    let inert = [("pointer-events", "none")];
433
434    // swimlanes (under everything): a tinted band per lane, its name on top
435    let (mut even, mut odd, mut names) = (Frame::default(), Frame::default(), Frame::default());
436    for &(l, x0, y0, x1, y1) in &layout.bands {
437        let name = lanes.get(l).cloned().unwrap_or_default();
438        let f = if l % 2 == 0 { &mut even } else { &mut odd };
439        f.num("x0", x0).num("x1", x1).num("y0", y0).num("y1", y1);
440        f.txt("lane", name.clone());
441        names
442            .num("x", x0 + 8.0)
443            .num("y", mid_of_baseline(y0 + 13.0, SMALL_PX));
444        names.txt("label", name);
445    }
446    for (mut f, alpha) in [(even, 0.35), (odd, 0.18)] {
447        if f.is_empty() {
448            continue;
449        }
450        plot = plot
451            .geom_rect_with(
452                GeomRect {
453                    fill: s.band,
454                    alpha,
455                    line_width: 0.0,
456                    ..Default::default()
457                }
458                .with_radius(6.0),
459            )
460            .layer_data(f.take(h))
461            .layer_aes(rect_aes().data_attr("lane", "lane"))
462            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("lane"))]);
463    }
464    if !names.is_empty() {
465        plot = plot
466            .geom_text_with(GeomText {
467                size: SMALL_PX,
468                color: s.muted,
469                hjust: 0.0,
470                fontface: FontFace::Bold,
471                ..Default::default()
472            })
473            .layer_data(names.take(h))
474            .layer_aes(Aes::new().x("x").y("y").label("label"))
475            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("lane-label"))]);
476    }
477
478    // edges (under the nodes): one path per edge
479    let (mut ef, mut lf) = (Frame::default(), Frame::default());
480    for (k, e) in edges.iter().enumerate() {
481        let Some(route) = layout.edges.get(k) else {
482            continue;
483        };
484        let mut pts = route.clone();
485        if pts.len() < 2 {
486            continue;
487        }
488        // the arrow's tip is on the path's last point and its head is
489        // stroked as wide as the line (its mitred tip reaching about one
490        // line width further): end the path one width short of the box so
491        // the tip lands on its edge.
492        let n = pts.len();
493        let ((fx, fy), (tx, ty)) = (pts[n - 2], pts[n - 1]);
494        let len = ((tx - fx).powi(2) + (ty - fy).powi(2)).sqrt().max(1e-6);
495        let pull = e.width.min(len / 2.0);
496        pts[n - 1] = (tx - (tx - fx) / len * pull, ty - (ty - fy) / len * pull);
497        let lt = match e.line {
498            EdgeLine::Solid => "solid",
499            EdgeLine::Dashed => "dashed",
500            EdgeLine::Dots => "dots",
501        };
502        for &(x, y) in &pts {
503            ef.num("x", x).num("y", y).txt("g", format!("e{k:04}"));
504            ef.txt("col", hex(e.colour))
505                .num("lw", e.width)
506                .txt("lt", lt);
507            ef.opt("cls", e.class.clone()).txt("tip", e.tooltip.clone());
508            ef.attrs(&e.attrs);
509        }
510        if let Some(l) = &e.label {
511            let (mx, my) = edge_label_at(route, l, &layout.nodes);
512            lf.num("x", mx)
513                .num("y", mid_of_baseline(my - 4.0, SMALL_PX));
514            lf.txt("label", l.clone());
515            lf.attrs(&e.label_attrs);
516        }
517    }
518    if !ef.is_empty() {
519        let aes = ef.attr_aes(
520            Aes::new()
521                .x("x")
522                .y("y")
523                .group("g")
524                .color("col")
525                .linewidth("lw")
526                .linetype("lt")
527                .class("cls")
528                .tooltip("tip"),
529        );
530        plot = plot
531            .geom_path_with(
532                GeomPath::default()
533                    .lineend(LineEnd::Round)
534                    // a 7 px + width head, ~29° half-angle
535                    .arrow(
536                        arrow()
537                            .closed()
538                            .angle(29.0)
539                            .length(ARROW + 1.0)
540                            .length_per_linewidth(1.2),
541                    ),
542            )
543            .layer_data(ef.take(h))
544            .layer_aes(aes)
545            .layer_svg_attr("class", class("edge"));
546    }
547    if !lf.is_empty() {
548        // text directly on the line, a card-coloured halo keeping it legible
549        let aes = lf.attr_aes(Aes::new().x("x").y("y").label("label"));
550        plot = plot
551            .geom_text_with(
552                GeomText {
553                    size: SMALL_PX,
554                    color: s.muted,
555                    ..Default::default()
556                }
557                .bg_colour(s.card)
558                .bg_r(2.0),
559            )
560            .layer_data(lf.take(h))
561            .layer_aes(aes)
562            .layer_svg_attrs(inert)
563            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("edge-label"))]);
564    }
565
566    // nodes in reading order: the box (the hit target, outside any `data-of`
567    // group), then what it shows, grouped per node
568    let mut order: Vec<usize> = (0..nodes.len().min(layout.nodes.len())).collect();
569    order.sort_by_key(|&i| (layout.nodes[i].order, i));
570    let mut boxes: Vec<(Rgb, Frame)> = Vec::new();
571    let mut badges: Vec<(Rgb, Frame)> = Vec::new();
572    let (mut accent, mut lines, mut values) =
573        (Frame::default(), Frame::default(), Frame::default());
574    let (mut dots, mut btext, mut spark, mut ends) = (
575        Frame::default(),
576        Frame::default(),
577        Frame::default(),
578        Frame::default(),
579    );
580    for v in order {
581        let nd = &nodes[v];
582        let pn = &layout.nodes[v];
583        let id = nd.id.clone();
584        let (x, y) = (pn.cx - pn.w / 2.0, pn.cy - pn.h / 2.0);
585        let f = by_colour(&mut boxes, nd.stroke);
586        f.num("x0", x)
587            .num("x1", x + pn.w)
588            .num("y0", y)
589            .num("y1", y + pn.h);
590        f.txt("tip", nd.tooltip.clone()).attrs(&nd.attrs);
591
592        if let Some(a) = nd.accent {
593            accent.num("x0", x + 1.5).num("x1", x + 4.5);
594            accent
595                .num("y0", y + 6.0)
596                .num("y1", y + 6.0 + (pn.h - 12.0).max(4.0));
597            accent.txt("fill", hex(a)).txt("of", id.clone());
598        }
599        let mut base = y + BOX_PAD_Y + 12.0;
600        for line in &nd.lines {
601            lines
602                .num("x", pn.cx)
603                .num("y", mid_of_baseline(base, LABEL_PX));
604            lines.txt("label", line.clone()).txt("of", id.clone());
605            base += LINE_H;
606        }
607        if let Some(fv) = &nd.value {
608            values
609                .num("x", pn.cx)
610                .num("y", mid_of_baseline(base, VALUE_PX));
611            values.txt("label", fv.clone()).txt("of", id.clone());
612            base += LINE_H;
613        }
614        if let Some((b, dot)) = &nd.badge {
615            let bw = text_w(b, BADGE_PX, false) + 20.0;
616            let (bx, by) = (pn.cx - bw / 2.0, base - 9.0);
617            let f = by_colour(&mut badges, *dot);
618            f.num("x0", bx)
619                .num("x1", bx + bw)
620                .num("y0", by)
621                .num("y1", by + 16.0);
622            f.txt("of", id.clone());
623            dots.num("x", bx + 9.0).num("y", by + 8.0);
624            dots.txt("col", hex(*dot)).txt("of", id.clone());
625            btext.num("x", bx + 15.0).num("y", by + 8.0);
626            btext.txt("label", b.clone()).txt("of", id.clone());
627            base += 20.0;
628        }
629        // a mini trend along the bottom of the box
630        if let Some((values, colour)) = nd.spark.as_ref().filter(|(v, _)| v.len() >= 2) {
631            let (lo, hi) = values
632                .iter()
633                .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &v| {
634                    (a.min(v), b.max(v))
635                });
636            let rng = if hi > lo { hi - lo } else { 1.0 };
637            let (sx0, sw) = (x + BOX_PAD_X, pn.w - 2.0 * BOX_PAD_X);
638            let (top, sh) = (base - 8.0, SPARK_H - 8.0);
639            let last = values.len() - 1;
640            let col = hex(*colour);
641            for (i, &sv) in values.iter().enumerate() {
642                let (px, py) = (
643                    sx0 + sw * i as f64 / last as f64,
644                    top + sh - (sv - lo) / rng * sh,
645                );
646                spark.num("x", px).num("y", py).txt("g", id.clone());
647                spark.txt("col", col.clone()).txt("of", id.clone());
648                if i == last {
649                    ends.num("x", px).num("y", py);
650                    ends.txt("col", col.clone()).txt("of", id.clone());
651                }
652            }
653        }
654    }
655    for (stroke, mut f) in boxes {
656        let aes = f.attr_aes(rect_aes().tooltip("tip"));
657        plot = plot
658            .geom_rect_with(
659                GeomRect {
660                    fill: s.card,
661                    color: stroke,
662                    alpha: 1.0,
663                    line_width: 1.2,
664                    ..Default::default()
665                }
666                .with_radius(8.0),
667            )
668            .layer_data(f.take(h))
669            .layer_aes(aes)
670            .layer_svg_attr("class", class("node"));
671    }
672    // everything below: dims / lights with its node (`data-of`), inert
673    let of = |a: Aes| a.group_wrap("of", "of");
674    if !accent.is_empty() {
675        plot = plot
676            .geom_rect_with(
677                GeomRect {
678                    alpha: 1.0,
679                    line_width: 0.0,
680                    ..Default::default()
681                }
682                .with_radius(1.5),
683            )
684            .layer_data(accent.take(h))
685            .layer_aes(of(rect_aes().fill("fill")))
686            .decorative()
687            .layer_svg_attrs(inert);
688    }
689    for (mut f, size, colour, cls) in [
690        (lines, LABEL_PX, s.heading, class("node-label")),
691        (values, VALUE_PX, s.ink, class("node-value")),
692    ] {
693        if f.is_empty() {
694            continue;
695        }
696        plot = plot
697            .geom_text_with(GeomText {
698                size,
699                color: colour,
700                fontface: FontFace::Bold,
701                ..Default::default()
702            })
703            .layer_data(f.take(h))
704            .layer_aes(of(Aes::new().x("x").y("y").label("label")))
705            .layer_svg_attrs(inert)
706            .layer_svg_attrs([("aria-hidden", "true"), ("class", cls.as_str())]);
707    }
708    for (dot, mut f) in badges {
709        plot = plot
710            .geom_rect_with(
711                GeomRect {
712                    fill: s.card,
713                    color: dot,
714                    alpha: 0.0,
715                    line_width: 1.0,
716                    ..Default::default()
717                }
718                .with_radius(8.0),
719            )
720            .layer_data(f.take(h))
721            .layer_aes(of(rect_aes()))
722            .decorative()
723            .layer_svg_attrs(inert);
724    }
725    for (mut f, size) in [(dots, 3.0), (ends, 2.2)] {
726        if f.is_empty() {
727            continue;
728        }
729        plot = plot
730            .geom_point_with(GeomPoint {
731                size,
732                ..Default::default()
733            })
734            .layer_data(f.take(h))
735            .layer_aes(of(Aes::new().x("x").y("y").color("col")))
736            .decorative()
737            .layer_svg_attrs(inert);
738    }
739    if !btext.is_empty() {
740        plot = plot
741            .geom_text_with(GeomText {
742                size: BADGE_PX,
743                color: s.ink,
744                hjust: 0.0,
745                ..Default::default()
746            })
747            .layer_data(btext.take(h))
748            .layer_aes(of(Aes::new().x("x").y("y").label("label")))
749            .layer_svg_attrs(inert)
750            .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("node-badge"))]);
751    }
752    if !spark.is_empty() {
753        plot = plot
754            .geom_path_with(GeomPath::default().linewidth(1.4))
755            .layer_data(spark.take(h))
756            .layer_aes(of(Aes::new().x("x").y("y").group("g").color("col")))
757            .decorative()
758            .layer_svg_attrs(inert)
759            .layer_svg_attr("class", class("node-spark"));
760    }
761    plot
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use crate::graph_layout::{layered, GraphEdge, GraphNode, LayeredParams};
768
769    #[test]
770    fn wraps_long_labels_in_three_lines() {
771        let l = wrap(
772            "Recommend replenishment orders for every store and every week of the coming year",
773            152.0,
774        );
775        assert_eq!(l.len(), 3);
776        assert!(l[2].ends_with('…'));
777        assert_eq!(wrap("Ingest", 152.0), ["Ingest"]);
778    }
779
780    /// No ellipsis while the label fits; breaks at word boundaries and
781    /// after `/` / `-`, never mid-word when there is a separator.
782    #[test]
783    fn wraps_at_word_boundaries() {
784        let max = BOX_MAX_W - 2.0 * BOX_PAD_X;
785        for label in [
786            "duckdb/community-extensions (descriptions)",
787            "Locked DuckDB server (anofox-visualization)",
788            "Parquet snapshot (downloads, extensions)",
789            "fetch.sh: download, file by ISO week, merge",
790        ] {
791            let l = wrap(label, max);
792            assert!(l.len() <= MAX_LINES, "{l:?}");
793            assert!(!l.iter().any(|x| x.contains('…')), "{label}: {l:?}");
794            // every line ends at a space or after a separator
795            let joined: String = l.concat();
796            assert_eq!(joined.replace(' ', ""), label.replace(' ', ""), "{l:?}");
797            for w in l.windows(2) {
798                let end = w[0].chars().last().unwrap();
799                let next = w[1].chars().next().unwrap();
800                let at_space = label.contains(&format!(
801                    "{} {}",
802                    w[0].split(' ').next_back().unwrap(),
803                    w[1].split(' ').next().unwrap()
804                ));
805                assert!(
806                    at_space || matches!(end, '/' | '-' | '_' | '.' | ','),
807                    "{l:?} breaks at {end}{next}"
808                );
809            }
810        }
811        assert_eq!(
812            wrap("duckdb/community-extensions (descriptions)", max),
813            ["duckdb/community-", "extensions", "(descriptions)"]
814        );
815    }
816
817    #[test]
818    fn node_box_grows_with_its_contents() {
819        let plain = node_box("Ingest", None, None, false);
820        assert_eq!((plain.w, plain.h), (BOX_MIN_W, 2.0 * BOX_PAD_Y + LINE_H));
821        let full = node_box("Ingest", Some("1,240"), Some("late"), true);
822        assert_eq!(full.h, plain.h + LINE_H + 20.0 + SPARK_H);
823        assert!(full.w >= 110.0 + 2.0 * BOX_PAD_X);
824        let long = node_box(&"word ".repeat(40), None, None, false);
825        assert!(long.w <= BOX_MAX_W && long.lines.len() == MAX_LINES);
826    }
827
828    /// An edge label a node box would cover moves along the edge.
829    #[test]
830    fn edge_label_keeps_clear_of_nodes() {
831        let node = |cx: f64, w: f64| PlacedNode {
832            cx,
833            cy: 0.0,
834            w,
835            h: 40.0,
836            layer: 0,
837            order: 0,
838        };
839        let pts = [(0.0, 0.0), (300.0, 0.0)];
840        // nothing in the way: the midpoint
841        assert_eq!(edge_label_at(&pts, "spikes flagged", &[]), (150.0, 0.0));
842        // a box over the middle: somewhere else along the edge, clear of it
843        let nodes = [node(150.0, 60.0)];
844        let (x, _) = edge_label_at(&pts, "spikes flagged", &nodes);
845        let hw = edge_label_width("spikes flagged") / 2.0 + 3.0;
846        assert!((x - 150.0).abs() >= hw + 30.0, "{x}");
847        assert_eq!(point_at(&pts, 0.25), (75.0, 0.0));
848    }
849
850    #[test]
851    fn flow_width_is_area_true_and_rounded() {
852        assert_eq!(flow_width(None, 10.0, EdgeLine::Solid), 1.6);
853        assert_eq!(flow_width(Some(10.0), 10.0, EdgeLine::Solid), 7.0);
854        assert_eq!(flow_width(Some(2.5), 10.0, EdgeLine::Solid), 4.2);
855        assert_eq!(flow_width(None, 10.0, EdgeLine::Dots), 3.0);
856    }
857
858    /// Two lanes, three boxes, a labelled edge: the marks carry the
859    /// caller's attributes and classes, a node's contents sit in its
860    /// `data-of` group, and the box itself (the hit target) stays outside.
861    #[test]
862    fn draws_marks_with_attributes_and_groups() {
863        let labels = ["Ingest", "Model", "Publish"];
864        let boxes: Vec<NodeBox> = labels
865            .iter()
866            .enumerate()
867            .map(|(i, l)| node_box(l, (i == 1).then_some("42"), None, i == 2))
868            .collect();
869        let gnodes: Vec<GraphNode> = boxes
870            .iter()
871            .enumerate()
872            .map(|(i, b)| GraphNode {
873                w: b.w,
874                h: b.h,
875                lane: Some(i % 2),
876                ..Default::default()
877            })
878            .collect();
879        let gedges = [
880            GraphEdge {
881                from: 0,
882                to: 1,
883                label_w: Some(edge_label_width("rows")),
884            },
885            GraphEdge {
886                from: 1,
887                to: 2,
888                label_w: None,
889            },
890        ];
891        let params = LayeredParams {
892            lanes: 2,
893            ..Default::default()
894        };
895        let l = layered(&gnodes, &gedges, &params);
896        let nodes: Vec<DrawNode> = labels
897            .iter()
898            .zip(&boxes)
899            .enumerate()
900            .map(|(i, (id, b))| DrawNode {
901                id: id.to_string(),
902                lines: b.lines.clone(),
903                value: (i == 1).then(|| "42".to_string()),
904                stroke: (90, 90, 90),
905                accent: (i == 0).then_some((200, 0, 0)),
906                spark: (i == 2).then(|| (vec![1.0, 3.0, 2.0], (0, 0, 200))),
907                tooltip: format!("{id}!"),
908                attrs: vec![
909                    ("node".to_string(), Some(id.to_string())),
910                    ("value".to_string(), (i == 1).then(|| "42".to_string())),
911                ],
912                ..Default::default()
913            })
914            .collect();
915        let edges: Vec<DrawEdge> = [(0, 1), (1, 2)]
916            .iter()
917            .map(|&(a, b)| DrawEdge {
918                colour: (100, 100, 100),
919                width: flow_width(None, 0.0, EdgeLine::Solid),
920                line: if a == 0 {
921                    EdgeLine::Solid
922                } else {
923                    EdgeLine::Dashed
924                },
925                class: (a == 1).then(|| "flowing".to_string()),
926                tooltip: format!("{} → {}", labels[a], labels[b]),
927                label: (a == 0).then(|| "rows".to_string()),
928                attrs: vec![("edge".to_string(), Some(format!("{a}>{b}")))],
929                label_attrs: vec![("from".to_string(), Some(labels[a].to_string()))],
930            })
931            .collect();
932        let lanes = ["Left".to_string(), "Right".to_string()];
933        let svg = draw(
934            &l,
935            &lanes,
936            &nodes,
937            &edges,
938            &GraphStyle::default(),
939            crate::theme::presets::theme_void(),
940        )
941        .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
942        .unwrap();
943        // start tags, each with whether it sits inside a `data-of` group
944        let mut tags: Vec<(&str, bool)> = Vec::new();
945        let mut stack: Vec<bool> = Vec::new();
946        for part in svg.split('<').skip(1) {
947            let tag = &part[..part.find('>').unwrap()];
948            if tag.starts_with('/') {
949                stack.pop();
950                continue;
951            }
952            let inside = stack.iter().any(|&g| g);
953            tags.push((tag, inside));
954            if !tag.ends_with('/') {
955                stack.push(tag.contains("data-of="));
956            }
957        }
958        let attr = |tag: &str, name: &str| {
959            let key = format!(" {name}=\"");
960            tag.find(&key).map(|i| {
961                let rest = &tag[i + key.len()..];
962                rest[..rest.find('"').unwrap()].to_string()
963            })
964        };
965        // boxes: one per node, outside the groups, an NA attribute left off
966        let boxes: Vec<_> = tags
967            .iter()
968            .filter(|(t, _)| t.contains("data-node="))
969            .collect();
970        assert_eq!(boxes.len(), 3);
971        for (b, inside) in &boxes {
972            assert!(!inside, "{b}");
973            let id = attr(b, "data-node").unwrap();
974            assert_eq!(attr(b, "data-value").is_some(), id == "Model", "{id}");
975        }
976        // edges: the caller's class on a path; the label's attributes
977        assert!(tags.iter().any(|(t, _)| t.contains("data-edge=\"1&gt;2\"")
978            && attr(t, "class").is_some_and(|c| c.contains("flowing"))));
979        assert!(tags
980            .iter()
981            .any(|(t, _)| attr(t, "data-from").as_deref() == Some("Ingest")));
982        assert!(svg.contains(" gg-graph-node\"") && svg.contains("gg-graph-edge-label"));
983        // node contents grouped by node; lanes drawn with their names
984        for id in labels {
985            assert!(svg.contains(&format!("data-of=\"{id}\"")), "{id}");
986        }
987        assert!(svg.contains("data-lane=\"Right\""));
988        assert!(svg.contains(">Left<") && svg.contains(">42<"));
989    }
990}