1use std::{
2 hash::{DefaultHasher, Hash, Hasher},
3 rc::Rc,
4};
5
6use gpui_kit::{
7 AnyElement, App, Bounds, Corners, ElementId, Hsla, IntoElement, Pixels, Point, SharedString,
8 TextAlign, Window, fill, linear_color_stop, linear_gradient, point, prelude::FluentBuilder, px,
9};
10
11use super::caller_id;
12use crate::{
13 ActiveTheme,
14 plot::{
15 Plot,
16 label::{PlotLabel, TEXT_GAP, TEXT_SIZE, Text, measure_text_width, truncate_text_to_width},
17 origin_point,
18 shape::{
19 Sankey, SankeyAlign, SankeyGraph, SankeyLink, SankeyLinkLayout, SankeyValueScale,
20 sankey_link_path,
21 },
22 tooltip::{PlotHover, Tooltip, TooltipState},
23 },
24};
25
26const DEFAULT_NODE_WIDTH: f32 = 10.;
27const DEFAULT_NODE_PADDING: f32 = 16.;
28const DEFAULT_LINK_OPACITY: f32 = 0.3;
29const DEFAULT_MIN_LINK_WIDTH: f32 = 1.;
30const DEFAULT_LABEL_GAP: f32 = 6.;
31const MAX_LABEL_WIDTH_RATIO: f32 = 0.2;
34const MAX_LABEL_MARGIN_RATIO: f32 = 0.6;
36const HOVER_DIM: f32 = 0.7;
39
40struct SankeyFrame {
47 graph: SankeyGraph,
48 layer_count: usize,
49 node_labels: Vec<Vec<SankeyLabel>>,
50 left: f32,
52 right: f32,
53}
54
55#[derive(Default)]
57struct SankeyFrameCache {
58 key: Option<u64>,
59 frame: Option<Rc<SankeyFrame>>,
60}
61
62#[derive(Clone, Copy)]
64struct SankeyHover {
65 node: usize,
67 focus: f32,
69}
70
71#[derive(Clone)]
73pub struct SankeyLabel {
74 text: SharedString,
75 color: Option<Hsla>,
76 font_size: Option<f32>,
77}
78
79impl SankeyLabel {
80 pub fn new(text: impl Into<SharedString>) -> Self {
82 Self {
83 text: text.into(),
84 color: None,
85 font_size: None,
86 }
87 }
88
89 pub fn color(mut self, color: impl Into<Hsla>) -> Self {
91 self.color = Some(color.into());
92 self
93 }
94
95 pub fn font_size(mut self, font_size: f32) -> Self {
97 self.font_size = Some(font_size);
98 self
99 }
100
101 fn line_height(&self) -> f32 {
102 self.font_size.unwrap_or(TEXT_SIZE) + TEXT_GAP
103 }
104}
105
106fn block_height(lines: &[SankeyLabel]) -> f32 {
107 lines.iter().map(|line| line.line_height()).sum()
108}
109
110pub struct SankeyChart<T: 'static> {
115 nodes: Vec<T>,
116 links: Vec<SankeyLink>,
117 node_width: f32,
118 node_padding: f32,
119 align: SankeyAlign,
120 iterations: usize,
121 value_scale: SankeyValueScale,
122 node_corner_radius: Option<Pixels>,
123 node_color: Option<Rc<dyn Fn(&T) -> Hsla>>,
124 node_label: Option<Rc<dyn Fn(&T) -> SharedString>>,
125 value_label: Option<Rc<dyn Fn(&T, f64) -> SharedString>>,
126 labels: Option<Rc<dyn Fn(&T, f64) -> Vec<SankeyLabel>>>,
127 link_opacity: f32,
128 min_link_width: f32,
129 label_gap: f32,
130 tooltip_name: Option<Rc<dyn Fn(&T) -> SharedString + 'static>>,
131 tooltip_value: Option<Rc<dyn Fn(&T, f64) -> SharedString + 'static>>,
132 id: ElementId,
133 interactive: bool,
134 frame: Option<Rc<SankeyFrame>>,
137 hover: Option<SankeyHover>,
138}
139
140impl<T> SankeyChart<T> {
141 #[track_caller]
144 pub fn new<I, L>(nodes: I, links: L) -> Self
145 where
146 I: IntoIterator<Item = T>,
147 L: IntoIterator<Item = SankeyLink>,
148 {
149 Self {
150 nodes: nodes.into_iter().collect(),
151 links: links.into_iter().collect(),
152 node_width: DEFAULT_NODE_WIDTH,
153 node_padding: DEFAULT_NODE_PADDING,
154 align: SankeyAlign::default(),
155 iterations: 6,
156 value_scale: SankeyValueScale::default(),
157 node_corner_radius: None,
158 node_color: None,
159 node_label: None,
160 value_label: None,
161 labels: None,
162 link_opacity: DEFAULT_LINK_OPACITY,
163 min_link_width: DEFAULT_MIN_LINK_WIDTH,
164 label_gap: DEFAULT_LABEL_GAP,
165 tooltip_name: None,
166 tooltip_value: None,
167 id: caller_id(),
168 interactive: true,
169 frame: None,
170 hover: None,
171 }
172 }
173
174 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
181 self.id = id.into();
182 self
183 }
184
185 pub fn interactive(mut self, interactive: bool) -> Self {
194 self.interactive = interactive;
195 self
196 }
197
198 pub fn node_width(mut self, node_width: f32) -> Self {
200 self.node_width = node_width;
201 self
202 }
203
204 pub fn node_padding(mut self, node_padding: f32) -> Self {
206 self.node_padding = node_padding;
207 self
208 }
209
210 pub fn node_align(mut self, align: SankeyAlign) -> Self {
212 self.align = align;
213 self
214 }
215
216 pub fn iterations(mut self, iterations: usize) -> Self {
218 self.iterations = iterations;
219 self
220 }
221
222 pub fn value_scale(mut self, value_scale: SankeyValueScale) -> Self {
228 self.value_scale = value_scale;
229 self
230 }
231
232 pub fn node_corner_radius(mut self, radius: impl Into<Pixels>) -> Self {
234 self.node_corner_radius = Some(radius.into());
235 self
236 }
237
238 pub fn node_color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
242 where
243 H: Into<Hsla> + 'static,
244 {
245 self.node_color = Some(Rc::new(move |t| color(t).into()));
246 self
247 }
248
249 pub fn node_label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
252 self.node_label = Some(Rc::new(label));
253 self
254 }
255
256 pub fn value_label(mut self, label: impl Fn(&T, f64) -> SharedString + 'static) -> Self {
262 self.value_label = Some(Rc::new(label));
263 self
264 }
265
266 pub fn labels(mut self, labels: impl Fn(&T, f64) -> Vec<SankeyLabel> + 'static) -> Self {
273 self.labels = Some(Rc::new(labels));
274 self
275 }
276
277 pub fn tooltip_name(mut self, name: impl Fn(&T) -> SharedString + 'static) -> Self {
286 self.tooltip_name = Some(Rc::new(name));
287 self
288 }
289
290 pub fn tooltip_value(mut self, value: impl Fn(&T, f64) -> SharedString + 'static) -> Self {
296 self.tooltip_value = Some(Rc::new(value));
297 self
298 }
299
300 pub fn link_opacity(mut self, opacity: f32) -> Self {
302 self.link_opacity = opacity;
303 self
304 }
305
306 pub fn min_link_width(mut self, width: f32) -> Self {
308 self.min_link_width = width;
309 self
310 }
311
312 pub fn label_gap(mut self, gap: f32) -> Self {
314 self.label_gap = gap;
315 self
316 }
317
318 fn sankey(&self) -> Sankey {
319 Sankey::new()
320 .node_width(self.node_width)
321 .node_padding(self.node_padding)
322 .node_align(self.align)
323 .iterations(self.iterations)
324 .value_scale(self.value_scale)
325 }
326
327 fn raw_throughput(&self) -> Vec<f64> {
331 let mut incoming = vec![0f64; self.nodes.len()];
332 let mut outgoing = vec![0f64; self.nodes.len()];
333 for link in &self.links {
334 if let (Some(o), Some(i)) =
335 (outgoing.get_mut(link.source), incoming.get_mut(link.target))
336 {
337 *o += link.value;
338 *i += link.value;
339 }
340 }
341 incoming
342 .into_iter()
343 .zip(outgoing)
344 .map(|(i, o)| i.max(o))
345 .collect()
346 }
347}
348
349impl<T> SankeyChart<T> {
350 fn node_labels(&self, cx: &App) -> Vec<Vec<SankeyLabel>> {
354 let raw_value = self.raw_throughput();
355 self.nodes
356 .iter()
357 .zip(raw_value)
358 .map(|(datum, value)| {
359 if let Some(labels) = &self.labels {
360 labels(datum, value)
361 } else {
362 let mut lines = Vec::new();
363 if let Some(value_label) = &self.value_label {
364 lines.push(SankeyLabel::new(value_label(datum, value)));
365 }
366 if let Some(node_label) = &self.node_label {
367 lines.push(SankeyLabel::new(node_label(datum)).color(cx.macos().secondary));
368 }
369 lines
370 }
371 })
372 .collect()
373 }
374
375 fn frame_key(&self, bounds: Bounds<Pixels>, node_labels: &[Vec<SankeyLabel>]) -> u64 {
378 let mut hasher = DefaultHasher::new();
379 bounds.size.width.as_f32().to_bits().hash(&mut hasher);
380 bounds.size.height.as_f32().to_bits().hash(&mut hasher);
381 self.nodes.len().hash(&mut hasher);
382 for link in &self.links {
383 link.source.hash(&mut hasher);
384 link.target.hash(&mut hasher);
385 link.value.to_bits().hash(&mut hasher);
386 }
387 self.node_width.to_bits().hash(&mut hasher);
388 self.node_padding.to_bits().hash(&mut hasher);
389 self.align.hash(&mut hasher);
390 self.iterations.hash(&mut hasher);
391 self.value_scale.hash(&mut hasher);
392 self.label_gap.to_bits().hash(&mut hasher);
393 for lines in node_labels {
394 lines.len().hash(&mut hasher);
395 for line in lines {
396 line.text.hash(&mut hasher);
397 line.font_size.map(f32::to_bits).hash(&mut hasher);
398 line.color
399 .map(|color| [color.h, color.s, color.l, color.a].map(f32::to_bits))
400 .hash(&mut hasher);
401 }
402 }
403 hasher.finish()
404 }
405
406 fn place(
408 &self,
409 bounds: Bounds<Pixels>,
410 node_labels: Vec<Vec<SankeyLabel>>,
411 window: &mut Window,
412 ) -> Option<SankeyFrame> {
413 let width = bounds.size.width.as_f32();
414 let height = bounds.size.height.as_f32();
415
416 let topology = self.sankey().topology(self.nodes.len(), &self.links).ok()?;
419 let layer_count = topology.layer_count();
420 let has_labels = node_labels.iter().any(|lines| !lines.is_empty());
421
422 let mut left = 0f32;
425 let mut right = 0f32;
426 if has_labels {
427 for node in &topology.nodes {
428 if node.layer != 0 && node.layer + 1 != layer_count {
429 continue;
430 }
431 let mut label_width = 0f32;
432 for line in &node_labels[node.index] {
433 label_width = label_width.max(measure_text_width(
434 &line.text,
435 px(line.font_size.unwrap_or(TEXT_SIZE)),
436 window,
437 ));
438 }
439 if node.layer == 0 {
440 left = left.max(label_width + self.label_gap);
441 } else {
442 right = right.max(label_width + self.label_gap);
443 }
444 }
445
446 let side_cap = width * MAX_LABEL_WIDTH_RATIO;
449 left = left.min(side_cap);
450 right = right.min(side_cap);
451 }
452 let mut top = 0f32;
457 if has_labels && layer_count > 2 {
458 for node in &topology.nodes {
459 if node.layer == 0 || node.layer + 1 == layer_count {
460 continue;
461 }
462 let block = block_height(&node_labels[node.index]);
463 if block > 0. {
464 top = top.max(block + TEXT_GAP);
465 }
466 }
467 }
468 let mut bottom = if has_labels { TEXT_GAP } else { 0. };
469 let max_vertical = height * MAX_LABEL_MARGIN_RATIO;
470 if top + bottom > max_vertical {
471 let k = max_vertical / (top + bottom);
472 top *= k;
473 bottom *= k;
474 }
475
476 let graph = self
479 .sankey()
480 .extent(
481 left,
482 top,
483 (width - right).max(left + 1.),
484 (height - bottom).max(top + 1.),
485 )
486 .layout_from(topology);
487
488 Some(SankeyFrame {
489 graph,
490 layer_count,
491 node_labels,
492 left,
493 right,
494 })
495 }
496
497 fn is_attached(link: &SankeyLinkLayout, node: usize) -> bool {
499 link.source == node || link.target == node
500 }
501}
502
503crate::impl_into_plot!(impl<T> SankeyChart<T>);
504
505impl<T> Plot for SankeyChart<T> {
506 fn prepaint(
510 &mut self,
511 bounds: Bounds<Pixels>,
512 window: &mut Window,
513 cx: &mut App,
514 ) -> Vec<AnyElement> {
515 self.frame = None;
516 let width = bounds.size.width.as_f32();
517 let height = bounds.size.height.as_f32();
518 if self.nodes.is_empty() || self.links.is_empty() || width <= 0. || height <= 0. {
519 return vec![];
520 }
521
522 let node_labels = self.node_labels(cx);
523
524 self.frame = if self.interactive {
528 let key = self.frame_key(bounds, &node_labels);
529 let cache =
530 window.use_keyed_state("sankey-frame", cx, |_, _| SankeyFrameCache::default());
531 let cached = cache.read(cx);
532 if cached.key == Some(key) {
533 cached.frame.clone()
534 } else {
535 let frame = self.place(bounds, node_labels, window).map(Rc::new);
536 cache.update(cx, |cache, _| {
537 cache.key = Some(key);
538 cache.frame = frame.clone();
539 });
540 frame
541 }
542 } else {
543 self.place(bounds, node_labels, window).map(Rc::new)
544 };
545
546 vec![]
547 }
548
549 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
550 let Some(frame) = self.frame.clone() else {
551 return;
552 };
553 let SankeyFrame {
554 graph,
555 layer_count,
556 node_labels,
557 left,
558 right,
559 } = &*frame;
560 let (layer_count, left, right) = (*layer_count, *left, *right);
561 let width = bounds.size.width.as_f32();
562 let height = bounds.size.height.as_f32();
563
564 let palette = [
565 cx.macos().chart[0],
566 cx.macos().chart[1],
567 cx.macos().chart[2],
568 cx.macos().chart[3],
569 cx.macos().chart[4],
570 ];
571 let colors: Vec<Hsla> = self
572 .nodes
573 .iter()
574 .enumerate()
575 .map(|(index, datum)| match &self.node_color {
576 Some(color) => color(datum),
577 None => palette[index % palette.len()],
578 })
579 .collect();
580
581 for link in &graph.links {
584 if link.value <= 0. {
585 continue;
586 }
587 let source = &graph.nodes[link.source];
588 let target = &graph.nodes[link.target];
589 let Some(path) =
590 sankey_link_path(source, target, link, self.min_link_width, bounds.origin)
591 else {
592 continue;
593 };
594 let opacity = match self.hover {
595 Some(hover) if !Self::is_attached(link, hover.node) => {
596 self.link_opacity * (1. - HOVER_DIM * hover.focus)
597 }
598 _ => self.link_opacity,
599 };
600 window.paint_path(
601 path,
602 linear_gradient(
603 90.,
604 linear_color_stop(colors[link.source].opacity(opacity), 0.),
605 linear_color_stop(colors[link.target].opacity(opacity), 1.),
606 ),
607 );
608 }
609
610 let corner_radii = Corners::all(self.node_corner_radius.unwrap_or_default());
611 for node in &graph.nodes {
612 let node_bounds = Bounds::from_corners(
613 origin_point(px(node.x0), px(node.y0), bounds.origin),
614 origin_point(px(node.x1), px(node.y1.max(node.y0 + 1.)), bounds.origin),
616 );
617 window.paint_quad(fill(node_bounds, colors[node.index]).corner_radii(corner_radii));
618 }
619
620 let mut texts = Vec::new();
621 for node in &graph.nodes {
622 let lines = &node_labels[node.index];
623 if lines.is_empty() {
624 continue;
625 }
626
627 let is_first = node.layer == 0;
628 let is_last = node.layer + 1 == layer_count;
629 let (x, align, max_width) = if is_first {
636 (
637 node.x0 - self.label_gap,
638 TextAlign::Right,
639 left - self.label_gap,
640 )
641 } else if is_last {
642 (
643 node.x1 + self.label_gap,
644 TextAlign::Left,
645 right - self.label_gap,
646 )
647 } else {
648 let center = (node.x0 + node.x1) / 2.;
649 let edge_budget = 2. * center.min(width - center);
650 (center, TextAlign::Center, edge_budget)
651 };
652
653 let block = block_height(lines);
654 let mut y = if is_first || is_last {
655 ((node.y0 + node.y1) / 2. - block / 2.)
659 .min(height - block)
660 .max(0.)
661 } else {
662 node.y0 - block - TEXT_GAP
664 };
665
666 for line in lines {
667 let font_size = px(line.font_size.unwrap_or(TEXT_SIZE));
668 let text = truncate_text_to_width(&line.text, font_size, max_width, window);
669 texts.push(
670 Text::new(
671 text,
672 point(px(x), px(y)),
673 line.color.unwrap_or(cx.macos().foreground),
674 )
675 .font_size(font_size)
676 .align(align),
677 );
678 y += line.line_height();
679 }
680 }
681 PlotLabel::new(texts).paint(&bounds, window, cx);
682 }
683
684 fn id(&self) -> Option<ElementId> {
685 self.interactive.then(|| self.id.clone())
686 }
687
688 fn tooltip_state(
689 &self,
690 position: Point<Pixels>,
691 _bounds: Bounds<Pixels>,
692 _cx: &App,
693 ) -> Option<TooltipState> {
694 let frame = self.frame.as_ref()?;
695 let (x, y) = (position.x.as_f32(), position.y.as_f32());
696 let node = frame.graph.nodes.iter().find(|node| {
697 (node.x0..=node.x1).contains(&x) && (node.y0..=node.y1.max(node.y0 + 1.)).contains(&y)
698 })?;
699 Some(TooltipState::new(node.index, position, vec![]))
700 }
701
702 fn hover(&mut self, hover: Option<&PlotHover>, _window: &mut Window, _cx: &mut App) {
703 self.hover = hover.map(|hover| SankeyHover {
704 node: hover.state().index,
705 focus: hover.progress(),
706 });
707 }
708
709 fn tooltip(
710 &self,
711 state: &TooltipState,
712 cursor: Point<Pixels>,
713 bounds: Bounds<Pixels>,
714 _window: &mut Window,
715 cx: &mut App,
716 ) -> Option<AnyElement> {
717 let datum = self.nodes.get(state.index)?;
718 let value = self.raw_throughput().get(state.index).copied()?;
719 let color = match &self.node_color {
720 Some(color) => color(datum),
721 None => {
722 let palette = [
723 cx.macos().chart[0],
724 cx.macos().chart[1],
725 cx.macos().chart[2],
726 cx.macos().chart[3],
727 cx.macos().chart[4],
728 ];
729 palette[state.index % palette.len()]
730 }
731 };
732 let value_text = match self.tooltip_value.as_ref().or(self.value_label.as_ref()) {
733 Some(value_text) => value_text(datum, value),
734 None => format!("{value}").into(),
735 };
736
737 Some(
738 Tooltip::new(cursor, bounds.size)
740 .gap(px(8.))
741 .when_some(self.node_label.as_ref(), |this, label| {
742 this.title(label(datum))
743 })
744 .row(
745 color,
746 match self.tooltip_name.as_ref() {
747 Some(tooltip_name) => tooltip_name(datum),
748 None => SharedString::default(),
749 },
750 value_text,
751 )
752 .into_any_element(),
753 )
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760
761 #[test]
762 fn test_sankey_chart_builder() {
763 let chart = SankeyChart::new(vec!["a", "b"], vec![SankeyLink::new(0, 1, 5.)]);
764 assert_eq!(chart.nodes.len(), 2);
765 assert_eq!(chart.links.len(), 1);
766 assert_eq!(chart.node_width, DEFAULT_NODE_WIDTH);
767 assert_eq!(chart.node_padding, DEFAULT_NODE_PADDING);
768 assert_eq!(chart.align, SankeyAlign::Justify);
769 assert_eq!(chart.iterations, 6);
770 assert_eq!(chart.node_corner_radius, None);
771 assert_eq!(chart.link_opacity, DEFAULT_LINK_OPACITY);
772 assert_eq!(chart.min_link_width, DEFAULT_MIN_LINK_WIDTH);
773 assert_eq!(chart.label_gap, DEFAULT_LABEL_GAP);
774 assert!(chart.node_color.is_none());
775 assert!(chart.node_label.is_none());
776 assert!(chart.value_label.is_none());
777 assert!(chart.labels.is_none());
778
779 let chart = chart
780 .node_width(8.)
781 .node_padding(20.)
782 .node_align(SankeyAlign::Left)
783 .iterations(10)
784 .node_corner_radius(px(2.))
785 .node_color(|_| gpui_kit::red())
786 .node_label(|d| SharedString::from(d.to_string()))
787 .value_label(|_, value| SharedString::from(format!("{}", value)))
788 .labels(|d, value| {
789 vec![
790 SankeyLabel::new(format!("{}", value)),
791 SankeyLabel::new(d.to_string()),
792 ]
793 })
794 .link_opacity(0.5)
795 .min_link_width(2.)
796 .label_gap(10.);
797 assert_eq!(chart.node_width, 8.);
798 assert_eq!(chart.node_padding, 20.);
799 assert_eq!(chart.align, SankeyAlign::Left);
800 assert_eq!(chart.iterations, 10);
801 assert_eq!(chart.node_corner_radius, Some(px(2.)));
802 assert_eq!(chart.link_opacity, 0.5);
803 assert_eq!(chart.min_link_width, 2.);
804 assert_eq!(chart.label_gap, 10.);
805 assert!(chart.node_color.is_some());
806 assert!(chart.node_label.is_some());
807 assert!(chart.value_label.is_some());
808 assert!(chart.labels.is_some());
809 }
810
811 #[test]
812 fn test_sankey_label_builder() {
813 let label = SankeyLabel::new("a");
814 assert_eq!(label.text, "a");
815 assert_eq!(label.color, None);
816 assert_eq!(label.font_size, None);
817 assert_eq!(label.line_height(), TEXT_SIZE + TEXT_GAP);
818
819 let label = SankeyLabel::new("b").color(gpui_kit::red()).font_size(14.);
820 assert_eq!(label.color, Some(gpui_kit::red()));
821 assert_eq!(label.font_size, Some(14.));
822 assert_eq!(label.line_height(), 14. + TEXT_GAP);
823
824 assert_eq!(
825 block_height(&[SankeyLabel::new("a"), SankeyLabel::new("b").font_size(14.)]),
826 TEXT_SIZE + TEXT_GAP + 14. + TEXT_GAP
827 );
828 assert_eq!(block_height(&[]), 0.);
829 }
830
831 #[test]
832 fn test_sankey_chart_raw_throughput() {
833 let chart = SankeyChart::new(
835 vec!["a", "b", "c", "d"],
836 vec![
837 SankeyLink::new(0, 1, 30.),
838 SankeyLink::new(1, 2, 20.),
839 SankeyLink::new(1, 3, 10.),
840 ],
841 );
842 let raw = chart.raw_throughput();
843 assert_eq!(raw, vec![30., 30., 20., 10.]);
844
845 let sqrt = chart
848 .value_scale(SankeyValueScale::Sqrt)
849 .sankey()
850 .layout(4, &chart_links())
851 .unwrap();
852 assert!((sqrt.nodes[0].value - 30f64.sqrt()).abs() < 1e-6);
854 assert!((raw[0] - 30.).abs() < 1e-6);
855 assert!(raw[0] != sqrt.nodes[0].value);
856 }
857
858 fn chart_links() -> Vec<SankeyLink> {
859 vec![
860 SankeyLink::new(0, 1, 30.),
861 SankeyLink::new(1, 2, 20.),
862 SankeyLink::new(1, 3, 10.),
863 ]
864 }
865
866 #[test]
869 fn test_tooltip_text_is_settable_without_drawing_labels() {
870 let chart = SankeyChart::new(vec!["Revenue"], Vec::<SankeyLink>::new())
871 .tooltip_name(|_| "Revenue".into())
872 .tooltip_value(|_, value| format!("{value:.0}M").into());
873 assert!(chart.tooltip_name.is_some());
874 assert!(chart.tooltip_value.is_some());
875 assert!(chart.node_label.is_none());
876 assert!(chart.value_label.is_none());
877 }
878}