1use std::rc::Rc;
2
3use gpui::{
4 App, Bounds, Corners, Hsla, Pixels, SharedString, TextAlign, Window, fill, linear_color_stop,
5 linear_gradient, point, px,
6};
7use gpui_component_macros::IntoPlot;
8
9use crate::{
10 ActiveTheme,
11 plot::{
12 Plot,
13 label::{PlotLabel, TEXT_GAP, TEXT_SIZE, Text, measure_text_width, truncate_text_to_width},
14 origin_point,
15 shape::{Sankey, SankeyAlign, SankeyLink, SankeyValueScale, sankey_link_path},
16 },
17};
18
19const DEFAULT_NODE_WIDTH: f32 = 10.;
20const DEFAULT_NODE_PADDING: f32 = 16.;
21const DEFAULT_LINK_OPACITY: f32 = 0.3;
22const DEFAULT_MIN_LINK_WIDTH: f32 = 1.;
23const DEFAULT_LABEL_GAP: f32 = 6.;
24const MAX_LABEL_WIDTH_RATIO: f32 = 0.2;
27const MAX_LABEL_MARGIN_RATIO: f32 = 0.6;
29
30#[derive(Clone)]
32pub struct SankeyLabel {
33 text: SharedString,
34 color: Option<Hsla>,
35 font_size: Option<f32>,
36}
37
38impl SankeyLabel {
39 pub fn new(text: impl Into<SharedString>) -> Self {
41 Self {
42 text: text.into(),
43 color: None,
44 font_size: None,
45 }
46 }
47
48 pub fn color(mut self, color: impl Into<Hsla>) -> Self {
50 self.color = Some(color.into());
51 self
52 }
53
54 pub fn font_size(mut self, font_size: f32) -> Self {
56 self.font_size = Some(font_size);
57 self
58 }
59
60 fn line_height(&self) -> f32 {
61 self.font_size.unwrap_or(TEXT_SIZE) + TEXT_GAP
62 }
63}
64
65fn block_height(lines: &[SankeyLabel]) -> f32 {
66 lines.iter().map(|line| line.line_height()).sum()
67}
68
69#[derive(IntoPlot)]
74pub struct SankeyChart<T: 'static> {
75 nodes: Vec<T>,
76 links: Vec<SankeyLink>,
77 node_width: f32,
78 node_padding: f32,
79 align: SankeyAlign,
80 iterations: usize,
81 value_scale: SankeyValueScale,
82 node_corner_radius: Option<Pixels>,
83 node_color: Option<Rc<dyn Fn(&T) -> Hsla>>,
84 node_label: Option<Rc<dyn Fn(&T) -> SharedString>>,
85 value_label: Option<Rc<dyn Fn(&T, f64) -> SharedString>>,
86 labels: Option<Rc<dyn Fn(&T, f64) -> Vec<SankeyLabel>>>,
87 link_opacity: f32,
88 min_link_width: f32,
89 label_gap: f32,
90}
91
92impl<T> SankeyChart<T> {
93 pub fn new<I, L>(nodes: I, links: L) -> Self
96 where
97 I: IntoIterator<Item = T>,
98 L: IntoIterator<Item = SankeyLink>,
99 {
100 Self {
101 nodes: nodes.into_iter().collect(),
102 links: links.into_iter().collect(),
103 node_width: DEFAULT_NODE_WIDTH,
104 node_padding: DEFAULT_NODE_PADDING,
105 align: SankeyAlign::default(),
106 iterations: 6,
107 value_scale: SankeyValueScale::default(),
108 node_corner_radius: None,
109 node_color: None,
110 node_label: None,
111 value_label: None,
112 labels: None,
113 link_opacity: DEFAULT_LINK_OPACITY,
114 min_link_width: DEFAULT_MIN_LINK_WIDTH,
115 label_gap: DEFAULT_LABEL_GAP,
116 }
117 }
118
119 pub fn node_width(mut self, node_width: f32) -> Self {
121 self.node_width = node_width;
122 self
123 }
124
125 pub fn node_padding(mut self, node_padding: f32) -> Self {
127 self.node_padding = node_padding;
128 self
129 }
130
131 pub fn node_align(mut self, align: SankeyAlign) -> Self {
133 self.align = align;
134 self
135 }
136
137 pub fn iterations(mut self, iterations: usize) -> Self {
139 self.iterations = iterations;
140 self
141 }
142
143 pub fn value_scale(mut self, value_scale: SankeyValueScale) -> Self {
149 self.value_scale = value_scale;
150 self
151 }
152
153 pub fn node_corner_radius(mut self, radius: impl Into<Pixels>) -> Self {
155 self.node_corner_radius = Some(radius.into());
156 self
157 }
158
159 pub fn node_color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
163 where
164 H: Into<Hsla> + 'static,
165 {
166 self.node_color = Some(Rc::new(move |t| color(t).into()));
167 self
168 }
169
170 pub fn node_label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
173 self.node_label = Some(Rc::new(label));
174 self
175 }
176
177 pub fn value_label(mut self, label: impl Fn(&T, f64) -> SharedString + 'static) -> Self {
183 self.value_label = Some(Rc::new(label));
184 self
185 }
186
187 pub fn labels(mut self, labels: impl Fn(&T, f64) -> Vec<SankeyLabel> + 'static) -> Self {
194 self.labels = Some(Rc::new(labels));
195 self
196 }
197
198 pub fn link_opacity(mut self, opacity: f32) -> Self {
200 self.link_opacity = opacity;
201 self
202 }
203
204 pub fn min_link_width(mut self, width: f32) -> Self {
206 self.min_link_width = width;
207 self
208 }
209
210 pub fn label_gap(mut self, gap: f32) -> Self {
212 self.label_gap = gap;
213 self
214 }
215
216 fn sankey(&self) -> Sankey {
217 Sankey::new()
218 .node_width(self.node_width)
219 .node_padding(self.node_padding)
220 .node_align(self.align)
221 .iterations(self.iterations)
222 .value_scale(self.value_scale)
223 }
224
225 fn raw_throughput(&self) -> Vec<f64> {
229 let mut incoming = vec![0f64; self.nodes.len()];
230 let mut outgoing = vec![0f64; self.nodes.len()];
231 for link in &self.links {
232 if let (Some(o), Some(i)) =
233 (outgoing.get_mut(link.source), incoming.get_mut(link.target))
234 {
235 *o += link.value;
236 *i += link.value;
237 }
238 }
239 incoming
240 .into_iter()
241 .zip(outgoing)
242 .map(|(i, o)| i.max(o))
243 .collect()
244 }
245}
246
247impl<T> Plot for SankeyChart<T> {
248 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
249 let width = bounds.size.width.as_f32();
250 let height = bounds.size.height.as_f32();
251 if self.nodes.is_empty() || self.links.is_empty() || width <= 0. || height <= 0. {
252 return;
253 }
254
255 let Ok(topology) = self.sankey().topology(self.nodes.len(), &self.links) else {
258 return;
259 };
260 let layer_count = topology.layer_count();
261 let raw_value = self.raw_throughput();
263
264 let node_labels: Vec<Vec<SankeyLabel>> = topology
267 .nodes
268 .iter()
269 .map(|node| {
270 let datum = &self.nodes[node.index];
271 let value = raw_value[node.index];
272 if let Some(labels) = &self.labels {
273 labels(datum, value)
274 } else {
275 let mut lines = Vec::new();
276 if let Some(value_label) = &self.value_label {
277 lines.push(SankeyLabel::new(value_label(datum, value)));
278 }
279 if let Some(node_label) = &self.node_label {
280 lines.push(
281 SankeyLabel::new(node_label(datum)).color(cx.theme().muted_foreground),
282 );
283 }
284 lines
285 }
286 })
287 .collect();
288 let has_labels = node_labels.iter().any(|lines| !lines.is_empty());
289
290 let mut left = 0f32;
293 let mut right = 0f32;
294 if has_labels {
295 for node in &topology.nodes {
296 if node.layer != 0 && node.layer + 1 != layer_count {
297 continue;
298 }
299 let mut label_width = 0f32;
300 for line in &node_labels[node.index] {
301 label_width = label_width.max(measure_text_width(
302 &line.text,
303 px(line.font_size.unwrap_or(TEXT_SIZE)),
304 window,
305 ));
306 }
307 if node.layer == 0 {
308 left = left.max(label_width + self.label_gap);
309 } else {
310 right = right.max(label_width + self.label_gap);
311 }
312 }
313
314 let side_cap = width * MAX_LABEL_WIDTH_RATIO;
317 left = left.min(side_cap);
318 right = right.min(side_cap);
319 }
320 let mut top = 0f32;
325 if has_labels && layer_count > 2 {
326 for node in &topology.nodes {
327 if node.layer == 0 || node.layer + 1 == layer_count {
328 continue;
329 }
330 let block = block_height(&node_labels[node.index]);
331 if block > 0. {
332 top = top.max(block + TEXT_GAP);
333 }
334 }
335 }
336 let mut bottom = if has_labels { TEXT_GAP } else { 0. };
337 let max_vertical = height * MAX_LABEL_MARGIN_RATIO;
338 if top + bottom > max_vertical {
339 let k = max_vertical / (top + bottom);
340 top *= k;
341 bottom *= k;
342 }
343
344 let graph = self
347 .sankey()
348 .extent(
349 left,
350 top,
351 (width - right).max(left + 1.),
352 (height - bottom).max(top + 1.),
353 )
354 .layout_from(topology);
355
356 let palette = [
357 cx.theme().chart_1,
358 cx.theme().chart_2,
359 cx.theme().chart_3,
360 cx.theme().chart_4,
361 cx.theme().chart_5,
362 ];
363 let colors: Vec<Hsla> = self
364 .nodes
365 .iter()
366 .enumerate()
367 .map(|(index, datum)| match &self.node_color {
368 Some(color) => color(datum),
369 None => palette[index % palette.len()],
370 })
371 .collect();
372
373 for link in &graph.links {
375 if link.value <= 0. {
376 continue;
377 }
378 let source = &graph.nodes[link.source];
379 let target = &graph.nodes[link.target];
380 let Some(path) =
381 sankey_link_path(source, target, link, self.min_link_width, bounds.origin)
382 else {
383 continue;
384 };
385 window.paint_path(
386 path,
387 linear_gradient(
388 90.,
389 linear_color_stop(colors[link.source].opacity(self.link_opacity), 0.),
390 linear_color_stop(colors[link.target].opacity(self.link_opacity), 1.),
391 ),
392 );
393 }
394
395 let corner_radii = Corners::all(self.node_corner_radius.unwrap_or_default());
396 for node in &graph.nodes {
397 let node_bounds = Bounds::from_corners(
398 origin_point(px(node.x0), px(node.y0), bounds.origin),
399 origin_point(px(node.x1), px(node.y1.max(node.y0 + 1.)), bounds.origin),
401 );
402 window.paint_quad(fill(node_bounds, colors[node.index]).corner_radii(corner_radii));
403 }
404
405 let mut texts = Vec::new();
406 for node in &graph.nodes {
407 let lines = &node_labels[node.index];
408 if lines.is_empty() {
409 continue;
410 }
411
412 let is_first = node.layer == 0;
413 let is_last = node.layer + 1 == layer_count;
414 let (x, align, max_width) = if is_first {
421 (
422 node.x0 - self.label_gap,
423 TextAlign::Right,
424 left - self.label_gap,
425 )
426 } else if is_last {
427 (
428 node.x1 + self.label_gap,
429 TextAlign::Left,
430 right - self.label_gap,
431 )
432 } else {
433 let center = (node.x0 + node.x1) / 2.;
434 let edge_budget = 2. * center.min(width - center);
435 (center, TextAlign::Center, edge_budget)
436 };
437
438 let block = block_height(lines);
439 let mut y = if is_first || is_last {
440 ((node.y0 + node.y1) / 2. - block / 2.)
444 .min(height - block)
445 .max(0.)
446 } else {
447 node.y0 - block - TEXT_GAP
449 };
450
451 for line in lines {
452 let font_size = px(line.font_size.unwrap_or(TEXT_SIZE));
453 let text = truncate_text_to_width(&line.text, font_size, max_width, window);
454 texts.push(
455 Text::new(
456 text,
457 point(px(x), px(y)),
458 line.color.unwrap_or(cx.theme().foreground),
459 )
460 .font_size(font_size)
461 .align(align),
462 );
463 y += line.line_height();
464 }
465 }
466 PlotLabel::new(texts).paint(&bounds, window, cx);
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn test_sankey_chart_builder() {
476 let chart = SankeyChart::new(vec!["a", "b"], vec![SankeyLink::new(0, 1, 5.)]);
477 assert_eq!(chart.nodes.len(), 2);
478 assert_eq!(chart.links.len(), 1);
479 assert_eq!(chart.node_width, DEFAULT_NODE_WIDTH);
480 assert_eq!(chart.node_padding, DEFAULT_NODE_PADDING);
481 assert_eq!(chart.align, SankeyAlign::Justify);
482 assert_eq!(chart.iterations, 6);
483 assert_eq!(chart.node_corner_radius, None);
484 assert_eq!(chart.link_opacity, DEFAULT_LINK_OPACITY);
485 assert_eq!(chart.min_link_width, DEFAULT_MIN_LINK_WIDTH);
486 assert_eq!(chart.label_gap, DEFAULT_LABEL_GAP);
487 assert!(chart.node_color.is_none());
488 assert!(chart.node_label.is_none());
489 assert!(chart.value_label.is_none());
490 assert!(chart.labels.is_none());
491
492 let chart = chart
493 .node_width(8.)
494 .node_padding(20.)
495 .node_align(SankeyAlign::Left)
496 .iterations(10)
497 .node_corner_radius(px(2.))
498 .node_color(|_| gpui::red())
499 .node_label(|d| SharedString::from(d.to_string()))
500 .value_label(|_, value| SharedString::from(format!("{}", value)))
501 .labels(|d, value| {
502 vec![
503 SankeyLabel::new(format!("{}", value)),
504 SankeyLabel::new(d.to_string()),
505 ]
506 })
507 .link_opacity(0.5)
508 .min_link_width(2.)
509 .label_gap(10.);
510 assert_eq!(chart.node_width, 8.);
511 assert_eq!(chart.node_padding, 20.);
512 assert_eq!(chart.align, SankeyAlign::Left);
513 assert_eq!(chart.iterations, 10);
514 assert_eq!(chart.node_corner_radius, Some(px(2.)));
515 assert_eq!(chart.link_opacity, 0.5);
516 assert_eq!(chart.min_link_width, 2.);
517 assert_eq!(chart.label_gap, 10.);
518 assert!(chart.node_color.is_some());
519 assert!(chart.node_label.is_some());
520 assert!(chart.value_label.is_some());
521 assert!(chart.labels.is_some());
522 }
523
524 #[test]
525 fn test_sankey_label_builder() {
526 let label = SankeyLabel::new("a");
527 assert_eq!(label.text, "a");
528 assert_eq!(label.color, None);
529 assert_eq!(label.font_size, None);
530 assert_eq!(label.line_height(), TEXT_SIZE + TEXT_GAP);
531
532 let label = SankeyLabel::new("b").color(gpui::red()).font_size(14.);
533 assert_eq!(label.color, Some(gpui::red()));
534 assert_eq!(label.font_size, Some(14.));
535 assert_eq!(label.line_height(), 14. + TEXT_GAP);
536
537 assert_eq!(
538 block_height(&[SankeyLabel::new("a"), SankeyLabel::new("b").font_size(14.)]),
539 TEXT_SIZE + TEXT_GAP + 14. + TEXT_GAP
540 );
541 assert_eq!(block_height(&[]), 0.);
542 }
543
544 #[test]
545 fn test_sankey_chart_raw_throughput() {
546 let chart = SankeyChart::new(
548 vec!["a", "b", "c", "d"],
549 vec![
550 SankeyLink::new(0, 1, 30.),
551 SankeyLink::new(1, 2, 20.),
552 SankeyLink::new(1, 3, 10.),
553 ],
554 );
555 let raw = chart.raw_throughput();
556 assert_eq!(raw, vec![30., 30., 20., 10.]);
557
558 let sqrt = chart
561 .value_scale(SankeyValueScale::Sqrt)
562 .sankey()
563 .layout(4, &chart_links())
564 .unwrap();
565 assert!((sqrt.nodes[0].value - 30f64.sqrt()).abs() < 1e-6);
567 assert!((raw[0] - 30.).abs() < 1e-6);
568 assert!(raw[0] != sqrt.nodes[0].value);
569 }
570
571 fn chart_links() -> Vec<SankeyLink> {
572 vec![
573 SankeyLink::new(0, 1, 30.),
574 SankeyLink::new(1, 2, 20.),
575 SankeyLink::new(1, 3, 10.),
576 ]
577 }
578}