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