Skip to main content

gpui_macos/chart/
sankey_chart.rs

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.;
31/// Cap each side's label margin (as a fraction of width) so a long label is
32/// truncated to a modest column beside the flow instead of dominating it.
33const MAX_LABEL_WIDTH_RATIO: f32 = 0.2;
34/// Cap the reserved top+bottom label band as a fraction of height.
35const MAX_LABEL_MARGIN_RATIO: f32 = 0.6;
36/// How much the links not attached to the hovered node fade, as a share of
37/// their opacity.
38const HOVER_DIM: f32 = 0.7;
39
40/// The placement of a sankey chart for one bounds size: the graph plus the
41/// label lines and margins it was laid out with.
42///
43/// Placing the graph relaxes the node order over several iterations, and the
44/// label margins need every label measured, so a chart keeps the frame in
45/// element state and reuses it while its key is unchanged.
46struct SankeyFrame {
47    graph: SankeyGraph,
48    layer_count: usize,
49    node_labels: Vec<Vec<SankeyLabel>>,
50    /// The label margins reserved on the left and right of the flow.
51    left: f32,
52    right: f32,
53}
54
55/// The frame of the last placement with the key it was placed for.
56#[derive(Default)]
57struct SankeyFrameCache {
58    key: Option<u64>,
59    frame: Option<Rc<SankeyFrame>>,
60}
61
62/// The hover a sankey chart paints, sampled once per frame in [`Plot::hover`].
63#[derive(Clone, Copy)]
64struct SankeyHover {
65    /// The hovered node.
66    node: usize,
67    /// How far the hover has faded in.
68    focus: f32,
69}
70
71/// A styled line of a sankey node label.
72#[derive(Clone)]
73pub struct SankeyLabel {
74    text: SharedString,
75    color: Option<Hsla>,
76    font_size: Option<f32>,
77}
78
79impl SankeyLabel {
80    /// Create a label line with the default color and font size.
81    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    /// Set the text color. Defaults to the theme foreground.
90    pub fn color(mut self, color: impl Into<Hsla>) -> Self {
91        self.color = Some(color.into());
92        self
93    }
94
95    /// Set the font size. Defaults to 10.
96    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/// A Sankey diagram, layout modeled after [d3-sankey](https://github.com/d3/d3-sankey).
111///
112/// Links reference nodes by their index in the node list; map string ids to
113/// indices before constructing.
114pub 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    /// The placement for this frame, resolved in `prepaint` (measuring labels
135    /// needs the window) and read by `tooltip_state` and `paint`.
136    frame: Option<Rc<SankeyFrame>>,
137    hover: Option<SankeyHover>,
138}
139
140impl<T> SankeyChart<T> {
141    /// Create a chart from nodes and links; links reference nodes by their
142    /// index in `nodes` (map string ids to indices before constructing).
143    #[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    /// Name this chart's [`ElementId`], replacing the default taken from the
175    /// construction site.
176    ///
177    /// Pass one where a single construction site renders several of these
178    /// charts as siblings: they share the default id, and with it one hover
179    /// state and one path cache. The id must be unique among those siblings.
180    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
181        self.id = id.into();
182        self
183    }
184
185    /// Turn this chart's interactive layer on or off. On by default.
186    ///
187    /// The layer is the hitbox under the cursor and what it drives: the hovered
188    /// node's links stand out from the rest, and a tooltip shows its label and
189    /// throughput. Turn it off for a chart that only decorates, or one an element
190    /// above it wants the cursor for: without a hitbox it neither answers the
191    /// mouse nor takes the hover from what sits over it. A chart that is off also
192    /// drops its path cache, which is keyed on the same id.
193    pub fn interactive(mut self, interactive: bool) -> Self {
194        self.interactive = interactive;
195        self
196    }
197
198    /// Set the node rectangle width. Defaults to 10.
199    pub fn node_width(mut self, node_width: f32) -> Self {
200        self.node_width = node_width;
201        self
202    }
203
204    /// Set the vertical gap between nodes in a column. Defaults to 16.
205    pub fn node_padding(mut self, node_padding: f32) -> Self {
206        self.node_padding = node_padding;
207        self
208    }
209
210    /// Set the node alignment. Defaults to [`SankeyAlign::Justify`].
211    pub fn node_align(mut self, align: SankeyAlign) -> Self {
212        self.align = align;
213        self
214    }
215
216    /// Set the number of relaxation passes. Defaults to 6.
217    pub fn iterations(mut self, iterations: usize) -> Self {
218        self.iterations = iterations;
219        self
220    }
221
222    /// Set how flow values map to node heights.
223    ///
224    /// Defaults to [`SankeyValueScale::Linear`]. Use [`SankeyValueScale::Sqrt`]
225    /// to keep a dominant flow from dwarfing the small ones without
226    /// pre-transforming the data; labels still receive the raw values.
227    pub fn value_scale(mut self, value_scale: SankeyValueScale) -> Self {
228        self.value_scale = value_scale;
229        self
230    }
231
232    /// Set the corner radius of the node rectangles. Defaults to 0.
233    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    /// Set the color of each node.
239    ///
240    /// Defaults to cycling the theme chart palette by node index.
241    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    /// Set the name label of each node, drawn in muted foreground. No name
250    /// label is drawn unless set.
251    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    /// Set the value label of each node, drawn above the name label. No value
257    /// label is drawn unless set.
258    ///
259    /// The closure receives the datum and the node's raw computed throughput
260    /// (max of incoming and outgoing flow, in unscaled units).
261    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    /// Set fully custom node labels, one [`SankeyLabel`] per line, top to
267    /// bottom. Takes precedence over `node_label`/`value_label` when set;
268    /// unset by default.
269    ///
270    /// The closure receives the datum and the node's raw computed throughput
271    /// (max of incoming and outgoing flow, in unscaled units).
272    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    /// Name the node under the cursor in the hover tooltip's row, beside its
278    /// throughput. Unset, the row carries no name at all.
279    ///
280    /// A sankey shows one number per node, so the node's own name is what the
281    /// row wants; without it the row reads as a swatch and a number with a gap
282    /// between them. The alternative was to title the tooltip from
283    /// `node_label`, but that also draws the name beside the node — and a chart
284    /// drawing its text through `labels` sets neither.
285    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    /// Set the text of the hover tooltip's row, the node's throughput.
291    ///
292    /// `value_label` supplies it when this is unset, and the raw number when
293    /// neither is set — which is what a chart drawing its text through `labels`
294    /// gets, however carefully it formats the value it draws.
295    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    /// Set the opacity of the link ribbons. Defaults to 0.3.
301    pub fn link_opacity(mut self, opacity: f32) -> Self {
302        self.link_opacity = opacity;
303        self
304    }
305
306    /// Set the minimum ribbon thickness, so tiny flows stay visible. Defaults to 1.
307    pub fn min_link_width(mut self, width: f32) -> Self {
308        self.min_link_width = width;
309        self
310    }
311
312    /// Set the gap between a node and its labels. Defaults to 6.
313    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    /// Raw per-node throughput (max of raw incoming and outgoing sums), for
328    /// labels — the layout's `node.value` is in scaled units under a
329    /// non-linear value scale, so labels must not use it.
330    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    /// Each node's label lines: the custom `labels` closure wins, otherwise the
351    /// value/name lines with the default styles. Labels get the raw throughput,
352    /// not the layout's (possibly scaled) value.
353    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    /// The key a placement is reused under: everything that shapes it, which is
376    /// the bounds size, the graph, the placement settings and the label lines.
377    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    /// Place the graph within `bounds`, reserving margins for the labels.
407    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        // First pass: only the topology (each node's `layer`) is needed to
417        // measure the label margins.
418        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        // Reserve margins so the labels beside the first/last columns and
423        // above the middle columns are not clipped.
424        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            // Cap each side independently so one long label is truncated to a
447            // modest column rather than eating into the flow area.
448            let side_cap = width * MAX_LABEL_WIDTH_RATIO;
449            left = left.min(side_cap);
450            right = right.min(side_cap);
451        }
452        // Above-node labels are only emitted for middle columns, so reserve
453        // the top band for the tallest such label block. Cap the vertical
454        // margins like the horizontal ones so a short chart doesn't collapse
455        // the flow.
456        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        // Second pass: complete the placement on the final extent, reusing
477        // the first pass's topology.
478        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    /// Whether `link` starts or ends at `node`.
498    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    /// Resolve the placement for the frame, reusing the last one while nothing
507    /// that shapes it has changed. Measuring the labels needs the window, which
508    /// `tooltip_state` does not have, so this runs here rather than in `paint`.
509    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        // Caching hangs off the chart's own id, which only an interactive chart
525        // puts on the stack; without one, siblings would share a slot and thrash
526        // it, so a chart that is off places itself afresh each paint.
527        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        // Links first, under the nodes. The links of the hovered node keep their
582        // opacity while the rest fade behind them.
583        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                // Keep tiny nodes visible with a minimum 1px height.
615                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            // `x`/`align` place the label beside (first/last) or centered above
630            // (middle) the node, and `max_width` bounds it so a long label is
631            // truncated with an ellipsis instead of drawn outside the plot:
632            // first/last to their reserved margin, middle to twice the smaller
633            // gap to the plot edge (generous for interior nodes, only bites a
634            // label long enough to actually run off-plot).
635            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                // Block vertically centered beside the node, clamped into
656                // the plot area so labels of nodes near the top or bottom
657                // edge are not clipped.
658                ((node.y0 + node.y1) / 2. - block / 2.)
659                    .min(height - block)
660                    .max(0.)
661            } else {
662                // Block above the node.
663                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            // Follow the cursor; the node's links mark it.
739            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        // A(out 30) -> B, B -> C(20) + D(10): B's throughput is max(in, out).
834        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        // Under Sqrt the layout's node value is scaled, but raw_throughput
846        // (used for labels) must stay in raw units — the two must differ.
847        let sqrt = chart
848            .value_scale(SankeyValueScale::Sqrt)
849            .sankey()
850            .layout(4, &chart_links())
851            .unwrap();
852        // Node A: layout value is sqrt-scaled (30 -> sqrt(30)), raw is 30.
853        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    /// A chart drawing its text through `labels` sets neither `node_label` nor
867    /// `value_label`, so its tooltip row had no name and an unformatted number.
868    #[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}