Skip to main content

gpui_component/chart/
sankey_chart.rs

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.;
24/// Cap each side's label margin (as a fraction of width) so a long label is
25/// truncated to a modest column beside the flow instead of dominating it.
26const MAX_LABEL_WIDTH_RATIO: f32 = 0.2;
27/// Cap the reserved top+bottom label band as a fraction of height.
28const MAX_LABEL_MARGIN_RATIO: f32 = 0.6;
29
30/// A styled line of a sankey node label.
31#[derive(Clone)]
32pub struct SankeyLabel {
33    text: SharedString,
34    color: Option<Hsla>,
35    font_size: Option<f32>,
36}
37
38impl SankeyLabel {
39    /// Create a label line with the default color and font size.
40    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    /// Set the text color. Defaults to the theme foreground.
49    pub fn color(mut self, color: impl Into<Hsla>) -> Self {
50        self.color = Some(color.into());
51        self
52    }
53
54    /// Set the font size. Defaults to 10.
55    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/// A Sankey diagram, layout modeled after [d3-sankey](https://github.com/d3/d3-sankey).
70///
71/// Links reference nodes by their index in the node list; map string ids to
72/// indices before constructing.
73#[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    /// Create a chart from nodes and links; links reference nodes by their
94    /// index in `nodes` (map string ids to indices before constructing).
95    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    /// Set the node rectangle width. Defaults to 10.
120    pub fn node_width(mut self, node_width: f32) -> Self {
121        self.node_width = node_width;
122        self
123    }
124
125    /// Set the vertical gap between nodes in a column. Defaults to 16.
126    pub fn node_padding(mut self, node_padding: f32) -> Self {
127        self.node_padding = node_padding;
128        self
129    }
130
131    /// Set the node alignment. Defaults to [`SankeyAlign::Justify`].
132    pub fn node_align(mut self, align: SankeyAlign) -> Self {
133        self.align = align;
134        self
135    }
136
137    /// Set the number of relaxation passes. Defaults to 6.
138    pub fn iterations(mut self, iterations: usize) -> Self {
139        self.iterations = iterations;
140        self
141    }
142
143    /// Set how flow values map to node heights.
144    ///
145    /// Defaults to [`SankeyValueScale::Linear`]. Use [`SankeyValueScale::Sqrt`]
146    /// to keep a dominant flow from dwarfing the small ones without
147    /// pre-transforming the data; labels still receive the raw values.
148    pub fn value_scale(mut self, value_scale: SankeyValueScale) -> Self {
149        self.value_scale = value_scale;
150        self
151    }
152
153    /// Set the corner radius of the node rectangles. Defaults to 0.
154    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    /// Set the color of each node.
160    ///
161    /// Defaults to cycling the theme chart palette by node index.
162    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    /// Set the name label of each node, drawn in muted foreground. No name
171    /// label is drawn unless set.
172    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    /// Set the value label of each node, drawn above the name label. No value
178    /// label is drawn unless set.
179    ///
180    /// The closure receives the datum and the node's raw computed throughput
181    /// (max of incoming and outgoing flow, in unscaled units).
182    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    /// Set fully custom node labels, one [`SankeyLabel`] per line, top to
188    /// bottom. Takes precedence over `node_label`/`value_label` when set;
189    /// unset by default.
190    ///
191    /// The closure receives the datum and the node's raw computed throughput
192    /// (max of incoming and outgoing flow, in unscaled units).
193    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    /// Set the opacity of the link ribbons. Defaults to 0.3.
199    pub fn link_opacity(mut self, opacity: f32) -> Self {
200        self.link_opacity = opacity;
201        self
202    }
203
204    /// Set the minimum ribbon thickness, so tiny flows stay visible. Defaults to 1.
205    pub fn min_link_width(mut self, width: f32) -> Self {
206        self.min_link_width = width;
207        self
208    }
209
210    /// Set the gap between a node and its labels. Defaults to 6.
211    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    /// Raw per-node throughput (max of raw incoming and outgoing sums), for
226    /// labels — the layout's `node.value` is in scaled units under a
227    /// non-linear value scale, so labels must not use it.
228    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        // First pass: only the topology (each node's `layer`) is needed to
256        // measure the label margins; label values come from `raw_throughput`.
257        let Ok(topology) = self.sankey().topology(self.nodes.len(), &self.links) else {
258            return;
259        };
260        let layer_count = topology.layer_count();
261        // Labels get the raw throughput, not the layout's (possibly scaled) value.
262        let raw_value = self.raw_throughput();
263
264        // Collect each node's label lines: the custom `labels` closure wins,
265        // otherwise synthesize the value/name lines with the default styles.
266        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        // Reserve margins so the labels beside the first/last columns and
291        // above the middle columns are not clipped.
292        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            // Cap each side independently so one long label is truncated to a
315            // modest column rather than eating into the flow area.
316            let side_cap = width * MAX_LABEL_WIDTH_RATIO;
317            left = left.min(side_cap);
318            right = right.min(side_cap);
319        }
320        // Above-node labels are only emitted for middle columns, so reserve
321        // the top band for the tallest such label block. Cap the vertical
322        // margins like the horizontal ones so a short chart doesn't collapse
323        // the flow.
324        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        // Second pass: complete the placement on the final extent, reusing
345        // the first pass's topology.
346        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        // Links first, under the nodes.
374        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                // Keep tiny nodes visible with a minimum 1px height.
400                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            // `x`/`align` place the label beside (first/last) or centered above
415            // (middle) the node, and `max_width` bounds it so a long label is
416            // truncated with an ellipsis instead of drawn outside the plot:
417            // first/last to their reserved margin, middle to twice the smaller
418            // gap to the plot edge (generous for interior nodes, only bites a
419            // label long enough to actually run off-plot).
420            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                // Block vertically centered beside the node, clamped into
441                // the plot area so labels of nodes near the top or bottom
442                // edge are not clipped.
443                ((node.y0 + node.y1) / 2. - block / 2.)
444                    .min(height - block)
445                    .max(0.)
446            } else {
447                // Block above the node.
448                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        // A(out 30) -> B, B -> C(20) + D(10): B's throughput is max(in, out).
547        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        // Under Sqrt the layout's node value is scaled, but raw_throughput
559        // (used for labels) must stay in raw units — the two must differ.
560        let sqrt = chart
561            .value_scale(SankeyValueScale::Sqrt)
562            .sankey()
563            .layout(4, &chart_links())
564            .unwrap();
565        // Node A: layout value is sqrt-scaled (30 -> sqrt(30)), raw is 30.
566        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}