1use 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
25const LABEL_PX: f64 = 13.0;
27const VALUE_PX: f64 = 12.0;
28const BADGE_PX: f64 = 11.0;
29const 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;
36const SPARK_H: f64 = 22.0;
38const ARROW: f64 = 7.0;
41const MAX_LINES: usize = 3;
43
44fn 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
50pub 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
59fn wrap(s: &str, max: f64) -> Vec<String> {
65 let cap = ((max / (LABEL_PX * 0.6)).floor() as usize).max(4);
66 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#[derive(Clone, Debug, PartialEq)]
116pub struct NodeBox {
117 pub w: f64,
118 pub h: f64,
119 pub lines: Vec<String>,
120}
121
122pub 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#[derive(Clone, Debug)]
153pub struct GraphStyle {
154 pub card: Rgb,
156 pub band: Rgb,
158 pub muted: Rgb,
160 pub heading: Rgb,
162 pub ink: Rgb,
164 pub variable_fill: Rgb,
166 pub observed_fill: Rgb,
169 pub plate: Rgb,
171 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
200pub enum NodeShape {
201 #[default]
203 Box,
204 Circle,
205 FilledCircle,
206 Square,
208 FilledSquare,
210 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
227const SQUARE: f64 = 14.0;
229
230pub 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#[derive(Clone, Debug, Default)]
261pub struct Plate {
262 pub label: String,
263 pub nodes: Vec<usize>,
264}
265
266pub type DataAttr = (String, Option<String>);
269
270#[derive(Clone, Debug, Default)]
273pub struct DrawNode {
274 pub id: String,
276 pub shape: NodeShape,
278 pub lines: Vec<String>,
280 pub value: Option<String>,
282 pub badge: Option<(String, Rgb)>,
284 pub stroke: Rgb,
286 pub accent: Option<Rgb>,
288 pub spark: Option<(Vec<f64>, Rgb)>,
290 pub tooltip: String,
291 pub attrs: Vec<DataAttr>,
294}
295
296#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
298pub enum EdgeLine {
299 #[default]
300 Solid,
301 Dashed,
302 Dots,
304}
305
306#[derive(Clone, Debug, Default)]
310pub struct DrawEdge {
311 pub colour: Rgb,
312 pub width: f64,
314 pub line: EdgeLine,
315 pub class: Option<String>,
317 pub tooltip: String,
318 pub label: Option<String>,
320 pub label_at: Option<f64>,
324 pub undirected: bool,
326 pub attrs: Vec<DataAttr>,
329 pub label_attrs: Vec<DataAttr>,
331}
332
333pub 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#[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 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 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 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
418fn 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
435fn 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
455fn 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
476fn mid_of_baseline(baseline: f64, px: f64) -> f64 {
479 baseline - 0.36 * px
480}
481
482pub 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 .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 ("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 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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 assert_eq!(edge_label_at(&pts, "spikes flagged", &[]), (150.0, 0.0));
1084 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 #[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, ¶ms);
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 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 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 assert!(tags.iter().any(|(t, _)| t.contains("data-edge=\"1>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 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 #[test]
1240 fn draws_a_factor_graph_with_shapes_and_a_plate() {
1241 let n = 3;
1242 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 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 let params = LayeredParams {
1280 margin: 32.0,
1281 ..Default::default()
1282 };
1283 let l = layered(&gnodes, &gedges, ¶ms);
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 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 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 assert!(svg.contains("data-plate=\"i = 1..3\"") && svg.contains(">i = 1..3<"));
1343 assert!(svg.contains(&format!(
1345 "fill=\"{}\"",
1346 hex(style.observed_fill).to_uppercase()
1347 )));
1348 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 let c = &l.nodes[1];
1358 assert!(svg.contains(&format!("rx=\"{:.2}\"", c.w / 2.0)), "{}", c.w);
1359 }
1360
1361 #[test]
1365 fn banded_hmm_keeps_the_state_chain_straight() {
1366 use crate::graph_layout::{banded, BandedParams};
1367 let t_n = 5;
1368 let mut spec: Vec<(String, NodeShape, usize)> = vec![
1370 ("Dir".into(), NodeShape::Box, 0), ("A".into(), NodeShape::Square, 0), ("Dir".into(), NodeShape::Box, 0), ("B".into(), NodeShape::Square, 0), ("Cat".into(), NodeShape::Box, 1), ("s₀".into(), NodeShape::Circle, 1), ];
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 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 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 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}