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 class_prefix: String,
169}
170
171impl Default for GraphStyle {
172 fn default() -> Self {
173 GraphStyle {
174 card: (255, 255, 255),
175 band: (226, 228, 233),
176 muted: (107, 114, 128),
177 heading: (17, 24, 39),
178 ink: (31, 41, 55),
179 class_prefix: "gg-graph-".to_string(),
180 }
181 }
182}
183
184pub type DataAttr = (String, Option<String>);
187
188#[derive(Clone, Debug, Default)]
191pub struct DrawNode {
192 pub id: String,
194 pub lines: Vec<String>,
196 pub value: Option<String>,
198 pub badge: Option<(String, Rgb)>,
200 pub stroke: Rgb,
202 pub accent: Option<Rgb>,
204 pub spark: Option<(Vec<f64>, Rgb)>,
206 pub tooltip: String,
207 pub attrs: Vec<DataAttr>,
210}
211
212#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub enum EdgeLine {
215 #[default]
216 Solid,
217 Dashed,
218 Dots,
220}
221
222#[derive(Clone, Debug, Default)]
225pub struct DrawEdge {
226 pub colour: Rgb,
227 pub width: f64,
229 pub line: EdgeLine,
230 pub class: Option<String>,
232 pub tooltip: String,
233 pub label: Option<String>,
235 pub attrs: Vec<DataAttr>,
238 pub label_attrs: Vec<DataAttr>,
240}
241
242pub fn flow_width(value: Option<f64>, max: f64, line: EdgeLine) -> f64 {
246 let width = match value {
247 Some(v) if max > 0.0 => 1.4 + 5.6 * (v.max(0.0) / max).sqrt(),
248 _ => 1.6,
249 };
250 let width = match line {
251 EdgeLine::Dots => width.clamp(3.0, 6.0),
252 _ => width,
253 };
254 (width * 10.0).round() / 10.0
255}
256
257#[derive(Default)]
259struct Frame(Vec<(String, Vec<Value>)>);
260
261impl Frame {
262 fn col(&mut self, name: &str) -> &mut Vec<Value> {
263 let i = match self.0.iter().position(|(n, _)| n == name) {
264 Some(i) => i,
265 None => {
266 self.0.push((name.to_string(), Vec::new()));
267 self.0.len() - 1
268 }
269 };
270 &mut self.0[i].1
271 }
272 fn num(&mut self, name: &str, v: f64) -> &mut Self {
273 self.col(name).push(Value::Float(v));
274 self
275 }
276 fn txt(&mut self, name: &str, v: impl Into<String>) -> &mut Self {
277 self.col(name).push(Value::Str(v.into()));
278 self
279 }
280 fn opt(&mut self, name: &str, v: Option<String>) -> &mut Self {
281 self.col(name).push(v.map_or(Value::Na, Value::Str));
282 self
283 }
284 fn attrs(&mut self, attrs: &[DataAttr]) -> &mut Self {
286 for (n, v) in attrs {
287 self.opt(&format!("a:{n}"), v.clone());
288 }
289 self
290 }
291 fn is_empty(&self) -> bool {
292 self.0.first().is_none_or(|(_, v)| v.is_empty())
293 }
294 fn attr_aes(&self, mut aes: Aes) -> Aes {
296 for (n, _) in &self.0 {
297 if let Some(name) = n.strip_prefix("a:") {
298 aes = aes.data_attr(name, n);
299 }
300 }
301 aes
302 }
303 fn take(&mut self, h: f64) -> Vec<(String, Vec<Value>)> {
307 let flip = |v: Vec<Value>| -> Vec<Value> {
308 v.into_iter()
309 .map(|x| match x {
310 Value::Float(y) => Value::Float(h - y),
311 other => other,
312 })
313 .collect()
314 };
315 std::mem::take(&mut self.0)
316 .into_iter()
317 .map(|(n, v)| match n.as_str() {
318 "y" => (n, flip(v)),
319 "y0" => ("y1".to_string(), flip(v)),
320 "y1" => ("y0".to_string(), flip(v)),
321 _ => (n, v),
322 })
323 .collect()
324 }
325}
326
327fn by_colour(v: &mut Vec<(Rgb, Frame)>, c: Rgb) -> &mut Frame {
330 let i = match v.iter().position(|(k, _)| *k == c) {
331 Some(i) => i,
332 None => {
333 v.push((c, Frame::default()));
334 v.len() - 1
335 }
336 };
337 &mut v[i].1
338}
339
340fn hex(c: Rgb) -> String {
341 format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
342}
343
344fn point_at(pts: &[(f64, f64)], t: f64) -> (f64, f64) {
346 let seg = |i: usize| {
347 let (a, b) = (pts[i], pts[i + 1]);
348 ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()
349 };
350 let total: f64 = (0..pts.len().saturating_sub(1)).map(seg).sum();
351 let mut left = total * t;
352 for i in 0..pts.len().saturating_sub(1) {
353 let l = seg(i);
354 if l >= left && l > 0.0 {
355 let t = left / l;
356 let (a, b) = (pts[i], pts[i + 1]);
357 return (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t);
358 }
359 left -= l;
360 }
361 pts.first().copied().unwrap_or((0.0, 0.0))
362}
363
364fn edge_label_at(pts: &[(f64, f64)], label: &str, nodes: &[PlacedNode]) -> (f64, f64) {
368 let lines = label.split('\n').count();
369 let hw = edge_label_width(label) / 2.0 + 3.0;
370 let hh = lines as f64 * 6.6 + 3.0;
371 let clear = |(x, y): (f64, f64)| {
372 let cy = y - 4.0 - 0.36 * SMALL_PX;
373 nodes
374 .iter()
375 .all(|n| (x - n.cx).abs() >= hw + n.w / 2.0 || (cy - n.cy).abs() >= hh + n.h / 2.0)
376 };
377 let mid = point_at(pts, 0.5);
378 [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74, 0.18, 0.82]
379 .into_iter()
380 .map(|t| point_at(pts, t))
381 .find(|&p| clear(p))
382 .unwrap_or(mid)
383}
384
385fn mid_of_baseline(baseline: f64, px: f64) -> f64 {
388 baseline - 0.36 * px
389}
390
391pub fn draw(
397 layout: &GraphLayout,
398 lanes: &[String],
399 nodes: &[DrawNode],
400 edges: &[DrawEdge],
401 style: &GraphStyle,
402 mut theme: Theme,
403) -> GGPlot {
404 let s = style;
405 let class = |name: &str| format!("{}{name}", s.class_prefix);
406 let (w, h) = (layout.w.ceil().max(1.0), layout.h.ceil().max(1.0));
407 theme.legend_position = crate::theme::LegendPosition::None;
408 let mut plot = GGPlot::new(vec![
409 ("x".to_string(), vec![Value::Float(0.0), Value::Float(w)]),
410 ("y".to_string(), vec![Value::Float(0.0), Value::Float(h)]),
411 ])
412 .theme(theme)
413 .scale_x_continuous(ScaleContinuous::new().with_expand(0.0, 0.0))
416 .scale_y_continuous(ScaleContinuous::new().with_expand(0.0, 0.0))
417 .coord_cartesian_zoom(Some((0.0, w)), Some((0.0, h)))
418 .scale_color_identity()
419 .scale_fill_identity()
420 .scale_linewidth(
421 ScaleLinewidthContinuous::new()
422 .with_limits(0.0, 16.0)
423 .with_range(0.0, 16.0),
424 )
425 .scale_linetype_manual(vec![
426 ("solid", Linetype::Solid),
427 ("dashed", Linetype::Custom(vec![(6.0, 4.0)])),
428 ("dots", Linetype::Custom(vec![(0.0, 10.0)])),
430 ]);
431 let rect_aes = || Aes::new().xmin("x0").xmax("x1").ymin("y0").ymax("y1");
432 let inert = [("pointer-events", "none")];
433
434 let (mut even, mut odd, mut names) = (Frame::default(), Frame::default(), Frame::default());
436 for &(l, x0, y0, x1, y1) in &layout.bands {
437 let name = lanes.get(l).cloned().unwrap_or_default();
438 let f = if l % 2 == 0 { &mut even } else { &mut odd };
439 f.num("x0", x0).num("x1", x1).num("y0", y0).num("y1", y1);
440 f.txt("lane", name.clone());
441 names
442 .num("x", x0 + 8.0)
443 .num("y", mid_of_baseline(y0 + 13.0, SMALL_PX));
444 names.txt("label", name);
445 }
446 for (mut f, alpha) in [(even, 0.35), (odd, 0.18)] {
447 if f.is_empty() {
448 continue;
449 }
450 plot = plot
451 .geom_rect_with(
452 GeomRect {
453 fill: s.band,
454 alpha,
455 line_width: 0.0,
456 ..Default::default()
457 }
458 .with_radius(6.0),
459 )
460 .layer_data(f.take(h))
461 .layer_aes(rect_aes().data_attr("lane", "lane"))
462 .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("lane"))]);
463 }
464 if !names.is_empty() {
465 plot = plot
466 .geom_text_with(GeomText {
467 size: SMALL_PX,
468 color: s.muted,
469 hjust: 0.0,
470 fontface: FontFace::Bold,
471 ..Default::default()
472 })
473 .layer_data(names.take(h))
474 .layer_aes(Aes::new().x("x").y("y").label("label"))
475 .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("lane-label"))]);
476 }
477
478 let (mut ef, mut lf) = (Frame::default(), Frame::default());
480 for (k, e) in edges.iter().enumerate() {
481 let Some(route) = layout.edges.get(k) else {
482 continue;
483 };
484 let mut pts = route.clone();
485 if pts.len() < 2 {
486 continue;
487 }
488 let n = pts.len();
493 let ((fx, fy), (tx, ty)) = (pts[n - 2], pts[n - 1]);
494 let len = ((tx - fx).powi(2) + (ty - fy).powi(2)).sqrt().max(1e-6);
495 let pull = e.width.min(len / 2.0);
496 pts[n - 1] = (tx - (tx - fx) / len * pull, ty - (ty - fy) / len * pull);
497 let lt = match e.line {
498 EdgeLine::Solid => "solid",
499 EdgeLine::Dashed => "dashed",
500 EdgeLine::Dots => "dots",
501 };
502 for &(x, y) in &pts {
503 ef.num("x", x).num("y", y).txt("g", format!("e{k:04}"));
504 ef.txt("col", hex(e.colour))
505 .num("lw", e.width)
506 .txt("lt", lt);
507 ef.opt("cls", e.class.clone()).txt("tip", e.tooltip.clone());
508 ef.attrs(&e.attrs);
509 }
510 if let Some(l) = &e.label {
511 let (mx, my) = edge_label_at(route, l, &layout.nodes);
512 lf.num("x", mx)
513 .num("y", mid_of_baseline(my - 4.0, SMALL_PX));
514 lf.txt("label", l.clone());
515 lf.attrs(&e.label_attrs);
516 }
517 }
518 if !ef.is_empty() {
519 let aes = ef.attr_aes(
520 Aes::new()
521 .x("x")
522 .y("y")
523 .group("g")
524 .color("col")
525 .linewidth("lw")
526 .linetype("lt")
527 .class("cls")
528 .tooltip("tip"),
529 );
530 plot = plot
531 .geom_path_with(
532 GeomPath::default()
533 .lineend(LineEnd::Round)
534 .arrow(
536 arrow()
537 .closed()
538 .angle(29.0)
539 .length(ARROW + 1.0)
540 .length_per_linewidth(1.2),
541 ),
542 )
543 .layer_data(ef.take(h))
544 .layer_aes(aes)
545 .layer_svg_attr("class", class("edge"));
546 }
547 if !lf.is_empty() {
548 let aes = lf.attr_aes(Aes::new().x("x").y("y").label("label"));
550 plot = plot
551 .geom_text_with(
552 GeomText {
553 size: SMALL_PX,
554 color: s.muted,
555 ..Default::default()
556 }
557 .bg_colour(s.card)
558 .bg_r(2.0),
559 )
560 .layer_data(lf.take(h))
561 .layer_aes(aes)
562 .layer_svg_attrs(inert)
563 .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("edge-label"))]);
564 }
565
566 let mut order: Vec<usize> = (0..nodes.len().min(layout.nodes.len())).collect();
569 order.sort_by_key(|&i| (layout.nodes[i].order, i));
570 let mut boxes: Vec<(Rgb, Frame)> = Vec::new();
571 let mut badges: Vec<(Rgb, Frame)> = Vec::new();
572 let (mut accent, mut lines, mut values) =
573 (Frame::default(), Frame::default(), Frame::default());
574 let (mut dots, mut btext, mut spark, mut ends) = (
575 Frame::default(),
576 Frame::default(),
577 Frame::default(),
578 Frame::default(),
579 );
580 for v in order {
581 let nd = &nodes[v];
582 let pn = &layout.nodes[v];
583 let id = nd.id.clone();
584 let (x, y) = (pn.cx - pn.w / 2.0, pn.cy - pn.h / 2.0);
585 let f = by_colour(&mut boxes, nd.stroke);
586 f.num("x0", x)
587 .num("x1", x + pn.w)
588 .num("y0", y)
589 .num("y1", y + pn.h);
590 f.txt("tip", nd.tooltip.clone()).attrs(&nd.attrs);
591
592 if let Some(a) = nd.accent {
593 accent.num("x0", x + 1.5).num("x1", x + 4.5);
594 accent
595 .num("y0", y + 6.0)
596 .num("y1", y + 6.0 + (pn.h - 12.0).max(4.0));
597 accent.txt("fill", hex(a)).txt("of", id.clone());
598 }
599 let mut base = y + BOX_PAD_Y + 12.0;
600 for line in &nd.lines {
601 lines
602 .num("x", pn.cx)
603 .num("y", mid_of_baseline(base, LABEL_PX));
604 lines.txt("label", line.clone()).txt("of", id.clone());
605 base += LINE_H;
606 }
607 if let Some(fv) = &nd.value {
608 values
609 .num("x", pn.cx)
610 .num("y", mid_of_baseline(base, VALUE_PX));
611 values.txt("label", fv.clone()).txt("of", id.clone());
612 base += LINE_H;
613 }
614 if let Some((b, dot)) = &nd.badge {
615 let bw = text_w(b, BADGE_PX, false) + 20.0;
616 let (bx, by) = (pn.cx - bw / 2.0, base - 9.0);
617 let f = by_colour(&mut badges, *dot);
618 f.num("x0", bx)
619 .num("x1", bx + bw)
620 .num("y0", by)
621 .num("y1", by + 16.0);
622 f.txt("of", id.clone());
623 dots.num("x", bx + 9.0).num("y", by + 8.0);
624 dots.txt("col", hex(*dot)).txt("of", id.clone());
625 btext.num("x", bx + 15.0).num("y", by + 8.0);
626 btext.txt("label", b.clone()).txt("of", id.clone());
627 base += 20.0;
628 }
629 if let Some((values, colour)) = nd.spark.as_ref().filter(|(v, _)| v.len() >= 2) {
631 let (lo, hi) = values
632 .iter()
633 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &v| {
634 (a.min(v), b.max(v))
635 });
636 let rng = if hi > lo { hi - lo } else { 1.0 };
637 let (sx0, sw) = (x + BOX_PAD_X, pn.w - 2.0 * BOX_PAD_X);
638 let (top, sh) = (base - 8.0, SPARK_H - 8.0);
639 let last = values.len() - 1;
640 let col = hex(*colour);
641 for (i, &sv) in values.iter().enumerate() {
642 let (px, py) = (
643 sx0 + sw * i as f64 / last as f64,
644 top + sh - (sv - lo) / rng * sh,
645 );
646 spark.num("x", px).num("y", py).txt("g", id.clone());
647 spark.txt("col", col.clone()).txt("of", id.clone());
648 if i == last {
649 ends.num("x", px).num("y", py);
650 ends.txt("col", col.clone()).txt("of", id.clone());
651 }
652 }
653 }
654 }
655 for (stroke, mut f) in boxes {
656 let aes = f.attr_aes(rect_aes().tooltip("tip"));
657 plot = plot
658 .geom_rect_with(
659 GeomRect {
660 fill: s.card,
661 color: stroke,
662 alpha: 1.0,
663 line_width: 1.2,
664 ..Default::default()
665 }
666 .with_radius(8.0),
667 )
668 .layer_data(f.take(h))
669 .layer_aes(aes)
670 .layer_svg_attr("class", class("node"));
671 }
672 let of = |a: Aes| a.group_wrap("of", "of");
674 if !accent.is_empty() {
675 plot = plot
676 .geom_rect_with(
677 GeomRect {
678 alpha: 1.0,
679 line_width: 0.0,
680 ..Default::default()
681 }
682 .with_radius(1.5),
683 )
684 .layer_data(accent.take(h))
685 .layer_aes(of(rect_aes().fill("fill")))
686 .decorative()
687 .layer_svg_attrs(inert);
688 }
689 for (mut f, size, colour, cls) in [
690 (lines, LABEL_PX, s.heading, class("node-label")),
691 (values, VALUE_PX, s.ink, class("node-value")),
692 ] {
693 if f.is_empty() {
694 continue;
695 }
696 plot = plot
697 .geom_text_with(GeomText {
698 size,
699 color: colour,
700 fontface: FontFace::Bold,
701 ..Default::default()
702 })
703 .layer_data(f.take(h))
704 .layer_aes(of(Aes::new().x("x").y("y").label("label")))
705 .layer_svg_attrs(inert)
706 .layer_svg_attrs([("aria-hidden", "true"), ("class", cls.as_str())]);
707 }
708 for (dot, mut f) in badges {
709 plot = plot
710 .geom_rect_with(
711 GeomRect {
712 fill: s.card,
713 color: dot,
714 alpha: 0.0,
715 line_width: 1.0,
716 ..Default::default()
717 }
718 .with_radius(8.0),
719 )
720 .layer_data(f.take(h))
721 .layer_aes(of(rect_aes()))
722 .decorative()
723 .layer_svg_attrs(inert);
724 }
725 for (mut f, size) in [(dots, 3.0), (ends, 2.2)] {
726 if f.is_empty() {
727 continue;
728 }
729 plot = plot
730 .geom_point_with(GeomPoint {
731 size,
732 ..Default::default()
733 })
734 .layer_data(f.take(h))
735 .layer_aes(of(Aes::new().x("x").y("y").color("col")))
736 .decorative()
737 .layer_svg_attrs(inert);
738 }
739 if !btext.is_empty() {
740 plot = plot
741 .geom_text_with(GeomText {
742 size: BADGE_PX,
743 color: s.ink,
744 hjust: 0.0,
745 ..Default::default()
746 })
747 .layer_data(btext.take(h))
748 .layer_aes(of(Aes::new().x("x").y("y").label("label")))
749 .layer_svg_attrs(inert)
750 .layer_svg_attrs([("aria-hidden", "true"), ("class", &*class("node-badge"))]);
751 }
752 if !spark.is_empty() {
753 plot = plot
754 .geom_path_with(GeomPath::default().linewidth(1.4))
755 .layer_data(spark.take(h))
756 .layer_aes(of(Aes::new().x("x").y("y").group("g").color("col")))
757 .decorative()
758 .layer_svg_attrs(inert)
759 .layer_svg_attr("class", class("node-spark"));
760 }
761 plot
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::graph_layout::{layered, GraphEdge, GraphNode, LayeredParams};
768
769 #[test]
770 fn wraps_long_labels_in_three_lines() {
771 let l = wrap(
772 "Recommend replenishment orders for every store and every week of the coming year",
773 152.0,
774 );
775 assert_eq!(l.len(), 3);
776 assert!(l[2].ends_with('…'));
777 assert_eq!(wrap("Ingest", 152.0), ["Ingest"]);
778 }
779
780 #[test]
783 fn wraps_at_word_boundaries() {
784 let max = BOX_MAX_W - 2.0 * BOX_PAD_X;
785 for label in [
786 "duckdb/community-extensions (descriptions)",
787 "Locked DuckDB server (anofox-visualization)",
788 "Parquet snapshot (downloads, extensions)",
789 "fetch.sh: download, file by ISO week, merge",
790 ] {
791 let l = wrap(label, max);
792 assert!(l.len() <= MAX_LINES, "{l:?}");
793 assert!(!l.iter().any(|x| x.contains('…')), "{label}: {l:?}");
794 let joined: String = l.concat();
796 assert_eq!(joined.replace(' ', ""), label.replace(' ', ""), "{l:?}");
797 for w in l.windows(2) {
798 let end = w[0].chars().last().unwrap();
799 let next = w[1].chars().next().unwrap();
800 let at_space = label.contains(&format!(
801 "{} {}",
802 w[0].split(' ').next_back().unwrap(),
803 w[1].split(' ').next().unwrap()
804 ));
805 assert!(
806 at_space || matches!(end, '/' | '-' | '_' | '.' | ','),
807 "{l:?} breaks at {end}{next}"
808 );
809 }
810 }
811 assert_eq!(
812 wrap("duckdb/community-extensions (descriptions)", max),
813 ["duckdb/community-", "extensions", "(descriptions)"]
814 );
815 }
816
817 #[test]
818 fn node_box_grows_with_its_contents() {
819 let plain = node_box("Ingest", None, None, false);
820 assert_eq!((plain.w, plain.h), (BOX_MIN_W, 2.0 * BOX_PAD_Y + LINE_H));
821 let full = node_box("Ingest", Some("1,240"), Some("late"), true);
822 assert_eq!(full.h, plain.h + LINE_H + 20.0 + SPARK_H);
823 assert!(full.w >= 110.0 + 2.0 * BOX_PAD_X);
824 let long = node_box(&"word ".repeat(40), None, None, false);
825 assert!(long.w <= BOX_MAX_W && long.lines.len() == MAX_LINES);
826 }
827
828 #[test]
830 fn edge_label_keeps_clear_of_nodes() {
831 let node = |cx: f64, w: f64| PlacedNode {
832 cx,
833 cy: 0.0,
834 w,
835 h: 40.0,
836 layer: 0,
837 order: 0,
838 };
839 let pts = [(0.0, 0.0), (300.0, 0.0)];
840 assert_eq!(edge_label_at(&pts, "spikes flagged", &[]), (150.0, 0.0));
842 let nodes = [node(150.0, 60.0)];
844 let (x, _) = edge_label_at(&pts, "spikes flagged", &nodes);
845 let hw = edge_label_width("spikes flagged") / 2.0 + 3.0;
846 assert!((x - 150.0).abs() >= hw + 30.0, "{x}");
847 assert_eq!(point_at(&pts, 0.25), (75.0, 0.0));
848 }
849
850 #[test]
851 fn flow_width_is_area_true_and_rounded() {
852 assert_eq!(flow_width(None, 10.0, EdgeLine::Solid), 1.6);
853 assert_eq!(flow_width(Some(10.0), 10.0, EdgeLine::Solid), 7.0);
854 assert_eq!(flow_width(Some(2.5), 10.0, EdgeLine::Solid), 4.2);
855 assert_eq!(flow_width(None, 10.0, EdgeLine::Dots), 3.0);
856 }
857
858 #[test]
862 fn draws_marks_with_attributes_and_groups() {
863 let labels = ["Ingest", "Model", "Publish"];
864 let boxes: Vec<NodeBox> = labels
865 .iter()
866 .enumerate()
867 .map(|(i, l)| node_box(l, (i == 1).then_some("42"), None, i == 2))
868 .collect();
869 let gnodes: Vec<GraphNode> = boxes
870 .iter()
871 .enumerate()
872 .map(|(i, b)| GraphNode {
873 w: b.w,
874 h: b.h,
875 lane: Some(i % 2),
876 ..Default::default()
877 })
878 .collect();
879 let gedges = [
880 GraphEdge {
881 from: 0,
882 to: 1,
883 label_w: Some(edge_label_width("rows")),
884 },
885 GraphEdge {
886 from: 1,
887 to: 2,
888 label_w: None,
889 },
890 ];
891 let params = LayeredParams {
892 lanes: 2,
893 ..Default::default()
894 };
895 let l = layered(&gnodes, &gedges, ¶ms);
896 let nodes: Vec<DrawNode> = labels
897 .iter()
898 .zip(&boxes)
899 .enumerate()
900 .map(|(i, (id, b))| DrawNode {
901 id: id.to_string(),
902 lines: b.lines.clone(),
903 value: (i == 1).then(|| "42".to_string()),
904 stroke: (90, 90, 90),
905 accent: (i == 0).then_some((200, 0, 0)),
906 spark: (i == 2).then(|| (vec![1.0, 3.0, 2.0], (0, 0, 200))),
907 tooltip: format!("{id}!"),
908 attrs: vec![
909 ("node".to_string(), Some(id.to_string())),
910 ("value".to_string(), (i == 1).then(|| "42".to_string())),
911 ],
912 ..Default::default()
913 })
914 .collect();
915 let edges: Vec<DrawEdge> = [(0, 1), (1, 2)]
916 .iter()
917 .map(|&(a, b)| DrawEdge {
918 colour: (100, 100, 100),
919 width: flow_width(None, 0.0, EdgeLine::Solid),
920 line: if a == 0 {
921 EdgeLine::Solid
922 } else {
923 EdgeLine::Dashed
924 },
925 class: (a == 1).then(|| "flowing".to_string()),
926 tooltip: format!("{} → {}", labels[a], labels[b]),
927 label: (a == 0).then(|| "rows".to_string()),
928 attrs: vec![("edge".to_string(), Some(format!("{a}>{b}")))],
929 label_attrs: vec![("from".to_string(), Some(labels[a].to_string()))],
930 })
931 .collect();
932 let lanes = ["Left".to_string(), "Right".to_string()];
933 let svg = draw(
934 &l,
935 &lanes,
936 &nodes,
937 &edges,
938 &GraphStyle::default(),
939 crate::theme::presets::theme_void(),
940 )
941 .render_svg_native_at(0.0, 0.0, l.w.ceil() as u32, l.h.ceil() as u32)
942 .unwrap();
943 let mut tags: Vec<(&str, bool)> = Vec::new();
945 let mut stack: Vec<bool> = Vec::new();
946 for part in svg.split('<').skip(1) {
947 let tag = &part[..part.find('>').unwrap()];
948 if tag.starts_with('/') {
949 stack.pop();
950 continue;
951 }
952 let inside = stack.iter().any(|&g| g);
953 tags.push((tag, inside));
954 if !tag.ends_with('/') {
955 stack.push(tag.contains("data-of="));
956 }
957 }
958 let attr = |tag: &str, name: &str| {
959 let key = format!(" {name}=\"");
960 tag.find(&key).map(|i| {
961 let rest = &tag[i + key.len()..];
962 rest[..rest.find('"').unwrap()].to_string()
963 })
964 };
965 let boxes: Vec<_> = tags
967 .iter()
968 .filter(|(t, _)| t.contains("data-node="))
969 .collect();
970 assert_eq!(boxes.len(), 3);
971 for (b, inside) in &boxes {
972 assert!(!inside, "{b}");
973 let id = attr(b, "data-node").unwrap();
974 assert_eq!(attr(b, "data-value").is_some(), id == "Model", "{id}");
975 }
976 assert!(tags.iter().any(|(t, _)| t.contains("data-edge=\"1>2\"")
978 && attr(t, "class").is_some_and(|c| c.contains("flowing"))));
979 assert!(tags
980 .iter()
981 .any(|(t, _)| attr(t, "data-from").as_deref() == Some("Ingest")));
982 assert!(svg.contains(" gg-graph-node\"") && svg.contains("gg-graph-edge-label"));
983 for id in labels {
985 assert!(svg.contains(&format!("data-of=\"{id}\"")), "{id}");
986 }
987 assert!(svg.contains("data-lane=\"Right\""));
988 assert!(svg.contains(">Left<") && svg.contains(">42<"));
989 }
990}