1use std::{
2 hash::{DefaultHasher, Hash, Hasher},
3 rc::Rc,
4};
5
6use gpui::{
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};
10use gpui_component_macros::IntoPlot;
11
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
110#[derive(IntoPlot)]
115pub struct SankeyChart<T: 'static> {
116 nodes: Vec<T>,
117 links: Vec<SankeyLink>,
118 node_width: f32,
119 node_padding: f32,
120 align: SankeyAlign,
121 iterations: usize,
122 value_scale: SankeyValueScale,
123 node_corner_radius: Option<Pixels>,
124 node_color: Option<Rc<dyn Fn(&T) -> Hsla>>,
125 node_label: Option<Rc<dyn Fn(&T) -> SharedString>>,
126 value_label: Option<Rc<dyn Fn(&T, f64) -> SharedString>>,
127 labels: Option<Rc<dyn Fn(&T, f64) -> Vec<SankeyLabel>>>,
128 link_opacity: f32,
129 min_link_width: f32,
130 label_gap: f32,
131 id: Option<ElementId>,
132 frame: Option<Rc<SankeyFrame>>,
135 hover: Option<SankeyHover>,
136}
137
138impl<T> SankeyChart<T> {
139 pub fn new<I, L>(nodes: I, links: L) -> Self
142 where
143 I: IntoIterator<Item = T>,
144 L: IntoIterator<Item = SankeyLink>,
145 {
146 Self {
147 nodes: nodes.into_iter().collect(),
148 links: links.into_iter().collect(),
149 node_width: DEFAULT_NODE_WIDTH,
150 node_padding: DEFAULT_NODE_PADDING,
151 align: SankeyAlign::default(),
152 iterations: 6,
153 value_scale: SankeyValueScale::default(),
154 node_corner_radius: None,
155 node_color: None,
156 node_label: None,
157 value_label: None,
158 labels: None,
159 link_opacity: DEFAULT_LINK_OPACITY,
160 min_link_width: DEFAULT_MIN_LINK_WIDTH,
161 label_gap: DEFAULT_LABEL_GAP,
162 id: None,
163 frame: None,
164 hover: None,
165 }
166 }
167
168 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
175 self.id = Some(id.into());
176 self
177 }
178
179 pub fn node_width(mut self, node_width: f32) -> Self {
181 self.node_width = node_width;
182 self
183 }
184
185 pub fn node_padding(mut self, node_padding: f32) -> Self {
187 self.node_padding = node_padding;
188 self
189 }
190
191 pub fn node_align(mut self, align: SankeyAlign) -> Self {
193 self.align = align;
194 self
195 }
196
197 pub fn iterations(mut self, iterations: usize) -> Self {
199 self.iterations = iterations;
200 self
201 }
202
203 pub fn value_scale(mut self, value_scale: SankeyValueScale) -> Self {
209 self.value_scale = value_scale;
210 self
211 }
212
213 pub fn node_corner_radius(mut self, radius: impl Into<Pixels>) -> Self {
215 self.node_corner_radius = Some(radius.into());
216 self
217 }
218
219 pub fn node_color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
223 where
224 H: Into<Hsla> + 'static,
225 {
226 self.node_color = Some(Rc::new(move |t| color(t).into()));
227 self
228 }
229
230 pub fn node_label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
233 self.node_label = Some(Rc::new(label));
234 self
235 }
236
237 pub fn value_label(mut self, label: impl Fn(&T, f64) -> SharedString + 'static) -> Self {
243 self.value_label = Some(Rc::new(label));
244 self
245 }
246
247 pub fn labels(mut self, labels: impl Fn(&T, f64) -> Vec<SankeyLabel> + 'static) -> Self {
254 self.labels = Some(Rc::new(labels));
255 self
256 }
257
258 pub fn link_opacity(mut self, opacity: f32) -> Self {
260 self.link_opacity = opacity;
261 self
262 }
263
264 pub fn min_link_width(mut self, width: f32) -> Self {
266 self.min_link_width = width;
267 self
268 }
269
270 pub fn label_gap(mut self, gap: f32) -> Self {
272 self.label_gap = gap;
273 self
274 }
275
276 fn sankey(&self) -> Sankey {
277 Sankey::new()
278 .node_width(self.node_width)
279 .node_padding(self.node_padding)
280 .node_align(self.align)
281 .iterations(self.iterations)
282 .value_scale(self.value_scale)
283 }
284
285 fn raw_throughput(&self) -> Vec<f64> {
289 let mut incoming = vec![0f64; self.nodes.len()];
290 let mut outgoing = vec![0f64; self.nodes.len()];
291 for link in &self.links {
292 if let (Some(o), Some(i)) =
293 (outgoing.get_mut(link.source), incoming.get_mut(link.target))
294 {
295 *o += link.value;
296 *i += link.value;
297 }
298 }
299 incoming
300 .into_iter()
301 .zip(outgoing)
302 .map(|(i, o)| i.max(o))
303 .collect()
304 }
305}
306
307impl<T> SankeyChart<T> {
308 fn node_labels(&self, cx: &App) -> Vec<Vec<SankeyLabel>> {
312 let raw_value = self.raw_throughput();
313 self.nodes
314 .iter()
315 .zip(raw_value)
316 .map(|(datum, value)| {
317 if let Some(labels) = &self.labels {
318 labels(datum, value)
319 } else {
320 let mut lines = Vec::new();
321 if let Some(value_label) = &self.value_label {
322 lines.push(SankeyLabel::new(value_label(datum, value)));
323 }
324 if let Some(node_label) = &self.node_label {
325 lines.push(
326 SankeyLabel::new(node_label(datum)).color(cx.theme().muted_foreground),
327 );
328 }
329 lines
330 }
331 })
332 .collect()
333 }
334
335 fn frame_key(&self, bounds: Bounds<Pixels>, node_labels: &[Vec<SankeyLabel>]) -> u64 {
338 let mut hasher = DefaultHasher::new();
339 bounds.size.width.as_f32().to_bits().hash(&mut hasher);
340 bounds.size.height.as_f32().to_bits().hash(&mut hasher);
341 self.nodes.len().hash(&mut hasher);
342 for link in &self.links {
343 link.source.hash(&mut hasher);
344 link.target.hash(&mut hasher);
345 link.value.to_bits().hash(&mut hasher);
346 }
347 self.node_width.to_bits().hash(&mut hasher);
348 self.node_padding.to_bits().hash(&mut hasher);
349 self.align.hash(&mut hasher);
350 self.iterations.hash(&mut hasher);
351 self.value_scale.hash(&mut hasher);
352 self.label_gap.to_bits().hash(&mut hasher);
353 for lines in node_labels {
354 lines.len().hash(&mut hasher);
355 for line in lines {
356 line.text.hash(&mut hasher);
357 line.font_size.map(f32::to_bits).hash(&mut hasher);
358 line.color
359 .map(|color| [color.h, color.s, color.l, color.a].map(f32::to_bits))
360 .hash(&mut hasher);
361 }
362 }
363 hasher.finish()
364 }
365
366 fn place(
368 &self,
369 bounds: Bounds<Pixels>,
370 node_labels: Vec<Vec<SankeyLabel>>,
371 window: &mut Window,
372 ) -> Option<SankeyFrame> {
373 let width = bounds.size.width.as_f32();
374 let height = bounds.size.height.as_f32();
375
376 let topology = self.sankey().topology(self.nodes.len(), &self.links).ok()?;
379 let layer_count = topology.layer_count();
380 let has_labels = node_labels.iter().any(|lines| !lines.is_empty());
381
382 let mut left = 0f32;
385 let mut right = 0f32;
386 if has_labels {
387 for node in &topology.nodes {
388 if node.layer != 0 && node.layer + 1 != layer_count {
389 continue;
390 }
391 let mut label_width = 0f32;
392 for line in &node_labels[node.index] {
393 label_width = label_width.max(measure_text_width(
394 &line.text,
395 px(line.font_size.unwrap_or(TEXT_SIZE)),
396 window,
397 ));
398 }
399 if node.layer == 0 {
400 left = left.max(label_width + self.label_gap);
401 } else {
402 right = right.max(label_width + self.label_gap);
403 }
404 }
405
406 let side_cap = width * MAX_LABEL_WIDTH_RATIO;
409 left = left.min(side_cap);
410 right = right.min(side_cap);
411 }
412 let mut top = 0f32;
417 if has_labels && layer_count > 2 {
418 for node in &topology.nodes {
419 if node.layer == 0 || node.layer + 1 == layer_count {
420 continue;
421 }
422 let block = block_height(&node_labels[node.index]);
423 if block > 0. {
424 top = top.max(block + TEXT_GAP);
425 }
426 }
427 }
428 let mut bottom = if has_labels { TEXT_GAP } else { 0. };
429 let max_vertical = height * MAX_LABEL_MARGIN_RATIO;
430 if top + bottom > max_vertical {
431 let k = max_vertical / (top + bottom);
432 top *= k;
433 bottom *= k;
434 }
435
436 let graph = self
439 .sankey()
440 .extent(
441 left,
442 top,
443 (width - right).max(left + 1.),
444 (height - bottom).max(top + 1.),
445 )
446 .layout_from(topology);
447
448 Some(SankeyFrame {
449 graph,
450 layer_count,
451 node_labels,
452 left,
453 right,
454 })
455 }
456
457 fn is_attached(link: &SankeyLinkLayout, node: usize) -> bool {
459 link.source == node || link.target == node
460 }
461}
462
463impl<T> Plot for SankeyChart<T> {
464 fn prepaint(
468 &mut self,
469 bounds: Bounds<Pixels>,
470 window: &mut Window,
471 cx: &mut App,
472 ) -> Vec<AnyElement> {
473 self.frame = None;
474 let width = bounds.size.width.as_f32();
475 let height = bounds.size.height.as_f32();
476 if self.nodes.is_empty() || self.links.is_empty() || width <= 0. || height <= 0. {
477 return vec![];
478 }
479
480 let node_labels = self.node_labels(cx);
481
482 self.frame = if self.id.is_some() {
485 let key = self.frame_key(bounds, &node_labels);
486 let cache =
487 window.use_keyed_state("sankey-frame", cx, |_, _| SankeyFrameCache::default());
488 let cached = cache.read(cx);
489 if cached.key == Some(key) {
490 cached.frame.clone()
491 } else {
492 let frame = self.place(bounds, node_labels, window).map(Rc::new);
493 cache.update(cx, |cache, _| {
494 cache.key = Some(key);
495 cache.frame = frame.clone();
496 });
497 frame
498 }
499 } else {
500 self.place(bounds, node_labels, window).map(Rc::new)
501 };
502
503 vec![]
504 }
505
506 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
507 let Some(frame) = self.frame.clone() else {
508 return;
509 };
510 let SankeyFrame {
511 graph,
512 layer_count,
513 node_labels,
514 left,
515 right,
516 } = &*frame;
517 let (layer_count, left, right) = (*layer_count, *left, *right);
518 let width = bounds.size.width.as_f32();
519 let height = bounds.size.height.as_f32();
520
521 let palette = [
522 cx.theme().chart_1,
523 cx.theme().chart_2,
524 cx.theme().chart_3,
525 cx.theme().chart_4,
526 cx.theme().chart_5,
527 ];
528 let colors: Vec<Hsla> = self
529 .nodes
530 .iter()
531 .enumerate()
532 .map(|(index, datum)| match &self.node_color {
533 Some(color) => color(datum),
534 None => palette[index % palette.len()],
535 })
536 .collect();
537
538 for link in &graph.links {
541 if link.value <= 0. {
542 continue;
543 }
544 let source = &graph.nodes[link.source];
545 let target = &graph.nodes[link.target];
546 let Some(path) =
547 sankey_link_path(source, target, link, self.min_link_width, bounds.origin)
548 else {
549 continue;
550 };
551 let opacity = match self.hover {
552 Some(hover) if !Self::is_attached(link, hover.node) => {
553 self.link_opacity * (1. - HOVER_DIM * hover.focus)
554 }
555 _ => self.link_opacity,
556 };
557 window.paint_path(
558 path,
559 linear_gradient(
560 90.,
561 linear_color_stop(colors[link.source].opacity(opacity), 0.),
562 linear_color_stop(colors[link.target].opacity(opacity), 1.),
563 ),
564 );
565 }
566
567 let corner_radii = Corners::all(self.node_corner_radius.unwrap_or_default());
568 for node in &graph.nodes {
569 let node_bounds = Bounds::from_corners(
570 origin_point(px(node.x0), px(node.y0), bounds.origin),
571 origin_point(px(node.x1), px(node.y1.max(node.y0 + 1.)), bounds.origin),
573 );
574 window.paint_quad(fill(node_bounds, colors[node.index]).corner_radii(corner_radii));
575 }
576
577 let mut texts = Vec::new();
578 for node in &graph.nodes {
579 let lines = &node_labels[node.index];
580 if lines.is_empty() {
581 continue;
582 }
583
584 let is_first = node.layer == 0;
585 let is_last = node.layer + 1 == layer_count;
586 let (x, align, max_width) = if is_first {
593 (
594 node.x0 - self.label_gap,
595 TextAlign::Right,
596 left - self.label_gap,
597 )
598 } else if is_last {
599 (
600 node.x1 + self.label_gap,
601 TextAlign::Left,
602 right - self.label_gap,
603 )
604 } else {
605 let center = (node.x0 + node.x1) / 2.;
606 let edge_budget = 2. * center.min(width - center);
607 (center, TextAlign::Center, edge_budget)
608 };
609
610 let block = block_height(lines);
611 let mut y = if is_first || is_last {
612 ((node.y0 + node.y1) / 2. - block / 2.)
616 .min(height - block)
617 .max(0.)
618 } else {
619 node.y0 - block - TEXT_GAP
621 };
622
623 for line in lines {
624 let font_size = px(line.font_size.unwrap_or(TEXT_SIZE));
625 let text = truncate_text_to_width(&line.text, font_size, max_width, window);
626 texts.push(
627 Text::new(
628 text,
629 point(px(x), px(y)),
630 line.color.unwrap_or(cx.theme().foreground),
631 )
632 .font_size(font_size)
633 .align(align),
634 );
635 y += line.line_height();
636 }
637 }
638 PlotLabel::new(texts).paint(&bounds, window, cx);
639 }
640
641 fn id(&self) -> Option<ElementId> {
642 self.id.clone()
643 }
644
645 fn tooltip_state(
646 &self,
647 position: Point<Pixels>,
648 _bounds: Bounds<Pixels>,
649 _cx: &App,
650 ) -> Option<TooltipState> {
651 let frame = self.frame.as_ref()?;
652 let (x, y) = (position.x.as_f32(), position.y.as_f32());
653 let node = frame.graph.nodes.iter().find(|node| {
654 (node.x0..=node.x1).contains(&x) && (node.y0..=node.y1.max(node.y0 + 1.)).contains(&y)
655 })?;
656 Some(TooltipState::new(node.index, position, vec![]))
657 }
658
659 fn hover(&mut self, hover: Option<&PlotHover>, _window: &mut Window, _cx: &mut App) {
660 self.hover = hover.map(|hover| SankeyHover {
661 node: hover.state().index,
662 focus: hover.focus(),
663 });
664 }
665
666 fn tooltip(
667 &self,
668 state: &TooltipState,
669 cursor: Point<Pixels>,
670 bounds: Bounds<Pixels>,
671 _window: &mut Window,
672 cx: &mut App,
673 ) -> Option<AnyElement> {
674 let datum = self.nodes.get(state.index)?;
675 let value = self.raw_throughput().get(state.index).copied()?;
676 let color = match &self.node_color {
677 Some(color) => color(datum),
678 None => {
679 let palette = [
680 cx.theme().chart_1,
681 cx.theme().chart_2,
682 cx.theme().chart_3,
683 cx.theme().chart_4,
684 cx.theme().chart_5,
685 ];
686 palette[state.index % palette.len()]
687 }
688 };
689 let value_text = match &self.value_label {
690 Some(value_label) => value_label(datum, value),
691 None => format!("{}", value).into(),
692 };
693
694 Some(
695 Tooltip::new(cursor, bounds.size)
697 .gap(px(8.))
698 .when_some(self.node_label.as_ref(), |this, label| {
699 this.title(label(datum))
700 })
701 .row(color, SharedString::default(), value_text)
702 .into_any_element(),
703 )
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 #[test]
712 fn test_sankey_chart_builder() {
713 let chart = SankeyChart::new(vec!["a", "b"], vec![SankeyLink::new(0, 1, 5.)]);
714 assert_eq!(chart.nodes.len(), 2);
715 assert_eq!(chart.links.len(), 1);
716 assert_eq!(chart.node_width, DEFAULT_NODE_WIDTH);
717 assert_eq!(chart.node_padding, DEFAULT_NODE_PADDING);
718 assert_eq!(chart.align, SankeyAlign::Justify);
719 assert_eq!(chart.iterations, 6);
720 assert_eq!(chart.node_corner_radius, None);
721 assert_eq!(chart.link_opacity, DEFAULT_LINK_OPACITY);
722 assert_eq!(chart.min_link_width, DEFAULT_MIN_LINK_WIDTH);
723 assert_eq!(chart.label_gap, DEFAULT_LABEL_GAP);
724 assert!(chart.node_color.is_none());
725 assert!(chart.node_label.is_none());
726 assert!(chart.value_label.is_none());
727 assert!(chart.labels.is_none());
728
729 let chart = chart
730 .node_width(8.)
731 .node_padding(20.)
732 .node_align(SankeyAlign::Left)
733 .iterations(10)
734 .node_corner_radius(px(2.))
735 .node_color(|_| gpui::red())
736 .node_label(|d| SharedString::from(d.to_string()))
737 .value_label(|_, value| SharedString::from(format!("{}", value)))
738 .labels(|d, value| {
739 vec![
740 SankeyLabel::new(format!("{}", value)),
741 SankeyLabel::new(d.to_string()),
742 ]
743 })
744 .link_opacity(0.5)
745 .min_link_width(2.)
746 .label_gap(10.);
747 assert_eq!(chart.node_width, 8.);
748 assert_eq!(chart.node_padding, 20.);
749 assert_eq!(chart.align, SankeyAlign::Left);
750 assert_eq!(chart.iterations, 10);
751 assert_eq!(chart.node_corner_radius, Some(px(2.)));
752 assert_eq!(chart.link_opacity, 0.5);
753 assert_eq!(chart.min_link_width, 2.);
754 assert_eq!(chart.label_gap, 10.);
755 assert!(chart.node_color.is_some());
756 assert!(chart.node_label.is_some());
757 assert!(chart.value_label.is_some());
758 assert!(chart.labels.is_some());
759 }
760
761 #[test]
762 fn test_sankey_label_builder() {
763 let label = SankeyLabel::new("a");
764 assert_eq!(label.text, "a");
765 assert_eq!(label.color, None);
766 assert_eq!(label.font_size, None);
767 assert_eq!(label.line_height(), TEXT_SIZE + TEXT_GAP);
768
769 let label = SankeyLabel::new("b").color(gpui::red()).font_size(14.);
770 assert_eq!(label.color, Some(gpui::red()));
771 assert_eq!(label.font_size, Some(14.));
772 assert_eq!(label.line_height(), 14. + TEXT_GAP);
773
774 assert_eq!(
775 block_height(&[SankeyLabel::new("a"), SankeyLabel::new("b").font_size(14.)]),
776 TEXT_SIZE + TEXT_GAP + 14. + TEXT_GAP
777 );
778 assert_eq!(block_height(&[]), 0.);
779 }
780
781 #[test]
782 fn test_sankey_chart_raw_throughput() {
783 let chart = SankeyChart::new(
785 vec!["a", "b", "c", "d"],
786 vec![
787 SankeyLink::new(0, 1, 30.),
788 SankeyLink::new(1, 2, 20.),
789 SankeyLink::new(1, 3, 10.),
790 ],
791 );
792 let raw = chart.raw_throughput();
793 assert_eq!(raw, vec![30., 30., 20., 10.]);
794
795 let sqrt = chart
798 .value_scale(SankeyValueScale::Sqrt)
799 .sankey()
800 .layout(4, &chart_links())
801 .unwrap();
802 assert!((sqrt.nodes[0].value - 30f64.sqrt()).abs() < 1e-6);
804 assert!((raw[0] - 30.).abs() < 1e-6);
805 assert!(raw[0] != sqrt.nodes[0].value);
806 }
807
808 fn chart_links() -> Vec<SankeyLink> {
809 vec![
810 SankeyLink::new(0, 1, 30.),
811 SankeyLink::new(1, 2, 20.),
812 SankeyLink::new(1, 3, 10.),
813 ]
814 }
815}