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 pub math_font: Option<String>,
182 pub glyph_fill: Option<Rgb>,
184 pub glyph_ink: Option<Rgb>,
186}
187
188impl Default for GraphStyle {
189 fn default() -> Self {
190 GraphStyle {
191 card: (255, 255, 255),
192 band: (226, 228, 233),
193 muted: (107, 114, 128),
194 heading: (17, 24, 39),
195 ink: (31, 41, 55),
196 variable_fill: (229, 231, 235),
197 observed_fill: (75, 85, 99),
198 plate: (107, 114, 128),
199 class_prefix: "gg-graph-".to_string(),
200 math_font: None,
201 glyph_fill: None,
202 glyph_ink: None,
203 }
204 }
205}
206
207#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
212pub enum NodeShape {
213 #[default]
215 Box,
216 Circle,
217 FilledCircle,
218 Square,
220 FilledSquare,
222 Text,
224 Glyph,
227}
228
229impl NodeShape {
230 fn name(self) -> &'static str {
231 match self {
232 NodeShape::Box => "box",
233 NodeShape::Circle => "circle",
234 NodeShape::FilledCircle => "filled-circle",
235 NodeShape::Square => "square",
236 NodeShape::FilledSquare => "filled-square",
237 NodeShape::Text => "text",
238 NodeShape::Glyph => "glyph",
239 }
240 }
241}
242
243const LABEL_REACH: f64 = 22.0;
245pub const BELIEF_W: f64 = 46.0;
247pub const BELIEF_H: f64 = 16.0;
248const SQUARE: f64 = 14.0;
250const GLYPH_SIDE: f64 = 34.0;
252const GLYPH_PX: f64 = 15.0;
253
254pub fn node_size(shape: NodeShape, label: &str) -> NodeBox {
258 let one = || vec![label.to_string()];
259 match shape {
260 NodeShape::Box => node_box(label, None, None, false),
261 NodeShape::Circle | NodeShape::FilledCircle => {
262 let d = (text_w(label, LABEL_PX, true) + 14.0).max(30.0);
263 NodeBox {
264 w: d,
265 h: d,
266 lines: one(),
267 }
268 }
269 NodeShape::Square | NodeShape::FilledSquare => NodeBox {
270 w: SQUARE,
271 h: SQUARE,
272 lines: if label.is_empty() { Vec::new() } else { one() },
273 },
274 NodeShape::Text => NodeBox {
275 w: text_w(label, LABEL_PX, false) + 8.0,
276 h: 20.0,
277 lines: one(),
278 },
279 NodeShape::Glyph => {
280 let side = (text_w(label, GLYPH_PX, true) + 14.0).max(GLYPH_SIDE);
281 NodeBox {
282 w: side,
283 h: GLYPH_SIDE,
284 lines: one(),
285 }
286 }
287 }
288}
289
290#[derive(Clone, Debug, Default)]
293pub struct Plate {
294 pub label: String,
295 pub nodes: Vec<usize>,
296}
297
298pub type DataAttr = (String, Option<String>);
301
302#[derive(Clone, Debug, Default)]
305pub struct DrawNode {
306 pub id: String,
308 pub shape: NodeShape,
310 pub lines: Vec<String>,
312 pub value: Option<String>,
314 pub badge: Option<(String, Rgb)>,
316 pub stroke: Rgb,
318 pub accent: Option<Rgb>,
320 pub spark: Option<(Vec<f64>, Rgb)>,
322 pub belief: Option<(Vec<f64>, Rgb)>,
325 pub prior: Option<(Vec<f64>, Rgb)>,
329 pub tooltip: String,
330 pub attrs: Vec<DataAttr>,
333}
334
335#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
337pub enum EdgeLine {
338 #[default]
339 Solid,
340 Dashed,
341 Dots,
343}
344
345#[derive(Clone, Debug, Default)]
349pub struct DrawEdge {
350 pub colour: Rgb,
351 pub width: f64,
353 pub line: EdgeLine,
354 pub class: Option<String>,
356 pub tooltip: String,
357 pub label: Option<String>,
359 pub label_at: Option<f64>,
363 pub undirected: bool,
365 pub attrs: Vec<DataAttr>,
368 pub label_attrs: Vec<DataAttr>,
370}
371
372pub fn flow_width(value: Option<f64>, max: f64, line: EdgeLine) -> f64 {
376 let width = match value {
377 Some(v) if max > 0.0 => 1.4 + 5.6 * (v.max(0.0) / max).sqrt(),
378 _ => 1.6,
379 };
380 let width = match line {
381 EdgeLine::Dots => width.clamp(3.0, 6.0),
382 _ => width,
383 };
384 (width * 10.0).round() / 10.0
385}
386
387#[derive(Default)]
389struct Frame(Vec<(String, Vec<Value>)>);
390
391impl Frame {
392 fn col(&mut self, name: &str) -> &mut Vec<Value> {
393 let i = match self.0.iter().position(|(n, _)| n == name) {
394 Some(i) => i,
395 None => {
396 self.0.push((name.to_string(), Vec::new()));
397 self.0.len() - 1
398 }
399 };
400 &mut self.0[i].1
401 }
402 fn num(&mut self, name: &str, v: f64) -> &mut Self {
403 self.col(name).push(Value::Float(v));
404 self
405 }
406 fn txt(&mut self, name: &str, v: impl Into<String>) -> &mut Self {
407 self.col(name).push(Value::Str(v.into()));
408 self
409 }
410 fn opt(&mut self, name: &str, v: Option<String>) -> &mut Self {
411 self.col(name).push(v.map_or(Value::Na, Value::Str));
412 self
413 }
414 fn attrs(&mut self, attrs: &[DataAttr]) -> &mut Self {
416 for (n, v) in attrs {
417 self.opt(&format!("a:{n}"), v.clone());
418 }
419 self
420 }
421 fn is_empty(&self) -> bool {
422 self.0.first().is_none_or(|(_, v)| v.is_empty())
423 }
424 fn attr_aes(&self, mut aes: Aes) -> Aes {
426 for (n, _) in &self.0 {
427 if let Some(name) = n.strip_prefix("a:") {
428 aes = aes.data_attr(name, n);
429 }
430 }
431 aes
432 }
433 fn take(&mut self, h: f64) -> Vec<(String, Vec<Value>)> {
437 let flip = |v: Vec<Value>| -> Vec<Value> {
438 v.into_iter()
439 .map(|x| match x {
440 Value::Float(y) => Value::Float(h - y),
441 other => other,
442 })
443 .collect()
444 };
445 std::mem::take(&mut self.0)
446 .into_iter()
447 .map(|(n, v)| match n.as_str() {
448 "y" => (n, flip(v)),
449 "y0" => ("y1".to_string(), flip(v)),
450 "y1" => ("y0".to_string(), flip(v)),
451 _ => (n, v),
452 })
453 .collect()
454 }
455}
456
457fn by_colour(v: &mut Vec<(Rgb, Frame)>, c: Rgb) -> &mut Frame {
460 let i = match v.iter().position(|(k, _)| *k == c) {
461 Some(i) => i,
462 None => {
463 v.push((c, Frame::default()));
464 v.len() - 1
465 }
466 };
467 &mut v[i].1
468}
469
470fn hex(c: Rgb) -> String {
471 format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
472}
473
474fn point_at(pts: &[(f64, f64)], t: f64) -> (f64, f64) {
476 let seg = |i: usize| {
477 let (a, b) = (pts[i], pts[i + 1]);
478 ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()
479 };
480 let total: f64 = (0..pts.len().saturating_sub(1)).map(seg).sum();
481 let mut left = total * t;
482 for i in 0..pts.len().saturating_sub(1) {
483 let l = seg(i);
484 if l >= left && l > 0.0 {
485 let t = left / l;
486 let (a, b) = (pts[i], pts[i + 1]);
487 return (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t);
488 }
489 left -= l;
490 }
491 pts.first().copied().unwrap_or((0.0, 0.0))
492}
493
494fn edge_label_at(pts: &[(f64, f64)], label: &str, nodes: &[PlacedNode]) -> (f64, f64) {
498 let lines = label.split('\n').count();
499 let hw = edge_label_width(label) / 2.0 + 3.0;
500 let hh = lines as f64 * 6.6 + 3.0;
501 let clear = |(x, y): (f64, f64)| {
502 let cy = y - 4.0 - 0.36 * SMALL_PX;
503 nodes
504 .iter()
505 .all(|n| (x - n.cx).abs() >= hw + n.w / 2.0 || (cy - n.cy).abs() >= hh + n.h / 2.0)
506 };
507 let mid = point_at(pts, 0.5);
508 [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74, 0.18, 0.82]
509 .into_iter()
510 .map(|t| point_at(pts, t))
511 .find(|&p| clear(p))
512 .unwrap_or(mid)
513}
514
515fn mid_of_baseline(baseline: f64, px: f64) -> f64 {
518 baseline - 0.36 * px
519}
520
521pub fn draw(
528 layout: &GraphLayout,
529 lanes: &[String],
530 plates: &[Plate],
531 nodes: &[DrawNode],
532 edges: &[DrawEdge],
533 style: &GraphStyle,
534 mut theme: Theme,
535) -> GGPlot {
536 let s = style;
537 let class = |name: &str| format!("{}{name}", s.class_prefix);
538 let (w, h) = (layout.w.ceil().max(1.0), layout.h.ceil().max(1.0));
539 theme.legend_position = crate::theme::LegendPosition::None;
540 let mut plot = GGPlot::new(vec![
541 ("x".to_string(), vec![Value::Float(0.0), Value::Float(w)]),
542 ("y".to_string(), vec![Value::Float(0.0), Value::Float(h)]),
543 ])
544 .theme(theme)
545 .scale_x_continuous(ScaleContinuous::new().with_expand(0.0, 0.0))
548 .scale_y_continuous(ScaleContinuous::new().with_expand(0.0, 0.0))
549 .coord_cartesian_zoom(Some((0.0, w)), Some((0.0, h)))
550 .scale_color_identity()
551 .scale_fill_identity()
552 .scale_linewidth(
553 ScaleLinewidthContinuous::new()
554 .with_limits(0.0, 16.0)
555 .with_range(0.0, 16.0),
556 )
557 .scale_linetype_manual(vec![
558 ("solid", Linetype::Solid),
559 ("dashed", Linetype::Custom(vec![(6.0, 4.0)])),
560 ("dots", Linetype::Custom(vec![(0.0, 10.0)])),
562 ]);
563 let rect_aes = || Aes::new().xmin("x0").xmax("x1").ymin("y0").ymax("y1");
564 let inert = [("pointer-events", "none")];
565
566 let (mut even, mut odd, mut names) = (Frame::default(), Frame::default(), Frame::default());
568 for &(l, x0, y0, x1, y1) in &layout.bands {
569 let name = lanes.get(l).cloned().unwrap_or_default();
570 let f = if l % 2 == 0 { &mut even } else { &mut odd };
571 f.num("x0", x0).num("x1", x1).num("y0", y0).num("y1", y1);
572 f.txt("lane", name.clone());
573 names
574 .num("x", x0 + 8.0)
575 .num("y", mid_of_baseline(y0 + 13.0, SMALL_PX));
576 names.txt("label", name);
577 }
578 for (mut f, alpha) in [(even, 0.35), (odd, 0.18)] {
579 if f.is_empty() {
580 continue;
581 }
582 plot = plot
583 .geom_rect_with(
584 GeomRect {
585 fill: s.band,
586 alpha,
587 line_width: 0.0,
588 ..Default::default()
589 }
590 .with_radius(6.0),
591 )
592 .layer_data(f.take(h))
593 .layer_aes(rect_aes().data_attr("lane", "lane"))
594 .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("lane"))]);
595 }
596 if !names.is_empty() {
597 plot = plot
598 .geom_text_with(GeomText {
599 size: SMALL_PX,
600 color: s.muted,
601 hjust: 0.0,
602 fontface: FontFace::Bold,
603 ..Default::default()
604 })
605 .layer_data(names.take(h))
606 .layer_aes(Aes::new().x("x").y("y").label("label"))
607 .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("lane-label"))]);
608 }
609
610 let (mut pf, mut pl) = (Frame::default(), Frame::default());
612 for p in plates {
613 let members: Vec<&PlacedNode> = p
614 .nodes
615 .iter()
616 .filter_map(|&i| layout.nodes.get(i))
617 .collect();
618 if members.is_empty() {
619 continue;
620 }
621 let pad = 8.0;
622 let x0 = members
623 .iter()
624 .map(|n| n.cx - n.w / 2.0)
625 .fold(f64::INFINITY, f64::min)
626 - pad;
627 let x1 = members
628 .iter()
629 .map(|n| n.cx + n.w / 2.0)
630 .fold(f64::NEG_INFINITY, f64::max)
631 + pad;
632 let y0 = members
633 .iter()
634 .map(|n| n.cy - n.h / 2.0)
635 .fold(f64::INFINITY, f64::min)
636 - pad
637 - 14.0;
638 let y1 = members
639 .iter()
640 .map(|n| n.cy + n.h / 2.0)
641 .fold(f64::NEG_INFINITY, f64::max)
642 + pad;
643 for (x, y) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] {
644 pf.num("x", x).num("y", y).txt("lt", "dashed");
645 pf.txt("g", p.label.clone()).txt("plate", p.label.clone());
646 }
647 pl.num("x", x0 + 6.0)
648 .num("y", mid_of_baseline(y0 + 13.0, SMALL_PX));
649 pl.txt("label", p.label.clone());
650 }
651 if !pf.is_empty() {
652 plot = plot
653 .geom_path_with(GeomPath::default().color(s.plate).linewidth(1.0))
654 .layer_data(pf.take(h))
655 .layer_aes(
656 Aes::new()
657 .x("x")
658 .y("y")
659 .group("g")
660 .linetype("lt")
661 .data_attr("plate", "plate"),
662 )
663 .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("plate"))]);
664 plot = plot
665 .geom_text_with(GeomText {
666 size: SMALL_PX,
667 color: s.plate,
668 hjust: 0.0,
669 ..Default::default()
670 })
671 .layer_data(pl.take(h))
672 .layer_aes(Aes::new().x("x").y("y").label("label"))
673 .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("plate-label"))]);
674 }
675
676 let (mut ef, mut uf, mut lf) = (Frame::default(), Frame::default(), Frame::default());
678 for (k, e) in edges.iter().enumerate() {
679 let Some(route) = layout.edges.get(k) else {
680 continue;
681 };
682 let mut pts = route.clone();
683 if pts.len() < 2 {
684 continue;
685 }
686 if !e.undirected {
691 let n = pts.len();
692 let ((fx, fy), (tx, ty)) = (pts[n - 2], pts[n - 1]);
693 let len = ((tx - fx).powi(2) + (ty - fy).powi(2)).sqrt().max(1e-6);
694 let pull = e.width.min(len / 2.0);
695 pts[n - 1] = (tx - (tx - fx) / len * pull, ty - (ty - fy) / len * pull);
696 }
697 let ef = if e.undirected { &mut uf } else { &mut ef };
698 let lt = match e.line {
699 EdgeLine::Solid => "solid",
700 EdgeLine::Dashed => "dashed",
701 EdgeLine::Dots => "dots",
702 };
703 for &(x, y) in &pts {
704 ef.num("x", x).num("y", y).txt("g", format!("e{k:04}"));
705 ef.txt("col", hex(e.colour))
706 .num("lw", e.width)
707 .txt("lt", lt);
708 ef.opt("cls", e.class.clone()).txt("tip", e.tooltip.clone());
709 ef.attrs(&e.attrs);
710 }
711 let routed = layout.routed.get(k).copied().unwrap_or(false);
714 if let Some(l) = e.label.as_ref().filter(|_| !routed) {
715 let (mx, my) = match e.label_at {
716 Some(t) => {
718 let t = t.clamp(0.0, 1.0);
719 let len: f64 = route
720 .windows(2)
721 .map(|w| ((w[1].0 - w[0].0).powi(2) + (w[1].1 - w[0].1).powi(2)).sqrt())
722 .sum();
723 let reach = |f: f64| {
724 if len > 0.0 {
725 (len * f).min(LABEL_REACH) / len
726 } else {
727 f
728 }
729 };
730 point_at(
731 route,
732 if t >= 0.5 {
733 1.0 - reach(1.0 - t)
734 } else {
735 reach(t)
736 },
737 )
738 }
739 None => edge_label_at(route, l, &layout.nodes),
740 };
741 lf.num("x", mx)
742 .num("y", mid_of_baseline(my - 4.0, SMALL_PX));
743 lf.txt("label", l.clone());
744 lf.attrs(&e.label_attrs);
745 }
746 }
747 for (mut f, arrowed) in [(ef, true), (uf, false)] {
748 if f.is_empty() {
749 continue;
750 }
751 let aes = f.attr_aes(
752 Aes::new()
753 .x("x")
754 .y("y")
755 .group("g")
756 .color("col")
757 .linewidth("lw")
758 .linetype("lt")
759 .class("cls")
760 .tooltip("tip"),
761 );
762 let mut geom = GeomPath::default().lineend(LineEnd::Round);
763 if arrowed {
764 geom = geom.arrow(
766 arrow()
767 .closed()
768 .angle(29.0)
769 .length(ARROW + 1.0)
770 .length_per_linewidth(1.2),
771 );
772 }
773 plot = plot
774 .geom_path_with(geom)
775 .layer_data(f.take(h))
776 .layer_aes(aes)
777 .layer_svg_attr("class", class("edge"));
778 }
779 if !lf.is_empty() {
780 let aes = lf.attr_aes(Aes::new().x("x").y("y").label("label"));
782 plot = plot
783 .geom_text_with(
784 GeomText {
785 size: SMALL_PX,
786 color: s.muted,
787 ..Default::default()
788 }
789 .bg_colour(s.card)
790 .bg_r(2.0),
791 )
792 .layer_data(lf.take(h))
793 .layer_aes(aes)
794 .layer_svg_attrs(inert)
795 .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("edge-label"))]);
796 }
797
798 let mut order: Vec<usize> = (0..nodes.len().min(layout.nodes.len())).collect();
801 order.sort_by_key(|&i| (layout.nodes[i].order, i));
802 let mut boxes: Vec<(Rgb, Frame)> = Vec::new();
803 let mut beliefs = Frame::default();
804 let mut priors = Frame::default();
805 let mut shaped: Vec<((NodeShape, u64, Rgb), Frame)> = Vec::new();
807 let (mut inside, mut inverse, mut above, mut glyphs, mut texts) = (
808 Frame::default(),
809 Frame::default(),
810 Frame::default(),
811 Frame::default(),
812 Frame::default(),
813 );
814 let mut badges: Vec<(Rgb, Frame)> = Vec::new();
815 let (mut accent, mut lines, mut values) =
816 (Frame::default(), Frame::default(), Frame::default());
817 let (mut dots, mut btext, mut spark, mut ends) = (
818 Frame::default(),
819 Frame::default(),
820 Frame::default(),
821 Frame::default(),
822 );
823 for v in order {
824 let nd = &nodes[v];
825 let pn = &layout.nodes[v];
826 let id = nd.id.clone();
827 let (x, y) = (pn.cx - pn.w / 2.0, pn.cy - pn.h / 2.0);
828 let squared = matches!(nd.shape, NodeShape::Square | NodeShape::FilledSquare);
830 let base = y - if squared { 17.0 } else { 3.0 };
831 let x0 = pn.cx - BELIEF_W / 2.0;
832 let curve = |hs: &[f64]| -> Vec<(f64, f64)> {
833 let last = (hs.len() - 1) as f64;
834 hs.iter()
835 .enumerate()
836 .map(|(i, &h)| {
837 (
838 x0 + BELIEF_W * i as f64 / last,
839 base - h.clamp(0.0, 1.0) * BELIEF_H,
840 )
841 })
842 .collect()
843 };
844 if let Some((hs, col)) = nd.prior.as_ref().filter(|(h, _)| h.len() >= 3) {
846 for (px, py) in curve(hs) {
847 priors.num("x", px).num("y", py).txt("g", id.clone());
848 priors.txt("col", hex(*col)).txt("lt", "dashed");
849 priors.txt("of", id.clone());
850 }
851 }
852 if let Some((hs, col)) = nd.belief.as_ref().filter(|(h, _)| h.len() >= 3) {
853 let mut pts = curve(hs);
854 pts.push((x0 + BELIEF_W, base));
855 pts.push((x0, base));
856 for (px, py) in pts {
857 beliefs.num("x", px).num("y", py).txt("g", id.clone());
858 beliefs.txt("fill", hex(*col)).txt("of", id.clone());
859 }
860 }
861 if nd.shape != NodeShape::Box {
862 let r = match nd.shape {
863 NodeShape::Circle | NodeShape::FilledCircle => pn.w.min(pn.h) / 2.0,
864 NodeShape::Square | NodeShape::FilledSquare => 1.5,
865 NodeShape::Glyph => 4.0,
866 _ => 0.0,
867 };
868 let (fill, stroke) = match nd.shape {
869 NodeShape::Circle => (s.variable_fill, nd.stroke),
870 NodeShape::FilledCircle | NodeShape::FilledSquare => {
871 (s.observed_fill, s.observed_fill)
872 }
873 NodeShape::Square => (s.card, nd.stroke),
874 NodeShape::Glyph => (s.glyph_fill.unwrap_or(s.card), nd.stroke),
875 _ => (s.card, s.card),
876 };
877 let key = (nd.shape, r.to_bits(), stroke);
878 let i = match shaped.iter().position(|(k, _)| *k == key) {
879 Some(i) => i,
880 None => {
881 shaped.push((key, Frame::default()));
882 shaped.len() - 1
883 }
884 };
885 let f = &mut shaped[i].1;
886 f.num("x0", x)
887 .num("x1", x + pn.w)
888 .num("y0", y)
889 .num("y1", y + pn.h);
890 f.txt("fill", hex(fill));
891 f.txt("tip", nd.tooltip.clone()).attrs(&nd.attrs);
892 let label = nd.lines.join(" ");
893 if label.is_empty() {
894 continue;
895 }
896 let target = match nd.shape {
897 NodeShape::Glyph => &mut glyphs,
898 NodeShape::FilledCircle => &mut inverse,
899 NodeShape::Square | NodeShape::FilledSquare => &mut above,
900 NodeShape::Text => &mut texts,
902 _ => &mut inside,
903 };
904 let ly = match nd.shape {
905 NodeShape::Square | NodeShape::FilledSquare => y - 4.0 - 0.36 * SMALL_PX,
906 _ => pn.cy,
907 };
908 target.num("x", pn.cx).num("y", ly);
909 target.txt("label", label).txt("of", id.clone());
910 continue;
911 }
912 let f = by_colour(&mut boxes, nd.stroke);
913 f.num("x0", x)
914 .num("x1", x + pn.w)
915 .num("y0", y)
916 .num("y1", y + pn.h);
917 f.txt("tip", nd.tooltip.clone()).attrs(&nd.attrs);
918
919 if let Some(a) = nd.accent {
920 accent.num("x0", x + 1.5).num("x1", x + 4.5);
921 accent
922 .num("y0", y + 6.0)
923 .num("y1", y + 6.0 + (pn.h - 12.0).max(4.0));
924 accent.txt("fill", hex(a)).txt("of", id.clone());
925 }
926 let mut base = y + BOX_PAD_Y + 12.0;
927 for line in &nd.lines {
928 lines
929 .num("x", pn.cx)
930 .num("y", mid_of_baseline(base, LABEL_PX));
931 lines.txt("label", line.clone()).txt("of", id.clone());
932 base += LINE_H;
933 }
934 if let Some(fv) = &nd.value {
935 values
936 .num("x", pn.cx)
937 .num("y", mid_of_baseline(base, VALUE_PX));
938 values.txt("label", fv.clone()).txt("of", id.clone());
939 base += LINE_H;
940 }
941 if let Some((b, dot)) = &nd.badge {
942 let bw = text_w(b, BADGE_PX, false) + 20.0;
943 let (bx, by) = (pn.cx - bw / 2.0, base - 9.0);
944 let f = by_colour(&mut badges, *dot);
945 f.num("x0", bx)
946 .num("x1", bx + bw)
947 .num("y0", by)
948 .num("y1", by + 16.0);
949 f.txt("of", id.clone());
950 dots.num("x", bx + 9.0).num("y", by + 8.0);
951 dots.txt("col", hex(*dot)).txt("of", id.clone());
952 btext.num("x", bx + 15.0).num("y", by + 8.0);
953 btext.txt("label", b.clone()).txt("of", id.clone());
954 base += 20.0;
955 }
956 if let Some((values, colour)) = nd.spark.as_ref().filter(|(v, _)| v.len() >= 2) {
958 let (lo, hi) = values
959 .iter()
960 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &v| {
961 (a.min(v), b.max(v))
962 });
963 let rng = if hi > lo { hi - lo } else { 1.0 };
964 let (sx0, sw) = (x + BOX_PAD_X, pn.w - 2.0 * BOX_PAD_X);
965 let (top, sh) = (base - 8.0, SPARK_H - 8.0);
966 let last = values.len() - 1;
967 let col = hex(*colour);
968 for (i, &sv) in values.iter().enumerate() {
969 let (px, py) = (
970 sx0 + sw * i as f64 / last as f64,
971 top + sh - (sv - lo) / rng * sh,
972 );
973 spark.num("x", px).num("y", py).txt("g", id.clone());
974 spark.txt("col", col.clone()).txt("of", id.clone());
975 if i == last {
976 ends.num("x", px).num("y", py);
977 ends.txt("col", col.clone()).txt("of", id.clone());
978 }
979 }
980 }
981 }
982 for (stroke, mut f) in boxes {
983 let aes = f.attr_aes(rect_aes().tooltip("tip"));
984 plot = plot
985 .geom_rect_with(
986 GeomRect {
987 fill: s.card,
988 color: stroke,
989 alpha: 1.0,
990 line_width: 1.2,
991 ..Default::default()
992 }
993 .with_radius(8.0),
994 )
995 .layer_data(f.take(h))
996 .layer_aes(aes)
997 .layer_svg_attr("class", class("node"));
998 }
999 for ((shape, r, stroke), mut f) in shaped {
1000 let aes = f.attr_aes(rect_aes().fill("fill").tooltip("tip"));
1001 let cls = format!(
1002 "{} {}",
1003 class("node"),
1004 class(&format!("node-{}", shape.name()))
1005 );
1006 plot = plot
1007 .geom_rect_with(
1008 GeomRect {
1009 color: stroke,
1010 alpha: 1.0,
1011 line_width: if shape == NodeShape::Text { 0.0 } else { 1.2 },
1012 ..Default::default()
1013 }
1014 .with_radius(f64::from_bits(r)),
1015 )
1016 .layer_data(f.take(h))
1017 .layer_aes(aes)
1018 .layer_svg_attr("class", cls);
1019 }
1020 if !priors.is_empty() {
1021 plot = plot
1022 .geom_path_with(GeomPath {
1023 alpha: 0.7,
1024 ..GeomPath::default().linewidth(1.0)
1025 })
1026 .layer_data(priors.take(h))
1027 .layer_aes(
1028 Aes::new()
1029 .x("x")
1030 .y("y")
1031 .group("g")
1032 .color("col")
1033 .linetype("lt")
1034 .group_wrap("of", "of"),
1035 )
1036 .decorative()
1037 .layer_svg_attrs(inert)
1038 .layer_svg_attr("class", class("node-prior"));
1039 }
1040 if !beliefs.is_empty() {
1041 plot = plot
1042 .geom_polygon_with(GeomPolygon {
1043 alpha: 0.45,
1044 line_width: 0.0,
1045 ..Default::default()
1046 })
1047 .layer_data(beliefs.take(h))
1048 .layer_aes(
1049 Aes::new()
1050 .x("x")
1051 .y("y")
1052 .group("g")
1053 .fill("fill")
1054 .group_wrap("of", "of"),
1055 )
1056 .decorative()
1057 .layer_svg_attrs(inert)
1058 .layer_svg_attr("class", class("node-belief"));
1059 }
1060 let of = |a: Aes| a.group_wrap("of", "of");
1062 if !accent.is_empty() {
1063 plot = plot
1064 .geom_rect_with(
1065 GeomRect {
1066 alpha: 1.0,
1067 line_width: 0.0,
1068 ..Default::default()
1069 }
1070 .with_radius(1.5),
1071 )
1072 .layer_data(accent.take(h))
1073 .layer_aes(of(rect_aes().fill("fill")))
1074 .decorative()
1075 .layer_svg_attrs(inert);
1076 }
1077 for (mut f, size, colour, cls, math) in [
1078 (lines, LABEL_PX, s.heading, class("node-label"), false),
1079 (values, VALUE_PX, s.ink, class("node-value"), false),
1080 (inside, LABEL_PX, s.heading, class("node-label"), true),
1081 (texts, LABEL_PX, s.heading, class("node-label"), false),
1082 (inverse, LABEL_PX, s.card, class("node-label"), true),
1083 (above, SMALL_PX, s.muted, class("node-label"), true),
1084 (
1085 glyphs,
1086 GLYPH_PX,
1087 s.glyph_ink.unwrap_or(s.heading),
1088 class("node-label"),
1089 true,
1090 ),
1091 ] {
1092 if f.is_empty() {
1093 continue;
1094 }
1095 let (fontfamily, fontface, size) = match s.math_font.as_ref().filter(|_| math) {
1097 Some(family) => (family.clone(), FontFace::Italic, size + 2.0),
1098 None => (String::new(), FontFace::Bold, size),
1099 };
1100 plot = plot
1101 .geom_text_with(GeomText {
1102 size,
1103 color: colour,
1104 fontface,
1105 fontfamily,
1106 ..Default::default()
1107 })
1108 .layer_data(f.take(h))
1109 .layer_aes(of(Aes::new().x("x").y("y").label("label")))
1110 .layer_svg_attrs(inert)
1111 .layer_svg_attrs([("aria-hidden", "true"), ("class", cls.as_str())]);
1112 }
1113 for (dot, mut f) in badges {
1114 plot = plot
1115 .geom_rect_with(
1116 GeomRect {
1117 fill: s.card,
1118 color: dot,
1119 alpha: 0.0,
1120 line_width: 1.0,
1121 ..Default::default()
1122 }
1123 .with_radius(8.0),
1124 )
1125 .layer_data(f.take(h))
1126 .layer_aes(of(rect_aes()))
1127 .decorative()
1128 .layer_svg_attrs(inert);
1129 }
1130 for (mut f, size) in [(dots, 3.0), (ends, 2.2)] {
1131 if f.is_empty() {
1132 continue;
1133 }
1134 plot = plot
1135 .geom_point_with(GeomPoint {
1136 size,
1137 ..Default::default()
1138 })
1139 .layer_data(f.take(h))
1140 .layer_aes(of(Aes::new().x("x").y("y").color("col")))
1141 .decorative()
1142 .layer_svg_attrs(inert);
1143 }
1144 if !btext.is_empty() {
1145 plot = plot
1146 .geom_text_with(GeomText {
1147 size: BADGE_PX,
1148 color: s.ink,
1149 hjust: 0.0,
1150 ..Default::default()
1151 })
1152 .layer_data(btext.take(h))
1153 .layer_aes(of(Aes::new().x("x").y("y").label("label")))
1154 .layer_svg_attrs(inert)
1155 .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("node-badge"))]);
1156 }
1157 if !spark.is_empty() {
1158 plot = plot
1159 .geom_path_with(GeomPath::default().linewidth(1.4))
1160 .layer_data(spark.take(h))
1161 .layer_aes(of(Aes::new().x("x").y("y").group("g").color("col")))
1162 .decorative()
1163 .layer_svg_attrs(inert)
1164 .layer_svg_attr("class", class("node-spark"));
1165 }
1166 plot
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::*;
1172 use crate::graph_layout::{layered, GraphEdge, GraphNode, LayeredParams};
1173
1174 #[test]
1175 fn wraps_long_labels_in_three_lines() {
1176 let l = wrap(
1177 "Recommend replenishment orders for every store and every week of the coming year",
1178 152.0,
1179 );
1180 assert_eq!(l.len(), 3);
1181 assert!(l[2].ends_with('…'));
1182 assert_eq!(wrap("Ingest", 152.0), ["Ingest"]);
1183 }
1184
1185 #[test]
1188 fn wraps_at_word_boundaries() {
1189 let max = BOX_MAX_W - 2.0 * BOX_PAD_X;
1190 for label in [
1191 "duckdb/community-extensions (descriptions)",
1192 "Locked DuckDB server (anofox-visualization)",
1193 "Parquet snapshot (downloads, extensions)",
1194 "fetch.sh: download, file by ISO week, merge",
1195 ] {
1196 let l = wrap(label, max);
1197 assert!(l.len() <= MAX_LINES, "{l:?}");
1198 assert!(!l.iter().any(|x| x.contains('…')), "{label}: {l:?}");
1199 let joined: String = l.concat();
1201 assert_eq!(joined.replace(' ', ""), label.replace(' ', ""), "{l:?}");
1202 for w in l.windows(2) {
1203 let end = w[0].chars().last().unwrap();
1204 let next = w[1].chars().next().unwrap();
1205 let at_space = label.contains(&format!(
1206 "{} {}",
1207 w[0].split(' ').next_back().unwrap(),
1208 w[1].split(' ').next().unwrap()
1209 ));
1210 assert!(
1211 at_space || matches!(end, '/' | '-' | '_' | '.' | ','),
1212 "{l:?} breaks at {end}{next}"
1213 );
1214 }
1215 }
1216 assert_eq!(
1217 wrap("duckdb/community-extensions (descriptions)", max),
1218 ["duckdb/community-", "extensions", "(descriptions)"]
1219 );
1220 }
1221
1222 #[test]
1223 fn node_box_grows_with_its_contents() {
1224 let plain = node_box("Ingest", None, None, false);
1225 assert_eq!((plain.w, plain.h), (BOX_MIN_W, 2.0 * BOX_PAD_Y + LINE_H));
1226 let full = node_box("Ingest", Some("1,240"), Some("late"), true);
1227 assert_eq!(full.h, plain.h + LINE_H + 20.0 + SPARK_H);
1228 assert!(full.w >= 110.0 + 2.0 * BOX_PAD_X);
1229 let long = node_box(&"word ".repeat(40), None, None, false);
1230 assert!(long.w <= BOX_MAX_W && long.lines.len() == MAX_LINES);
1231 }
1232
1233 #[test]
1235 fn edge_label_keeps_clear_of_nodes() {
1236 let node = |cx: f64, w: f64| PlacedNode {
1237 cx,
1238 cy: 0.0,
1239 w,
1240 h: 40.0,
1241 layer: 0,
1242 order: 0,
1243 };
1244 let pts = [(0.0, 0.0), (300.0, 0.0)];
1245 assert_eq!(edge_label_at(&pts, "spikes flagged", &[]), (150.0, 0.0));
1247 let nodes = [node(150.0, 60.0)];
1249 let (x, _) = edge_label_at(&pts, "spikes flagged", &nodes);
1250 let hw = edge_label_width("spikes flagged") / 2.0 + 3.0;
1251 assert!((x - 150.0).abs() >= hw + 30.0, "{x}");
1252 assert_eq!(point_at(&pts, 0.25), (75.0, 0.0));
1253 }
1254
1255 #[test]
1256 fn flow_width_is_area_true_and_rounded() {
1257 assert_eq!(flow_width(None, 10.0, EdgeLine::Solid), 1.6);
1258 assert_eq!(flow_width(Some(10.0), 10.0, EdgeLine::Solid), 7.0);
1259 assert_eq!(flow_width(Some(2.5), 10.0, EdgeLine::Solid), 4.2);
1260 assert_eq!(flow_width(None, 10.0, EdgeLine::Dots), 3.0);
1261 }
1262
1263 #[test]
1267 fn draws_marks_with_attributes_and_groups() {
1268 let labels = ["Ingest", "Model", "Publish"];
1269 let boxes: Vec<NodeBox> = labels
1270 .iter()
1271 .enumerate()
1272 .map(|(i, l)| node_box(l, (i == 1).then_some("42"), None, i == 2))
1273 .collect();
1274 let gnodes: Vec<GraphNode> = boxes
1275 .iter()
1276 .enumerate()
1277 .map(|(i, b)| GraphNode {
1278 w: b.w,
1279 h: b.h,
1280 lane: Some(i % 2),
1281 ..Default::default()
1282 })
1283 .collect();
1284 let gedges = [
1285 GraphEdge {
1286 from: 0,
1287 to: 1,
1288 label_w: Some(edge_label_width("rows")),
1289 },
1290 GraphEdge {
1291 from: 1,
1292 to: 2,
1293 label_w: None,
1294 },
1295 ];
1296 let params = LayeredParams {
1297 lanes: 2,
1298 ..Default::default()
1299 };
1300 let l = layered(&gnodes, &gedges, ¶ms);
1301 let nodes: Vec<DrawNode> = labels
1302 .iter()
1303 .zip(&boxes)
1304 .enumerate()
1305 .map(|(i, (id, b))| DrawNode {
1306 id: id.to_string(),
1307 lines: b.lines.clone(),
1308 value: (i == 1).then(|| "42".to_string()),
1309 stroke: (90, 90, 90),
1310 accent: (i == 0).then_some((200, 0, 0)),
1311 spark: (i == 2).then(|| (vec![1.0, 3.0, 2.0], (0, 0, 200))),
1312 tooltip: format!("{id}!"),
1313 attrs: vec![
1314 ("node".to_string(), Some(id.to_string())),
1315 ("value".to_string(), (i == 1).then(|| "42".to_string())),
1316 ],
1317 ..Default::default()
1318 })
1319 .collect();
1320 let edges: Vec<DrawEdge> = [(0, 1), (1, 2)]
1321 .iter()
1322 .map(|&(a, b)| DrawEdge {
1323 colour: (100, 100, 100),
1324 width: flow_width(None, 0.0, EdgeLine::Solid),
1325 line: if a == 0 {
1326 EdgeLine::Solid
1327 } else {
1328 EdgeLine::Dashed
1329 },
1330 class: (a == 1).then(|| "flowing".to_string()),
1331 tooltip: format!("{} → {}", labels[a], labels[b]),
1332 label: (a == 0).then(|| "rows".to_string()),
1333 attrs: vec![("edge".to_string(), Some(format!("{a}>{b}")))],
1334 label_attrs: vec![("from".to_string(), Some(labels[a].to_string()))],
1335 ..Default::default()
1336 })
1337 .collect();
1338 let lanes = ["Left".to_string(), "Right".to_string()];
1339 let svg = draw(
1340 &l,
1341 &lanes,
1342 &[],
1343 &nodes,
1344 &edges,
1345 &GraphStyle::default(),
1346 crate::theme::presets::theme_void(),
1347 )
1348 .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1349 .unwrap();
1350 let mut tags: Vec<(&str, bool)> = Vec::new();
1352 let mut stack: Vec<bool> = Vec::new();
1353 for part in svg.split('<').skip(1) {
1354 let tag = &part[..part.find('>').unwrap()];
1355 if tag.starts_with('/') {
1356 stack.pop();
1357 continue;
1358 }
1359 let inside = stack.iter().any(|&g| g);
1360 tags.push((tag, inside));
1361 if !tag.ends_with('/') {
1362 stack.push(tag.contains("data-of="));
1363 }
1364 }
1365 let attr = |tag: &str, name: &str| {
1366 let key = format!(" {name}=\"");
1367 tag.find(&key).map(|i| {
1368 let rest = &tag[i + key.len()..];
1369 rest[..rest.find('"').unwrap()].to_string()
1370 })
1371 };
1372 let boxes: Vec<_> = tags
1374 .iter()
1375 .filter(|(t, _)| t.contains("data-node="))
1376 .collect();
1377 assert_eq!(boxes.len(), 3);
1378 for (b, inside) in &boxes {
1379 assert!(!inside, "{b}");
1380 let id = attr(b, "data-node").unwrap();
1381 assert_eq!(attr(b, "data-value").is_some(), id == "Model", "{id}");
1382 }
1383 assert!(tags.iter().any(|(t, _)| t.contains("data-edge=\"1>2\"")
1385 && attr(t, "class").is_some_and(|c| c.contains("flowing"))));
1386 assert!(tags
1387 .iter()
1388 .any(|(t, _)| attr(t, "data-from").as_deref() == Some("Ingest")));
1389 assert!(svg.contains(" gg-graph-node\"") && svg.contains("gg-graph-edge-label"));
1390 for id in labels {
1392 assert!(svg.contains(&format!("data-of=\"{id}\"")), "{id}");
1393 }
1394 assert!(svg.contains("data-lane=\"Right\""));
1395 assert!(svg.contains(">Left<") && svg.contains(">42<"));
1396 }
1397
1398 #[test]
1403 fn draws_a_factor_graph_with_shapes_and_a_plate() {
1404 let n = 3;
1405 let mut spec = vec![
1407 ("Beta", NodeShape::Box),
1408 ("θ", NodeShape::Circle),
1409 ("=", NodeShape::Square),
1410 ];
1411 for _ in 0..n {
1412 spec.push(("Ber", NodeShape::Box));
1413 }
1414 for _ in 0..n {
1415 spec.push(("y", NodeShape::FilledCircle));
1416 }
1417 spec.push(("2.0", NodeShape::Text));
1418 let sizes: Vec<NodeBox> = spec.iter().map(|(l, sh)| node_size(*sh, l)).collect();
1419 let mut links = vec![(0, 1, "out"), (1, 2, ""), (9, 0, "a")];
1421 for i in 0..n {
1422 links.push((2, 3 + i, "p"));
1423 links.push((3 + i, 3 + n + i, "out"));
1424 }
1425 let gnodes: Vec<GraphNode> = sizes
1426 .iter()
1427 .map(|b| GraphNode {
1428 w: b.w,
1429 h: b.h,
1430 ..Default::default()
1431 })
1432 .collect();
1433 let gedges: Vec<GraphEdge> = links
1434 .iter()
1435 .map(|&(a, b, _)| GraphEdge {
1436 from: a,
1437 to: b,
1438 label_w: None,
1439 })
1440 .collect();
1441 let params = LayeredParams {
1443 margin: 32.0,
1444 ..Default::default()
1445 };
1446 let l = layered(&gnodes, &gedges, ¶ms);
1447 let nodes: Vec<DrawNode> = spec
1448 .iter()
1449 .zip(&sizes)
1450 .enumerate()
1451 .map(|(i, ((_, shape), b))| DrawNode {
1452 id: format!("n{i}"),
1453 shape: *shape,
1454 lines: b.lines.clone(),
1455 stroke: (60, 60, 60),
1456 tooltip: b.lines.join(" "),
1457 attrs: vec![("node".to_string(), Some(format!("n{i}")))],
1458 belief: (i == 1).then(|| (vec![0.0, 0.3, 1.0, 0.3, 0.0], (120, 80, 200))),
1460 prior: (i == 1).then(|| (vec![0.2, 0.4, 0.5, 0.4, 0.2], (150, 150, 150))),
1462 ..Default::default()
1463 })
1464 .collect();
1465 let edges: Vec<DrawEdge> = links
1466 .iter()
1467 .map(|&(a, b, iface)| DrawEdge {
1468 colour: (90, 90, 90),
1469 width: 1.2,
1470 undirected: true,
1471 label: (!iface.is_empty()).then(|| iface.to_string()),
1472 label_at: Some(if spec[b].1 == NodeShape::Box {
1474 0.8
1475 } else {
1476 0.2
1477 }),
1478 attrs: vec![("edge".to_string(), Some(format!("{a}>{b}")))],
1479 ..Default::default()
1480 })
1481 .collect();
1482 let plates = [Plate {
1483 label: "i = 1..3".to_string(),
1484 nodes: (3..3 + 2 * n).collect(),
1485 }];
1486 let style = GraphStyle::default();
1487 let svg = draw(
1488 &l,
1489 &[],
1490 &plates,
1491 &nodes,
1492 &edges,
1493 &style,
1494 crate::theme::presets::theme_void(),
1495 )
1496 .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1497 .unwrap();
1498 assert_eq!(svg.matches("data-node=").count(), spec.len());
1500 for shape in ["circle", "filled-circle", "square", "text"] {
1501 assert!(svg.contains(&format!("gg-graph-node-{shape}")), "{shape}");
1502 }
1503 assert!(
1504 !svg.contains("data-part=\"arrow\""),
1505 "an undirected edge has no arrowhead"
1506 );
1507 assert_eq!(svg.matches("data-edge=").count(), links.len());
1508 assert!(svg.contains("gg-graph-node-belief"), "belief layer");
1510 assert!(svg.contains("fill=\"#7850C8\""), "belief colour");
1511 let (pr, be) = (
1513 svg.find("gg-graph-node-prior").expect("prior layer"),
1514 svg.find("gg-graph-node-belief").unwrap(),
1515 );
1516 assert!(pr < be, "the prior under the belief");
1517 let mark = &svg[svg[..pr].rfind('<').unwrap()..pr];
1518 assert!(mark.contains("stroke-dasharray"), "dashed prior: {mark}");
1519 assert!(!svg.contains("font-style=\"italic\""), "plain labels");
1521 let math = GraphStyle {
1523 math_font: Some("STIX Two Math, serif".to_string()),
1524 glyph_fill: Some((240, 247, 246)),
1525 glyph_ink: Some((13, 148, 136)),
1526 ..style.clone()
1527 };
1528 let msvg = draw(
1529 &l,
1530 &[],
1531 &plates,
1532 &nodes,
1533 &edges,
1534 &math,
1535 crate::theme::presets::theme_void(),
1536 )
1537 .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1538 .unwrap();
1539 assert!(msvg.contains("STIX Two Math"), "math font");
1540 assert!(msvg.contains("font-style=\"italic\""), "italic labels");
1541 let two = msvg.find(">2.0<").expect("the constant 2.0");
1543 let el = &msvg[msvg[..two].rfind('<').unwrap()..two];
1544 assert!(!el.contains("italic"), "upright constant: {el}");
1545 assert!(svg.contains("data-plate=\"i = 1..3\"") && svg.contains(">i = 1..3<"));
1547 assert!(svg.contains(&format!(
1549 "fill=\"{}\"",
1550 hex(style.observed_fill).to_uppercase()
1551 )));
1552 for (i, a) in l.nodes.iter().enumerate() {
1554 for b in &l.nodes[i + 1..] {
1555 let apart = (a.cx - b.cx).abs() >= (a.w + b.w) / 2.0
1556 || (a.cy - b.cy).abs() >= (a.h + b.h) / 2.0;
1557 assert!(apart, "overlap");
1558 }
1559 }
1560 let c = &l.nodes[1];
1562 assert!(svg.contains(&format!("rx=\"{:.2}\"", c.w / 2.0)), "{}", c.w);
1563 }
1564
1565 #[test]
1569 fn banded_hmm_keeps_the_state_chain_straight() {
1570 use crate::graph_layout::{banded, BandedParams};
1571 let t_n = 5;
1572 let mut spec: Vec<(String, NodeShape, usize)> = vec![
1574 ("Dir".into(), NodeShape::Glyph, 0), ("A".into(), NodeShape::Square, 0), ("Dir".into(), NodeShape::Glyph, 0), ("B".into(), NodeShape::Square, 0), ("Cat".into(), NodeShape::Glyph, 1), ("s₀".into(), NodeShape::Circle, 1), ];
1581 let mut links: Vec<(usize, usize, &str)> =
1582 vec![(0, 1, "out"), (2, 3, "out"), (4, 5, "out")];
1583 let mut prev = 5;
1584 for t in 1..=t_n {
1585 let tr = spec.len();
1586 spec.push(("T".into(), NodeShape::Glyph, 1));
1587 let eq = spec.len();
1588 spec.push((format!("s{t}"), NodeShape::Square, 1));
1589 let ob = spec.len();
1590 spec.push(("T".into(), NodeShape::Glyph, 2));
1591 let x = spec.len();
1592 spec.push((format!("x{t}"), NodeShape::FilledCircle, 2));
1593 links.extend([
1594 (prev, tr, "in"),
1595 (1, tr, "a"),
1596 (tr, eq, "out"),
1597 (eq, ob, "in"),
1598 (3, ob, "a"),
1599 (ob, x, "out"),
1600 ]);
1601 prev = eq;
1602 }
1603 let sizes: Vec<NodeBox> = spec.iter().map(|(l, sh, _)| node_size(*sh, l)).collect();
1604 let gnodes: Vec<GraphNode> = spec
1605 .iter()
1606 .zip(&sizes)
1607 .map(|((_, _, b), s)| GraphNode {
1608 w: s.w,
1609 h: s.h,
1610 lane: Some(*b),
1611 ..Default::default()
1612 })
1613 .collect();
1614 let gedges: Vec<GraphEdge> = links
1615 .iter()
1616 .map(|&(a, b, _)| GraphEdge {
1617 from: a,
1618 to: b,
1619 label_w: None,
1620 })
1621 .collect();
1622 let l = banded(
1623 &gnodes,
1624 &gedges,
1625 &BandedParams {
1626 bands: 3,
1627 margin: 32.0,
1628 ..Default::default()
1629 },
1630 );
1631 let chain: Vec<usize> = (0..spec.len())
1633 .filter(|&i| spec[i].2 == 1 && i != 4)
1634 .collect();
1635 let y = l.nodes[chain[0]].cy;
1636 for &i in &chain {
1637 assert_eq!(l.nodes[i].cy, y, "{} off the row", spec[i].0);
1638 }
1639 for t in 0..t_n {
1641 let (eq, ob) = (6 + 4 * t + 1, 6 + 4 * t + 2);
1642 assert_eq!(l.nodes[eq].cx, l.nodes[ob].cx, "slice {t}");
1643 assert!(l.nodes[ob].cy > y);
1644 }
1645 let crosses = |(x0, y0): (f64, f64), (x1, y1): (f64, f64), n: &PlacedNode| {
1647 (0..=40).any(|k| {
1649 let t = k as f64 / 40.0;
1650 let (x, y) = (x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
1651 (x - n.cx).abs() < n.w / 2.0 - 1.0 && (y - n.cy).abs() < n.h / 2.0 - 1.0
1652 })
1653 };
1654 for (k, &(a, b, _)) in links.iter().enumerate() {
1655 for w in l.edges[k].windows(2) {
1656 for (i, n) in l.nodes.iter().enumerate() {
1657 if i != a && i != b {
1658 assert!(
1659 !crosses(w[0], w[1], n),
1660 "edge {a}->{b} crosses {}",
1661 spec[i].0
1662 );
1663 }
1664 }
1665 }
1666 }
1667 for (i, a) in l.nodes.iter().enumerate() {
1669 for b in &l.nodes[i + 1..] {
1670 assert!(
1671 (a.cx - b.cx).abs() >= (a.w + b.w) / 2.0
1672 || (a.cy - b.cy).abs() >= (a.h + b.h) / 2.0
1673 );
1674 }
1675 }
1676 let nodes: Vec<DrawNode> = spec
1677 .iter()
1678 .zip(&sizes)
1679 .enumerate()
1680 .map(|(i, ((_, shape, _), b))| DrawNode {
1681 id: format!("n{i}"),
1682 shape: *shape,
1683 lines: b.lines.clone(),
1684 stroke: (60, 60, 60),
1685 ..Default::default()
1686 })
1687 .collect();
1688 let edges: Vec<DrawEdge> = links
1689 .iter()
1690 .map(|&(_, b, iface)| DrawEdge {
1691 colour: (90, 90, 90),
1692 width: 1.2,
1693 undirected: true,
1694 label: Some(iface.to_string()),
1695 label_at: Some(if spec[b].1 == NodeShape::Glyph {
1696 0.8
1697 } else {
1698 0.2
1699 }),
1700 ..Default::default()
1701 })
1702 .collect();
1703 let lanes = ["param".to_string(), "state".to_string(), "obs".to_string()];
1704 let svg = draw(
1705 &l,
1706 &lanes,
1707 &[],
1708 &nodes,
1709 &edges,
1710 &GraphStyle::default(),
1711 crate::theme::presets::theme_void(),
1712 )
1713 .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
1714 .unwrap();
1715 if let Ok(path) = std::env::var("GGPLOT_GRAPH_SVG") {
1716 std::fs::write(path, &svg).unwrap();
1717 }
1718 assert_eq!(svg.matches("data-lane=").count(), 3);
1719 }
1720}