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