Skip to main content

ravenrs_widgets/
sankey.rs

1//! A sankey diagram: how much flows from what to what. The layout comes from
2//! `ravenrs-graphics`; here its nodes become quads, its ribbons become shapes, so
3//! they can fade one by one, and its labels become text.
4
5use std::cell::{Cell, RefCell};
6use std::rc::Rc;
7use std::sync::Arc;
8
9use ravenrs_core::{
10    ColorStop, CursorIcon, Effect, Key, PathElement, PathFill, PathPaint, Point, Quad, Rect, Role, Size,
11};
12use ravenrs_graphics::chart::{ChartLabel, LabelAlign};
13use ravenrs_graphics::sankey::{self, SankeyHit, SankeyLayout, SankeyLink, SankeyStyle};
14use ravenrs_layout::{Align, Axis, LayoutStyle};
15use ravenrs_reactive::State;
16use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
17use ravenrs_text::TextLayout;
18
19pub use ravenrs_graphics::sankey::SankeyAlign;
20
21use crate::chart::{self, ChartTooltip, KeyRow, TooltipPanel, ValueFormat};
22use crate::context::{BuildCx, NodeId, Paint, PaintCx, Placement, Semantics};
23use crate::motion::Motion;
24use crate::{View, util};
25
26/// How solid a ribbon is.
27const FLOW_ALPHA: f32 = 0.38;
28/// How solid a ribbon in focus is.
29const FLOW_FOCUS_ALPHA: f32 = 0.7;
30/// How much of its strength what is not in focus gives up.
31const FOCUS_DIM: f32 = 0.7;
32const NODE_RADIUS: f32 = 2.0;
33const LABEL_GAP: f32 = 6.0;
34/// How many flows a screen reader hears, and a tooltip shows, before the rest is left out.
35const SPOKEN_FLOWS: usize = 16;
36const TOOLTIP_FLOWS: usize = 8;
37
38/// A node or a flow of the diagram, as one list: the nodes first.
39type Item = usize;
40
41/// What a layout depends on.
42#[derive(PartialEq)]
43struct Laid {
44    size: Size,
45    links: Vec<SankeyLink>,
46    names: Vec<String>,
47    text_size: f32,
48    align: SankeyAlign,
49}
50
51/// What the diagram looked like when it was last laid out, relative to its frame.
52#[derive(Default)]
53struct Picture {
54    key: Option<Laid>,
55    layout: SankeyLayout,
56    paths: Vec<Arc<[PathElement]>>,
57    labels: Vec<(TextLayout, Point, ravenrs_core::Color)>,
58}
59
60#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
61impl Picture {
62    fn refresh(&mut self, chart: &SankeyChart, size: Size, ink: ravenrs_core::Color, cx: &mut PaintCx<'_>) {
63        let key = Laid {
64            size,
65            links: chart.links.clone(),
66            names: chart.names.clone(),
67            text_size: chart.text_size,
68            align: chart.align,
69        };
70        if self.key.as_ref() == Some(&key) {
71            return;
72        }
73        let mut style = SankeyStyle::default();
74        style.align = chart.align;
75        let area = Rect::new(0.0, 0.0, size.width, size.height);
76        let layout = sankey::layout(chart.names.len(), &chart.links, area, &style).unwrap_or_default();
77        self.paths = layout.flows.iter().map(|flow| chart::path_elements(&flow.path()).into()).collect();
78        let height = chart.text_size * 1.3;
79        self.labels = layout
80            .nodes
81            .iter()
82            .zip(&chart.names)
83            .map(|(node, name)| {
84                // Inward, over the flows, so that the outer columns need no margin.
85                let last = node.column + 1 == layout.columns && layout.columns > 1;
86                let (x, align) = if last {
87                    (node.rect.x() - LABEL_GAP - size.width, LabelAlign::Right)
88                } else {
89                    (node.rect.max_x() + LABEL_GAP, LabelAlign::Left)
90                };
91                let middle = node.rect.center().y;
92                let label = ChartLabel {
93                    text: name.clone(),
94                    rect: Rect::new(x, middle - height / 2.0, size.width, height),
95                    align,
96                    size: chart.text_size,
97                    bold: false,
98                    color: ink,
99                };
100                chart::typeset(&label, cx)
101            })
102            .collect();
103        self.layout = layout;
104        self.key = Some(key);
105    }
106}
107
108/// What stands out while a node or a flow is in focus, or was until a moment ago.
109#[derive(Clone, Copy)]
110struct Focus {
111    item: Item,
112    /// From nothing to in full.
113    strength: f32,
114}
115
116#[cfg_attr(feature = "live", ravenrs_live::expose)]
117impl Focus {
118    /// Whether this flow is what is in focus, or goes in or out of the node that is.
119    fn touches_flow(&self, layout: &SankeyLayout, flow: usize) -> bool {
120        let nodes = layout.nodes.len();
121        if self.item >= nodes {
122            return self.item - nodes == flow;
123        }
124        layout.flows.get(flow).is_some_and(|f| f.source == self.item || f.target == self.item)
125    }
126
127    fn touches_node(&self, layout: &SankeyLayout, node: usize) -> bool {
128        let nodes = layout.nodes.len();
129        if self.item >= nodes {
130            return layout.flows.get(self.item - nodes).is_some_and(|f| f.source == node || f.target == node);
131        }
132        self.item == node
133            || layout.flows.iter().any(|f| {
134                (f.source == self.item && f.target == node) || (f.target == self.item && f.source == node)
135            })
136    }
137}
138
139/// How much flows from what to what. Each [`flow`](Self::flow) names its two ends, and
140/// nodes are created as they are named. Ribbons go from the color of their source to
141/// that of their target; under the pointer or with the arrow keys, what is connected
142/// stands out and the tooltip says how much. A screen reader hears every flow.
143///
144/// If the flows go in a circle there is no way to draw them, and it says which nodes
145/// the circle goes through, in place of the diagram. Available with the `charts`
146/// feature, on by default.
147///
148/// ```
149/// use ravenrs_widgets::SankeyChart;
150///
151/// let budget = SankeyChart::new()
152///     .title("Budget")
153///     .flow("Salary", "Rent", 1200.0)
154///     .flow("Salary", "Food", 600.0)
155///     .flow("Salary", "Savings", 400.0)
156///     .flow("Savings", "Funds", 400.0);
157/// ```
158#[derive(Clone)]
159#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
160pub struct SankeyChart {
161    names: Vec<String>,
162    links: Vec<SankeyLink>,
163    colors: Vec<(String, Semantic)>,
164    title: String,
165    alt: Option<String>,
166    height: f32,
167    width: Option<f32>,
168    align: SankeyAlign,
169    format: ValueFormat,
170    gradient: bool,
171    text_size: f32,
172    no_tooltip: bool,
173    appear: bool,
174    appear_key: u64,
175}
176
177impl std::fmt::Debug for SankeyChart {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        f.debug_struct("SankeyChart")
180            .field("title", &self.title)
181            .field("nodes", &self.names.len())
182            .field("flows", &self.links.len())
183            .finish_non_exhaustive()
184    }
185}
186
187impl Default for SankeyChart {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193#[cfg_attr(feature = "live", ravenrs_live::expose)]
194impl SankeyChart {
195    /// An empty diagram, 300 logical pixels tall, as wide as its parent allows.
196    pub fn new() -> Self {
197        Self {
198            names: Vec::new(),
199            links: Vec::new(),
200            colors: Vec::new(),
201            title: String::new(),
202            alt: None,
203            height: 300.0,
204            width: None,
205            align: SankeyAlign::default(),
206            format: Rc::new(chart::plain_number),
207            gradient: true,
208            text_size: 12.0,
209            no_tooltip: false,
210            appear: true,
211            appear_key: 0,
212        }
213    }
214
215    fn node_of(&mut self, name: String) -> usize {
216        if let Some(found) = self.names.iter().position(|known| *known == name) {
217            return found;
218        }
219        self.names.push(name);
220        self.names.len() - 1
221    }
222
223    /// So much flows from `source` to `target`. Nothing, or less, draws nothing.
224    pub fn flow(mut self, source: impl Into<String>, target: impl Into<String>, value: f64) -> Self {
225        let source = self.node_of(source.into());
226        let target = self.node_of(target.into());
227        self.links.push(SankeyLink { source, target, value });
228        self
229    }
230
231    /// A title above the diagram.
232    pub fn title(mut self, title: impl Into<String>) -> Self {
233        self.title = title.into();
234        self
235    }
236
237    /// Describes the diagram for screen readers, in place of its title. Its flows are
238    /// read after it.
239    pub fn alt(mut self, text: impl Into<String>) -> Self {
240        self.alt = Some(text.into());
241        self
242    }
243
244    /// Height in logical pixels (default 300).
245    pub fn height(mut self, height: f32) -> Self {
246        self.height = height;
247        self
248    }
249
250    /// Fixed size in logical pixels.
251    pub fn size(mut self, width: f32, height: f32) -> Self {
252        self.width = Some(width);
253        self.height = height;
254        self
255    }
256
257    /// Which column a node goes to, among those its flows allow.
258    pub fn align(mut self, align: SankeyAlign) -> Self {
259        self.align = align;
260        self
261    }
262
263    /// How numbers are written (at most two decimals by default).
264    pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self {
265        self.format = Rc::new(format);
266        self
267    }
268
269    /// Gives this node a color of the theme, in place of the next chart color.
270    pub fn node_color(mut self, name: impl Into<String>, color: Semantic) -> Self {
271        self.colors.push((name.into(), color));
272        self
273    }
274
275    /// Ribbons in the color of their source, instead of going to that of their target.
276    pub fn no_gradient(mut self) -> Self {
277        self.gradient = false;
278        self
279    }
280
281    /// Says nothing under the pointer and takes no keyboard focus.
282    pub fn hide_tooltip(mut self) -> Self {
283        self.no_tooltip = true;
284        self
285    }
286
287    /// The flows are whole from the first frame, instead of drawing in from the left
288    /// the first time the chart is painted.
289    pub fn no_appear(mut self) -> Self {
290        self.appear = false;
291        self
292    }
293
294    /// Draws the flows in again whenever `key` changes.
295    pub fn appear_key(mut self, key: impl std::hash::Hash) -> Self {
296        self.appear_key = chart::appear_key_of(key);
297        self
298    }
299
300    fn color_of(&self, node: usize, colors: &[ravenrs_core::Color], theme: &Theme) -> ravenrs_core::Color {
301        let name = &self.names[node];
302        match self.colors.iter().find(|(known, _)| known == name) {
303            Some((_, role)) => theme.color(*role),
304            None => colors[node % colors.len()],
305        }
306    }
307
308    fn usable(&self) -> impl Iterator<Item = (usize, &SankeyLink)> {
309        self.links.iter().filter(|link| link.value.is_finite() && link.value > 0.0).enumerate()
310    }
311
312    /// Every flow, as a screen reader hears it.
313    fn spoken(&self) -> String {
314        let mut spoken: Vec<String> = self
315            .usable()
316            .take(SPOKEN_FLOWS)
317            .map(|(_, link)| {
318                let (from, to) = (&self.names[link.source], &self.names[link.target]);
319                format!("{from} → {to}: {}", (self.format)(link.value))
320            })
321            .collect();
322        if self.usable().count() > SPOKEN_FLOWS {
323            spoken.push("…".to_owned());
324        }
325        spoken.join("; ")
326    }
327
328    /// What a tooltip says about an item.
329    fn panel(&self, item: Item, colors: &[ravenrs_core::Color], theme: &Theme) -> TooltipPanel {
330        let nodes = self.names.len();
331        let flows: Vec<&SankeyLink> = self.usable().map(|(_, link)| link).collect();
332        let row = |name: String, node: usize, value: f64| {
333            KeyRow::named(name, self.color_of(node, colors, theme), None, Some(value))
334        };
335        let (title, rows, total) = if item < nodes {
336            let incoming = flows.iter().filter(|l| l.target == item);
337            let outgoing = flows.iter().filter(|l| l.source == item);
338            let coming: f64 = incoming.clone().map(|l| l.value).sum();
339            let going: f64 = outgoing.clone().map(|l| l.value).sum();
340            let rows: Vec<KeyRow> = incoming
341                .map(|l| row(format!("← {}", self.names[l.source]), l.source, l.value))
342                .chain(outgoing.map(|l| row(format!("→ {}", self.names[l.target]), l.target, l.value)))
343                .take(TOOLTIP_FLOWS)
344                .collect();
345            let name = self.names[item].clone();
346            (name.clone(), rows, Some((name, coming.max(going))))
347        } else {
348            let link = flows.get(item - nodes).copied();
349            let title = link
350                .map_or(String::new(), |l| format!("{} → {}", self.names[l.source], self.names[l.target]));
351            let rows =
352                link.map_or_else(Vec::new, |l| vec![row(self.names[l.target].clone(), l.target, l.value)]);
353            (title, rows, None)
354        };
355        TooltipPanel {
356            title: Some(title.clone()),
357            rows,
358            total,
359            tooltip: ChartTooltip::new(),
360            format: self.format.clone(),
361            index: item,
362            category: title,
363        }
364    }
365
366    /// Where a tooltip about an item points at.
367    fn anchor(layout: &SankeyLayout, item: Item) -> Option<Point> {
368        let nodes = layout.nodes.len();
369        if item < nodes {
370            let rect = layout.nodes[item].rect;
371            return Some(Point::new(rect.center().x, rect.center().y));
372        }
373        let flow = layout.flows.get(item - nodes)?;
374        Some(Point::new((flow.from.x + flow.to.x) / 2.0, (flow.from.y + flow.to.y) / 2.0))
375    }
376}
377
378/// What a flow shows: its own color from one end to the other, faded unless in focus.
379fn flow_paint(
380    from: ravenrs_core::Color,
381    to: ravenrs_core::Color,
382    flow: &sankey::SankeyFlow,
383    alpha: f32,
384) -> PathPaint {
385    PathPaint::Linear {
386        start: flow.from,
387        end: flow.to,
388        stops: Arc::from(
389            [
390                ColorStop { offset: 0.0, color: from.with_alpha(alpha) },
391                ColorStop { offset: 1.0, color: to.with_alpha(alpha) },
392            ]
393            .as_slice(),
394        ),
395    }
396}
397
398impl View for SankeyChart {
399    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
400        let theme = *cx.theme();
401        if let Err(problem) = sankey::check(self.names.len(), &self.links) {
402            return self.problem(cx, &problem, &theme);
403        }
404        let colors = chart::palette(&theme);
405        let picture = cx.use_state(|| Rc::new(RefCell::new(Picture::default()))).peek().clone();
406        let active: State<Option<Item>> = cx.use_state(|| None::<Item>);
407        let hovered = cx.is_hover_within();
408        let focused = cx.is_focused();
409        // The flows draw in from the left the first time it is painted, and the
410        // tooltip waits until they are whole.
411        let draw_in = if self.appear { theme.motion.draw_in } else { ravenrs_animation::Transition::Instant };
412        let progress = cx.appear(self.appear_key, draw_in);
413        let shown = active.get().filter(|_| (hovered || focused) && !self.no_tooltip && progress >= 1.0);
414
415        // What stands out outlasts the pointer for as long as it takes to fade.
416        let held = cx.use_state(|| Rc::new(Cell::new(None::<Item>))).peek().clone();
417        if shown.is_some() {
418            held.set(shown);
419        }
420        let m = theme.motion;
421        let strength = cx.animated(if shown.is_some() { 1.0_f32 } else { 0.0 }, m.base);
422        let focus = held.get().filter(|_| strength > 0.001).map(|item| Focus { item, strength });
423
424        let mut children = Vec::new();
425        let spot = shown.and_then(|item| {
426            let picture = picture.borrow();
427            let at = Self::anchor(&picture.layout, item)?;
428            let past = at.x > picture.key.as_ref().map_or(0.0, |key| key.size.width) / 2.0;
429            Some((at, if past { Placement::LeftCenter } else { Placement::RightCenter }))
430        });
431        let anchor = spot.map(|(at, _)| {
432            cx.container(
433                LayoutStyle {
434                    absolute: Some((at.x, at.y)),
435                    width: Some(0.0),
436                    height: Some(0.0),
437                    ..LayoutStyle::leaf()
438                },
439                &[],
440                Paint::None,
441            )
442        });
443        children.extend(anchor);
444
445        let mut own = self.clone();
446        own.text_size = theme.text(TextStyle::Caption).size;
447        let (ink, radius) = (theme.color(Semantic::Ink), NODE_RADIUS);
448        let node_colors: Vec<ravenrs_core::Color> =
449            (0..self.names.len()).map(|node| self.color_of(node, &colors, &theme)).collect();
450        let (seen, gradient) = (picture.clone(), self.gradient);
451        let node = cx.container(
452            LayoutStyle { grow: 1.0, align_self: Some(Align::Stretch), ..LayoutStyle::leaf() },
453            &children,
454            Paint::Rich(Rc::new(move |frame: Rect, paint: &mut PaintCx<'_>| {
455                if frame.is_empty() {
456                    return;
457                }
458                let mut picture = seen.borrow_mut();
459                picture.refresh(&own, frame.size, ink, paint);
460                let drawing_in = progress < 1.0;
461                if drawing_in {
462                    let whole = Rect::new(0.0, 0.0, frame.width(), frame.height());
463                    paint.scene.push_clip(chart::revealed(frame, whole, progress.max(0.0)));
464                }
465                let layout = &picture.layout;
466                for (index, flow) in layout.flows.iter().enumerate() {
467                    let on = focus.is_some_and(|f| f.touches_flow(layout, index));
468                    let dim = focus.map_or(0.0, |f| if on { 0.0 } else { FOCUS_DIM * f.strength });
469                    let lift = focus.map_or(0.0, |f| if on { f.strength } else { 0.0 });
470                    let alpha = (FLOW_ALPHA + (FLOW_FOCUS_ALPHA - FLOW_ALPHA) * lift) * (1.0 - dim);
471                    let from = node_colors[flow.source];
472                    let to = if gradient { node_colors[flow.target] } else { from };
473                    let Some(elements) = picture.paths.get(index) else { continue };
474                    paint.scene.push_path(PathFill {
475                        elements: elements.clone(),
476                        paint: flow_paint(from, to, flow, alpha),
477                        transform: [1.0, 0.0, 0.0, 1.0, frame.x(), frame.y()],
478                        ..PathFill::solid(elements.clone(), from)
479                    });
480                }
481                for (index, node) in layout.nodes.iter().enumerate() {
482                    let on = focus.is_none_or(|f| f.touches_node(layout, index));
483                    let dim = focus.map_or(0.0, |f| if on { 0.0 } else { FOCUS_DIM * f.strength });
484                    let rect = Rect::new(
485                        frame.x() + node.rect.x(),
486                        frame.y() + node.rect.y(),
487                        node.rect.width(),
488                        node.rect.height(),
489                    );
490                    let color = node_colors[index];
491                    paint
492                        .scene
493                        .push_quad(Quad::new(rect, color.with_alpha(color.a * (1.0 - dim))).radius(radius));
494                }
495                for (layout, at, color) in &picture.labels {
496                    let origin = Point::new(frame.x() + at.x, frame.y() + at.y);
497                    layout.paint(origin, *color, paint.scene);
498                }
499                if drawing_in {
500                    paint.scene.pop_clip();
501                }
502            })),
503        );
504
505        if !self.no_tooltip {
506            let (write, seen) = (active.clone(), picture.clone());
507            cx.on_pointer_move(
508                node,
509                Rc::new(move |position, frame| {
510                    let local = Point::new(position.x - frame.x(), position.y - frame.y());
511                    let picture = seen.borrow();
512                    let hit = picture.layout.hit(local).map(|hit| match hit {
513                        SankeyHit::Node(node) => node,
514                        SankeyHit::Flow(flow) => picture.layout.nodes.len() + flow,
515                    });
516                    if write.get() != hit {
517                        write.set(hit);
518                    }
519                }),
520            );
521            cx.cursor(node, CursorIcon::Default);
522            let (write, seen) = (active.clone(), picture);
523            cx.on_key(
524                node,
525                Rc::new(move |key, _| {
526                    let picture = seen.borrow();
527                    let count = picture.layout.nodes.len() + picture.layout.flows.len();
528                    if count == 0 {
529                        return false;
530                    }
531                    let last = count - 1;
532                    let now = write.get();
533                    let next = match key {
534                        Key::ArrowRight | Key::ArrowDown => now.map_or(0, |i| (i + 1).min(last)),
535                        Key::ArrowLeft | Key::ArrowUp => now.map_or(last, |i| i.saturating_sub(1)),
536                        Key::Home => 0,
537                        Key::End => last,
538                        Key::Escape if now.is_some() => {
539                            write.set(None);
540                            return true;
541                        }
542                        _ => return false,
543                    };
544                    write.set(Some(next));
545                    true
546                }),
547            );
548            cx.focusable(node, theme.radii.sm);
549        }
550
551        if let (Some(item), Some((_, placement)), Some(anchor)) = (shown, spot, anchor) {
552            cx.overlay(anchor, placement, &self.panel(item, &colors, &theme), None);
553        }
554
555        let name = self.alt.as_deref().unwrap_or(&self.title);
556        let values = self.spoken();
557        let mut semantics = if name.is_empty() {
558            Semantics::new(Role::Image).name(values)
559        } else {
560            Semantics::new(Role::Image).name(name.to_owned()).description(values)
561        };
562        if let Some(item) = shown {
563            semantics = semantics.value(self.panel(item, &colors, &theme).spoken());
564        }
565        cx.semantics(node, semantics);
566        if !self.appear {
567            cx.motion(node, Motion::new().enter(Effect::hidden(), m.base));
568        }
569
570        let mut parts = Vec::new();
571        if !self.title.is_empty() {
572            let ink = theme.color(Semantic::Ink);
573            parts.push(util::label(cx, &self.title, TextStyle::Callout, Some(Weight::SEMIBOLD), ink));
574        }
575        parts.push(node);
576        cx.container(
577            LayoutStyle {
578                width: self.width,
579                height: Some(self.height),
580                spacing: 8.0,
581                align: Align::Stretch,
582                align_self: self.width.is_none().then_some(Align::Stretch),
583                ..LayoutStyle::stack(Axis::Vertical)
584            },
585            &parts,
586            Paint::None,
587        )
588    }
589}
590
591#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
592impl SankeyChart {
593    /// Says what is wrong with the flows, in place of a diagram that cannot exist.
594    fn problem(&self, cx: &mut BuildCx<'_>, problem: &sankey::SankeyError, theme: &Theme) -> NodeId {
595        let names = |nodes: &[usize]| {
596            let names: Vec<&str> =
597                nodes.iter().filter_map(|n| self.names.get(*n)).map(String::as_str).collect();
598            names.join(" → ")
599        };
600        let text = match problem {
601            sankey::SankeyError::Cycle(nodes) => format!("{}: {}", cx.labels().sankey_cycle, names(nodes)),
602            other => other.to_string(),
603        };
604        let node = util::label(cx, &text, TextStyle::Caption, None, theme.color(Semantic::Danger));
605        cx.semantics(node, Semantics::new(Role::Label).name(text));
606        node
607    }
608}