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