Skip to main content

gpui_component/plot/shape/
sankey.rs

1// @reference: https://github.com/d3/d3-sankey
2
3use gpui::{Path, PathBuilder, Pixels, Point, px};
4
5use crate::plot::origin_point;
6
7/// Vertical offset, as a fraction of node height, applied to stagger runs of
8/// equal-height single-node columns so their otherwise-flat ribbons curve.
9const STAGGER_RATIO: f32 = 0.15;
10
11/// Horizontal alignment of nodes across layers.
12///
13/// Mirrors d3-sankey's `sankeyLeft` / `sankeyRight` / `sankeyCenter` /
14/// `sankeyJustify`.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum SankeyAlign {
17    Left,
18    Right,
19    Center,
20    #[default]
21    Justify,
22}
23
24/// An input link of a Sankey diagram.
25///
26/// `source` and `target` are indices into the node list (d3-sankey's default
27/// `nodeId`), `value` is the flow amount.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct SankeyLink {
30    pub source: usize,
31    pub target: usize,
32    pub value: f64,
33}
34
35impl SankeyLink {
36    /// Create a link from the `source` node index to the `target` node index
37    /// carrying `value`.
38    pub fn new(source: usize, target: usize, value: f64) -> Self {
39        Self {
40            source,
41            target,
42            value,
43        }
44    }
45}
46
47/// A node with computed layout (d3-sankey's computed node fields).
48#[derive(Clone, Debug, Default)]
49pub struct SankeyNodeLayout {
50    pub index: usize,
51    /// The node's throughput in the layout's value space: max(sum of incoming,
52    /// sum of outgoing). With a non-linear [`SankeyValueScale`] this is in
53    /// scaled units, not raw values — read raw values from the input links.
54    pub value: f64,
55    /// Topological distance from any source node (longest path).
56    pub depth: usize,
57    /// Topological distance to any sink node (longest path).
58    pub height: usize,
59    /// Horizontal column index after alignment.
60    pub layer: usize,
61    pub x0: f32,
62    pub x1: f32,
63    pub y0: f32,
64    pub y1: f32,
65    /// Indices into [`SankeyGraph::links`] of the outgoing links.
66    pub source_links: Vec<usize>,
67    /// Indices into [`SankeyGraph::links`] of the incoming links.
68    pub target_links: Vec<usize>,
69}
70
71/// A link with computed layout.
72///
73/// Like d3-sankey, `y0` and `y1` are the vertical centers of the ribbon at
74/// the source and target end. Each end has its own width: the links of a
75/// node's side share the node height in proportion to their values, so both
76/// sides of every node are always fully covered. On a balanced graph
77/// (incoming sum == outgoing sum everywhere) the two ends are equal; on an
78/// imbalanced one (e.g. sqrt-compressed values) the ribbon transitions
79/// smoothly between the two widths.
80#[derive(Clone, Debug)]
81pub struct SankeyLinkLayout {
82    pub index: usize,
83    pub source: usize,
84    pub target: usize,
85    /// The flow value in the layout's value space (scaled by
86    /// [`SankeyValueScale`]; equals the raw input value under `Linear`).
87    pub value: f64,
88    pub y0: f32,
89    pub y1: f32,
90    /// The nominal width from the global value scale, used by the layout
91    /// relaxation; equals both end widths on a balanced graph.
92    pub width: f32,
93    /// The ribbon width at the source end.
94    pub source_width: f32,
95    /// The ribbon width at the target end.
96    pub target_width: f32,
97}
98
99/// The computed Sankey layout.
100#[derive(Clone, Debug, Default)]
101pub struct SankeyGraph {
102    pub nodes: Vec<SankeyNodeLayout>,
103    pub links: Vec<SankeyLinkLayout>,
104}
105
106impl SankeyGraph {
107    /// Number of layers (max layer + 1), 0 for an empty graph.
108    pub fn layer_count(&self) -> usize {
109        self.nodes
110            .iter()
111            .map(|node| node.layer + 1)
112            .max()
113            .unwrap_or(0)
114    }
115}
116
117/// A reason a Sankey layout could not be computed.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum SankeyError {
120    /// A link references a node index out of range.
121    MissingNode(usize),
122    /// The graph contains a circular link.
123    CircularLink,
124}
125
126impl std::fmt::Display for SankeyError {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            Self::MissingNode(index) => write!(f, "missing node: {}", index),
130            Self::CircularLink => write!(f, "circular link"),
131        }
132    }
133}
134
135impl std::error::Error for SankeyError {}
136
137/// How flow values map to node heights and ribbon widths.
138#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
139pub enum SankeyValueScale {
140    /// Height is proportional to the value (standard sankey semantics).
141    #[default]
142    Linear,
143    /// Height is proportional to the square root of the value, compressing a
144    /// wide value range so a dominant flow doesn't dwarf the small ones (and
145    /// the small ones stay visible) without the caller pre-transforming data.
146    Sqrt,
147}
148
149impl SankeyValueScale {
150    fn apply(self, value: f64) -> f64 {
151        match self {
152            Self::Linear => value,
153            // Guard against tiny negatives from bad data.
154            Self::Sqrt => value.max(0.).sqrt(),
155        }
156    }
157}
158
159/// The Sankey layout generator.
160pub struct Sankey {
161    node_width: f32,
162    node_padding: f32,
163    align: SankeyAlign,
164    iterations: usize,
165    value_scale: SankeyValueScale,
166    x0: f32,
167    y0: f32,
168    x1: f32,
169    y1: f32,
170}
171
172impl Default for Sankey {
173    fn default() -> Self {
174        Self {
175            node_width: 24.,
176            node_padding: 8.,
177            align: SankeyAlign::default(),
178            iterations: 6,
179            value_scale: SankeyValueScale::default(),
180            x0: 0.,
181            y0: 0.,
182            x1: 1.,
183            y1: 1.,
184        }
185    }
186}
187
188impl Sankey {
189    /// Create a generator with the d3-sankey defaults (see [`Sankey::default`]).
190    pub fn new() -> Self {
191        Self::default()
192    }
193
194    /// Set the node rectangle width. Defaults to 24.
195    pub fn node_width(mut self, node_width: f32) -> Self {
196        self.node_width = node_width;
197        self
198    }
199
200    /// Set the vertical gap between nodes in a column. Defaults to 8.
201    pub fn node_padding(mut self, node_padding: f32) -> Self {
202        self.node_padding = node_padding;
203        self
204    }
205
206    /// Set the node alignment. Defaults to [`SankeyAlign::Justify`].
207    pub fn node_align(mut self, align: SankeyAlign) -> Self {
208        self.align = align;
209        self
210    }
211
212    /// Set the number of relaxation passes. Defaults to 6.
213    pub fn iterations(mut self, iterations: usize) -> Self {
214        self.iterations = iterations;
215        self
216    }
217
218    /// Set how values map to heights. Defaults to [`SankeyValueScale::Linear`].
219    pub fn value_scale(mut self, value_scale: SankeyValueScale) -> Self {
220        self.value_scale = value_scale;
221        self
222    }
223
224    /// Set the layout bounds as `[[x0, y0], [x1, y1]]`. Defaults to `[[0, 0], [1, 1]]`.
225    pub fn extent(mut self, x0: f32, y0: f32, x1: f32, y1: f32) -> Self {
226        self.x0 = x0;
227        self.y0 = y0;
228        self.x1 = x1;
229        self.y1 = y1;
230        self
231    }
232
233    /// Equivalent to `extent(0., 0., width, height)`.
234    pub fn size(self, width: f32, height: f32) -> Self {
235        self.extent(0., 0., width, height)
236    }
237
238    /// Compute the topology only: node `value`, `depth`, `height`, `layer`
239    /// and horizontal positions, without the vertical placement. Much cheaper
240    /// than [`Sankey::layout`] when only the column structure is needed
241    /// (e.g. to measure labels before fixing the extent).
242    ///
243    /// `node_count` is the number of nodes; links reference nodes by index
244    /// (d3-sankey's default `nodeId`). Returns an error if a link references
245    /// a node out of range or the graph contains a cycle.
246    pub fn topology(
247        &self,
248        node_count: usize,
249        links: &[SankeyLink],
250    ) -> Result<SankeyGraph, SankeyError> {
251        for link in links {
252            if link.source >= node_count {
253                return Err(SankeyError::MissingNode(link.source));
254            }
255            if link.target >= node_count {
256                return Err(SankeyError::MissingNode(link.target));
257            }
258        }
259
260        let mut graph = SankeyGraph {
261            nodes: (0..node_count)
262                .map(|index| SankeyNodeLayout {
263                    index,
264                    ..Default::default()
265                })
266                .collect(),
267            links: links
268                .iter()
269                .enumerate()
270                .map(|(index, link)| SankeyLinkLayout {
271                    index,
272                    source: link.source,
273                    target: link.target,
274                    // Layout works in scaled value space; all downstream math
275                    // (node values, widths, breadths) stays coherent because
276                    // it is additive in this space, so nodes remain exactly
277                    // filled by their ribbons regardless of the scale.
278                    value: self.value_scale.apply(link.value),
279                    y0: 0.,
280                    y1: 0.,
281                    width: 0.,
282                    source_width: 0.,
283                    target_width: 0.,
284                })
285                .collect(),
286        };
287        if node_count == 0 {
288            return Ok(graph);
289        }
290
291        compute_node_links(&mut graph);
292        compute_node_values(&mut graph);
293        compute_node_ranks(&mut graph)?;
294        self.compute_node_layers(&mut graph);
295
296        Ok(graph)
297    }
298
299    /// Compute the full layout: [`Sankey::topology`] plus the vertical node
300    /// placement and link breadths.
301    pub fn layout(
302        &self,
303        node_count: usize,
304        links: &[SankeyLink],
305    ) -> Result<SankeyGraph, SankeyError> {
306        Ok(self.layout_from(self.topology(node_count, links)?))
307    }
308
309    /// Complete the layout for a graph produced by [`Sankey::topology`],
310    /// avoiding a second topology pass when the extent only becomes known
311    /// after measuring against the column structure (e.g. label margins).
312    ///
313    /// The topological fields are extent-independent; the horizontal and
314    /// vertical positions are recomputed for this generator's extent.
315    pub fn layout_from(&self, mut graph: SankeyGraph) -> SankeyGraph {
316        if graph.nodes.is_empty() {
317            return graph;
318        }
319
320        self.compute_node_layers(&mut graph);
321
322        let mut columns = vec![Vec::new(); graph.layer_count()];
323        for node in &graph.nodes {
324            columns[node.layer].push(node.index);
325        }
326
327        self.compute_node_breadths(&mut graph, &mut columns);
328        compute_link_breadths(&mut graph);
329        self.center_columns(&mut graph);
330        self.stagger_flat_columns(&mut graph);
331
332        graph
333    }
334
335    /// Vertically center each column's stack of nodes within the extent.
336    ///
337    /// When a crowded column forces a small scale, the sparser columns don't
338    /// fill the height and the relaxation aligns them to their flows' weighted
339    /// center, which leaves the trunk sitting high with empty space below.
340    /// d3-sankey doesn't correct this; centering each column (translating it,
341    /// so the relaxation's within-column arrangement and the ribbon fits are
342    /// preserved) keeps the diagram balanced on the canvas.
343    fn center_columns(&self, graph: &mut SankeyGraph) {
344        let layers = graph.layer_count();
345        if layers == 0 {
346            return;
347        }
348
349        // Per-layer bounding box, then the offset that centers it.
350        let mut lo = vec![f32::INFINITY; layers];
351        let mut hi = vec![f32::NEG_INFINITY; layers];
352        for node in &graph.nodes {
353            lo[node.layer] = lo[node.layer].min(node.y0);
354            hi[node.layer] = hi[node.layer].max(node.y1);
355        }
356        let offsets: Vec<f32> = (0..layers)
357            .map(|l| {
358                if lo[l].is_finite() && hi[l] > lo[l] {
359                    (self.y0 + self.y1 - lo[l] - hi[l]) / 2.
360                } else {
361                    0.
362                }
363            })
364            .collect();
365
366        apply_layer_offsets(graph, &offsets);
367    }
368
369    /// Nudge runs of adjacent single-node columns of (near-)equal height off
370    /// the shared center line. Centering aligns such columns exactly, so the
371    /// ribbon between them is a flat rectangle; a small alternating stagger
372    /// turns it into a gentle S-curve. Only the flat single-node case is
373    /// touched, so multi-node or unequal columns (which already curve) are
374    /// left alone.
375    fn stagger_flat_columns(&self, graph: &mut SankeyGraph) {
376        let layers = graph.layer_count();
377        if layers < 2 {
378            return;
379        }
380
381        let mut count = vec![0usize; layers];
382        let mut single = vec![usize::MAX; layers];
383        for node in &graph.nodes {
384            count[node.layer] += 1;
385            single[node.layer] = node.index;
386        }
387        let heights: Vec<f32> = (0..layers)
388            .map(|l| {
389                if count[l] == 1 {
390                    let n = &graph.nodes[single[l]];
391                    n.y1 - n.y0
392                } else {
393                    0.
394                }
395            })
396            .collect();
397
398        // Offset the odd columns of each flat run, leaving the even ones on
399        // the center line, so consecutive ribbons bend down then back up.
400        let mut offsets = vec![0f32; layers];
401        let mut run = 0usize;
402        for l in 1..layers {
403            let flat =
404                count[l] == 1 && count[l - 1] == 1 && (heights[l] - heights[l - 1]).abs() < 1e-3;
405            if flat {
406                run += 1;
407                if run % 2 == 1 {
408                    // Bound the nudge by the slack so the node stays inside
409                    // the extent (a column that fills the height can't move).
410                    let slack = (self.y1 - self.y0 - heights[l]).max(0.);
411                    offsets[l] = (heights[l] * STAGGER_RATIO).min(slack / 2.);
412                }
413            } else {
414                run = 0;
415            }
416        }
417
418        apply_layer_offsets(graph, &offsets);
419    }
420
421    fn align_layer(&self, graph: &SankeyGraph, index: usize, n: usize) -> usize {
422        let node = &graph.nodes[index];
423        match self.align {
424            SankeyAlign::Left => node.depth,
425            SankeyAlign::Right => n - 1 - node.height,
426            SankeyAlign::Justify => {
427                if node.source_links.is_empty() {
428                    n - 1
429                } else {
430                    node.depth
431                }
432            }
433            SankeyAlign::Center => {
434                if !node.target_links.is_empty() {
435                    node.depth
436                } else if !node.source_links.is_empty() {
437                    node.source_links
438                        .iter()
439                        .map(|&link| graph.nodes[graph.links[link].target].depth)
440                        .min()
441                        .unwrap_or(1)
442                        .saturating_sub(1)
443                } else {
444                    0
445                }
446            }
447        }
448    }
449
450    fn compute_node_layers(&self, graph: &mut SankeyGraph) {
451        let n = graph
452            .nodes
453            .iter()
454            .map(|node| node.depth + 1)
455            .max()
456            .unwrap_or(0);
457        let kx = if n > 1 {
458            (self.x1 - self.x0 - self.node_width) / (n - 1) as f32
459        } else {
460            0.
461        };
462
463        let layers: Vec<usize> = (0..graph.nodes.len())
464            .map(|index| self.align_layer(graph, index, n).min(n - 1))
465            .collect();
466
467        for (index, layer) in layers.into_iter().enumerate() {
468            let node = &mut graph.nodes[index];
469            node.layer = layer;
470            node.x0 = self.x0 + layer as f32 * kx;
471            node.x1 = node.x0 + self.node_width;
472        }
473    }
474
475    fn compute_node_breadths(&self, graph: &mut SankeyGraph, columns: &mut [Vec<usize>]) {
476        let max_column_len = columns.iter().map(|column| column.len()).max().unwrap_or(0);
477        let py = if max_column_len > 1 {
478            self.node_padding
479                .min((self.y1 - self.y0) / (max_column_len - 1) as f32)
480        } else {
481            self.node_padding
482        };
483
484        self.initialize_node_breadths(graph, columns, py);
485
486        for i in 0..self.iterations {
487            let alpha = 0.99_f32.powi(i as i32);
488            let beta = (1. - alpha).max((i + 1) as f32 / self.iterations as f32);
489            self.relax_right_to_left(graph, columns, alpha, beta, py);
490            self.relax_left_to_right(graph, columns, alpha, beta, py);
491        }
492    }
493
494    fn initialize_node_breadths(&self, graph: &mut SankeyGraph, columns: &[Vec<usize>], py: f32) {
495        // Scale factor between flow value and pixels. d3 lets an over-crowded
496        // column produce a negative ky; clamp to zero so heights never invert.
497        let mut ky = f32::INFINITY;
498        for column in columns {
499            let value_sum: f64 = column.iter().map(|&index| graph.nodes[index].value).sum();
500            if value_sum > 0. {
501                let k = (self.y1 - self.y0 - (column.len() - 1) as f32 * py) / value_sum as f32;
502                ky = ky.min(k);
503            }
504        }
505        if !ky.is_finite() {
506            ky = 0.;
507        }
508        ky = ky.max(0.);
509
510        for column in columns {
511            let mut y = self.y0;
512            for &index in column {
513                let node_height = graph.nodes[index].value as f32 * ky;
514                let node = &mut graph.nodes[index];
515                node.y0 = y;
516                node.y1 = y + node_height;
517                y = node.y1 + py;
518            }
519
520            // Distribute the leftover vertical space evenly (d3 keeps this
521            // signed: an over-crowded column shifts nodes back up).
522            let leftover = (self.y1 - y + py) / (column.len() + 1) as f32;
523            for (i, &index) in column.iter().enumerate() {
524                let node = &mut graph.nodes[index];
525                let dy = leftover * (i + 1) as f32;
526                node.y0 += dy;
527                node.y1 += dy;
528            }
529        }
530
531        for link in &mut graph.links {
532            link.width = link.value as f32 * ky;
533        }
534
535        for column in columns {
536            for &index in column {
537                sort_source_links(graph, index);
538                sort_target_links(graph, index);
539            }
540        }
541    }
542
543    /// Reposition each node based on its incoming links.
544    fn relax_left_to_right(
545        &self,
546        graph: &mut SankeyGraph,
547        columns: &mut [Vec<usize>],
548        alpha: f32,
549        beta: f32,
550        py: f32,
551    ) {
552        for i in 1..columns.len() {
553            for &target in &columns[i] {
554                let mut y = 0.;
555                let mut w = 0.;
556                for &link_index in &graph.nodes[target].target_links {
557                    let link = &graph.links[link_index];
558                    let v = link.value as f32
559                        * (graph.nodes[target].layer as f32
560                            - graph.nodes[link.source].layer as f32);
561                    y += target_top(graph, link.source, target, py) * v;
562                    w += v;
563                }
564                if w <= 0. {
565                    continue;
566                }
567                let dy = (y / w - graph.nodes[target].y0) * alpha;
568                graph.nodes[target].y0 += dy;
569                graph.nodes[target].y1 += dy;
570                reorder_node_links(graph, target);
571            }
572            sort_column(graph, &mut columns[i]);
573            self.resolve_collisions(graph, &columns[i], beta, py);
574        }
575    }
576
577    /// Reposition each node based on its outgoing links.
578    fn relax_right_to_left(
579        &self,
580        graph: &mut SankeyGraph,
581        columns: &mut [Vec<usize>],
582        alpha: f32,
583        beta: f32,
584        py: f32,
585    ) {
586        for i in (0..columns.len().saturating_sub(1)).rev() {
587            for &source in &columns[i] {
588                let mut y = 0.;
589                let mut w = 0.;
590                for &link_index in &graph.nodes[source].source_links {
591                    let link = &graph.links[link_index];
592                    let v = link.value as f32
593                        * (graph.nodes[link.target].layer as f32
594                            - graph.nodes[source].layer as f32);
595                    y += source_top(graph, source, link.target, py) * v;
596                    w += v;
597                }
598                if w <= 0. {
599                    continue;
600                }
601                let dy = (y / w - graph.nodes[source].y0) * alpha;
602                graph.nodes[source].y0 += dy;
603                graph.nodes[source].y1 += dy;
604                reorder_node_links(graph, source);
605            }
606            sort_column(graph, &mut columns[i]);
607            self.resolve_collisions(graph, &columns[i], beta, py);
608        }
609    }
610
611    /// d3's middle-out collision resolution: push nodes away from the middle
612    /// node, then clamp the column against the extent edges.
613    fn resolve_collisions(&self, graph: &mut SankeyGraph, column: &[usize], beta: f32, py: f32) {
614        if column.is_empty() {
615            return;
616        }
617
618        let i = column.len() >> 1;
619        let subject_y0 = graph.nodes[column[i]].y0;
620        let subject_y1 = graph.nodes[column[i]].y1;
621        push_up(graph, &column[..i], subject_y0 - py, beta, py);
622        push_down(graph, &column[i + 1..], subject_y1 + py, beta, py);
623        push_up(graph, column, self.y1, beta, py);
624        push_down(graph, column, self.y0, beta, py);
625    }
626}
627
628/// Shift every node (and its attached link ends) by its layer's offset.
629/// Used by both column centering and flat-run staggering.
630fn apply_layer_offsets(graph: &mut SankeyGraph, offsets: &[f32]) {
631    // Precompute per-node offsets so the link loop doesn't borrow `nodes`
632    // while mutating `links`.
633    let node_offset: Vec<f32> = graph.nodes.iter().map(|n| offsets[n.layer]).collect();
634    for node in &mut graph.nodes {
635        let dy = node_offset[node.index];
636        node.y0 += dy;
637        node.y1 += dy;
638    }
639    for link in &mut graph.links {
640        link.y0 += node_offset[link.source];
641        link.y1 += node_offset[link.target];
642    }
643}
644
645fn compute_node_links(graph: &mut SankeyGraph) {
646    for index in 0..graph.links.len() {
647        let (source, target) = (graph.links[index].source, graph.links[index].target);
648        graph.nodes[source].source_links.push(index);
649        graph.nodes[target].target_links.push(index);
650    }
651}
652
653fn compute_node_values(graph: &mut SankeyGraph) {
654    for index in 0..graph.nodes.len() {
655        let outgoing: f64 = graph.nodes[index]
656            .source_links
657            .iter()
658            .map(|&link| graph.links[link].value)
659            .sum();
660        let incoming: f64 = graph.nodes[index]
661            .target_links
662            .iter()
663            .map(|&link| graph.links[link].value)
664            .sum();
665        graph.nodes[index].value = outgoing.max(incoming);
666    }
667}
668
669/// Assign the longest-path `depth` (from any source) and `height` (to any
670/// sink) from a single topological ordering. A node left unordered means its
671/// incoming links never resolved, i.e. a cycle.
672fn compute_node_ranks(graph: &mut SankeyGraph) -> Result<(), SankeyError> {
673    let n = graph.nodes.len();
674    let mut incoming: Vec<usize> = graph
675        .nodes
676        .iter()
677        .map(|node| node.target_links.len())
678        .collect();
679    // Doubles as the traversal queue and, once drained, the topological order.
680    let mut order: Vec<usize> = (0..n).filter(|&index| incoming[index] == 0).collect();
681
682    // Ranks live in their own arrays rather than in the nodes: the traversal
683    // hops between them by index, and the node struct is an order of magnitude
684    // wider than a `usize`.
685    let mut depths = vec![0usize; n];
686    let mut visited = 0;
687    while visited < order.len() {
688        let index = order[visited];
689        visited += 1;
690        let depth = depths[index] + 1;
691        for &link in &graph.nodes[index].source_links {
692            let target = graph.links[link].target;
693            depths[target] = depths[target].max(depth);
694            incoming[target] -= 1;
695            if incoming[target] == 0 {
696                order.push(target);
697            }
698        }
699    }
700    if order.len() != n {
701        return Err(SankeyError::CircularLink);
702    }
703
704    // Walking the order backwards means every target's height is already final
705    // when its sources are visited.
706    let mut heights = vec![0usize; n];
707    for &index in order.iter().rev() {
708        for &link in &graph.nodes[index].source_links {
709            heights[index] = heights[index].max(heights[graph.links[link].target] + 1);
710        }
711    }
712
713    for (node, (depth, height)) in graph.nodes.iter_mut().zip(depths.into_iter().zip(heights)) {
714        node.depth = depth;
715        node.height = height;
716    }
717    Ok(())
718}
719
720/// Sort a node's outgoing links by the target node's `y0` (then link index).
721fn sort_source_links(graph: &mut SankeyGraph, index: usize) {
722    let mut links = std::mem::take(&mut graph.nodes[index].source_links);
723    // The link-index tie-break makes the order total, so an unstable sort
724    // is deterministic and avoids the stable sort's scratch allocation.
725    links.sort_unstable_by(|&a, &b| {
726        let ya = graph.nodes[graph.links[a].target].y0;
727        let yb = graph.nodes[graph.links[b].target].y0;
728        ya.partial_cmp(&yb)
729            .unwrap_or(std::cmp::Ordering::Equal)
730            .then(a.cmp(&b))
731    });
732    graph.nodes[index].source_links = links;
733}
734
735/// Sort a node's incoming links by the source node's `y0` (then link index).
736fn sort_target_links(graph: &mut SankeyGraph, index: usize) {
737    let mut links = std::mem::take(&mut graph.nodes[index].target_links);
738    links.sort_unstable_by(|&a, &b| {
739        let ya = graph.nodes[graph.links[a].source].y0;
740        let yb = graph.nodes[graph.links[b].source].y0;
741        ya.partial_cmp(&yb)
742            .unwrap_or(std::cmp::Ordering::Equal)
743            .then(a.cmp(&b))
744    });
745    graph.nodes[index].target_links = links;
746}
747
748/// After a node moved, re-sort the link lists of its neighbors on the
749/// opposite ends (d3's reorderNodeLinks).
750///
751/// Iterates by position to avoid cloning the link lists on this hot path;
752/// the sorts only mutate the neighbors' lists, never the one being walked.
753fn reorder_node_links(graph: &mut SankeyGraph, index: usize) {
754    for i in 0..graph.nodes[index].target_links.len() {
755        let link = graph.nodes[index].target_links[i];
756        let source = graph.links[link].source;
757        sort_source_links(graph, source);
758    }
759    for i in 0..graph.nodes[index].source_links.len() {
760        let link = graph.nodes[index].source_links[i];
761        let target = graph.links[link].target;
762        sort_target_links(graph, target);
763    }
764}
765
766fn sort_column(graph: &SankeyGraph, column: &mut [usize]) {
767    column.sort_by(|&a, &b| {
768        graph.nodes[a]
769            .y0
770            .partial_cmp(&graph.nodes[b].y0)
771            .unwrap_or(std::cmp::Ordering::Equal)
772    });
773}
774
775/// Push overlapping nodes down (d3's resolveCollisionsTopToBottom).
776fn push_down(graph: &mut SankeyGraph, column: &[usize], mut y: f32, alpha: f32, py: f32) {
777    for &index in column {
778        let node = &mut graph.nodes[index];
779        let dy = (y - node.y0) * alpha;
780        if dy > 1e-6 {
781            node.y0 += dy;
782            node.y1 += dy;
783        }
784        y = node.y1 + py;
785    }
786}
787
788/// Push overlapping nodes up (d3's resolveCollisionsBottomToTop).
789fn push_up(graph: &mut SankeyGraph, column: &[usize], mut y: f32, alpha: f32, py: f32) {
790    for &index in column.iter().rev() {
791        let node = &mut graph.nodes[index];
792        let dy = (node.y1 - y) * alpha;
793        if dy > 1e-6 {
794            node.y0 -= dy;
795            node.y1 -= dy;
796        }
797        y = node.y0 - py;
798    }
799}
800
801/// The ideal `y0` for `target` so that its ribbon from `source` lines up
802/// with the slot the ribbon occupies in the source's outgoing stack
803/// (d3's targetTop).
804fn target_top(graph: &SankeyGraph, source: usize, target: usize, py: f32) -> f32 {
805    let source_node = &graph.nodes[source];
806    let mut y = source_node.y0 - source_node.source_links.len().saturating_sub(1) as f32 * py / 2.;
807    for &link_index in &source_node.source_links {
808        let link = &graph.links[link_index];
809        if link.target == target {
810            break;
811        }
812        y += link.width + py;
813    }
814    for &link_index in &graph.nodes[target].target_links {
815        let link = &graph.links[link_index];
816        if link.source == source {
817            break;
818        }
819        y -= link.width;
820    }
821    y
822}
823
824/// The ideal `y0` for `source` so that its ribbon to `target` lines up with
825/// the slot the ribbon occupies in the target's incoming stack
826/// (d3's sourceTop).
827fn source_top(graph: &SankeyGraph, source: usize, target: usize, py: f32) -> f32 {
828    let target_node = &graph.nodes[target];
829    let mut y = target_node.y0 - target_node.target_links.len().saturating_sub(1) as f32 * py / 2.;
830    for &link_index in &target_node.target_links {
831        let link = &graph.links[link_index];
832        if link.source == source {
833            break;
834        }
835        y += link.width + py;
836    }
837    for &link_index in &graph.nodes[source].source_links {
838        let link = &graph.links[link_index];
839        if link.target == target {
840            break;
841        }
842        y -= link.width;
843    }
844    y
845}
846
847/// Assign each link's `y0`/`y1` (ribbon centers) and per-end widths by
848/// stacking the sorted link lists within each node. Each side shares the
849/// node height in proportion to the link values, so both sides of a node
850/// are fully covered even when the graph is imbalanced (equivalent to the
851/// nominal `width` stacking when it is balanced).
852fn compute_link_breadths(graph: &mut SankeyGraph) {
853    for index in 0..graph.nodes.len() {
854        let node = &graph.nodes[index];
855        let node_y0 = node.y0;
856        let node_height = node.y1 - node.y0;
857
858        let outgoing: f64 = node
859            .source_links
860            .iter()
861            .map(|&link| graph.links[link].value)
862            .sum();
863        let mut y0 = node_y0;
864        for i in 0..graph.nodes[index].source_links.len() {
865            let link = &mut graph.links[graph.nodes[index].source_links[i]];
866            let width = if outgoing > 0. {
867                (link.value / outgoing) as f32 * node_height
868            } else {
869                0.
870            };
871            link.source_width = width;
872            link.y0 = y0 + width / 2.;
873            y0 += width;
874        }
875
876        let node = &graph.nodes[index];
877        let incoming: f64 = node
878            .target_links
879            .iter()
880            .map(|&link| graph.links[link].value)
881            .sum();
882        let mut y1 = node_y0;
883        for i in 0..graph.nodes[index].target_links.len() {
884            let link = &mut graph.links[graph.nodes[index].target_links[i]];
885            let width = if incoming > 0. {
886                (link.value / incoming) as f32 * node_height
887            } else {
888                0.
889            };
890            link.target_width = width;
891            link.y1 = y1 + width / 2.;
892            y1 += width;
893        }
894    }
895}
896
897/// Build the filled ribbon path for a link — the equivalent of d3-sankey's
898/// `sankeyLinkHorizontal()`: a horizontal cubic bezier with control points at
899/// the horizontal midpoint, thickened to the per-end link widths (clamped to
900/// `min_width` so tiny flows stay visible).
901pub fn sankey_link_path(
902    source: &SankeyNodeLayout,
903    target: &SankeyNodeLayout,
904    link: &SankeyLinkLayout,
905    min_width: f32,
906    origin: Point<Pixels>,
907) -> Option<Path<Pixels>> {
908    let source_half = link.source_width.max(min_width) / 2.;
909    let target_half = link.target_width.max(min_width) / 2.;
910    let sx = source.x1;
911    let tx = target.x0;
912    let mx = (sx + tx) / 2.;
913
914    let mut builder = PathBuilder::fill();
915    builder.move_to(origin_point(px(sx), px(link.y0 - source_half), origin));
916    builder.cubic_bezier_to(
917        origin_point(px(tx), px(link.y1 - target_half), origin),
918        origin_point(px(mx), px(link.y0 - source_half), origin),
919        origin_point(px(mx), px(link.y1 - target_half), origin),
920    );
921    builder.line_to(origin_point(px(tx), px(link.y1 + target_half), origin));
922    builder.cubic_bezier_to(
923        origin_point(px(sx), px(link.y0 + source_half), origin),
924        origin_point(px(mx), px(link.y1 + target_half), origin),
925        origin_point(px(mx), px(link.y0 + source_half), origin),
926    );
927    builder.close();
928    builder.build().ok()
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934
935    const EPSILON: f32 = 1e-3;
936
937    fn links(links: &[(usize, usize, f64)]) -> Vec<SankeyLink> {
938        links
939            .iter()
940            .map(|&(source, target, value)| SankeyLink::new(source, target, value))
941            .collect()
942    }
943
944    #[test]
945    fn test_sankey_builder() {
946        let sankey = Sankey::new();
947        assert_eq!(sankey.node_width, 24.);
948        assert_eq!(sankey.node_padding, 8.);
949        assert_eq!(sankey.align, SankeyAlign::Justify);
950        assert_eq!(sankey.iterations, 6);
951        assert_eq!(
952            (sankey.x0, sankey.y0, sankey.x1, sankey.y1),
953            (0., 0., 1., 1.)
954        );
955
956        let sankey = Sankey::new()
957            .node_width(12.)
958            .node_padding(10.)
959            .node_align(SankeyAlign::Left)
960            .iterations(10)
961            .size(400., 300.);
962        assert_eq!(sankey.node_width, 12.);
963        assert_eq!(sankey.node_padding, 10.);
964        assert_eq!(sankey.align, SankeyAlign::Left);
965        assert_eq!(sankey.iterations, 10);
966        assert_eq!(
967            (sankey.x0, sankey.y0, sankey.x1, sankey.y1),
968            (0., 0., 400., 300.)
969        );
970
971        let sankey = Sankey::new().extent(10., 20., 30., 40.);
972        assert_eq!(
973            (sankey.x0, sankey.y0, sankey.x1, sankey.y1),
974            (10., 20., 30., 40.)
975        );
976    }
977
978    #[test]
979    fn test_sankey_layout_chain() {
980        // A -> B -> C
981        let graph = Sankey::new()
982            .node_width(10.)
983            .size(100., 100.)
984            .layout(3, &links(&[(0, 1, 5.), (1, 2, 5.)]))
985            .unwrap();
986
987        let depths: Vec<usize> = graph.nodes.iter().map(|n| n.depth).collect();
988        let heights: Vec<usize> = graph.nodes.iter().map(|n| n.height).collect();
989        let layers: Vec<usize> = graph.nodes.iter().map(|n| n.layer).collect();
990        assert_eq!(depths, vec![0, 1, 2]);
991        assert_eq!(heights, vec![2, 1, 0]);
992        assert_eq!(layers, vec![0, 1, 2]);
993        assert_eq!(graph.layer_count(), 3);
994
995        assert_eq!(graph.nodes[0].x0, 0.);
996        assert_eq!(graph.nodes[1].x0, 45.);
997        assert_eq!(graph.nodes[2].x0, 90.);
998        for node in &graph.nodes {
999            assert_eq!(node.x1 - node.x0, 10.);
1000            assert_eq!(node.value, 5.);
1001            // Every node carries the full flow, so all span the full height.
1002            assert!((node.y1 - node.y0 - 100.).abs() < EPSILON);
1003        }
1004        for link in &graph.links {
1005            assert!((link.width - 100.).abs() < EPSILON);
1006            // The chain is balanced, so both ribbon ends span the nodes.
1007            assert!((link.source_width - 100.).abs() < EPSILON);
1008            assert!((link.target_width - 100.).abs() < EPSILON);
1009        }
1010
1011        // `topology` agrees with `layout` on the topological fields.
1012        let topology = Sankey::new()
1013            .node_width(10.)
1014            .size(100., 100.)
1015            .topology(3, &links(&[(0, 1, 5.), (1, 2, 5.)]))
1016            .unwrap();
1017        assert_eq!(topology.layer_count(), 3);
1018        for (a, b) in topology.nodes.iter().zip(&graph.nodes) {
1019            assert_eq!(a.depth, b.depth);
1020            assert_eq!(a.height, b.height);
1021            assert_eq!(a.layer, b.layer);
1022            assert_eq!(a.value, b.value);
1023            assert_eq!(a.x0, b.x0);
1024        }
1025
1026        // Completing a unit-extent topology on the final extent (the chart's
1027        // two-pass flow) matches a direct layout on that extent.
1028        let topology = Sankey::new()
1029            .node_width(10.)
1030            .topology(3, &links(&[(0, 1, 5.), (1, 2, 5.)]))
1031            .unwrap();
1032        let completed = Sankey::new()
1033            .node_width(10.)
1034            .size(100., 100.)
1035            .layout_from(topology);
1036        for (a, b) in completed.nodes.iter().zip(&graph.nodes) {
1037            assert_eq!((a.x0, a.y0, a.x1, a.y1), (b.x0, b.y0, b.x1, b.y1));
1038        }
1039        for (a, b) in completed.links.iter().zip(&graph.links) {
1040            assert_eq!((a.y0, a.y1, a.width), (b.y0, b.y1, b.width));
1041        }
1042    }
1043
1044    #[test]
1045    fn test_sankey_topology_large_chain() {
1046        const NODE_COUNT: usize = 50_000;
1047        let links: Vec<SankeyLink> = (0..NODE_COUNT - 1)
1048            .map(|source| SankeyLink::new(source, source + 1, 1.))
1049            .collect();
1050        let graph = Sankey::new().topology(NODE_COUNT, &links).unwrap();
1051
1052        assert_eq!(graph.nodes[0].height, NODE_COUNT - 1);
1053        assert_eq!(graph.nodes[NODE_COUNT - 1].depth, NODE_COUNT - 1);
1054    }
1055
1056    #[test]
1057    fn test_sankey_alignment() {
1058        // A -> B -> C, plus a short branch A -> D.
1059        let links = links(&[(0, 1, 1.), (1, 2, 1.), (0, 3, 1.)]);
1060        let layers = |align: SankeyAlign| -> Vec<usize> {
1061            Sankey::new()
1062                .node_align(align)
1063                .size(100., 100.)
1064                .layout(4, &links)
1065                .unwrap()
1066                .nodes
1067                .iter()
1068                .map(|n| n.layer)
1069                .collect()
1070        };
1071
1072        assert_eq!(layers(SankeyAlign::Left), vec![0, 1, 2, 1]);
1073        assert_eq!(layers(SankeyAlign::Right), vec![0, 1, 2, 2]);
1074        assert_eq!(layers(SankeyAlign::Justify), vec![0, 1, 2, 2]);
1075        assert_eq!(layers(SankeyAlign::Center), vec![0, 1, 2, 1]);
1076    }
1077
1078    #[test]
1079    fn test_sankey_link_offsets() {
1080        // One source fanning out into two targets.
1081        let graph = Sankey::new()
1082            .node_width(10.)
1083            .size(100., 100.)
1084            .layout(3, &links(&[(0, 1, 30.), (0, 2, 10.)]))
1085            .unwrap();
1086
1087        let source = &graph.nodes[0];
1088        let source_height = source.y1 - source.y0;
1089        let total_width: f32 = graph.links.iter().map(|l| l.width).sum();
1090        assert!((total_width - source_height).abs() < EPSILON);
1091
1092        // Widths are proportional to values.
1093        assert!((graph.links[0].width / graph.links[1].width - 3.).abs() < EPSILON);
1094
1095        // Outgoing ribbons stack contiguously within the source node.
1096        let (first, second) = if graph.links[0].y0 < graph.links[1].y0 {
1097            (&graph.links[0], &graph.links[1])
1098        } else {
1099            (&graph.links[1], &graph.links[0])
1100        };
1101        assert!((first.y0 - first.source_width / 2. - source.y0).abs() < EPSILON);
1102        assert!(
1103            (first.y0 + first.source_width / 2. - (second.y0 - second.source_width / 2.)).abs()
1104                < EPSILON
1105        );
1106
1107        // Each target has a single incoming ribbon filling its full height.
1108        for link in &graph.links {
1109            let target = &graph.nodes[link.target];
1110            assert!((link.y1 - link.target_width / 2. - target.y0).abs() < EPSILON);
1111            assert!((link.y1 + link.target_width / 2. - target.y1).abs() < EPSILON);
1112        }
1113    }
1114
1115    #[test]
1116    fn test_sankey_imbalanced_link_widths() {
1117        // A -> B (10) but B -> C (7): B is sized by its incoming flow, and
1118        // its single outgoing ribbon is stretched to cover its outgoing side
1119        // while the ribbon's target end matches C's height.
1120        let graph = Sankey::new()
1121            .size(100., 100.)
1122            .layout(3, &links(&[(0, 1, 10.), (1, 2, 7.)]))
1123            .unwrap();
1124
1125        let node_b = &graph.nodes[1];
1126        let node_c = &graph.nodes[2];
1127        let out_link = &graph.links[1];
1128        assert!((out_link.source_width - (node_b.y1 - node_b.y0)).abs() < EPSILON);
1129        assert!((out_link.target_width - (node_c.y1 - node_c.y0)).abs() < EPSILON);
1130        // The two ends differ: B is taller (value 10) than C (value 7).
1131        assert!(out_link.source_width > out_link.target_width);
1132
1133        // Both ends stay centered on their nodes' filled ranges.
1134        assert!((out_link.y0 - (node_b.y0 + node_b.y1) / 2.).abs() < EPSILON);
1135        assert!((out_link.y1 - (node_c.y0 + node_c.y1) / 2.).abs() < EPSILON);
1136    }
1137
1138    #[test]
1139    fn test_sankey_sqrt_scale_fills_nodes() {
1140        // With the sqrt scale, a node's children must still exactly fill it:
1141        // the incoming and outgoing ribbon widths each sum to the node height
1142        // (no gaps), and the diagram is compressed vs linear.
1143        let links = links(&[(0, 1, 90.), (1, 2, 40.), (1, 3, 50.)]);
1144        let sqrt = Sankey::new()
1145            .value_scale(SankeyValueScale::Sqrt)
1146            .size(100., 100.)
1147            .layout(4, &links)
1148            .unwrap();
1149
1150        for node in &sqrt.nodes {
1151            let node_height = node.y1 - node.y0;
1152            let incoming: f32 = node
1153                .target_links
1154                .iter()
1155                .map(|&l| sqrt.links[l].target_width)
1156                .sum();
1157            let outgoing: f32 = node
1158                .source_links
1159                .iter()
1160                .map(|&l| sqrt.links[l].source_width)
1161                .sum();
1162            if !node.target_links.is_empty() {
1163                assert!((incoming - node_height).abs() < EPSILON);
1164            }
1165            if !node.source_links.is_empty() {
1166                assert!((outgoing - node_height).abs() < EPSILON);
1167            }
1168        }
1169
1170        // Two leaf nodes (50 and 40) show the sqrt compression directly:
1171        // their height ratio is sqrt(50/40), not the linear 50/40.
1172        let ratio = (sqrt.nodes[3].y1 - sqrt.nodes[3].y0) / (sqrt.nodes[2].y1 - sqrt.nodes[2].y0);
1173        assert!((ratio - (50f32 / 40.).sqrt()).abs() < 0.02);
1174    }
1175
1176    #[test]
1177    fn test_sankey_value_conservation() {
1178        // Incoming 10, outgoing 7: node value takes the max.
1179        let graph = Sankey::new()
1180            .size(100., 100.)
1181            .layout(3, &links(&[(0, 1, 10.), (1, 2, 7.)]))
1182            .unwrap();
1183
1184        assert_eq!(graph.nodes[1].value, 10.);
1185
1186        for node in &graph.nodes {
1187            assert!(node.y0 <= node.y1);
1188            assert!(node.y0 >= -EPSILON);
1189            assert!(node.y1 <= 100. + EPSILON);
1190        }
1191    }
1192
1193    #[test]
1194    fn test_sankey_vertical_centering() {
1195        // Each column's stack is centered in the extent: its midpoint equals
1196        // the extent midpoint. Uses multi-node columns so the flat-run stagger
1197        // (which only touches equal single-node columns) does not apply.
1198        let graph = Sankey::new()
1199            .node_padding(20.)
1200            .extent(0., 10., 100., 90.)
1201            .layout(
1202                5,
1203                &links(&[(0, 2, 40.), (1, 2, 10.), (2, 3, 25.), (2, 4, 25.)]),
1204            )
1205            .unwrap();
1206
1207        let layers = graph.layer_count();
1208        let mut lo = vec![f32::INFINITY; layers];
1209        let mut hi = vec![f32::NEG_INFINITY; layers];
1210        for node in &graph.nodes {
1211            lo[node.layer] = lo[node.layer].min(node.y0);
1212            hi[node.layer] = hi[node.layer].max(node.y1);
1213        }
1214        // Extent is [10, 90], midpoint 50; every column's midpoint matches.
1215        for l in 0..layers {
1216            assert!(((lo[l] + hi[l]) / 2. - 50.).abs() < EPSILON);
1217        }
1218        // Centering keeps every node within the extent.
1219        for node in &graph.nodes {
1220            assert!(node.y0 >= 10. - EPSILON);
1221            assert!(node.y1 <= 90. + EPSILON);
1222        }
1223    }
1224
1225    #[test]
1226    fn test_sankey_stagger_flat_columns() {
1227        // Two equal single-node columns feeding a fan-out: the sparse trunk
1228        // columns don't fill the height, so the equal pair is staggered off
1229        // the center line to curve the otherwise-flat ribbon between them.
1230        let graph = Sankey::new()
1231            .node_padding(20.)
1232            .size(100., 100.)
1233            .layout(
1234                6,
1235                &links(&[
1236                    (0, 1, 100.),
1237                    (1, 2, 40.),
1238                    (1, 3, 30.),
1239                    (1, 4, 20.),
1240                    (1, 5, 10.),
1241                ]),
1242            )
1243            .unwrap();
1244
1245        // Nodes 0 and 1 are the equal single-node columns; one is nudged off
1246        // center so their centers differ (the ribbon is no longer flat), but
1247        // both stay within the extent.
1248        let c0 = (graph.nodes[0].y0 + graph.nodes[0].y1) / 2.;
1249        let c1 = (graph.nodes[1].y0 + graph.nodes[1].y1) / 2.;
1250        assert!((c0 - c1).abs() > EPSILON);
1251        for node in &graph.nodes {
1252            assert!(node.y0 >= -EPSILON);
1253            assert!(node.y1 <= 100. + EPSILON);
1254        }
1255
1256        // After the stagger's per-layer shift, each ribbon end must still be
1257        // attached to its node: `y0` centered on the source node's outgoing
1258        // stack, `y1` on the target node's incoming stack. (Guards against a
1259        // source/target mix-up in `apply_layer_offsets`.)
1260        for node in &graph.nodes {
1261            let mut y = node.y0;
1262            for &l in &node.source_links {
1263                let link = &graph.links[l];
1264                assert!((link.y0 - (y + link.source_width / 2.)).abs() < EPSILON);
1265                y += link.source_width;
1266            }
1267            let mut y = node.y0;
1268            for &l in &node.target_links {
1269                let link = &graph.links[l];
1270                assert!((link.y1 - (y + link.target_width / 2.)).abs() < EPSILON);
1271                y += link.target_width;
1272            }
1273        }
1274    }
1275
1276    #[test]
1277    fn test_sankey_circular_link() {
1278        let sankey = Sankey::new().size(100., 100.);
1279
1280        assert_eq!(
1281            sankey
1282                .layout(2, &links(&[(0, 1, 1.), (1, 0, 1.)]))
1283                .unwrap_err(),
1284            SankeyError::CircularLink
1285        );
1286        assert_eq!(
1287            sankey.layout(1, &links(&[(0, 0, 1.)])).unwrap_err(),
1288            SankeyError::CircularLink
1289        );
1290        assert_eq!(
1291            sankey.layout(2, &links(&[(0, 5, 1.)])).unwrap_err(),
1292            SankeyError::MissingNode(5)
1293        );
1294    }
1295
1296    #[test]
1297    fn test_sankey_degenerate() {
1298        // Empty graph.
1299        let graph = Sankey::new().size(100., 100.).layout(0, &[]).unwrap();
1300        assert!(graph.nodes.is_empty());
1301        assert!(graph.links.is_empty());
1302        assert_eq!(graph.layer_count(), 0);
1303
1304        // All-zero link values must not produce NaN coordinates.
1305        let graph = Sankey::new()
1306            .size(100., 100.)
1307            .layout(2, &links(&[(0, 1, 0.)]))
1308            .unwrap();
1309        for node in &graph.nodes {
1310            assert!((node.y1 - node.y0).abs() < EPSILON);
1311            assert!(node.x0.is_finite() && node.x1.is_finite());
1312            assert!(node.y0.is_finite() && node.y1.is_finite());
1313        }
1314
1315        // Isolated nodes collapse into a single column without dividing by zero.
1316        let graph = Sankey::new().size(100., 100.).layout(2, &[]).unwrap();
1317        assert_eq!(graph.layer_count(), 1);
1318        for node in &graph.nodes {
1319            assert_eq!(node.x0, 0.);
1320            assert!(node.y0.is_finite() && node.y1.is_finite());
1321        }
1322    }
1323}