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