From 22f1568c5d63138c52ec30afde6ec24606b8af09 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Mon, 6 Jul 2026 16:38:00 +0800 Subject: [PATCH 01/14] chart: Add `SankeyChart` component Add a Sankey diagram chart to the plot system: - `plot/shape/sankey.rs`: layout generator ported from d3-sankey (node depth/height via BFS, `Left`/`Right`/`Center`/`Justify` alignments, iterative relaxation with collision resolution), plus a `sankey_link_path()` helper building the filled cubic-bezier ribbon. Circular links return `SankeyError` instead of panicking. - `chart/sankey_chart.rs`: `SankeyChart` with builder-style API (`node_width`, `node_padding`, `node_align`, `node_color`, `node_label`, `value_label`, `link_opacity`, `min_link_width`). Ribbons are filled with a source-to-target color gradient; label margins are reserved via a two-pass layout. - Story: TSLA income statement examples (FY 2025 with sqrt-compressed values, FY 2024 linear) rendered from a fixture shaped like the real API response. - Docs: `SankeyChart` sections in both `en` and `zh-CN` locales. Co-Authored-By: Claude Fable 5 --- .../src/fixtures/tsla-income-statement.json | 367 +++++++ .../src/stories/chart_story/chart_story.rs | 135 ++- crates/ui/src/chart/mod.rs | 2 + crates/ui/src/chart/sankey_chart.rs | 410 ++++++++ crates/ui/src/plot/shape.rs | 5 + crates/ui/src/plot/shape/sankey.rs | 892 ++++++++++++++++++ docs/docs/components/chart.md | 93 +- docs/zh-CN/docs/components/chart.md | 93 +- 8 files changed, 1985 insertions(+), 12 deletions(-) create mode 100644 crates/story/src/fixtures/tsla-income-statement.json create mode 100644 crates/ui/src/chart/sankey_chart.rs create mode 100644 crates/ui/src/plot/shape/sankey.rs diff --git a/crates/story/src/fixtures/tsla-income-statement.json b/crates/story/src/fixtures/tsla-income-statement.json new file mode 100644 index 0000000000..d1f900b271 --- /dev/null +++ b/crates/story/src/fixtures/tsla-income-statement.json @@ -0,0 +1,367 @@ +{ + "list": [ + { + "fiscal_year": 2025, + "report": "2025 Annual", + "currency": "USD", + "period": "FY 2025", + "nodes": [ + { + "key": "bus_119884", + "name": "Automotive", + "value": "82056000000", + "yoy": "-6.33304415323501", + "level": 1, + "color": "#5A74FF" + }, + { + "key": "bus_130929", + "name": "Energy Generation and Storage", + "value": "12771000000", + "yoy": "26.62105889351576", + "level": 1, + "color": "#5A74FF" + }, + { + "key": "total_rev", + "name": "Total Revenue", + "value": "94827000000", + "yoy": "-2.93069915037363", + "level": 2, + "color": "#5A74FF" + }, + { + "key": "gp", + "name": "Gross Profit", + "value": "17094000000", + "yoy": "-2.04011461318052", + "level": 3, + "color": "#35C15C" + }, + { + "key": "cost_rev", + "name": "Cost Of Revenues", + "value": "77733000000", + "yoy": "-3.12437686939182", + "level": 3, + "color": "#FFA61F" + }, + { + "key": "other_f41", + "name": "Other", + "value": "862000000", + "yoy": "", + "level": 4, + "color": "#35C15C" + }, + { + "key": "oper_inc", + "name": "Operating Income", + "value": "4355000000", + "yoy": "-43.13879096487792", + "level": 4, + "color": "#35C15C" + }, + { + "key": "oper_fee", + "name": "Operating Exp.", + "value": "12739000000", + "yoy": "30.10928403635992", + "level": 4, + "color": "#FFA61F" + }, + { + "key": "ni", + "name": "Net Income", + "value": "3794000000", + "yoy": "-46.49555774925962", + "level": 5, + "color": "#35C15C" + }, + { + "key": "inc_tax", + "name": "Income Tax Exp.", + "value": "1423000000", + "yoy": "-22.53674469243332", + "level": 5, + "color": "#FFA61F" + }, + { + "key": "sga", + "name": "SG& A Exp.", + "value": "5834000000", + "yoy": "13.28155339805825", + "level": 5, + "color": "#FFA61F" + }, + { + "key": "rd_exp", + "name": "R&D Exp.", + "value": "6411000000", + "yoy": "41.21145374449339", + "level": 5, + "color": "#FFA61F" + }, + { + "key": "other_f42", + "name": "Other", + "value": "494000000", + "yoy": "", + "level": 5, + "color": "#FFA61F" + } + ], + "links": [ + { + "source": "bus_119884", + "target": "total_rev", + "value": "82056000000" + }, + { + "source": "bus_130929", + "target": "total_rev", + "value": "12771000000" + }, + { + "source": "total_rev", + "target": "gp", + "value": "17094000000" + }, + { + "source": "total_rev", + "target": "cost_rev", + "value": "77733000000" + }, + { + "source": "gp", + "target": "oper_inc", + "value": "4355000000" + }, + { + "source": "gp", + "target": "oper_fee", + "value": "12739000000" + }, + { + "source": "other_f41", + "target": "ni", + "value": "862000000" + }, + { + "source": "oper_inc", + "target": "ni", + "value": "2932000000" + }, + { + "source": "oper_inc", + "target": "inc_tax", + "value": "1423000000" + }, + { + "source": "oper_fee", + "target": "sga", + "value": "5834000000" + }, + { + "source": "oper_fee", + "target": "rd_exp", + "value": "6411000000" + }, + { + "source": "oper_fee", + "target": "other_f42", + "value": "494000000" + } + ] + }, + { + "fiscal_year": 2024, + "report": "2024 Annual", + "currency": "USD", + "period": "FY 2024", + "nodes": [ + { + "key": "bus_119884", + "name": "Automotive", + "value": "87604000000", + "yoy": "-3.45390024025215", + "level": 1, + "color": "#5A74FF" + }, + { + "key": "bus_130929", + "name": "Energy Generation and Storage", + "value": "10086000000", + "yoy": "67.12510356255178", + "level": 1, + "color": "#5A74FF" + }, + { + "key": "total_rev", + "name": "Total Revenue", + "value": "97690000000", + "yoy": "0.94757835346636", + "level": 2, + "color": "#5A74FF" + }, + { + "key": "gp", + "name": "Gross Profit", + "value": "17450000000", + "yoy": "-1.18912797281993", + "level": 3, + "color": "#35C15C" + }, + { + "key": "cost_rev", + "name": "Cost Of Revenues", + "value": "80240000000", + "yoy": "1.42454463868138", + "level": 3, + "color": "#FFA61F" + }, + { + "key": "other_f41", + "name": "Other", + "value": "1269000000", + "yoy": "", + "level": 4, + "color": "#35C15C" + }, + { + "key": "oper_inc", + "name": "Operating Income", + "value": "7659000000", + "yoy": "-13.85670903160499", + "level": 4, + "color": "#35C15C" + }, + { + "key": "oper_fee", + "name": "Operating Exp.", + "value": "9791000000", + "yoy": "11.6546926673509", + "level": 4, + "color": "#FFA61F" + }, + { + "key": "ni", + "name": "Net Income", + "value": "7091000000", + "yoy": "-52.7172101086884", + "level": 5, + "color": "#35C15C" + }, + { + "key": "inc_tax", + "name": "Income Tax Exp.", + "value": "1837000000", + "yoy": "136.73265346930614", + "level": 5, + "color": "#FFA61F" + }, + { + "key": "sga", + "name": "SG& A Exp.", + "value": "5150000000", + "yoy": "7.29166666666667", + "level": 5, + "color": "#FFA61F" + }, + { + "key": "rd_exp", + "name": "R&D Exp.", + "value": "4540000000", + "yoy": "14.38649533887629", + "level": 5, + "color": "#FFA61F" + }, + { + "key": "other_f42", + "name": "Other", + "value": "101000000", + "yoy": "", + "level": 5, + "color": "#FFA61F" + } + ], + "links": [ + { + "source": "bus_119884", + "target": "total_rev", + "value": "87604000000" + }, + { + "source": "bus_130929", + "target": "total_rev", + "value": "10086000000" + }, + { + "source": "total_rev", + "target": "gp", + "value": "17450000000" + }, + { + "source": "total_rev", + "target": "cost_rev", + "value": "80240000000" + }, + { + "source": "gp", + "target": "oper_inc", + "value": "7659000000" + }, + { + "source": "gp", + "target": "oper_fee", + "value": "9791000000" + }, + { + "source": "other_f41", + "target": "ni", + "value": "1269000000" + }, + { + "source": "oper_inc", + "target": "ni", + "value": "5822000000" + }, + { + "source": "oper_inc", + "target": "inc_tax", + "value": "1837000000" + }, + { + "source": "oper_fee", + "target": "sga", + "value": "5150000000" + }, + { + "source": "oper_fee", + "target": "rd_exp", + "value": "4540000000" + }, + { + "source": "oper_fee", + "target": "other_f42", + "value": "101000000" + } + ] + } + ], + "colors": [ + { + "name": "Revenue", + "color": "#5A74FF" + }, + { + "name": "Profit", + "color": "#35C15C" + }, + { + "name": "Cost", + "color": "#FFA61F" + } + ], + "ccy_symbol": "$" +} diff --git a/crates/story/src/stories/chart_story/chart_story.rs b/crates/story/src/stories/chart_story/chart_story.rs index 6f9161a1cf..b6c3e8e1f4 100644 --- a/crates/story/src/stories/chart_story/chart_story.rs +++ b/crates/story/src/stories/chart_story/chart_story.rs @@ -1,14 +1,14 @@ use gpui::{ App, AppContext, Context, Entity, FocusHandle, Focusable, Hsla, IntoElement, ParentElement, - Render, SharedString, Styled, Window, div, linear_color_stop, linear_gradient, + Render, Rgba, SharedString, Styled, Window, div, linear_color_stop, linear_gradient, prelude::FluentBuilder, px, }; use gpui_component::{ ActiveTheme, StyledExt, - chart::{AreaChart, BarChart, CandlestickChart, LineChart, PieChart}, + chart::{AreaChart, BarChart, CandlestickChart, LineChart, PieChart, SankeyChart}, dock::PanelControl, h_flex, - plot::shape::BarAlignment, + plot::shape::{BarAlignment, SankeyAlign, SankeyLink}, separator::Separator, v_flex, }; @@ -48,11 +48,49 @@ pub struct StockPrice { pub close: f64, } +/// TSLA income statement data, values and colors as strings like the real API. +#[derive(Clone, Deserialize)] +struct TslaStatementNode { + key: SharedString, + name: SharedString, + value: SharedString, + color: SharedString, +} + +#[derive(Clone, Deserialize)] +struct TslaStatementLink { + source: SharedString, + target: SharedString, + value: SharedString, +} + +#[derive(Clone, Deserialize)] +struct TslaStatement { + period: SharedString, + nodes: Vec, + links: Vec, +} + +#[derive(Clone, Deserialize)] +struct TslaIncomeStatement { + list: Vec, +} + +#[derive(Clone)] +pub struct TslaNode { + pub name: SharedString, + /// The real dollar value, for the label; the layout gets sqrt-compressed + /// link values to keep small flows readable. + pub value: f64, + pub color: Hsla, +} + pub struct ChartStory { focus_handle: FocusHandle, daily_devices: Vec, monthly_devices: Vec, stock_prices: Vec, + tsla_statements: Vec<(SharedString, Vec, Vec)>, } impl ChartStory { @@ -69,11 +107,52 @@ impl ChartStory { "../../fixtures/stock-prices.json" )) .unwrap(); + let tsla = serde_json::from_str::(include_str!( + "../../fixtures/tsla-income-statement.json" + )) + .unwrap(); + let tsla_statements = tsla + .list + .iter() + .map(|statement| { + // Map the fixture's string keys to node indices for `SankeyLink`. + let node_indexes: std::collections::HashMap = statement + .nodes + .iter() + .enumerate() + .map(|(index, node)| (node.key.clone(), index)) + .collect(); + let nodes = statement + .nodes + .iter() + .map(|node| TslaNode { + name: node.name.clone(), + value: node.value.parse().unwrap_or(0.), + color: Rgba::try_from(node.color.as_ref()) + .map(Into::into) + .unwrap_or(gpui::black()), + }) + .collect(); + let links = statement + .links + .iter() + .map(|link| { + SankeyLink::new( + node_indexes[&link.source], + node_indexes[&link.target], + link.value.parse().unwrap_or(0.), + ) + }) + .collect(); + (statement.period.clone(), nodes, links) + }) + .collect(); Self { daily_devices, monthly_devices, stock_prices, + tsla_statements, focus_handle: cx.focus_handle(), } } @@ -107,12 +186,12 @@ impl Focusable for ChartStory { } } -fn chart_container( +fn chart_container( title: &str, - chart: impl IntoElement, + chart: C, center: bool, cx: &mut Context, -) -> impl IntoElement { +) -> impl IntoElement + use { v_flex() .flex_1() .h(px(400.)) @@ -628,5 +707,49 @@ impl Render for ChartStory { cx, )), ) + .child(Separator::horizontal()) + .child( + h_flex().flex_wrap().gap_4().children( + self.tsla_statements + .iter() + .enumerate() + .map(|(index, (period, nodes, links))| { + // The first chart sqrt-compresses the link values + // so the huge revenue flows don't dwarf the small + // profit flows; the second keeps them linear. + let sqrt = index == 0; + let links: Vec = if sqrt { + links + .iter() + .map(|link| { + SankeyLink::new(link.source, link.target, link.value.sqrt()) + }) + .collect() + } else { + links.clone() + }; + let title = if sqrt { + format!("Sankey Chart - TSLA {} (sqrt values)", period) + } else { + format!("Sankey Chart - TSLA {}", period) + }; + + chart_container( + &title, + SankeyChart::new(nodes.clone(), links) + .node_align(SankeyAlign::Center) + .node_padding(40.) + .node_color(|d: &TslaNode| d.color) + .node_label(|d| d.name.clone()) + .value_label(|d, _| { + format!("${:.2}B", d.value / 1_000_000_000.).into() + }), + false, + cx, + ) + }) + .collect::>(), + ), + ) } } diff --git a/crates/ui/src/chart/mod.rs b/crates/ui/src/chart/mod.rs index c53a555ff7..3e301c2e6f 100644 --- a/crates/ui/src/chart/mod.rs +++ b/crates/ui/src/chart/mod.rs @@ -3,12 +3,14 @@ mod bar_chart; mod candlestick_chart; mod line_chart; mod pie_chart; +mod sankey_chart; pub use area_chart::AreaChart; pub use bar_chart::BarChart; pub use candlestick_chart::CandlestickChart; pub use line_chart::LineChart; pub use pie_chart::PieChart; +pub use sankey_chart::SankeyChart; use gpui::{Hsla, SharedString, TextAlign}; diff --git a/crates/ui/src/chart/sankey_chart.rs b/crates/ui/src/chart/sankey_chart.rs new file mode 100644 index 0000000000..03b42e5715 --- /dev/null +++ b/crates/ui/src/chart/sankey_chart.rs @@ -0,0 +1,410 @@ +use std::rc::Rc; + +use gpui::{ + App, Bounds, Corners, ElementId, Hsla, Pixels, SharedString, TextAlign, Window, fill, + linear_color_stop, linear_gradient, point, px, +}; +use gpui_component_macros::IntoPlot; + +use crate::{ + ActiveTheme, + plot::{ + Plot, + label::{PlotLabel, TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width}, + origin_point, + scale::ScaleOrdinal, + shape::{Sankey, SankeyAlign, SankeyLink, sankey_link_path}, + }, +}; + +const DEFAULT_NODE_WIDTH: f32 = 10.; +const DEFAULT_NODE_PADDING: f32 = 16.; +const DEFAULT_LINK_OPACITY: f32 = 0.3; +const DEFAULT_MIN_LINK_WIDTH: f32 = 1.; +const DEFAULT_LABEL_GAP: f32 = 6.; +/// Cap the label margins so very long labels never collapse the flow area. +const MAX_LABEL_MARGIN_RATIO: f32 = 0.6; + +/// A Sankey diagram, layout modeled after [d3-sankey](https://github.com/d3/d3-sankey). +/// +/// Links reference nodes by their index in the node list; map string ids to +/// indices before constructing. +#[derive(IntoPlot)] +pub struct SankeyChart { + id: Option, + nodes: Vec, + links: Vec, + node_width: f32, + node_padding: f32, + align: SankeyAlign, + iterations: usize, + node_corner_radius: Option, + node_color: Option Hsla>>, + node_label: Option SharedString>>, + value_label: Option SharedString>>, + link_opacity: f32, + min_link_width: f32, + label_gap: f32, +} + +impl SankeyChart { + pub fn new(nodes: I, links: L) -> Self + where + I: IntoIterator, + L: IntoIterator, + { + Self { + id: None, + nodes: nodes.into_iter().collect(), + links: links.into_iter().collect(), + node_width: DEFAULT_NODE_WIDTH, + node_padding: DEFAULT_NODE_PADDING, + align: SankeyAlign::default(), + iterations: 6, + node_corner_radius: None, + node_color: None, + node_label: None, + value_label: None, + link_opacity: DEFAULT_LINK_OPACITY, + min_link_width: DEFAULT_MIN_LINK_WIDTH, + label_gap: DEFAULT_LABEL_GAP, + } + } + + /// Set a stable element id (reserved for future tooltip support). + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Set the node rectangle width. Defaults to 10. + pub fn node_width(mut self, node_width: f32) -> Self { + self.node_width = node_width; + self + } + + /// Set the vertical gap between nodes in a column. Defaults to 16. + pub fn node_padding(mut self, node_padding: f32) -> Self { + self.node_padding = node_padding; + self + } + + /// Set the node alignment. Defaults to [`SankeyAlign::Justify`]. + pub fn node_align(mut self, align: SankeyAlign) -> Self { + self.align = align; + self + } + + /// Set the number of relaxation passes. Defaults to 6. + pub fn iterations(mut self, iterations: usize) -> Self { + self.iterations = iterations; + self + } + + /// Set the corner radius of the node rectangles. Defaults to 0. + pub fn node_corner_radius(mut self, radius: impl Into) -> Self { + self.node_corner_radius = Some(radius.into()); + self + } + + /// Set the color of each node. + /// + /// Defaults to cycling the theme chart palette by node index. + pub fn node_color(mut self, color: impl Fn(&T) -> H + 'static) -> Self + where + H: Into + 'static, + { + self.node_color = Some(Rc::new(move |t| color(t).into())); + self + } + + /// Set the name label of each node, drawn in muted foreground. + pub fn node_label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self { + self.node_label = Some(Rc::new(label)); + self + } + + /// Set the value label of each node, drawn above the name label. + /// + /// The closure receives the datum and the node's computed throughput + /// (max of incoming and outgoing flow). + pub fn value_label(mut self, label: impl Fn(&T, f64) -> SharedString + 'static) -> Self { + self.value_label = Some(Rc::new(label)); + self + } + + /// Set the opacity of the link ribbons. Defaults to 0.3. + pub fn link_opacity(mut self, opacity: f32) -> Self { + self.link_opacity = opacity; + self + } + + /// Set the minimum ribbon thickness, so tiny flows stay visible. Defaults to 1. + pub fn min_link_width(mut self, width: f32) -> Self { + self.min_link_width = width; + self + } + + /// Set the gap between a node and its labels. Defaults to 6. + pub fn label_gap(mut self, gap: f32) -> Self { + self.label_gap = gap; + self + } + + fn sankey(&self) -> Sankey { + Sankey::new() + .node_width(self.node_width) + .node_padding(self.node_padding) + .node_align(self.align) + .iterations(self.iterations) + } +} + +impl Plot for SankeyChart { + fn id(&self) -> Option { + self.id.clone() + } + + fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { + let width = bounds.size.width.as_f32(); + let height = bounds.size.height.as_f32(); + if self.nodes.is_empty() || self.links.is_empty() || width <= 0. || height <= 0. { + return; + } + + // First pass on the unit extent: the topology (layer, value) needed + // to measure the label margins. + let Ok(graph) = self.sankey().layout(self.nodes.len(), &self.links) else { + return; + }; + let layer_count = graph.layer_count(); + + let node_labels: Vec<(Option, Option)> = graph + .nodes + .iter() + .map(|node| { + let datum = &self.nodes[node.index]; + ( + self.value_label.as_ref().map(|f| f(datum, node.value)), + self.node_label.as_ref().map(|f| f(datum)), + ) + }) + .collect(); + let has_labels = self.value_label.is_some() || self.node_label.is_some(); + + // Reserve margins so the labels beside the first/last columns and + // above the middle columns are not clipped. + let mut left = 0f32; + let mut right = 0f32; + if has_labels { + for node in &graph.nodes { + if node.layer != 0 && node.layer + 1 != layer_count { + continue; + } + let (value_text, name_text) = &node_labels[node.index]; + let mut label_width = 0f32; + if let Some(text) = value_text { + label_width = label_width.max(measure_text_width(text, px(TEXT_SIZE), window)); + } + if let Some(text) = name_text { + label_width = label_width.max(measure_text_width(text, px(TEXT_SIZE), window)); + } + if node.layer == 0 { + left = left.max(label_width + self.label_gap); + } else { + right = right.max(label_width + self.label_gap); + } + } + + let max_margin = width * MAX_LABEL_MARGIN_RATIO; + if left + right > max_margin { + let k = max_margin / (left + right); + left *= k; + right *= k; + } + } + let top = if has_labels { + 2. * TEXT_HEIGHT + TEXT_GAP + } else { + 0. + }; + let bottom = if has_labels { TEXT_GAP } else { 0. }; + + // Second pass on the final extent. + let Ok(graph) = self + .sankey() + .extent( + left, + top, + (width - right).max(left + 1.), + (height - bottom).max(top + 1.), + ) + .layout(self.nodes.len(), &self.links) + else { + return; + }; + + let palette = ScaleOrdinal::new( + (0..self.nodes.len()).collect(), + vec![ + cx.theme().chart_1, + cx.theme().chart_2, + cx.theme().chart_3, + cx.theme().chart_4, + cx.theme().chart_5, + ], + ); + let colors: Vec = self + .nodes + .iter() + .enumerate() + .map(|(index, datum)| match &self.node_color { + Some(color) => color(datum), + None => palette.map(&index).unwrap_or_else(|| cx.theme().chart_1), + }) + .collect(); + + // Links first, under the nodes. + for link in &graph.links { + if link.value <= 0. { + continue; + } + let source = &graph.nodes[link.source]; + let target = &graph.nodes[link.target]; + let Some(path) = + sankey_link_path(source, target, link, self.min_link_width, bounds.origin) + else { + continue; + }; + window.paint_path( + path, + linear_gradient( + 90., + linear_color_stop(colors[link.source].opacity(self.link_opacity), 0.), + linear_color_stop(colors[link.target].opacity(self.link_opacity), 1.), + ), + ); + } + + for node in &graph.nodes { + let corner_radius = self.node_corner_radius.unwrap_or_default(); + let node_bounds = Bounds::from_corners( + origin_point(px(node.x0), px(node.y0), bounds.origin), + // Keep tiny nodes visible with a minimum 1px height. + origin_point(px(node.x1), px(node.y1.max(node.y0 + 1.)), bounds.origin), + ); + window.paint_quad( + fill(node_bounds, colors[node.index]).corner_radii(Corners::all(corner_radius)), + ); + } + + let mut texts = Vec::new(); + for node in &graph.nodes { + let (value_text, name_text) = &node_labels[node.index]; + if value_text.is_none() && name_text.is_none() { + continue; + } + + let is_first = node.layer == 0; + let is_last = node.layer + 1 == layer_count; + let (x, align) = if is_first { + (node.x0 - self.label_gap, TextAlign::Right) + } else if is_last { + (node.x1 + self.label_gap, TextAlign::Left) + } else { + ((node.x0 + node.x1) / 2., TextAlign::Center) + }; + + let two_lines = value_text.is_some() && name_text.is_some(); + let (value_y, name_y) = if is_first || is_last { + // Vertically centered beside the node. + let cy = (node.y0 + node.y1) / 2.; + if two_lines { + (cy - TEXT_HEIGHT, cy) + } else { + (cy - TEXT_SIZE / 2., cy - TEXT_SIZE / 2.) + } + } else if two_lines { + // Above the node. + ( + node.y0 - 2. * TEXT_HEIGHT - TEXT_GAP, + node.y0 - TEXT_HEIGHT - TEXT_GAP, + ) + } else { + let y = node.y0 - TEXT_HEIGHT - TEXT_GAP; + (y, y) + }; + + if let Some(text) = value_text { + texts.push( + Text::new( + text.clone(), + point(px(x), px(value_y)), + cx.theme().foreground, + ) + .align(align), + ); + } + if let Some(text) = name_text { + texts.push( + Text::new( + text.clone(), + point(px(x), px(name_y)), + cx.theme().muted_foreground, + ) + .align(align), + ); + } + } + PlotLabel::new(texts).paint(&bounds, window, cx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sankey_chart_builder() { + let chart = SankeyChart::new(vec!["a", "b"], vec![SankeyLink::new(0, 1, 5.)]); + assert_eq!(chart.nodes.len(), 2); + assert_eq!(chart.links.len(), 1); + assert_eq!(chart.node_width, DEFAULT_NODE_WIDTH); + assert_eq!(chart.node_padding, DEFAULT_NODE_PADDING); + assert_eq!(chart.align, SankeyAlign::Justify); + assert_eq!(chart.iterations, 6); + assert_eq!(chart.node_corner_radius, None); + assert_eq!(chart.link_opacity, DEFAULT_LINK_OPACITY); + assert_eq!(chart.min_link_width, DEFAULT_MIN_LINK_WIDTH); + assert_eq!(chart.label_gap, DEFAULT_LABEL_GAP); + assert!(chart.node_color.is_none()); + assert!(chart.node_label.is_none()); + assert!(chart.value_label.is_none()); + + let chart = chart + .id("sankey") + .node_width(8.) + .node_padding(20.) + .node_align(SankeyAlign::Left) + .iterations(10) + .node_corner_radius(px(2.)) + .node_color(|_| gpui::red()) + .node_label(|d| SharedString::from(d.to_string())) + .value_label(|_, value| SharedString::from(format!("{}", value))) + .link_opacity(0.5) + .min_link_width(2.) + .label_gap(10.); + assert!(chart.id.is_some()); + assert_eq!(chart.node_width, 8.); + assert_eq!(chart.node_padding, 20.); + assert_eq!(chart.align, SankeyAlign::Left); + assert_eq!(chart.iterations, 10); + assert_eq!(chart.node_corner_radius, Some(px(2.))); + assert_eq!(chart.link_opacity, 0.5); + assert_eq!(chart.min_link_width, 2.); + assert_eq!(chart.label_gap, 10.); + assert!(chart.node_color.is_some()); + assert!(chart.node_label.is_some()); + assert!(chart.value_label.is_some()); + } +} diff --git a/crates/ui/src/plot/shape.rs b/crates/ui/src/plot/shape.rs index 73de38ce15..b45f78f389 100644 --- a/crates/ui/src/plot/shape.rs +++ b/crates/ui/src/plot/shape.rs @@ -3,6 +3,7 @@ mod area; mod bar; mod line; mod pie; +mod sankey; mod stack; pub use arc::{Arc, ArcData}; @@ -10,4 +11,8 @@ pub use area::Area; pub use bar::{Bar, BarAlignment}; pub use line::Line; pub use pie::Pie; +pub use sankey::{ + Sankey, SankeyAlign, SankeyError, SankeyGraph, SankeyLink, SankeyLinkLayout, SankeyNodeLayout, + sankey_link_path, +}; pub use stack::{Stack, StackPoint, StackSeries}; diff --git a/crates/ui/src/plot/shape/sankey.rs b/crates/ui/src/plot/shape/sankey.rs new file mode 100644 index 0000000000..7d9e44a441 --- /dev/null +++ b/crates/ui/src/plot/shape/sankey.rs @@ -0,0 +1,892 @@ +// @reference: https://github.com/d3/d3-sankey + +use gpui::{Path, PathBuilder, Pixels, Point, px}; + +use crate::plot::origin_point; + +/// Horizontal alignment of nodes across layers. +/// +/// Mirrors d3-sankey's `sankeyLeft` / `sankeyRight` / `sankeyCenter` / +/// `sankeyJustify`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SankeyAlign { + Left, + Right, + Center, + #[default] + Justify, +} + +/// An input link of a Sankey diagram. +/// +/// `source` and `target` are indices into the node list (d3-sankey's default +/// `nodeId`), `value` is the flow amount. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SankeyLink { + pub source: usize, + pub target: usize, + pub value: f64, +} + +impl SankeyLink { + pub fn new(source: usize, target: usize, value: f64) -> Self { + Self { + source, + target, + value, + } + } +} + +/// A node with computed layout (d3-sankey's computed node fields). +#[derive(Clone, Debug, Default)] +pub struct SankeyNodeLayout { + pub index: usize, + /// The node's throughput: max(sum of incoming values, sum of outgoing values). + pub value: f64, + /// Topological distance from any source node (longest path). + pub depth: usize, + /// Topological distance to any sink node (longest path). + pub height: usize, + /// Horizontal column index after alignment. + pub layer: usize, + pub x0: f32, + pub x1: f32, + pub y0: f32, + pub y1: f32, + /// Indices into [`SankeyGraph::links`] of the outgoing links. + pub source_links: Vec, + /// Indices into [`SankeyGraph::links`] of the incoming links. + pub target_links: Vec, +} + +/// A link with computed layout. +/// +/// Like d3-sankey, `y0` and `y1` are the vertical centers of the ribbon at +/// the source and target end; the ribbon spans ±`width / 2` around them. +#[derive(Clone, Debug)] +pub struct SankeyLinkLayout { + pub index: usize, + pub source: usize, + pub target: usize, + pub value: f64, + pub y0: f32, + pub y1: f32, + pub width: f32, +} + +/// The computed Sankey layout. +#[derive(Clone, Debug, Default)] +pub struct SankeyGraph { + pub nodes: Vec, + pub links: Vec, +} + +impl SankeyGraph { + /// Number of layers (max layer + 1), 0 for an empty graph. + pub fn layer_count(&self) -> usize { + self.nodes + .iter() + .map(|node| node.layer + 1) + .max() + .unwrap_or(0) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SankeyError { + /// A link references a node index out of range. + MissingNode(usize), + /// The graph contains a circular link. + CircularLink, +} + +impl std::fmt::Display for SankeyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingNode(index) => write!(f, "missing node: {}", index), + Self::CircularLink => write!(f, "circular link"), + } + } +} + +impl std::error::Error for SankeyError {} + +/// The Sankey layout generator. +pub struct Sankey { + node_width: f32, + node_padding: f32, + align: SankeyAlign, + iterations: usize, + x0: f32, + y0: f32, + x1: f32, + y1: f32, +} + +impl Default for Sankey { + fn default() -> Self { + Self { + node_width: 24., + node_padding: 8., + align: SankeyAlign::default(), + iterations: 6, + x0: 0., + y0: 0., + x1: 1., + y1: 1., + } + } +} + +impl Sankey { + pub fn new() -> Self { + Self::default() + } + + /// Set the node rectangle width. Defaults to 24. + pub fn node_width(mut self, node_width: f32) -> Self { + self.node_width = node_width; + self + } + + /// Set the vertical gap between nodes in a column. Defaults to 8. + pub fn node_padding(mut self, node_padding: f32) -> Self { + self.node_padding = node_padding; + self + } + + /// Set the node alignment. Defaults to [`SankeyAlign::Justify`]. + pub fn node_align(mut self, align: SankeyAlign) -> Self { + self.align = align; + self + } + + /// Set the number of relaxation passes. Defaults to 6. + pub fn iterations(mut self, iterations: usize) -> Self { + self.iterations = iterations; + self + } + + /// Set the layout bounds as `[[x0, y0], [x1, y1]]`. Defaults to `[[0, 0], [1, 1]]`. + pub fn extent(mut self, x0: f32, y0: f32, x1: f32, y1: f32) -> Self { + self.x0 = x0; + self.y0 = y0; + self.x1 = x1; + self.y1 = y1; + self + } + + /// Equivalent to `extent(0., 0., width, height)`. + pub fn size(self, width: f32, height: f32) -> Self { + self.extent(0., 0., width, height) + } + + /// Compute the layout. + /// + /// `node_count` is the number of nodes; links reference nodes by index + /// (d3-sankey's default `nodeId`). Returns an error if a link references + /// a node out of range or the graph contains a cycle. + pub fn layout( + &self, + node_count: usize, + links: &[SankeyLink], + ) -> Result { + for link in links { + if link.source >= node_count { + return Err(SankeyError::MissingNode(link.source)); + } + if link.target >= node_count { + return Err(SankeyError::MissingNode(link.target)); + } + } + + let mut graph = SankeyGraph { + nodes: (0..node_count) + .map(|index| SankeyNodeLayout { + index, + ..Default::default() + }) + .collect(), + links: links + .iter() + .enumerate() + .map(|(index, link)| SankeyLinkLayout { + index, + source: link.source, + target: link.target, + value: link.value, + y0: 0., + y1: 0., + width: 0., + }) + .collect(), + }; + if node_count == 0 { + return Ok(graph); + } + + compute_node_links(&mut graph); + compute_node_values(&mut graph); + compute_node_depths(&mut graph)?; + compute_node_heights(&mut graph)?; + let mut columns = self.compute_node_layers(&mut graph); + self.compute_node_breadths(&mut graph, &mut columns); + compute_link_breadths(&mut graph); + + Ok(graph) + } + + fn align_layer(&self, graph: &SankeyGraph, index: usize, n: usize) -> usize { + let node = &graph.nodes[index]; + match self.align { + SankeyAlign::Left => node.depth, + SankeyAlign::Right => n - 1 - node.height, + SankeyAlign::Justify => { + if node.source_links.is_empty() { + n - 1 + } else { + node.depth + } + } + SankeyAlign::Center => { + if !node.target_links.is_empty() { + node.depth + } else if !node.source_links.is_empty() { + node.source_links + .iter() + .map(|&link| graph.nodes[graph.links[link].target].depth) + .min() + .unwrap_or(1) + .saturating_sub(1) + } else { + 0 + } + } + } + } + + fn compute_node_layers(&self, graph: &mut SankeyGraph) -> Vec> { + let n = graph + .nodes + .iter() + .map(|node| node.depth + 1) + .max() + .unwrap_or(0); + let kx = if n > 1 { + (self.x1 - self.x0 - self.node_width) / (n - 1) as f32 + } else { + 0. + }; + + let layers: Vec = (0..graph.nodes.len()) + .map(|index| self.align_layer(graph, index, n).min(n - 1)) + .collect(); + + let mut columns = vec![Vec::new(); n]; + for (index, layer) in layers.into_iter().enumerate() { + let node = &mut graph.nodes[index]; + node.layer = layer; + node.x0 = self.x0 + layer as f32 * kx; + node.x1 = node.x0 + self.node_width; + columns[layer].push(index); + } + + columns + } + + fn compute_node_breadths(&self, graph: &mut SankeyGraph, columns: &mut [Vec]) { + let max_column_len = columns.iter().map(|column| column.len()).max().unwrap_or(0); + let py = if max_column_len > 1 { + self.node_padding + .min((self.y1 - self.y0) / (max_column_len - 1) as f32) + } else { + self.node_padding + }; + + self.initialize_node_breadths(graph, columns, py); + + for i in 0..self.iterations { + let alpha = 0.99_f32.powi(i as i32); + let beta = (1. - alpha).max((i + 1) as f32 / self.iterations as f32); + self.relax_right_to_left(graph, columns, alpha, beta, py); + self.relax_left_to_right(graph, columns, alpha, beta, py); + } + } + + fn initialize_node_breadths(&self, graph: &mut SankeyGraph, columns: &[Vec], py: f32) { + // Scale factor between flow value and pixels. d3 lets an over-crowded + // column produce a negative ky; clamp to zero so heights never invert. + let mut ky = f32::INFINITY; + for column in columns { + let value_sum: f64 = column.iter().map(|&index| graph.nodes[index].value).sum(); + if value_sum > 0. { + let k = (self.y1 - self.y0 - (column.len() - 1) as f32 * py) / value_sum as f32; + ky = ky.min(k); + } + } + if !ky.is_finite() { + ky = 0.; + } + ky = ky.max(0.); + + for column in columns { + let mut y = self.y0; + for &index in column { + let node_height = graph.nodes[index].value as f32 * ky; + let node = &mut graph.nodes[index]; + node.y0 = y; + node.y1 = y + node_height; + y = node.y1 + py; + } + + // Distribute the leftover vertical space evenly (d3 keeps this + // signed: an over-crowded column shifts nodes back up). + let leftover = (self.y1 - y + py) / (column.len() + 1) as f32; + for (i, &index) in column.iter().enumerate() { + let node = &mut graph.nodes[index]; + let dy = leftover * (i + 1) as f32; + node.y0 += dy; + node.y1 += dy; + } + } + + for link in &mut graph.links { + link.width = link.value as f32 * ky; + } + + for column in columns { + for &index in column { + sort_source_links(graph, index); + sort_target_links(graph, index); + } + } + } + + /// Reposition each node based on its incoming links. + fn relax_left_to_right( + &self, + graph: &mut SankeyGraph, + columns: &mut [Vec], + alpha: f32, + beta: f32, + py: f32, + ) { + for i in 1..columns.len() { + for &target in &columns[i] { + let mut y = 0.; + let mut w = 0.; + for &link_index in &graph.nodes[target].target_links { + let link = &graph.links[link_index]; + let v = link.value as f32 + * (graph.nodes[target].layer as f32 + - graph.nodes[link.source].layer as f32); + y += target_top(graph, link.source, target, py) * v; + w += v; + } + if w <= 0. { + continue; + } + let dy = (y / w - graph.nodes[target].y0) * alpha; + graph.nodes[target].y0 += dy; + graph.nodes[target].y1 += dy; + reorder_node_links(graph, target); + } + sort_column(graph, &mut columns[i]); + self.resolve_collisions(graph, &columns[i], beta, py); + } + } + + /// Reposition each node based on its outgoing links. + fn relax_right_to_left( + &self, + graph: &mut SankeyGraph, + columns: &mut [Vec], + alpha: f32, + beta: f32, + py: f32, + ) { + for i in (0..columns.len().saturating_sub(1)).rev() { + for &source in &columns[i] { + let mut y = 0.; + let mut w = 0.; + for &link_index in &graph.nodes[source].source_links { + let link = &graph.links[link_index]; + let v = link.value as f32 + * (graph.nodes[link.target].layer as f32 + - graph.nodes[source].layer as f32); + y += source_top(graph, source, link.target, py) * v; + w += v; + } + if w <= 0. { + continue; + } + let dy = (y / w - graph.nodes[source].y0) * alpha; + graph.nodes[source].y0 += dy; + graph.nodes[source].y1 += dy; + reorder_node_links(graph, source); + } + sort_column(graph, &mut columns[i]); + self.resolve_collisions(graph, &columns[i], beta, py); + } + } + + /// d3's middle-out collision resolution: push nodes away from the middle + /// node, then clamp the column against the extent edges. + fn resolve_collisions(&self, graph: &mut SankeyGraph, column: &[usize], beta: f32, py: f32) { + if column.is_empty() { + return; + } + + let i = column.len() >> 1; + let subject_y0 = graph.nodes[column[i]].y0; + let subject_y1 = graph.nodes[column[i]].y1; + push_up(graph, &column[..i], subject_y0 - py, beta, py); + push_down(graph, &column[i + 1..], subject_y1 + py, beta, py); + push_up(graph, column, self.y1, beta, py); + push_down(graph, column, self.y0, beta, py); + } +} + +fn compute_node_links(graph: &mut SankeyGraph) { + for index in 0..graph.links.len() { + let (source, target) = (graph.links[index].source, graph.links[index].target); + graph.nodes[source].source_links.push(index); + graph.nodes[target].target_links.push(index); + } +} + +fn compute_node_values(graph: &mut SankeyGraph) { + for index in 0..graph.nodes.len() { + let outgoing: f64 = graph.nodes[index] + .source_links + .iter() + .map(|&link| graph.links[link].value) + .sum(); + let incoming: f64 = graph.nodes[index] + .target_links + .iter() + .map(|&link| graph.links[link].value) + .sum(); + graph.nodes[index].value = outgoing.max(incoming); + } +} + +/// Assign depths by BFS waves; later waves overwrite, yielding the +/// longest-path depth. More waves than nodes means a cycle. +fn compute_node_depths(graph: &mut SankeyGraph) -> Result<(), SankeyError> { + let n = graph.nodes.len(); + let mut current: Vec = (0..n).collect(); + let mut x = 0; + while !current.is_empty() { + let mut next = vec![false; n]; + for &index in ¤t { + graph.nodes[index].depth = x; + for &link in &graph.nodes[index].source_links { + next[graph.links[link].target] = true; + } + } + x += 1; + if x > n { + return Err(SankeyError::CircularLink); + } + current = (0..n).filter(|&index| next[index]).collect(); + } + Ok(()) +} + +fn compute_node_heights(graph: &mut SankeyGraph) -> Result<(), SankeyError> { + let n = graph.nodes.len(); + let mut current: Vec = (0..n).collect(); + let mut x = 0; + while !current.is_empty() { + let mut next = vec![false; n]; + for &index in ¤t { + graph.nodes[index].height = x; + for &link in &graph.nodes[index].target_links { + next[graph.links[link].source] = true; + } + } + x += 1; + if x > n { + return Err(SankeyError::CircularLink); + } + current = (0..n).filter(|&index| next[index]).collect(); + } + Ok(()) +} + +/// Sort a node's outgoing links by the target node's `y0` (then link index). +fn sort_source_links(graph: &mut SankeyGraph, index: usize) { + let mut links = std::mem::take(&mut graph.nodes[index].source_links); + links.sort_by(|&a, &b| { + let ya = graph.nodes[graph.links[a].target].y0; + let yb = graph.nodes[graph.links[b].target].y0; + ya.partial_cmp(&yb) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.cmp(&b)) + }); + graph.nodes[index].source_links = links; +} + +/// Sort a node's incoming links by the source node's `y0` (then link index). +fn sort_target_links(graph: &mut SankeyGraph, index: usize) { + let mut links = std::mem::take(&mut graph.nodes[index].target_links); + links.sort_by(|&a, &b| { + let ya = graph.nodes[graph.links[a].source].y0; + let yb = graph.nodes[graph.links[b].source].y0; + ya.partial_cmp(&yb) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.cmp(&b)) + }); + graph.nodes[index].target_links = links; +} + +/// After a node moved, re-sort the link lists of its neighbors on the +/// opposite ends (d3's reorderNodeLinks). +fn reorder_node_links(graph: &mut SankeyGraph, index: usize) { + for link in graph.nodes[index].target_links.clone() { + let source = graph.links[link].source; + sort_source_links(graph, source); + } + for link in graph.nodes[index].source_links.clone() { + let target = graph.links[link].target; + sort_target_links(graph, target); + } +} + +fn sort_column(graph: &SankeyGraph, column: &mut [usize]) { + column.sort_by(|&a, &b| { + graph.nodes[a] + .y0 + .partial_cmp(&graph.nodes[b].y0) + .unwrap_or(std::cmp::Ordering::Equal) + }); +} + +/// Push overlapping nodes down (d3's resolveCollisionsTopToBottom). +fn push_down(graph: &mut SankeyGraph, column: &[usize], mut y: f32, alpha: f32, py: f32) { + for &index in column { + let node = &mut graph.nodes[index]; + let dy = (y - node.y0) * alpha; + if dy > 1e-6 { + node.y0 += dy; + node.y1 += dy; + } + y = node.y1 + py; + } +} + +/// Push overlapping nodes up (d3's resolveCollisionsBottomToTop). +fn push_up(graph: &mut SankeyGraph, column: &[usize], mut y: f32, alpha: f32, py: f32) { + for &index in column.iter().rev() { + let node = &mut graph.nodes[index]; + let dy = (node.y1 - y) * alpha; + if dy > 1e-6 { + node.y0 -= dy; + node.y1 -= dy; + } + y = node.y0 - py; + } +} + +/// The ideal `y0` for `target` so that its ribbon from `source` lines up +/// with the slot the ribbon occupies in the source's outgoing stack +/// (d3's targetTop). +fn target_top(graph: &SankeyGraph, source: usize, target: usize, py: f32) -> f32 { + let source_node = &graph.nodes[source]; + let mut y = source_node.y0 - source_node.source_links.len().saturating_sub(1) as f32 * py / 2.; + for &link_index in &source_node.source_links { + let link = &graph.links[link_index]; + if link.target == target { + break; + } + y += link.width + py; + } + for &link_index in &graph.nodes[target].target_links { + let link = &graph.links[link_index]; + if link.source == source { + break; + } + y -= link.width; + } + y +} + +/// The ideal `y0` for `source` so that its ribbon to `target` lines up with +/// the slot the ribbon occupies in the target's incoming stack +/// (d3's sourceTop). +fn source_top(graph: &SankeyGraph, source: usize, target: usize, py: f32) -> f32 { + let target_node = &graph.nodes[target]; + let mut y = target_node.y0 - target_node.target_links.len().saturating_sub(1) as f32 * py / 2.; + for &link_index in &target_node.target_links { + let link = &graph.links[link_index]; + if link.source == source { + break; + } + y += link.width + py; + } + for &link_index in &graph.nodes[source].source_links { + let link = &graph.links[link_index]; + if link.target == target { + break; + } + y -= link.width; + } + y +} + +/// Assign each link's `y0`/`y1` (ribbon centers) by stacking the sorted link +/// lists within each node. +fn compute_link_breadths(graph: &mut SankeyGraph) { + for index in 0..graph.nodes.len() { + let node_y0 = graph.nodes[index].y0; + + let mut y0 = node_y0; + for link_index in graph.nodes[index].source_links.clone() { + let link = &mut graph.links[link_index]; + link.y0 = y0 + link.width / 2.; + y0 += link.width; + } + + let mut y1 = node_y0; + for link_index in graph.nodes[index].target_links.clone() { + let link = &mut graph.links[link_index]; + link.y1 = y1 + link.width / 2.; + y1 += link.width; + } + } +} + +/// Build the filled ribbon path for a link — the equivalent of d3-sankey's +/// `sankeyLinkHorizontal()`: a horizontal cubic bezier with control points at +/// the horizontal midpoint, thickened to the link width (clamped to +/// `min_width` so tiny flows stay visible). +pub fn sankey_link_path( + source: &SankeyNodeLayout, + target: &SankeyNodeLayout, + link: &SankeyLinkLayout, + min_width: f32, + origin: Point, +) -> Option> { + let half = link.width.max(min_width) / 2.; + let sx = source.x1; + let tx = target.x0; + let mx = (sx + tx) / 2.; + + let mut builder = PathBuilder::fill(); + builder.move_to(origin_point(px(sx), px(link.y0 - half), origin)); + builder.cubic_bezier_to( + origin_point(px(tx), px(link.y1 - half), origin), + origin_point(px(mx), px(link.y0 - half), origin), + origin_point(px(mx), px(link.y1 - half), origin), + ); + builder.line_to(origin_point(px(tx), px(link.y1 + half), origin)); + builder.cubic_bezier_to( + origin_point(px(sx), px(link.y0 + half), origin), + origin_point(px(mx), px(link.y1 + half), origin), + origin_point(px(mx), px(link.y0 + half), origin), + ); + builder.close(); + builder.build().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const EPSILON: f32 = 1e-3; + + fn links(links: &[(usize, usize, f64)]) -> Vec { + links + .iter() + .map(|&(source, target, value)| SankeyLink::new(source, target, value)) + .collect() + } + + #[test] + fn test_sankey_builder() { + let sankey = Sankey::new(); + assert_eq!(sankey.node_width, 24.); + assert_eq!(sankey.node_padding, 8.); + assert_eq!(sankey.align, SankeyAlign::Justify); + assert_eq!(sankey.iterations, 6); + assert_eq!( + (sankey.x0, sankey.y0, sankey.x1, sankey.y1), + (0., 0., 1., 1.) + ); + + let sankey = Sankey::new() + .node_width(12.) + .node_padding(10.) + .node_align(SankeyAlign::Left) + .iterations(10) + .size(400., 300.); + assert_eq!(sankey.node_width, 12.); + assert_eq!(sankey.node_padding, 10.); + assert_eq!(sankey.align, SankeyAlign::Left); + assert_eq!(sankey.iterations, 10); + assert_eq!( + (sankey.x0, sankey.y0, sankey.x1, sankey.y1), + (0., 0., 400., 300.) + ); + + let sankey = Sankey::new().extent(10., 20., 30., 40.); + assert_eq!( + (sankey.x0, sankey.y0, sankey.x1, sankey.y1), + (10., 20., 30., 40.) + ); + } + + #[test] + fn test_sankey_layout_chain() { + // A -> B -> C + let graph = Sankey::new() + .node_width(10.) + .size(100., 100.) + .layout(3, &links(&[(0, 1, 5.), (1, 2, 5.)])) + .unwrap(); + + let depths: Vec = graph.nodes.iter().map(|n| n.depth).collect(); + let heights: Vec = graph.nodes.iter().map(|n| n.height).collect(); + let layers: Vec = graph.nodes.iter().map(|n| n.layer).collect(); + assert_eq!(depths, vec![0, 1, 2]); + assert_eq!(heights, vec![2, 1, 0]); + assert_eq!(layers, vec![0, 1, 2]); + assert_eq!(graph.layer_count(), 3); + + assert_eq!(graph.nodes[0].x0, 0.); + assert_eq!(graph.nodes[1].x0, 45.); + assert_eq!(graph.nodes[2].x0, 90.); + for node in &graph.nodes { + assert_eq!(node.x1 - node.x0, 10.); + assert_eq!(node.value, 5.); + // Every node carries the full flow, so all span the full height. + assert!((node.y1 - node.y0 - 100.).abs() < EPSILON); + } + for link in &graph.links { + assert!((link.width - 100.).abs() < EPSILON); + } + } + + #[test] + fn test_sankey_alignment() { + // A -> B -> C, plus a short branch A -> D. + let links = links(&[(0, 1, 1.), (1, 2, 1.), (0, 3, 1.)]); + let layers = |align: SankeyAlign| -> Vec { + Sankey::new() + .node_align(align) + .size(100., 100.) + .layout(4, &links) + .unwrap() + .nodes + .iter() + .map(|n| n.layer) + .collect() + }; + + assert_eq!(layers(SankeyAlign::Left), vec![0, 1, 2, 1]); + assert_eq!(layers(SankeyAlign::Right), vec![0, 1, 2, 2]); + assert_eq!(layers(SankeyAlign::Justify), vec![0, 1, 2, 2]); + assert_eq!(layers(SankeyAlign::Center), vec![0, 1, 2, 1]); + } + + #[test] + fn test_sankey_link_offsets() { + // One source fanning out into two targets. + let graph = Sankey::new() + .node_width(10.) + .size(100., 100.) + .layout(3, &links(&[(0, 1, 30.), (0, 2, 10.)])) + .unwrap(); + + let source = &graph.nodes[0]; + let source_height = source.y1 - source.y0; + let total_width: f32 = graph.links.iter().map(|l| l.width).sum(); + assert!((total_width - source_height).abs() < EPSILON); + + // Widths are proportional to values. + assert!((graph.links[0].width / graph.links[1].width - 3.).abs() < EPSILON); + + // Outgoing ribbons stack contiguously within the source node. + let (first, second) = if graph.links[0].y0 < graph.links[1].y0 { + (&graph.links[0], &graph.links[1]) + } else { + (&graph.links[1], &graph.links[0]) + }; + assert!((first.y0 - first.width / 2. - source.y0).abs() < EPSILON); + assert!((first.y0 + first.width / 2. - (second.y0 - second.width / 2.)).abs() < EPSILON); + + // Each target has a single incoming ribbon filling its full height. + for link in &graph.links { + let target = &graph.nodes[link.target]; + assert!((link.y1 - link.width / 2. - target.y0).abs() < EPSILON); + assert!((link.y1 + link.width / 2. - target.y1).abs() < EPSILON); + } + } + + #[test] + fn test_sankey_value_conservation() { + // Incoming 10, outgoing 7: node value takes the max. + let graph = Sankey::new() + .size(100., 100.) + .layout(3, &links(&[(0, 1, 10.), (1, 2, 7.)])) + .unwrap(); + + assert_eq!(graph.nodes[1].value, 10.); + + for node in &graph.nodes { + assert!(node.y0 <= node.y1); + assert!(node.y0 >= -EPSILON); + assert!(node.y1 <= 100. + EPSILON); + } + } + + #[test] + fn test_sankey_circular_link() { + let sankey = Sankey::new().size(100., 100.); + + assert_eq!( + sankey + .layout(2, &links(&[(0, 1, 1.), (1, 0, 1.)])) + .unwrap_err(), + SankeyError::CircularLink + ); + assert_eq!( + sankey.layout(1, &links(&[(0, 0, 1.)])).unwrap_err(), + SankeyError::CircularLink + ); + assert_eq!( + sankey.layout(2, &links(&[(0, 5, 1.)])).unwrap_err(), + SankeyError::MissingNode(5) + ); + } + + #[test] + fn test_sankey_degenerate() { + // Empty graph. + let graph = Sankey::new().size(100., 100.).layout(0, &[]).unwrap(); + assert!(graph.nodes.is_empty()); + assert!(graph.links.is_empty()); + assert_eq!(graph.layer_count(), 0); + + // All-zero link values must not produce NaN coordinates. + let graph = Sankey::new() + .size(100., 100.) + .layout(2, &links(&[(0, 1, 0.)])) + .unwrap(); + for node in &graph.nodes { + assert!((node.y1 - node.y0).abs() < EPSILON); + assert!(node.x0.is_finite() && node.x1.is_finite()); + assert!(node.y0.is_finite() && node.y1.is_finite()); + } + + // Isolated nodes collapse into a single column without dividing by zero. + let graph = Sankey::new().size(100., 100.).layout(2, &[]).unwrap(); + assert_eq!(graph.layer_count(), 1); + for node in &graph.nodes { + assert_eq!(node.x0, 0.); + assert!(node.y0.is_finite() && node.y1.is_finite()); + } + } +} diff --git a/docs/docs/components/chart.md b/docs/docs/components/chart.md index 06b95bfe5e..3543ab93dc 100644 --- a/docs/docs/components/chart.md +++ b/docs/docs/components/chart.md @@ -1,16 +1,18 @@ --- title: Chart -description: Beautiful charts and graphs for data visualization including line, bar, area, pie, and candlestick charts. +description: Beautiful charts and graphs for data visualization including line, bar, area, pie, candlestick, and sankey charts. --- # Chart -A comprehensive charting library providing Line, Bar, Area, Pie, and Candlestick charts for data visualization. The charts feature smooth animations, customizable styling, tooltips, legends, and automatic theming that adapts to your application's theme. +A comprehensive charting library providing Line, Bar, Area, Pie, Candlestick, and Sankey charts for data visualization. The charts feature smooth animations, customizable styling, tooltips, legends, and automatic theming that adapts to your application's theme. ## Import ```rust -use gpui_component::chart::{LineChart, BarChart, AreaChart, PieChart, CandlestickChart}; +use gpui_component::chart::{ + LineChart, BarChart, AreaChart, PieChart, CandlestickChart, SankeyChart, +}; ``` ## Chart Types @@ -374,6 +376,83 @@ The candlestick chart automatically uses theme colors: - **Bullish** (close > open): `bullish` color (green) - **Bearish** (close < open): `bearish` color (red) +### SankeyChart + +A sankey diagram visualizes flows between nodes, ideal for financial statements, energy flows, and traffic analysis. The layout algorithm mirrors [d3-sankey](https://github.com/d3/d3-sankey). + +#### Basic Sankey Chart + +```rust +use gpui_component::plot::shape::SankeyLink; + +#[derive(Clone)] +struct FlowNode { + pub name: SharedString, +} + +let nodes = vec![ + FlowNode { name: "Revenue".into() }, + FlowNode { name: "Gross Profit".into() }, + FlowNode { name: "Cost".into() }, +]; + +// Links reference nodes by their index in `nodes`. +let links = vec![ + SankeyLink::new(0, 1, 45.0), + SankeyLink::new(0, 2, 55.0), +]; + +SankeyChart::new(nodes, links) + .node_label(|d| d.name.clone()) + .value_label(|_, value| format!("{:.1}", value).into()) +``` + +The value label is drawn above the name label. Its closure receives the node's computed throughput (the larger of incoming and outgoing flow). + +#### Node Alignment + +```rust +use gpui_component::plot::shape::SankeyAlign; + +// Justify (default): nodes without outgoing links move to the last column +SankeyChart::new(nodes, links).node_align(SankeyAlign::Justify) + +// Left: nodes stay at their topological depth +SankeyChart::new(nodes, links).node_align(SankeyAlign::Left) + +// Also available: SankeyAlign::Right, SankeyAlign::Center +``` + +#### Sankey Chart Styling + +```rust +SankeyChart::new(nodes, links) + .node_width(8.) // Node bar width (default: 10) + .node_padding(20.) // Vertical gap between nodes in a column (default: 16) + .node_corner_radius(px(2.)) // Corner radius of node bars (default: 0) + .node_color(|d| d.color) // Per-node color; defaults to the theme chart palette + .link_opacity(0.4) // Ribbon opacity (default: 0.3) + .min_link_width(2.) // Minimum ribbon thickness (default: 1) + .iterations(10) // Layout relaxation passes (default: 6) +``` + +Link ribbons are filled with a horizontal gradient from the source node color to the target node color. + +#### Compressing Large Value Ranges + +Node heights are linear in flow value, so a large value range (e.g. 200:1) leaves the small flows nearly invisible. Feed compressed values (e.g. square roots) to the layout, and show the real value in the label: + +```rust +let links = raw_links + .iter() + .map(|l| SankeyLink::new(l.source, l.target, l.value.sqrt())) + .collect::>(); + +SankeyChart::new(nodes, links) + // Show the real value from the datum, not the compressed throughput. + .value_label(|d: &FlowNode, _| format!("{:.1}", d.value).into()) +``` + ## Data Structures ### Example Data Types @@ -411,6 +490,13 @@ struct StockPrice { pub close: f64, pub volume: u64, } + +// Sankey flow: nodes are referenced by index (from gpui_component::plot::shape) +pub struct SankeyLink { + pub source: usize, + pub target: usize, + pub value: f64, +} ``` ## Chart Configuration @@ -485,6 +571,7 @@ let chart = LineChart::new(data) - [AreaChart] - [PieChart] - [CandlestickChart] +- [SankeyChart] ## Examples diff --git a/docs/zh-CN/docs/components/chart.md b/docs/zh-CN/docs/components/chart.md index 2daf124f35..864bb2e7be 100644 --- a/docs/zh-CN/docs/components/chart.md +++ b/docs/zh-CN/docs/components/chart.md @@ -1,16 +1,18 @@ --- title: Chart -description: 支持折线图、柱状图、面积图、饼图和 K 线图的数据可视化组件。 +description: 支持折线图、柱状图、面积图、饼图、K 线图和桑基图的数据可视化组件。 --- # Chart -Chart 是一组完整的数据可视化组件,提供 Line、Bar、Area、Pie 和 Candlestick 图表。它们支持动画、自定义样式、主题配色和多种展示方式,适合仪表盘、统计分析和行情场景。 +Chart 是一组完整的数据可视化组件,提供 Line、Bar、Area、Pie、Candlestick 和 Sankey 图表。它们支持动画、自定义样式、主题配色和多种展示方式,适合仪表盘、统计分析和行情场景。 ## 导入 ```rust -use gpui_component::chart::{LineChart, BarChart, AreaChart, PieChart, CandlestickChart}; +use gpui_component::chart::{ + LineChart, BarChart, AreaChart, PieChart, CandlestickChart, SankeyChart, +}; ``` ## 图表类型 @@ -354,6 +356,83 @@ CandlestickChart::new(data) 涨跌颜色会自动使用主题中的 bullish 和 bearish 配色。 +### SankeyChart + +桑基图用于展示节点之间的流量关系,适合财报资金流向、能源流动和流量分析等场景。布局算法对标 [d3-sankey](https://github.com/d3/d3-sankey)。 + +#### 基础桑基图 + +```rust +use gpui_component::plot::shape::SankeyLink; + +#[derive(Clone)] +struct FlowNode { + pub name: SharedString, +} + +let nodes = vec![ + FlowNode { name: "营业收入".into() }, + FlowNode { name: "毛利润".into() }, + FlowNode { name: "营业成本".into() }, +]; + +// 连接通过节点在 `nodes` 中的索引引用节点。 +let links = vec![ + SankeyLink::new(0, 1, 45.0), + SankeyLink::new(0, 2, 55.0), +]; + +SankeyChart::new(nodes, links) + .node_label(|d| d.name.clone()) + .value_label(|_, value| format!("{:.1}", value).into()) +``` + +数值标签显示在名称标签上方,闭包会收到节点的吞吐量(进出流量的较大值)。 + +#### 节点对齐 + +```rust +use gpui_component::plot::shape::SankeyAlign; + +// Justify(默认):没有出边的节点移到最后一列 +SankeyChart::new(nodes, links).node_align(SankeyAlign::Justify) + +// Left:节点保持在自己的拓扑深度列 +SankeyChart::new(nodes, links).node_align(SankeyAlign::Left) + +// 还支持:SankeyAlign::Right、SankeyAlign::Center +``` + +#### 样式 + +```rust +SankeyChart::new(nodes, links) + .node_width(8.) // 节点条宽度(默认 10) + .node_padding(20.) // 同列节点垂直间距(默认 16) + .node_corner_radius(px(2.)) // 节点条圆角(默认 0) + .node_color(|d| d.color) // 每个节点的颜色,默认循环主题图表配色 + .link_opacity(0.4) // 连接带透明度(默认 0.3) + .min_link_width(2.) // 连接带最小粗细(默认 1) + .iterations(10) // 布局松弛迭代次数(默认 6) +``` + +连接带使用从源节点颜色到目标节点颜色的水平渐变填充。 + +#### 压缩数值跨度 + +节点高度与流量值成线性关系,数值跨度很大时(如 200:1)小流量几乎不可见。可以把压缩后的值(如平方根)交给布局,标签仍显示真实值: + +```rust +let links = raw_links + .iter() + .map(|l| SankeyLink::new(l.source, l.target, l.value.sqrt())) + .collect::>(); + +SankeyChart::new(nodes, links) + // 标签从数据里取真实值,而不是压缩后的吞吐量。 + .value_label(|d: &FlowNode, _| format!("{:.1}", d.value).into()) +``` + ## 数据结构示例 ```rust @@ -386,6 +465,13 @@ struct StockPrice { pub close: f64, pub volume: u64, } + +// 桑基图连接:通过索引引用节点(来自 gpui_component::plot::shape) +pub struct SankeyLink { + pub source: usize, + pub target: usize, + pub value: f64, +} ``` ## 图表配置 @@ -455,6 +541,7 @@ let chart = LineChart::new(data) - [AreaChart] - [PieChart] - [CandlestickChart] +- [SankeyChart] ## 示例 From be81c14e58d3a59c107c4d9062e1f083f657d8a9 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Mon, 6 Jul 2026 18:50:12 +0800 Subject: [PATCH 02/14] chart: Address `SankeyChart` review findings - Remove the no-op `id()` builder: it opted into the `IntoPlot` tooltip machinery (full-window `refresh()` on every hover) while `tooltip_state` was never implemented, so it only caused repaint churn with no tooltip. - Replace the `ScaleOrdinal` color lookup (O(n^2) scan + Vec alloc, with a dead `unwrap_or_else` fallback) with a direct `palette[index % 5]`. - Only reserve the top label band when middle columns exist (`layer_count > 2`), and cap the top/bottom margins like the horizontal ones so a short chart no longer collapses the flow area to 1px. - Deduplicate `compute_node_depths`/`compute_node_heights` into a single `compute_node_ranks(forward)`. - Fix the docs' value-compression example: `FlowNode` now carries the `value` field the `value_label` closure reads (en + zh-CN). Co-Authored-By: Claude Fable 5 --- crates/ui/src/chart/sankey_chart.rs | 52 ++++++++++++---------------- crates/ui/src/plot/shape/sankey.rs | 53 ++++++++++++++--------------- docs/docs/components/chart.md | 8 +++++ docs/zh-CN/docs/components/chart.md | 8 +++++ 4 files changed, 63 insertions(+), 58 deletions(-) diff --git a/crates/ui/src/chart/sankey_chart.rs b/crates/ui/src/chart/sankey_chart.rs index 03b42e5715..0278a5fde6 100644 --- a/crates/ui/src/chart/sankey_chart.rs +++ b/crates/ui/src/chart/sankey_chart.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - App, Bounds, Corners, ElementId, Hsla, Pixels, SharedString, TextAlign, Window, fill, - linear_color_stop, linear_gradient, point, px, + App, Bounds, Corners, Hsla, Pixels, SharedString, TextAlign, Window, fill, linear_color_stop, + linear_gradient, point, px, }; use gpui_component_macros::IntoPlot; @@ -12,7 +12,6 @@ use crate::{ Plot, label::{PlotLabel, TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width}, origin_point, - scale::ScaleOrdinal, shape::{Sankey, SankeyAlign, SankeyLink, sankey_link_path}, }, }; @@ -31,7 +30,6 @@ const MAX_LABEL_MARGIN_RATIO: f32 = 0.6; /// indices before constructing. #[derive(IntoPlot)] pub struct SankeyChart { - id: Option, nodes: Vec, links: Vec, node_width: f32, @@ -54,7 +52,6 @@ impl SankeyChart { L: IntoIterator, { Self { - id: None, nodes: nodes.into_iter().collect(), links: links.into_iter().collect(), node_width: DEFAULT_NODE_WIDTH, @@ -71,12 +68,6 @@ impl SankeyChart { } } - /// Set a stable element id (reserved for future tooltip support). - pub fn id(mut self, id: impl Into) -> Self { - self.id = Some(id.into()); - self - } - /// Set the node rectangle width. Defaults to 10. pub fn node_width(mut self, node_width: f32) -> Self { self.node_width = node_width; @@ -161,10 +152,6 @@ impl SankeyChart { } impl Plot for SankeyChart { - fn id(&self) -> Option { - self.id.clone() - } - fn paint(&mut self, bounds: Bounds, window: &mut Window, cx: &mut App) { let width = bounds.size.width.as_f32(); let height = bounds.size.height.as_f32(); @@ -223,12 +210,22 @@ impl Plot for SankeyChart { right *= k; } } - let top = if has_labels { + // Above-node labels are only emitted for middle columns, so reserve + // the top band only when such columns exist. Cap the vertical margins + // like the horizontal ones so a short chart doesn't collapse the flow. + let has_middle_labels = has_labels && layer_count > 2; + let mut top = if has_middle_labels { 2. * TEXT_HEIGHT + TEXT_GAP } else { 0. }; - let bottom = if has_labels { TEXT_GAP } else { 0. }; + let mut bottom = if has_labels { TEXT_GAP } else { 0. }; + let max_vertical = height * MAX_LABEL_MARGIN_RATIO; + if top + bottom > max_vertical { + let k = max_vertical / (top + bottom); + top *= k; + bottom *= k; + } // Second pass on the final extent. let Ok(graph) = self @@ -244,23 +241,20 @@ impl Plot for SankeyChart { return; }; - let palette = ScaleOrdinal::new( - (0..self.nodes.len()).collect(), - vec![ - cx.theme().chart_1, - cx.theme().chart_2, - cx.theme().chart_3, - cx.theme().chart_4, - cx.theme().chart_5, - ], - ); + let palette = [ + cx.theme().chart_1, + cx.theme().chart_2, + cx.theme().chart_3, + cx.theme().chart_4, + cx.theme().chart_5, + ]; let colors: Vec = self .nodes .iter() .enumerate() .map(|(index, datum)| match &self.node_color { Some(color) => color(datum), - None => palette.map(&index).unwrap_or_else(|| cx.theme().chart_1), + None => palette[index % palette.len()], }) .collect(); @@ -382,7 +376,6 @@ mod tests { assert!(chart.value_label.is_none()); let chart = chart - .id("sankey") .node_width(8.) .node_padding(20.) .node_align(SankeyAlign::Left) @@ -394,7 +387,6 @@ mod tests { .link_opacity(0.5) .min_link_width(2.) .label_gap(10.); - assert!(chart.id.is_some()); assert_eq!(chart.node_width, 8.); assert_eq!(chart.node_padding, 20.); assert_eq!(chart.align, SankeyAlign::Left); diff --git a/crates/ui/src/plot/shape/sankey.rs b/crates/ui/src/plot/shape/sankey.rs index 7d9e44a441..b6b87ae332 100644 --- a/crates/ui/src/plot/shape/sankey.rs +++ b/crates/ui/src/plot/shape/sankey.rs @@ -228,8 +228,8 @@ impl Sankey { compute_node_links(&mut graph); compute_node_values(&mut graph); - compute_node_depths(&mut graph)?; - compute_node_heights(&mut graph)?; + compute_node_ranks(&mut graph, true)?; + compute_node_ranks(&mut graph, false)?; let mut columns = self.compute_node_layers(&mut graph); self.compute_node_breadths(&mut graph, &mut columns); compute_link_breadths(&mut graph); @@ -472,39 +472,36 @@ fn compute_node_values(graph: &mut SankeyGraph) { } } -/// Assign depths by BFS waves; later waves overwrite, yielding the -/// longest-path depth. More waves than nodes means a cycle. -fn compute_node_depths(graph: &mut SankeyGraph) -> Result<(), SankeyError> { +/// Assign topological ranks by BFS waves; later waves overwrite, yielding the +/// longest-path rank. More waves than nodes means a cycle. +/// +/// `forward` walks source links to their targets and writes `depth`; +/// otherwise it walks target links to their sources and writes `height`. +fn compute_node_ranks(graph: &mut SankeyGraph, forward: bool) -> Result<(), SankeyError> { let n = graph.nodes.len(); let mut current: Vec = (0..n).collect(); let mut x = 0; while !current.is_empty() { let mut next = vec![false; n]; for &index in ¤t { - graph.nodes[index].depth = x; - for &link in &graph.nodes[index].source_links { - next[graph.links[link].target] = true; + let node = &graph.nodes[index]; + let links = if forward { + &node.source_links + } else { + &node.target_links + }; + for &link in links { + let neighbor = if forward { + graph.links[link].target + } else { + graph.links[link].source + }; + next[neighbor] = true; } - } - x += 1; - if x > n { - return Err(SankeyError::CircularLink); - } - current = (0..n).filter(|&index| next[index]).collect(); - } - Ok(()) -} - -fn compute_node_heights(graph: &mut SankeyGraph) -> Result<(), SankeyError> { - let n = graph.nodes.len(); - let mut current: Vec = (0..n).collect(); - let mut x = 0; - while !current.is_empty() { - let mut next = vec![false; n]; - for &index in ¤t { - graph.nodes[index].height = x; - for &link in &graph.nodes[index].target_links { - next[graph.links[link].source] = true; + if forward { + graph.nodes[index].depth = x; + } else { + graph.nodes[index].height = x; } } x += 1; diff --git a/docs/docs/components/chart.md b/docs/docs/components/chart.md index 3543ab93dc..653fa15d15 100644 --- a/docs/docs/components/chart.md +++ b/docs/docs/components/chart.md @@ -443,6 +443,14 @@ Link ribbons are filled with a horizontal gradient from the source node color to Node heights are linear in flow value, so a large value range (e.g. 200:1) leaves the small flows nearly invisible. Feed compressed values (e.g. square roots) to the layout, and show the real value in the label: ```rust +// Nodes carry the real value for the label. +#[derive(Clone)] +struct FlowNode { + pub name: SharedString, + pub value: f64, +} + +// Feed sqrt-compressed values to the layout so small flows stay visible. let links = raw_links .iter() .map(|l| SankeyLink::new(l.source, l.target, l.value.sqrt())) diff --git a/docs/zh-CN/docs/components/chart.md b/docs/zh-CN/docs/components/chart.md index 864bb2e7be..621dec96d0 100644 --- a/docs/zh-CN/docs/components/chart.md +++ b/docs/zh-CN/docs/components/chart.md @@ -423,6 +423,14 @@ SankeyChart::new(nodes, links) 节点高度与流量值成线性关系,数值跨度很大时(如 200:1)小流量几乎不可见。可以把压缩后的值(如平方根)交给布局,标签仍显示真实值: ```rust +// 节点自身携带用于标签的真实值。 +#[derive(Clone)] +struct FlowNode { + pub name: SharedString, + pub value: f64, +} + +// 把 sqrt 压缩后的值交给布局,让小流量保持可见。 let links = raw_links .iter() .map(|l| SankeyLink::new(l.source, l.target, l.value.sqrt())) From e74ef042c4b02d8e1ae04f834d784b74ed43a44c Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Tue, 7 Jul 2026 10:03:43 +0800 Subject: [PATCH 03/14] chart: Optimize `SankeyChart` layout hot paths --- .../src/stories/chart_story/chart_story.rs | 14 ++- crates/ui/src/chart/sankey_chart.rs | 28 ++--- crates/ui/src/plot/shape/sankey.rs | 104 +++++++++++++++--- 3 files changed, 107 insertions(+), 39 deletions(-) diff --git a/crates/story/src/stories/chart_story/chart_story.rs b/crates/story/src/stories/chart_story/chart_story.rs index b6c3e8e1f4..c53d716e82 100644 --- a/crates/story/src/stories/chart_story/chart_story.rs +++ b/crates/story/src/stories/chart_story/chart_story.rs @@ -133,15 +133,17 @@ impl ChartStory { .unwrap_or(gpui::black()), }) .collect(); + // Skip links with unknown node keys or unparseable values + // instead of panicking on bad fixture data. let links = statement .links .iter() - .map(|link| { - SankeyLink::new( - node_indexes[&link.source], - node_indexes[&link.target], - link.value.parse().unwrap_or(0.), - ) + .filter_map(|link| { + Some(SankeyLink::new( + *node_indexes.get(&link.source)?, + *node_indexes.get(&link.target)?, + link.value.parse().ok()?, + )) }) .collect(); (statement.period.clone(), nodes, links) diff --git a/crates/ui/src/chart/sankey_chart.rs b/crates/ui/src/chart/sankey_chart.rs index 0278a5fde6..b1dd8e502f 100644 --- a/crates/ui/src/chart/sankey_chart.rs +++ b/crates/ui/src/chart/sankey_chart.rs @@ -159,14 +159,14 @@ impl Plot for SankeyChart { return; } - // First pass on the unit extent: the topology (layer, value) needed - // to measure the label margins. - let Ok(graph) = self.sankey().layout(self.nodes.len(), &self.links) else { + // First pass: only the topology (layer, value) is needed to measure + // the label margins. + let Ok(topology) = self.sankey().topology(self.nodes.len(), &self.links) else { return; }; - let layer_count = graph.layer_count(); + let layer_count = topology.layer_count(); - let node_labels: Vec<(Option, Option)> = graph + let node_labels: Vec<(Option, Option)> = topology .nodes .iter() .map(|node| { @@ -184,7 +184,7 @@ impl Plot for SankeyChart { let mut left = 0f32; let mut right = 0f32; if has_labels { - for node in &graph.nodes { + for node in &topology.nodes { if node.layer != 0 && node.layer + 1 != layer_count { continue; } @@ -227,8 +227,9 @@ impl Plot for SankeyChart { bottom *= k; } - // Second pass on the final extent. - let Ok(graph) = self + // Second pass: complete the placement on the final extent, reusing + // the first pass's topology. + let graph = self .sankey() .extent( left, @@ -236,10 +237,7 @@ impl Plot for SankeyChart { (width - right).max(left + 1.), (height - bottom).max(top + 1.), ) - .layout(self.nodes.len(), &self.links) - else { - return; - }; + .layout_from(topology); let palette = [ cx.theme().chart_1, @@ -280,16 +278,14 @@ impl Plot for SankeyChart { ); } + let corner_radii = Corners::all(self.node_corner_radius.unwrap_or_default()); for node in &graph.nodes { - let corner_radius = self.node_corner_radius.unwrap_or_default(); let node_bounds = Bounds::from_corners( origin_point(px(node.x0), px(node.y0), bounds.origin), // Keep tiny nodes visible with a minimum 1px height. origin_point(px(node.x1), px(node.y1.max(node.y0 + 1.)), bounds.origin), ); - window.paint_quad( - fill(node_bounds, colors[node.index]).corner_radii(Corners::all(corner_radius)), - ); + window.paint_quad(fill(node_bounds, colors[node.index]).corner_radii(corner_radii)); } let mut texts = Vec::new(); diff --git a/crates/ui/src/plot/shape/sankey.rs b/crates/ui/src/plot/shape/sankey.rs index b6b87ae332..f1c8d9e19f 100644 --- a/crates/ui/src/plot/shape/sankey.rs +++ b/crates/ui/src/plot/shape/sankey.rs @@ -182,12 +182,15 @@ impl Sankey { self.extent(0., 0., width, height) } - /// Compute the layout. + /// Compute the topology only: node `value`, `depth`, `height`, `layer` + /// and horizontal positions, without the vertical placement. Much cheaper + /// than [`Sankey::layout`] when only the column structure is needed + /// (e.g. to measure labels before fixing the extent). /// /// `node_count` is the number of nodes; links reference nodes by index /// (d3-sankey's default `nodeId`). Returns an error if a link references /// a node out of range or the graph contains a cycle. - pub fn layout( + pub fn topology( &self, node_count: usize, links: &[SankeyLink], @@ -230,11 +233,43 @@ impl Sankey { compute_node_values(&mut graph); compute_node_ranks(&mut graph, true)?; compute_node_ranks(&mut graph, false)?; - let mut columns = self.compute_node_layers(&mut graph); + self.compute_node_layers(&mut graph); + + Ok(graph) + } + + /// Compute the full layout: [`Sankey::topology`] plus the vertical node + /// placement and link breadths. + pub fn layout( + &self, + node_count: usize, + links: &[SankeyLink], + ) -> Result { + Ok(self.layout_from(self.topology(node_count, links)?)) + } + + /// Complete the layout for a graph produced by [`Sankey::topology`], + /// avoiding a second topology pass when the extent only becomes known + /// after measuring against the column structure (e.g. label margins). + /// + /// The topological fields are extent-independent; the horizontal and + /// vertical positions are recomputed for this generator's extent. + pub fn layout_from(&self, mut graph: SankeyGraph) -> SankeyGraph { + if graph.nodes.is_empty() { + return graph; + } + + self.compute_node_layers(&mut graph); + + let mut columns = vec![Vec::new(); graph.layer_count()]; + for node in &graph.nodes { + columns[node.layer].push(node.index); + } + self.compute_node_breadths(&mut graph, &mut columns); compute_link_breadths(&mut graph); - Ok(graph) + graph } fn align_layer(&self, graph: &SankeyGraph, index: usize, n: usize) -> usize { @@ -266,7 +301,7 @@ impl Sankey { } } - fn compute_node_layers(&self, graph: &mut SankeyGraph) -> Vec> { + fn compute_node_layers(&self, graph: &mut SankeyGraph) { let n = graph .nodes .iter() @@ -283,16 +318,12 @@ impl Sankey { .map(|index| self.align_layer(graph, index, n).min(n - 1)) .collect(); - let mut columns = vec![Vec::new(); n]; for (index, layer) in layers.into_iter().enumerate() { let node = &mut graph.nodes[index]; node.layer = layer; node.x0 = self.x0 + layer as f32 * kx; node.x1 = node.x0 + self.node_width; - columns[layer].push(index); } - - columns } fn compute_node_breadths(&self, graph: &mut SankeyGraph, columns: &mut [Vec]) { @@ -516,7 +547,9 @@ fn compute_node_ranks(graph: &mut SankeyGraph, forward: bool) -> Result<(), Sank /// Sort a node's outgoing links by the target node's `y0` (then link index). fn sort_source_links(graph: &mut SankeyGraph, index: usize) { let mut links = std::mem::take(&mut graph.nodes[index].source_links); - links.sort_by(|&a, &b| { + // The link-index tie-break makes the order total, so an unstable sort + // is deterministic and avoids the stable sort's scratch allocation. + links.sort_unstable_by(|&a, &b| { let ya = graph.nodes[graph.links[a].target].y0; let yb = graph.nodes[graph.links[b].target].y0; ya.partial_cmp(&yb) @@ -529,7 +562,7 @@ fn sort_source_links(graph: &mut SankeyGraph, index: usize) { /// Sort a node's incoming links by the source node's `y0` (then link index). fn sort_target_links(graph: &mut SankeyGraph, index: usize) { let mut links = std::mem::take(&mut graph.nodes[index].target_links); - links.sort_by(|&a, &b| { + links.sort_unstable_by(|&a, &b| { let ya = graph.nodes[graph.links[a].source].y0; let yb = graph.nodes[graph.links[b].source].y0; ya.partial_cmp(&yb) @@ -541,12 +574,17 @@ fn sort_target_links(graph: &mut SankeyGraph, index: usize) { /// After a node moved, re-sort the link lists of its neighbors on the /// opposite ends (d3's reorderNodeLinks). +/// +/// Iterates by position to avoid cloning the link lists on this hot path; +/// the sorts only mutate the neighbors' lists, never the one being walked. fn reorder_node_links(graph: &mut SankeyGraph, index: usize) { - for link in graph.nodes[index].target_links.clone() { + for i in 0..graph.nodes[index].target_links.len() { + let link = graph.nodes[index].target_links[i]; let source = graph.links[link].source; sort_source_links(graph, source); } - for link in graph.nodes[index].source_links.clone() { + for i in 0..graph.nodes[index].source_links.len() { + let link = graph.nodes[index].source_links[i]; let target = graph.links[link].target; sort_target_links(graph, target); } @@ -640,15 +678,15 @@ fn compute_link_breadths(graph: &mut SankeyGraph) { let node_y0 = graph.nodes[index].y0; let mut y0 = node_y0; - for link_index in graph.nodes[index].source_links.clone() { - let link = &mut graph.links[link_index]; + for i in 0..graph.nodes[index].source_links.len() { + let link = &mut graph.links[graph.nodes[index].source_links[i]]; link.y0 = y0 + link.width / 2.; y0 += link.width; } let mut y1 = node_y0; - for link_index in graph.nodes[index].target_links.clone() { - let link = &mut graph.links[link_index]; + for i in 0..graph.nodes[index].target_links.len() { + let link = &mut graph.links[graph.nodes[index].target_links[i]]; link.y1 = y1 + link.width / 2.; y1 += link.width; } @@ -764,6 +802,38 @@ mod tests { for link in &graph.links { assert!((link.width - 100.).abs() < EPSILON); } + + // `topology` agrees with `layout` on the topological fields. + let topology = Sankey::new() + .node_width(10.) + .size(100., 100.) + .topology(3, &links(&[(0, 1, 5.), (1, 2, 5.)])) + .unwrap(); + assert_eq!(topology.layer_count(), 3); + for (a, b) in topology.nodes.iter().zip(&graph.nodes) { + assert_eq!(a.depth, b.depth); + assert_eq!(a.height, b.height); + assert_eq!(a.layer, b.layer); + assert_eq!(a.value, b.value); + assert_eq!(a.x0, b.x0); + } + + // Completing a unit-extent topology on the final extent (the chart's + // two-pass flow) matches a direct layout on that extent. + let topology = Sankey::new() + .node_width(10.) + .topology(3, &links(&[(0, 1, 5.), (1, 2, 5.)])) + .unwrap(); + let completed = Sankey::new() + .node_width(10.) + .size(100., 100.) + .layout_from(topology); + for (a, b) in completed.nodes.iter().zip(&graph.nodes) { + assert_eq!((a.x0, a.y0, a.x1, a.y1), (b.x0, b.y0, b.x1, b.y1)); + } + for (a, b) in completed.links.iter().zip(&graph.links) { + assert_eq!((a.y0, a.y1, a.width), (b.y0, b.y1, b.width)); + } } #[test] From 84670737a17638f1fa31c5fd30eeb9e42c2fac3f Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Tue, 7 Jul 2026 10:31:52 +0800 Subject: [PATCH 04/14] story: Fix typo in the sankey links comment Co-Authored-By: Claude Fable 5 --- crates/story/src/stories/chart_story/chart_story.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/story/src/stories/chart_story/chart_story.rs b/crates/story/src/stories/chart_story/chart_story.rs index c53d716e82..e328d9ad09 100644 --- a/crates/story/src/stories/chart_story/chart_story.rs +++ b/crates/story/src/stories/chart_story/chart_story.rs @@ -133,7 +133,7 @@ impl ChartStory { .unwrap_or(gpui::black()), }) .collect(); - // Skip links with unknown node keys or unparseable values + // Skip links with unknown node keys or unparsable values // instead of panicking on bad fixture data. let links = statement .links From 8960a361d5c2af0b6701cdfad1938e4c467c527e Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Tue, 7 Jul 2026 17:20:15 +0800 Subject: [PATCH 05/14] chart: Support custom multi-line labels in `SankeyChart` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `SankeyLabel` line type (text with optional color and font size) and a `labels` builder that returns one styled line per label, taking precedence over `node_label`/`value_label`. The chart keeps handling placement, alignment and margin reservation; the top band and label margins now size to the actual label blocks. The TSLA FY 2025 story example uses it to render the reference design's three-line label: value, year-over-year growth (▲ green / ▼ red), and name. Co-Authored-By: Claude Fable 5 --- .../src/fixtures/tsla-income-statement.json | 52 ++--- .../src/stories/chart_story/chart_story.rs | 52 +++-- crates/ui/src/chart/mod.rs | 2 +- crates/ui/src/chart/sankey_chart.rs | 186 +++++++++++++----- docs/docs/components/chart.md | 20 ++ docs/zh-CN/docs/components/chart.md | 20 ++ 6 files changed, 237 insertions(+), 95 deletions(-) diff --git a/crates/story/src/fixtures/tsla-income-statement.json b/crates/story/src/fixtures/tsla-income-statement.json index d1f900b271..bb13631fe9 100644 --- a/crates/story/src/fixtures/tsla-income-statement.json +++ b/crates/story/src/fixtures/tsla-income-statement.json @@ -10,7 +10,7 @@ "key": "bus_119884", "name": "Automotive", "value": "82056000000", - "yoy": "-6.33304415323501", + "growth": "-6.33304415323501", "level": 1, "color": "#5A74FF" }, @@ -18,7 +18,7 @@ "key": "bus_130929", "name": "Energy Generation and Storage", "value": "12771000000", - "yoy": "26.62105889351576", + "growth": "26.62105889351576", "level": 1, "color": "#5A74FF" }, @@ -26,7 +26,7 @@ "key": "total_rev", "name": "Total Revenue", "value": "94827000000", - "yoy": "-2.93069915037363", + "growth": "-2.93069915037363", "level": 2, "color": "#5A74FF" }, @@ -34,7 +34,7 @@ "key": "gp", "name": "Gross Profit", "value": "17094000000", - "yoy": "-2.04011461318052", + "growth": "-2.04011461318052", "level": 3, "color": "#35C15C" }, @@ -42,7 +42,7 @@ "key": "cost_rev", "name": "Cost Of Revenues", "value": "77733000000", - "yoy": "-3.12437686939182", + "growth": "-3.12437686939182", "level": 3, "color": "#FFA61F" }, @@ -50,7 +50,7 @@ "key": "other_f41", "name": "Other", "value": "862000000", - "yoy": "", + "growth": "", "level": 4, "color": "#35C15C" }, @@ -58,7 +58,7 @@ "key": "oper_inc", "name": "Operating Income", "value": "4355000000", - "yoy": "-43.13879096487792", + "growth": "-43.13879096487792", "level": 4, "color": "#35C15C" }, @@ -66,7 +66,7 @@ "key": "oper_fee", "name": "Operating Exp.", "value": "12739000000", - "yoy": "30.10928403635992", + "growth": "30.10928403635992", "level": 4, "color": "#FFA61F" }, @@ -74,7 +74,7 @@ "key": "ni", "name": "Net Income", "value": "3794000000", - "yoy": "-46.49555774925962", + "growth": "-46.49555774925962", "level": 5, "color": "#35C15C" }, @@ -82,7 +82,7 @@ "key": "inc_tax", "name": "Income Tax Exp.", "value": "1423000000", - "yoy": "-22.53674469243332", + "growth": "-22.53674469243332", "level": 5, "color": "#FFA61F" }, @@ -90,7 +90,7 @@ "key": "sga", "name": "SG& A Exp.", "value": "5834000000", - "yoy": "13.28155339805825", + "growth": "13.28155339805825", "level": 5, "color": "#FFA61F" }, @@ -98,7 +98,7 @@ "key": "rd_exp", "name": "R&D Exp.", "value": "6411000000", - "yoy": "41.21145374449339", + "growth": "41.21145374449339", "level": 5, "color": "#FFA61F" }, @@ -106,7 +106,7 @@ "key": "other_f42", "name": "Other", "value": "494000000", - "yoy": "", + "growth": "", "level": 5, "color": "#FFA61F" } @@ -184,7 +184,7 @@ "key": "bus_119884", "name": "Automotive", "value": "87604000000", - "yoy": "-3.45390024025215", + "growth": "-3.45390024025215", "level": 1, "color": "#5A74FF" }, @@ -192,7 +192,7 @@ "key": "bus_130929", "name": "Energy Generation and Storage", "value": "10086000000", - "yoy": "67.12510356255178", + "growth": "67.12510356255178", "level": 1, "color": "#5A74FF" }, @@ -200,7 +200,7 @@ "key": "total_rev", "name": "Total Revenue", "value": "97690000000", - "yoy": "0.94757835346636", + "growth": "0.94757835346636", "level": 2, "color": "#5A74FF" }, @@ -208,7 +208,7 @@ "key": "gp", "name": "Gross Profit", "value": "17450000000", - "yoy": "-1.18912797281993", + "growth": "-1.18912797281993", "level": 3, "color": "#35C15C" }, @@ -216,7 +216,7 @@ "key": "cost_rev", "name": "Cost Of Revenues", "value": "80240000000", - "yoy": "1.42454463868138", + "growth": "1.42454463868138", "level": 3, "color": "#FFA61F" }, @@ -224,7 +224,7 @@ "key": "other_f41", "name": "Other", "value": "1269000000", - "yoy": "", + "growth": "", "level": 4, "color": "#35C15C" }, @@ -232,7 +232,7 @@ "key": "oper_inc", "name": "Operating Income", "value": "7659000000", - "yoy": "-13.85670903160499", + "growth": "-13.85670903160499", "level": 4, "color": "#35C15C" }, @@ -240,7 +240,7 @@ "key": "oper_fee", "name": "Operating Exp.", "value": "9791000000", - "yoy": "11.6546926673509", + "growth": "11.6546926673509", "level": 4, "color": "#FFA61F" }, @@ -248,7 +248,7 @@ "key": "ni", "name": "Net Income", "value": "7091000000", - "yoy": "-52.7172101086884", + "growth": "-52.7172101086884", "level": 5, "color": "#35C15C" }, @@ -256,7 +256,7 @@ "key": "inc_tax", "name": "Income Tax Exp.", "value": "1837000000", - "yoy": "136.73265346930614", + "growth": "136.73265346930614", "level": 5, "color": "#FFA61F" }, @@ -264,7 +264,7 @@ "key": "sga", "name": "SG& A Exp.", "value": "5150000000", - "yoy": "7.29166666666667", + "growth": "7.29166666666667", "level": 5, "color": "#FFA61F" }, @@ -272,7 +272,7 @@ "key": "rd_exp", "name": "R&D Exp.", "value": "4540000000", - "yoy": "14.38649533887629", + "growth": "14.38649533887629", "level": 5, "color": "#FFA61F" }, @@ -280,7 +280,7 @@ "key": "other_f42", "name": "Other", "value": "101000000", - "yoy": "", + "growth": "", "level": 5, "color": "#FFA61F" } diff --git a/crates/story/src/stories/chart_story/chart_story.rs b/crates/story/src/stories/chart_story/chart_story.rs index e328d9ad09..d429079937 100644 --- a/crates/story/src/stories/chart_story/chart_story.rs +++ b/crates/story/src/stories/chart_story/chart_story.rs @@ -5,7 +5,7 @@ use gpui::{ }; use gpui_component::{ ActiveTheme, StyledExt, - chart::{AreaChart, BarChart, CandlestickChart, LineChart, PieChart, SankeyChart}, + chart::{AreaChart, BarChart, CandlestickChart, LineChart, PieChart, SankeyChart, SankeyLabel}, dock::PanelControl, h_flex, plot::shape::{BarAlignment, SankeyAlign, SankeyLink}, @@ -54,6 +54,7 @@ struct TslaStatementNode { key: SharedString, name: SharedString, value: SharedString, + growth: SharedString, color: SharedString, } @@ -82,6 +83,8 @@ pub struct TslaNode { /// The real dollar value, for the label; the layout gets sqrt-compressed /// link values to keep small flows readable. pub value: f64, + /// Year-over-year growth in percent, for the label. + pub growth: Option, pub color: Hsla, } @@ -128,6 +131,7 @@ impl ChartStory { .map(|node| TslaNode { name: node.name.clone(), value: node.value.parse().unwrap_or(0.), + growth: node.growth.parse().ok(), color: Rgba::try_from(node.color.as_ref()) .map(Into::into) .unwrap_or(gpui::black()), @@ -736,19 +740,39 @@ impl Render for ChartStory { format!("Sankey Chart - TSLA {}", period) }; - chart_container( - &title, - SankeyChart::new(nodes.clone(), links) - .node_align(SankeyAlign::Center) - .node_padding(40.) - .node_color(|d: &TslaNode| d.color) - .node_label(|d| d.name.clone()) - .value_label(|d, _| { - format!("${:.2}B", d.value / 1_000_000_000.).into() - }), - false, - cx, - ) + let chart = SankeyChart::new(nodes.clone(), links) + .node_align(SankeyAlign::Center) + .node_padding(40.) + .node_color(|d: &TslaNode| d.color); + // The sqrt chart uses fully custom three-line + // labels with the year-over-year change; the + // other keeps the default value/name lines. + let chart = if sqrt { + let up = cx.theme().success; + let down = cx.theme().danger; + let muted = cx.theme().muted_foreground; + chart.labels(move |d: &TslaNode, _| { + let mut lines = vec![SankeyLabel::new(format!( + "${:.2}B", + d.value / 1_000_000_000. + ))]; + if let Some(growth) = d.growth { + let arrow = if growth >= 0. { "▲" } else { "▼" }; + lines.push( + SankeyLabel::new(format!("{} {:+.2}%", arrow, growth)) + .color(if growth >= 0. { up } else { down }), + ); + } + lines.push(SankeyLabel::new(d.name.clone()).color(muted)); + lines + }) + } else { + chart.node_label(|d| d.name.clone()).value_label(|d, _| { + format!("${:.2}B", d.value / 1_000_000_000.).into() + }) + }; + + chart_container(&title, chart, false, cx) }) .collect::>(), ), diff --git a/crates/ui/src/chart/mod.rs b/crates/ui/src/chart/mod.rs index 3e301c2e6f..6d8cff5ba7 100644 --- a/crates/ui/src/chart/mod.rs +++ b/crates/ui/src/chart/mod.rs @@ -10,7 +10,7 @@ pub use bar_chart::BarChart; pub use candlestick_chart::CandlestickChart; pub use line_chart::LineChart; pub use pie_chart::PieChart; -pub use sankey_chart::SankeyChart; +pub use sankey_chart::{SankeyChart, SankeyLabel}; use gpui::{Hsla, SharedString, TextAlign}; diff --git a/crates/ui/src/chart/sankey_chart.rs b/crates/ui/src/chart/sankey_chart.rs index b1dd8e502f..6f3d6be5f2 100644 --- a/crates/ui/src/chart/sankey_chart.rs +++ b/crates/ui/src/chart/sankey_chart.rs @@ -10,7 +10,7 @@ use crate::{ ActiveTheme, plot::{ Plot, - label::{PlotLabel, TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width}, + label::{PlotLabel, TEXT_GAP, TEXT_SIZE, Text, measure_text_width}, origin_point, shape::{Sankey, SankeyAlign, SankeyLink, sankey_link_path}, }, @@ -24,6 +24,44 @@ const DEFAULT_LABEL_GAP: f32 = 6.; /// Cap the label margins so very long labels never collapse the flow area. const MAX_LABEL_MARGIN_RATIO: f32 = 0.6; +/// A styled line of a sankey node label. +#[derive(Clone)] +pub struct SankeyLabel { + text: SharedString, + color: Option, + font_size: Option, +} + +impl SankeyLabel { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + color: None, + font_size: None, + } + } + + /// Set the text color. Defaults to the theme foreground. + pub fn color(mut self, color: impl Into) -> Self { + self.color = Some(color.into()); + self + } + + /// Set the font size. Defaults to 10. + pub fn font_size(mut self, font_size: f32) -> Self { + self.font_size = Some(font_size); + self + } + + fn line_height(&self) -> f32 { + self.font_size.unwrap_or(TEXT_SIZE) + TEXT_GAP + } +} + +fn block_height(lines: &[SankeyLabel]) -> f32 { + lines.iter().map(|line| line.line_height()).sum() +} + /// A Sankey diagram, layout modeled after [d3-sankey](https://github.com/d3/d3-sankey). /// /// Links reference nodes by their index in the node list; map string ids to @@ -40,6 +78,7 @@ pub struct SankeyChart { node_color: Option Hsla>>, node_label: Option SharedString>>, value_label: Option SharedString>>, + labels: Option Vec>>, link_opacity: f32, min_link_width: f32, label_gap: f32, @@ -62,6 +101,7 @@ impl SankeyChart { node_color: None, node_label: None, value_label: None, + labels: None, link_opacity: DEFAULT_LINK_OPACITY, min_link_width: DEFAULT_MIN_LINK_WIDTH, label_gap: DEFAULT_LABEL_GAP, @@ -124,6 +164,16 @@ impl SankeyChart { self } + /// Set fully custom node labels, one [`SankeyLabel`] per line, top to + /// bottom. Takes precedence over `node_label`/`value_label` when set. + /// + /// The closure receives the datum and the node's computed throughput + /// (max of incoming and outgoing flow). + pub fn labels(mut self, labels: impl Fn(&T, f64) -> Vec + 'static) -> Self { + self.labels = Some(Rc::new(labels)); + self + } + /// Set the opacity of the link ribbons. Defaults to 0.3. pub fn link_opacity(mut self, opacity: f32) -> Self { self.link_opacity = opacity; @@ -166,18 +216,30 @@ impl Plot for SankeyChart { }; let layer_count = topology.layer_count(); - let node_labels: Vec<(Option, Option)> = topology + // Collect each node's label lines: the custom `labels` closure wins, + // otherwise synthesize the value/name lines with the default styles. + let node_labels: Vec> = topology .nodes .iter() .map(|node| { let datum = &self.nodes[node.index]; - ( - self.value_label.as_ref().map(|f| f(datum, node.value)), - self.node_label.as_ref().map(|f| f(datum)), - ) + if let Some(labels) = &self.labels { + labels(datum, node.value) + } else { + let mut lines = Vec::new(); + if let Some(value_label) = &self.value_label { + lines.push(SankeyLabel::new(value_label(datum, node.value))); + } + if let Some(node_label) = &self.node_label { + lines.push( + SankeyLabel::new(node_label(datum)).color(cx.theme().muted_foreground), + ); + } + lines + } }) .collect(); - let has_labels = self.value_label.is_some() || self.node_label.is_some(); + let has_labels = node_labels.iter().any(|lines| !lines.is_empty()); // Reserve margins so the labels beside the first/last columns and // above the middle columns are not clipped. @@ -188,13 +250,13 @@ impl Plot for SankeyChart { if node.layer != 0 && node.layer + 1 != layer_count { continue; } - let (value_text, name_text) = &node_labels[node.index]; let mut label_width = 0f32; - if let Some(text) = value_text { - label_width = label_width.max(measure_text_width(text, px(TEXT_SIZE), window)); - } - if let Some(text) = name_text { - label_width = label_width.max(measure_text_width(text, px(TEXT_SIZE), window)); + for line in &node_labels[node.index] { + label_width = label_width.max(measure_text_width( + &line.text, + px(line.font_size.unwrap_or(TEXT_SIZE)), + window, + )); } if node.layer == 0 { left = left.max(label_width + self.label_gap); @@ -211,14 +273,21 @@ impl Plot for SankeyChart { } } // Above-node labels are only emitted for middle columns, so reserve - // the top band only when such columns exist. Cap the vertical margins - // like the horizontal ones so a short chart doesn't collapse the flow. - let has_middle_labels = has_labels && layer_count > 2; - let mut top = if has_middle_labels { - 2. * TEXT_HEIGHT + TEXT_GAP - } else { - 0. - }; + // the top band for the tallest such label block. Cap the vertical + // margins like the horizontal ones so a short chart doesn't collapse + // the flow. + let mut top = 0f32; + if has_labels && layer_count > 2 { + for node in &topology.nodes { + if node.layer == 0 || node.layer + 1 == layer_count { + continue; + } + let block = block_height(&node_labels[node.index]); + if block > 0. { + top = top.max(block + TEXT_GAP); + } + } + } let mut bottom = if has_labels { TEXT_GAP } else { 0. }; let max_vertical = height * MAX_LABEL_MARGIN_RATIO; if top + bottom > max_vertical { @@ -290,8 +359,8 @@ impl Plot for SankeyChart { let mut texts = Vec::new(); for node in &graph.nodes { - let (value_text, name_text) = &node_labels[node.index]; - if value_text.is_none() && name_text.is_none() { + let lines = &node_labels[node.index]; + if lines.is_empty() { continue; } @@ -305,45 +374,26 @@ impl Plot for SankeyChart { ((node.x0 + node.x1) / 2., TextAlign::Center) }; - let two_lines = value_text.is_some() && name_text.is_some(); - let (value_y, name_y) = if is_first || is_last { - // Vertically centered beside the node. - let cy = (node.y0 + node.y1) / 2.; - if two_lines { - (cy - TEXT_HEIGHT, cy) - } else { - (cy - TEXT_SIZE / 2., cy - TEXT_SIZE / 2.) - } - } else if two_lines { - // Above the node. - ( - node.y0 - 2. * TEXT_HEIGHT - TEXT_GAP, - node.y0 - TEXT_HEIGHT - TEXT_GAP, - ) + let block = block_height(lines); + let mut y = if is_first || is_last { + // Block vertically centered beside the node. + (node.y0 + node.y1) / 2. - block / 2. } else { - let y = node.y0 - TEXT_HEIGHT - TEXT_GAP; - (y, y) + // Block above the node. + node.y0 - block - TEXT_GAP }; - if let Some(text) = value_text { + for line in lines { texts.push( Text::new( - text.clone(), - point(px(x), px(value_y)), - cx.theme().foreground, - ) - .align(align), - ); - } - if let Some(text) = name_text { - texts.push( - Text::new( - text.clone(), - point(px(x), px(name_y)), - cx.theme().muted_foreground, + line.text.clone(), + point(px(x), px(y)), + line.color.unwrap_or(cx.theme().foreground), ) + .font_size(px(line.font_size.unwrap_or(TEXT_SIZE))) .align(align), ); + y += line.line_height(); } } PlotLabel::new(texts).paint(&bounds, window, cx); @@ -370,6 +420,7 @@ mod tests { assert!(chart.node_color.is_none()); assert!(chart.node_label.is_none()); assert!(chart.value_label.is_none()); + assert!(chart.labels.is_none()); let chart = chart .node_width(8.) @@ -380,6 +431,12 @@ mod tests { .node_color(|_| gpui::red()) .node_label(|d| SharedString::from(d.to_string())) .value_label(|_, value| SharedString::from(format!("{}", value))) + .labels(|d, value| { + vec![ + SankeyLabel::new(format!("{}", value)), + SankeyLabel::new(d.to_string()), + ] + }) .link_opacity(0.5) .min_link_width(2.) .label_gap(10.); @@ -394,5 +451,26 @@ mod tests { assert!(chart.node_color.is_some()); assert!(chart.node_label.is_some()); assert!(chart.value_label.is_some()); + assert!(chart.labels.is_some()); + } + + #[test] + fn test_sankey_label_builder() { + let label = SankeyLabel::new("a"); + assert_eq!(label.text, "a"); + assert_eq!(label.color, None); + assert_eq!(label.font_size, None); + assert_eq!(label.line_height(), TEXT_SIZE + TEXT_GAP); + + let label = SankeyLabel::new("b").color(gpui::red()).font_size(14.); + assert_eq!(label.color, Some(gpui::red())); + assert_eq!(label.font_size, Some(14.)); + assert_eq!(label.line_height(), 14. + TEXT_GAP); + + assert_eq!( + block_height(&[SankeyLabel::new("a"), SankeyLabel::new("b").font_size(14.)]), + TEXT_SIZE + TEXT_GAP + 14. + TEXT_GAP + ); + assert_eq!(block_height(&[]), 0.); } } diff --git a/docs/docs/components/chart.md b/docs/docs/components/chart.md index 653fa15d15..234ecf36fc 100644 --- a/docs/docs/components/chart.md +++ b/docs/docs/components/chart.md @@ -438,6 +438,26 @@ SankeyChart::new(nodes, links) Link ribbons are filled with a horizontal gradient from the source node color to the target node color. +#### Custom Labels + +For full control over the label lines, use `labels` — one `SankeyLabel` per line, top to bottom, each with its own color and font size. It takes precedence over `node_label`/`value_label` when set. For example, a financial-statement label with a year-over-year change line: + +```rust +use gpui_component::chart::SankeyLabel; + +SankeyChart::new(nodes, links).labels(move |d: &FlowNode, value| { + let arrow = if d.growth >= 0. { "▲" } else { "▼" }; + let growth_color = if d.growth >= 0. { green } else { red }; + vec![ + SankeyLabel::new(format!("{:.1}", value)), + SankeyLabel::new(format!("{} {:+.2}%", arrow, d.growth)).color(growth_color), + SankeyLabel::new(d.name.clone()).color(muted), + ] +}) +``` + +Line color defaults to the theme foreground and font size to 10; the chart keeps handling placement, alignment and margin reservation. + #### Compressing Large Value Ranges Node heights are linear in flow value, so a large value range (e.g. 200:1) leaves the small flows nearly invisible. Feed compressed values (e.g. square roots) to the layout, and show the real value in the label: diff --git a/docs/zh-CN/docs/components/chart.md b/docs/zh-CN/docs/components/chart.md index 621dec96d0..8e2f58e14c 100644 --- a/docs/zh-CN/docs/components/chart.md +++ b/docs/zh-CN/docs/components/chart.md @@ -418,6 +418,26 @@ SankeyChart::new(nodes, links) 连接带使用从源节点颜色到目标节点颜色的水平渐变填充。 +#### 自定义标签 + +需要完全控制标签行时使用 `labels`——每行一个 `SankeyLabel`,从上到下排列,每行可单独设置颜色和字号。设置后优先于 `node_label`/`value_label`。例如带同比涨跌幅行的财报标签: + +```rust +use gpui_component::chart::SankeyLabel; + +SankeyChart::new(nodes, links).labels(move |d: &FlowNode, value| { + let arrow = if d.growth >= 0. { "▲" } else { "▼" }; + let growth_color = if d.growth >= 0. { green } else { red }; + vec![ + SankeyLabel::new(format!("{:.1}", value)), + SankeyLabel::new(format!("{} {:+.2}%", arrow, d.growth)).color(growth_color), + SankeyLabel::new(d.name.clone()).color(muted), + ] +}) +``` + +行颜色默认为主题前景色,字号默认 10;摆位、对齐和边距预留仍由组件负责。 + #### 压缩数值跨度 节点高度与流量值成线性关系,数值跨度很大时(如 200:1)小流量几乎不可见。可以把压缩后的值(如平方根)交给布局,标签仍显示真实值: From c28f734c243b15d9c43fc8b619ec174d8457528e Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Wed, 8 Jul 2026 11:15:54 +0800 Subject: [PATCH 06/14] chart: Clamp sankey edge labels into the plot area First/last-column label blocks are vertically centered beside their node, so a short node near the top or bottom edge pushed part of its label outside the plot where it was clipped. Clamp the block into the plot area instead, keeping it beside the node whenever it fits. Co-Authored-By: Claude Fable 5 --- crates/ui/src/chart/sankey_chart.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/chart/sankey_chart.rs b/crates/ui/src/chart/sankey_chart.rs index 6f3d6be5f2..eab6850596 100644 --- a/crates/ui/src/chart/sankey_chart.rs +++ b/crates/ui/src/chart/sankey_chart.rs @@ -376,8 +376,12 @@ impl Plot for SankeyChart { let block = block_height(lines); let mut y = if is_first || is_last { - // Block vertically centered beside the node. - (node.y0 + node.y1) / 2. - block / 2. + // Block vertically centered beside the node, clamped into + // the plot area so labels of nodes near the top or bottom + // edge are not clipped. + ((node.y0 + node.y1) / 2. - block / 2.) + .min(height - block) + .max(0.) } else { // Block above the node. node.y0 - block - TEXT_GAP From f5fdc1ee08f2e4529661b0d92131f06afb2374b6 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Wed, 8 Jul 2026 11:21:34 +0800 Subject: [PATCH 07/14] chart: Fit sankey ribbons to both node sides Node heights take the max of incoming and outgoing sums, so on an imbalanced graph (e.g. sqrt-compressed link values, where sums are not additive) the smaller side's ribbons no longer covered the node, leaving gaps between the node and its links. Give each ribbon end its own width: the links on a node side share the node's full height in proportion to their values, so both sides are always fully covered and the ribbon transitions smoothly between its two ends. On a balanced graph this is identical to the previous uniform-width stacking. Co-Authored-By: Claude Fable 5 --- crates/ui/src/plot/shape/sankey.rs | 115 +++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 22 deletions(-) diff --git a/crates/ui/src/plot/shape/sankey.rs b/crates/ui/src/plot/shape/sankey.rs index f1c8d9e19f..e44310c9b4 100644 --- a/crates/ui/src/plot/shape/sankey.rs +++ b/crates/ui/src/plot/shape/sankey.rs @@ -63,7 +63,12 @@ pub struct SankeyNodeLayout { /// A link with computed layout. /// /// Like d3-sankey, `y0` and `y1` are the vertical centers of the ribbon at -/// the source and target end; the ribbon spans ±`width / 2` around them. +/// the source and target end. Each end has its own width: the links of a +/// node's side share the node height in proportion to their values, so both +/// sides of every node are always fully covered. On a balanced graph +/// (incoming sum == outgoing sum everywhere) the two ends are equal; on an +/// imbalanced one (e.g. sqrt-compressed values) the ribbon transitions +/// smoothly between the two widths. #[derive(Clone, Debug)] pub struct SankeyLinkLayout { pub index: usize, @@ -72,7 +77,13 @@ pub struct SankeyLinkLayout { pub value: f64, pub y0: f32, pub y1: f32, + /// The nominal width from the global value scale, used by the layout + /// relaxation; equals both end widths on a balanced graph. pub width: f32, + /// The ribbon width at the source end. + pub source_width: f32, + /// The ribbon width at the target end. + pub target_width: f32, } /// The computed Sankey layout. @@ -222,6 +233,8 @@ impl Sankey { y0: 0., y1: 0., width: 0., + source_width: 0., + target_width: 0., }) .collect(), }; @@ -671,31 +684,59 @@ fn source_top(graph: &SankeyGraph, source: usize, target: usize, py: f32) -> f32 y } -/// Assign each link's `y0`/`y1` (ribbon centers) by stacking the sorted link -/// lists within each node. +/// Assign each link's `y0`/`y1` (ribbon centers) and per-end widths by +/// stacking the sorted link lists within each node. Each side shares the +/// node height in proportion to the link values, so both sides of a node +/// are fully covered even when the graph is imbalanced (equivalent to the +/// nominal `width` stacking when it is balanced). fn compute_link_breadths(graph: &mut SankeyGraph) { for index in 0..graph.nodes.len() { - let node_y0 = graph.nodes[index].y0; + let node = &graph.nodes[index]; + let node_y0 = node.y0; + let node_height = node.y1 - node.y0; + let outgoing: f64 = node + .source_links + .iter() + .map(|&link| graph.links[link].value) + .sum(); let mut y0 = node_y0; for i in 0..graph.nodes[index].source_links.len() { let link = &mut graph.links[graph.nodes[index].source_links[i]]; - link.y0 = y0 + link.width / 2.; - y0 += link.width; + let width = if outgoing > 0. { + (link.value / outgoing) as f32 * node_height + } else { + 0. + }; + link.source_width = width; + link.y0 = y0 + width / 2.; + y0 += width; } + let node = &graph.nodes[index]; + let incoming: f64 = node + .target_links + .iter() + .map(|&link| graph.links[link].value) + .sum(); let mut y1 = node_y0; for i in 0..graph.nodes[index].target_links.len() { let link = &mut graph.links[graph.nodes[index].target_links[i]]; - link.y1 = y1 + link.width / 2.; - y1 += link.width; + let width = if incoming > 0. { + (link.value / incoming) as f32 * node_height + } else { + 0. + }; + link.target_width = width; + link.y1 = y1 + width / 2.; + y1 += width; } } } /// Build the filled ribbon path for a link — the equivalent of d3-sankey's /// `sankeyLinkHorizontal()`: a horizontal cubic bezier with control points at -/// the horizontal midpoint, thickened to the link width (clamped to +/// the horizontal midpoint, thickened to the per-end link widths (clamped to /// `min_width` so tiny flows stay visible). pub fn sankey_link_path( source: &SankeyNodeLayout, @@ -704,23 +745,24 @@ pub fn sankey_link_path( min_width: f32, origin: Point, ) -> Option> { - let half = link.width.max(min_width) / 2.; + let source_half = link.source_width.max(min_width) / 2.; + let target_half = link.target_width.max(min_width) / 2.; let sx = source.x1; let tx = target.x0; let mx = (sx + tx) / 2.; let mut builder = PathBuilder::fill(); - builder.move_to(origin_point(px(sx), px(link.y0 - half), origin)); + builder.move_to(origin_point(px(sx), px(link.y0 - source_half), origin)); builder.cubic_bezier_to( - origin_point(px(tx), px(link.y1 - half), origin), - origin_point(px(mx), px(link.y0 - half), origin), - origin_point(px(mx), px(link.y1 - half), origin), + origin_point(px(tx), px(link.y1 - target_half), origin), + origin_point(px(mx), px(link.y0 - source_half), origin), + origin_point(px(mx), px(link.y1 - target_half), origin), ); - builder.line_to(origin_point(px(tx), px(link.y1 + half), origin)); + builder.line_to(origin_point(px(tx), px(link.y1 + target_half), origin)); builder.cubic_bezier_to( - origin_point(px(sx), px(link.y0 + half), origin), - origin_point(px(mx), px(link.y1 + half), origin), - origin_point(px(mx), px(link.y0 + half), origin), + origin_point(px(sx), px(link.y0 + source_half), origin), + origin_point(px(mx), px(link.y1 + target_half), origin), + origin_point(px(mx), px(link.y0 + source_half), origin), ); builder.close(); builder.build().ok() @@ -801,6 +843,9 @@ mod tests { } for link in &graph.links { assert!((link.width - 100.).abs() < EPSILON); + // The chain is balanced, so both ribbon ends span the nodes. + assert!((link.source_width - 100.).abs() < EPSILON); + assert!((link.target_width - 100.).abs() < EPSILON); } // `topology` agrees with `layout` on the topological fields. @@ -881,17 +926,43 @@ mod tests { } else { (&graph.links[1], &graph.links[0]) }; - assert!((first.y0 - first.width / 2. - source.y0).abs() < EPSILON); - assert!((first.y0 + first.width / 2. - (second.y0 - second.width / 2.)).abs() < EPSILON); + assert!((first.y0 - first.source_width / 2. - source.y0).abs() < EPSILON); + assert!( + (first.y0 + first.source_width / 2. - (second.y0 - second.source_width / 2.)).abs() + < EPSILON + ); // Each target has a single incoming ribbon filling its full height. for link in &graph.links { let target = &graph.nodes[link.target]; - assert!((link.y1 - link.width / 2. - target.y0).abs() < EPSILON); - assert!((link.y1 + link.width / 2. - target.y1).abs() < EPSILON); + assert!((link.y1 - link.target_width / 2. - target.y0).abs() < EPSILON); + assert!((link.y1 + link.target_width / 2. - target.y1).abs() < EPSILON); } } + #[test] + fn test_sankey_imbalanced_link_widths() { + // A -> B (10) but B -> C (7): B is sized by its incoming flow, and + // its single outgoing ribbon is stretched to cover its outgoing side + // while the ribbon's target end matches C's height. + let graph = Sankey::new() + .size(100., 100.) + .layout(3, &links(&[(0, 1, 10.), (1, 2, 7.)])) + .unwrap(); + + let node_b = &graph.nodes[1]; + let node_c = &graph.nodes[2]; + let out_link = &graph.links[1]; + assert!((out_link.source_width - (node_b.y1 - node_b.y0)).abs() < EPSILON); + assert!((out_link.target_width - (node_c.y1 - node_c.y0)).abs() < EPSILON); + // The two ends differ: B is taller (value 10) than C (value 7). + assert!(out_link.source_width > out_link.target_width); + + // Both ends stay centered on their nodes' filled ranges. + assert!((out_link.y0 - (node_b.y0 + node_b.y1) / 2.).abs() < EPSILON); + assert!((out_link.y1 - (node_c.y0 + node_c.y1) / 2.).abs() < EPSILON); + } + #[test] fn test_sankey_value_conservation() { // Incoming 10, outgoing 7: node value takes the max. From 87465d20254cc6d4cfad474280457a49325d7506 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Thu, 9 Jul 2026 14:26:54 +0800 Subject: [PATCH 08/14] chart: Truncate overflowing sankey edge labels A first/last-column label wider than its reserved (and capped) margin was drawn at full width, spilling out of the plot area. Truncate it with a trailing ellipsis to the reserved width instead. Middle-column labels sit above interior nodes and never reach the plot edge, so they are left untruncated. Add a reusable `truncate_text_to_width` helper (binary search over char boundaries) to the plot label module. Co-Authored-By: Claude Fable 5 --- crates/ui/src/chart/sankey_chart.rs | 30 +++++++++++++++++----- crates/ui/src/plot/label.rs | 40 +++++++++++++++++++++++++++++ docs/docs/components/chart.md | 2 +- docs/zh-CN/docs/components/chart.md | 2 +- 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/chart/sankey_chart.rs b/crates/ui/src/chart/sankey_chart.rs index eab6850596..5aaa245b4c 100644 --- a/crates/ui/src/chart/sankey_chart.rs +++ b/crates/ui/src/chart/sankey_chart.rs @@ -10,7 +10,7 @@ use crate::{ ActiveTheme, plot::{ Plot, - label::{PlotLabel, TEXT_GAP, TEXT_SIZE, Text, measure_text_width}, + label::{PlotLabel, TEXT_GAP, TEXT_SIZE, Text, measure_text_width, truncate_text_to_width}, origin_point, shape::{Sankey, SankeyAlign, SankeyLink, sankey_link_path}, }, @@ -366,12 +366,26 @@ impl Plot for SankeyChart { let is_first = node.layer == 0; let is_last = node.layer + 1 == layer_count; - let (x, align) = if is_first { - (node.x0 - self.label_gap, TextAlign::Right) + // `x`/`align` place the label beside (first/last) or above + // (middle) the node. First/last labels are bounded to their + // reserved margin so a long one is truncated with an ellipsis + // instead of drawn outside the plot; middle labels sit above + // interior nodes and never reach the plot edge, so they are left + // untruncated (`INFINITY` makes the truncation a no-op). + let (x, align, max_width) = if is_first { + ( + node.x0 - self.label_gap, + TextAlign::Right, + left - self.label_gap, + ) } else if is_last { - (node.x1 + self.label_gap, TextAlign::Left) + ( + node.x1 + self.label_gap, + TextAlign::Left, + right - self.label_gap, + ) } else { - ((node.x0 + node.x1) / 2., TextAlign::Center) + ((node.x0 + node.x1) / 2., TextAlign::Center, f32::INFINITY) }; let block = block_height(lines); @@ -388,13 +402,15 @@ impl Plot for SankeyChart { }; for line in lines { + let font_size = px(line.font_size.unwrap_or(TEXT_SIZE)); + let text = truncate_text_to_width(&line.text, font_size, max_width, window); texts.push( Text::new( - line.text.clone(), + text, point(px(x), px(y)), line.color.unwrap_or(cx.theme().foreground), ) - .font_size(px(line.font_size.unwrap_or(TEXT_SIZE))) + .font_size(font_size) .align(align), ); y += line.line_height(); diff --git a/crates/ui/src/plot/label.rs b/crates/ui/src/plot/label.rs index 59a0817cc3..45809ee6fd 100644 --- a/crates/ui/src/plot/label.rs +++ b/crates/ui/src/plot/label.rs @@ -52,6 +52,46 @@ pub fn measure_text_width(text: &SharedString, font_size: Pixels, window: &mut W .as_f32() } +/// Truncate `text` with a trailing ellipsis so it fits within `max_width` at +/// `font_size`. Returns `text` unchanged when it already fits; returns just +/// the ellipsis when not even one character fits. `max_width <= 0` is treated +/// as "no budget" and returns `text` unchanged (the caller has no room to +/// reason about). +pub fn truncate_text_to_width( + text: &SharedString, + font_size: Pixels, + max_width: f32, + window: &mut Window, +) -> SharedString { + if max_width <= 0. || measure_text_width(text, font_size, window) <= max_width { + return text.clone(); + } + + const ELLIPSIS: &str = "…"; + // Byte offsets of every char boundary after the first char; cutting at + // `cuts[k]` keeps the first `k + 1` chars. + let cuts: Vec = text.char_indices().skip(1).map(|(i, _)| i).collect(); + + // Binary search the largest prefix whose text + ellipsis still fits. + let mut best: Option = None; + let (mut lo, mut hi) = (0usize, cuts.len()); + while lo < hi { + let mid = (lo + hi) / 2; + let candidate = SharedString::from(format!("{}{ELLIPSIS}", &text[..cuts[mid]])); + if measure_text_width(&candidate, font_size, window) <= max_width { + best = Some(mid); + lo = mid + 1; + } else { + hi = mid; + } + } + + match best { + Some(mid) => SharedString::from(format!("{}{ELLIPSIS}", &text[..cuts[mid]])), + None => SharedString::from(ELLIPSIS), + } +} + pub struct Text { pub text: SharedString, pub origin: Point, diff --git a/docs/docs/components/chart.md b/docs/docs/components/chart.md index 234ecf36fc..af49897158 100644 --- a/docs/docs/components/chart.md +++ b/docs/docs/components/chart.md @@ -456,7 +456,7 @@ SankeyChart::new(nodes, links).labels(move |d: &FlowNode, value| { }) ``` -Line color defaults to the theme foreground and font size to 10; the chart keeps handling placement, alignment and margin reservation. +Line color defaults to the theme foreground and font size to 10; the chart keeps handling placement, alignment and margin reservation. A first/last-column label wider than its reserved margin is truncated with a trailing ellipsis rather than drawn outside the plot, so break or shorten long labels yourself if you want the full text on multiple lines. #### Compressing Large Value Ranges diff --git a/docs/zh-CN/docs/components/chart.md b/docs/zh-CN/docs/components/chart.md index 8e2f58e14c..de71aa1f55 100644 --- a/docs/zh-CN/docs/components/chart.md +++ b/docs/zh-CN/docs/components/chart.md @@ -436,7 +436,7 @@ SankeyChart::new(nodes, links).labels(move |d: &FlowNode, value| { }) ``` -行颜色默认为主题前景色,字号默认 10;摆位、对齐和边距预留仍由组件负责。 +行颜色默认为主题前景色,字号默认 10;摆位、对齐和边距预留仍由组件负责。首/末列标签若超出预留边距,会被截断并加省略号,而不会画到图表外;若想让长标签完整分多行显示,请自行折断或缩短。 #### 压缩数值跨度 From 7443f1e8f00d8e12fb823a012ff2f8f028df782c Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Thu, 9 Jul 2026 16:24:51 +0800 Subject: [PATCH 09/14] chart: Vertically center each sankey column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A crowded column forces a small scale, so sparser columns don't fill the height, and the relaxation aligns them to their flows' weighted center — leaving the trunk sitting high with empty space below. Center each column's stack in the extent by translating it (preserving the relaxation's within-column arrangement and the ribbon fits), so the diagram stays balanced on the canvas. Co-Authored-By: Claude Fable 5 --- crates/ui/src/plot/shape/sankey.rs | 73 ++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/crates/ui/src/plot/shape/sankey.rs b/crates/ui/src/plot/shape/sankey.rs index e44310c9b4..1205187f3d 100644 --- a/crates/ui/src/plot/shape/sankey.rs +++ b/crates/ui/src/plot/shape/sankey.rs @@ -281,10 +281,56 @@ impl Sankey { self.compute_node_breadths(&mut graph, &mut columns); compute_link_breadths(&mut graph); + self.center_columns(&mut graph); graph } + /// Vertically center each column's stack of nodes within the extent. + /// + /// When a crowded column forces a small scale, the sparser columns don't + /// fill the height and the relaxation aligns them to their flows' weighted + /// center, which leaves the trunk sitting high with empty space below. + /// d3-sankey doesn't correct this; centering each column (translating it, + /// so the relaxation's within-column arrangement and the ribbon fits are + /// preserved) keeps the diagram balanced on the canvas. + fn center_columns(&self, graph: &mut SankeyGraph) { + let layers = graph.layer_count(); + if layers == 0 { + return; + } + + // Per-layer bounding box, then the offset that centers it. + let mut lo = vec![f32::INFINITY; layers]; + let mut hi = vec![f32::NEG_INFINITY; layers]; + for node in &graph.nodes { + lo[node.layer] = lo[node.layer].min(node.y0); + hi[node.layer] = hi[node.layer].max(node.y1); + } + let offsets: Vec = (0..layers) + .map(|l| { + if lo[l].is_finite() && hi[l] > lo[l] { + (self.y0 + self.y1 - lo[l] - hi[l]) / 2. + } else { + 0. + } + }) + .collect(); + + // Precompute each node's offset so the link loop can read it without + // borrowing `nodes` while mutating `links`. + let node_offset: Vec = graph.nodes.iter().map(|n| offsets[n.layer]).collect(); + for node in &mut graph.nodes { + let dy = node_offset[node.index]; + node.y0 += dy; + node.y1 += dy; + } + for link in &mut graph.links { + link.y0 += node_offset[link.source]; + link.y1 += node_offset[link.target]; + } + } + fn align_layer(&self, graph: &SankeyGraph, index: usize, n: usize) -> usize { let node = &graph.nodes[index]; match self.align { @@ -980,6 +1026,33 @@ mod tests { } } + #[test] + fn test_sankey_vertical_centering() { + // Each column's stack must be centered in the extent: its midpoint + // equals the extent midpoint. A chain that fans out at the end leaves + // the sparse early columns off-center before centering. + let graph = Sankey::new() + .node_padding(20.) + .extent(0., 10., 100., 90.) + .layout( + 5, + &links(&[(0, 1, 40.), (1, 2, 40.), (2, 3, 20.), (2, 4, 20.)]), + ) + .unwrap(); + + let layers = graph.layer_count(); + let mut lo = vec![f32::INFINITY; layers]; + let mut hi = vec![f32::NEG_INFINITY; layers]; + for node in &graph.nodes { + lo[node.layer] = lo[node.layer].min(node.y0); + hi[node.layer] = hi[node.layer].max(node.y1); + } + // Extent is [10, 90], midpoint 50; every column's midpoint matches. + for l in 0..layers { + assert!(((lo[l] + hi[l]) / 2. - 50.).abs() < EPSILON); + } + } + #[test] fn test_sankey_circular_link() { let sankey = Sankey::new().size(100., 100.); From 8cd42c8dfab6192954fbff82da3eb20859439f99 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Thu, 9 Jul 2026 16:45:45 +0800 Subject: [PATCH 10/14] chart: Stagger flat runs of equal single-node sankey columns Centering aligns adjacent single-node columns of equal height exactly, so the ribbon between them is a flat rectangle. Nudge the odd columns of such a run off the center line (bounded by the available slack, so a full-height column stays put and never leaves the extent) to turn the flat ribbon into a gentle S-curve. Only the degenerate flat case is touched. Co-Authored-By: Claude Fable 5 --- crates/ui/src/plot/shape/sankey.rs | 123 +++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 14 deletions(-) diff --git a/crates/ui/src/plot/shape/sankey.rs b/crates/ui/src/plot/shape/sankey.rs index 1205187f3d..c2b0fcb9e2 100644 --- a/crates/ui/src/plot/shape/sankey.rs +++ b/crates/ui/src/plot/shape/sankey.rs @@ -4,6 +4,10 @@ use gpui::{Path, PathBuilder, Pixels, Point, px}; use crate::plot::origin_point; +/// Vertical offset, as a fraction of node height, applied to stagger runs of +/// equal-height single-node columns so their otherwise-flat ribbons curve. +const STAGGER_RATIO: f32 = 0.15; + /// Horizontal alignment of nodes across layers. /// /// Mirrors d3-sankey's `sankeyLeft` / `sankeyRight` / `sankeyCenter` / @@ -282,6 +286,7 @@ impl Sankey { self.compute_node_breadths(&mut graph, &mut columns); compute_link_breadths(&mut graph); self.center_columns(&mut graph); + self.stagger_flat_columns(&mut graph); graph } @@ -317,18 +322,59 @@ impl Sankey { }) .collect(); - // Precompute each node's offset so the link loop can read it without - // borrowing `nodes` while mutating `links`. - let node_offset: Vec = graph.nodes.iter().map(|n| offsets[n.layer]).collect(); - for node in &mut graph.nodes { - let dy = node_offset[node.index]; - node.y0 += dy; - node.y1 += dy; + apply_layer_offsets(graph, &offsets); + } + + /// Nudge runs of adjacent single-node columns of (near-)equal height off + /// the shared center line. Centering aligns such columns exactly, so the + /// ribbon between them is a flat rectangle; a small alternating stagger + /// turns it into a gentle S-curve. Only the flat single-node case is + /// touched, so multi-node or unequal columns (which already curve) are + /// left alone. + fn stagger_flat_columns(&self, graph: &mut SankeyGraph) { + let layers = graph.layer_count(); + if layers < 2 { + return; } - for link in &mut graph.links { - link.y0 += node_offset[link.source]; - link.y1 += node_offset[link.target]; + + let mut count = vec![0usize; layers]; + let mut single = vec![usize::MAX; layers]; + for node in &graph.nodes { + count[node.layer] += 1; + single[node.layer] = node.index; + } + let heights: Vec = (0..layers) + .map(|l| { + if count[l] == 1 { + let n = &graph.nodes[single[l]]; + n.y1 - n.y0 + } else { + 0. + } + }) + .collect(); + + // Offset the odd columns of each flat run, leaving the even ones on + // the center line, so consecutive ribbons bend down then back up. + let mut offsets = vec![0f32; layers]; + let mut run = 0usize; + for l in 1..layers { + let flat = + count[l] == 1 && count[l - 1] == 1 && (heights[l] - heights[l - 1]).abs() < 1e-3; + if flat { + run += 1; + if run % 2 == 1 { + // Bound the nudge by the slack so the node stays inside + // the extent (a column that fills the height can't move). + let slack = (self.y1 - self.y0 - heights[l]).max(0.); + offsets[l] = (heights[l] * STAGGER_RATIO).min(slack / 2.); + } + } else { + run = 0; + } } + + apply_layer_offsets(graph, &offsets); } fn align_layer(&self, graph: &SankeyGraph, index: usize, n: usize) -> usize { @@ -538,6 +584,23 @@ impl Sankey { } } +/// Shift every node (and its attached link ends) by its layer's offset. +/// Used by both column centering and flat-run staggering. +fn apply_layer_offsets(graph: &mut SankeyGraph, offsets: &[f32]) { + // Precompute per-node offsets so the link loop doesn't borrow `nodes` + // while mutating `links`. + let node_offset: Vec = graph.nodes.iter().map(|n| offsets[n.layer]).collect(); + for node in &mut graph.nodes { + let dy = node_offset[node.index]; + node.y0 += dy; + node.y1 += dy; + } + for link in &mut graph.links { + link.y0 += node_offset[link.source]; + link.y1 += node_offset[link.target]; + } +} + fn compute_node_links(graph: &mut SankeyGraph) { for index in 0..graph.links.len() { let (source, target) = (graph.links[index].source, graph.links[index].target); @@ -1028,15 +1091,15 @@ mod tests { #[test] fn test_sankey_vertical_centering() { - // Each column's stack must be centered in the extent: its midpoint - // equals the extent midpoint. A chain that fans out at the end leaves - // the sparse early columns off-center before centering. + // Each column's stack is centered in the extent: its midpoint equals + // the extent midpoint. Uses multi-node columns so the flat-run stagger + // (which only touches equal single-node columns) does not apply. let graph = Sankey::new() .node_padding(20.) .extent(0., 10., 100., 90.) .layout( 5, - &links(&[(0, 1, 40.), (1, 2, 40.), (2, 3, 20.), (2, 4, 20.)]), + &links(&[(0, 2, 40.), (1, 2, 10.), (2, 3, 25.), (2, 4, 25.)]), ) .unwrap(); @@ -1053,6 +1116,38 @@ mod tests { } } + #[test] + fn test_sankey_stagger_flat_columns() { + // Two equal single-node columns feeding a fan-out: the sparse trunk + // columns don't fill the height, so the equal pair is staggered off + // the center line to curve the otherwise-flat ribbon between them. + let graph = Sankey::new() + .node_padding(20.) + .size(100., 100.) + .layout( + 6, + &links(&[ + (0, 1, 100.), + (1, 2, 40.), + (1, 3, 30.), + (1, 4, 20.), + (1, 5, 10.), + ]), + ) + .unwrap(); + + // Nodes 0 and 1 are the equal single-node columns; one is nudged off + // center so their centers differ (the ribbon is no longer flat), but + // both stay within the extent. + let c0 = (graph.nodes[0].y0 + graph.nodes[0].y1) / 2.; + let c1 = (graph.nodes[1].y0 + graph.nodes[1].y1) / 2.; + assert!((c0 - c1).abs() > EPSILON); + for node in &graph.nodes { + assert!(node.y0 >= -EPSILON); + assert!(node.y1 <= 100. + EPSILON); + } + } + #[test] fn test_sankey_circular_link() { let sankey = Sankey::new().size(100., 100.); From a3151c69eeebc4af2f9cc0ee458f2558d20b4af9 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Thu, 9 Jul 2026 16:45:45 +0800 Subject: [PATCH 11/14] chart: Cap each sankey label margin per side A very long first/last-column label reserved up to a shared 60% of the width, so even after truncation it dominated the chart and squeezed the flow into a narrow band. Cap each side independently at 20% of the width instead, so a long label is truncated to a modest column and the flow area stays dominant. Co-Authored-By: Claude Fable 5 --- crates/ui/src/chart/sankey_chart.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/chart/sankey_chart.rs b/crates/ui/src/chart/sankey_chart.rs index 5aaa245b4c..a1746397ad 100644 --- a/crates/ui/src/chart/sankey_chart.rs +++ b/crates/ui/src/chart/sankey_chart.rs @@ -21,7 +21,10 @@ const DEFAULT_NODE_PADDING: f32 = 16.; const DEFAULT_LINK_OPACITY: f32 = 0.3; const DEFAULT_MIN_LINK_WIDTH: f32 = 1.; const DEFAULT_LABEL_GAP: f32 = 6.; -/// Cap the label margins so very long labels never collapse the flow area. +/// Cap each side's label margin (as a fraction of width) so a long label is +/// truncated to a modest column beside the flow instead of dominating it. +const MAX_LABEL_WIDTH_RATIO: f32 = 0.2; +/// Cap the reserved top+bottom label band as a fraction of height. const MAX_LABEL_MARGIN_RATIO: f32 = 0.6; /// A styled line of a sankey node label. @@ -265,12 +268,11 @@ impl Plot for SankeyChart { } } - let max_margin = width * MAX_LABEL_MARGIN_RATIO; - if left + right > max_margin { - let k = max_margin / (left + right); - left *= k; - right *= k; - } + // Cap each side independently so one long label is truncated to a + // modest column rather than eating into the flow area. + let side_cap = width * MAX_LABEL_WIDTH_RATIO; + left = left.min(side_cap); + right = right.min(side_cap); } // Above-node labels are only emitted for middle columns, so reserve // the top band for the tallest such label block. Cap the vertical From cc3e7b6b91d84df3c6ea9660993a65df4363f6c0 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Thu, 9 Jul 2026 17:19:56 +0800 Subject: [PATCH 12/14] chart: Add a built-in sqrt value scale for `SankeyChart` Add `SankeyValueScale { Linear, Sqrt }` and a `value_scale` builder so a wide value range can be compressed without the caller pre-transforming its data: a dominant flow no longer dwarfs the small ones and the small ones stay visible. The scale is applied once at the layout entry, so all downstream math stays additive in scaled space and every node remains exactly filled by its ribbons (children match their parent's height) under either scale. Labels receive the raw throughput, computed from the input links, not the scaled layout value. The TSLA story examples now use `.value_scale(Sqrt)` instead of manually square-rooting the link values. Co-Authored-By: Claude Fable 5 --- .../src/stories/chart_story/chart_story.rs | 42 ++++------ crates/ui/src/chart/sankey_chart.rs | 43 +++++++++- crates/ui/src/plot/shape.rs | 2 +- crates/ui/src/plot/shape/sankey.rs | 80 ++++++++++++++++++- docs/docs/components/chart.md | 21 ++--- docs/zh-CN/docs/components/chart.md | 21 ++--- 6 files changed, 144 insertions(+), 65 deletions(-) diff --git a/crates/story/src/stories/chart_story/chart_story.rs b/crates/story/src/stories/chart_story/chart_story.rs index d429079937..05dd0d1ba9 100644 --- a/crates/story/src/stories/chart_story/chart_story.rs +++ b/crates/story/src/stories/chart_story/chart_story.rs @@ -8,7 +8,7 @@ use gpui_component::{ chart::{AreaChart, BarChart, CandlestickChart, LineChart, PieChart, SankeyChart, SankeyLabel}, dock::PanelControl, h_flex, - plot::shape::{BarAlignment, SankeyAlign, SankeyLink}, + plot::shape::{BarAlignment, SankeyAlign, SankeyLink, SankeyValueScale}, separator::Separator, v_flex, }; @@ -720,34 +720,17 @@ impl Render for ChartStory { .iter() .enumerate() .map(|(index, (period, nodes, links))| { - // The first chart sqrt-compresses the link values - // so the huge revenue flows don't dwarf the small - // profit flows; the second keeps them linear. - let sqrt = index == 0; - let links: Vec = if sqrt { - links - .iter() - .map(|link| { - SankeyLink::new(link.source, link.target, link.value.sqrt()) - }) - .collect() - } else { - links.clone() - }; - let title = if sqrt { - format!("Sankey Chart - TSLA {} (sqrt values)", period) - } else { - format!("Sankey Chart - TSLA {}", period) - }; - - let chart = SankeyChart::new(nodes.clone(), links) + // Sqrt value scale keeps the huge revenue flow from + // dwarfing the small profit/expense ones. + let chart = SankeyChart::new(nodes.clone(), links.clone()) .node_align(SankeyAlign::Center) .node_padding(40.) + .value_scale(SankeyValueScale::Sqrt) .node_color(|d: &TslaNode| d.color); - // The sqrt chart uses fully custom three-line - // labels with the year-over-year change; the - // other keeps the default value/name lines. - let chart = if sqrt { + // The first chart shows fully custom three-line + // labels with the year-over-year change; the other + // keeps the default value/name lines. + let chart = if index == 0 { let up = cx.theme().success; let down = cx.theme().danger; let muted = cx.theme().muted_foreground; @@ -772,7 +755,12 @@ impl Render for ChartStory { }) }; - chart_container(&title, chart, false, cx) + chart_container( + &format!("Sankey Chart - TSLA {}", period), + chart, + false, + cx, + ) }) .collect::>(), ), diff --git a/crates/ui/src/chart/sankey_chart.rs b/crates/ui/src/chart/sankey_chart.rs index a1746397ad..c7a2b28226 100644 --- a/crates/ui/src/chart/sankey_chart.rs +++ b/crates/ui/src/chart/sankey_chart.rs @@ -12,7 +12,7 @@ use crate::{ Plot, label::{PlotLabel, TEXT_GAP, TEXT_SIZE, Text, measure_text_width, truncate_text_to_width}, origin_point, - shape::{Sankey, SankeyAlign, SankeyLink, sankey_link_path}, + shape::{Sankey, SankeyAlign, SankeyLink, SankeyValueScale, sankey_link_path}, }, }; @@ -77,6 +77,7 @@ pub struct SankeyChart { node_padding: f32, align: SankeyAlign, iterations: usize, + value_scale: SankeyValueScale, node_corner_radius: Option, node_color: Option Hsla>>, node_label: Option SharedString>>, @@ -100,6 +101,7 @@ impl SankeyChart { node_padding: DEFAULT_NODE_PADDING, align: SankeyAlign::default(), iterations: 6, + value_scale: SankeyValueScale::default(), node_corner_radius: None, node_color: None, node_label: None, @@ -135,6 +137,16 @@ impl SankeyChart { self } + /// Set how flow values map to node heights. + /// + /// Defaults to [`SankeyValueScale::Linear`]. Use [`SankeyValueScale::Sqrt`] + /// to keep a dominant flow from dwarfing the small ones without + /// pre-transforming the data; labels still receive the raw values. + pub fn value_scale(mut self, value_scale: SankeyValueScale) -> Self { + self.value_scale = value_scale; + self + } + /// Set the corner radius of the node rectangles. Defaults to 0. pub fn node_corner_radius(mut self, radius: impl Into) -> Self { self.node_corner_radius = Some(radius.into()); @@ -201,6 +213,28 @@ impl SankeyChart { .node_padding(self.node_padding) .node_align(self.align) .iterations(self.iterations) + .value_scale(self.value_scale) + } + + /// Raw per-node throughput (max of raw incoming and outgoing sums), for + /// labels — the layout's `node.value` is in scaled units under a + /// non-linear value scale, so labels must not use it. + fn raw_throughput(&self) -> Vec { + let mut incoming = vec![0f64; self.nodes.len()]; + let mut outgoing = vec![0f64; self.nodes.len()]; + for link in &self.links { + if let (Some(o), Some(i)) = + (outgoing.get_mut(link.source), incoming.get_mut(link.target)) + { + *o += link.value; + *i += link.value; + } + } + incoming + .into_iter() + .zip(outgoing) + .map(|(i, o)| i.max(o)) + .collect() } } @@ -218,6 +252,8 @@ impl Plot for SankeyChart { return; }; let layer_count = topology.layer_count(); + // Labels get the raw throughput, not the layout's (possibly scaled) value. + let raw_value = self.raw_throughput(); // Collect each node's label lines: the custom `labels` closure wins, // otherwise synthesize the value/name lines with the default styles. @@ -226,12 +262,13 @@ impl Plot for SankeyChart { .iter() .map(|node| { let datum = &self.nodes[node.index]; + let value = raw_value[node.index]; if let Some(labels) = &self.labels { - labels(datum, node.value) + labels(datum, value) } else { let mut lines = Vec::new(); if let Some(value_label) = &self.value_label { - lines.push(SankeyLabel::new(value_label(datum, node.value))); + lines.push(SankeyLabel::new(value_label(datum, value))); } if let Some(node_label) = &self.node_label { lines.push( diff --git a/crates/ui/src/plot/shape.rs b/crates/ui/src/plot/shape.rs index b45f78f389..6b483c84fc 100644 --- a/crates/ui/src/plot/shape.rs +++ b/crates/ui/src/plot/shape.rs @@ -13,6 +13,6 @@ pub use line::Line; pub use pie::Pie; pub use sankey::{ Sankey, SankeyAlign, SankeyError, SankeyGraph, SankeyLink, SankeyLinkLayout, SankeyNodeLayout, - sankey_link_path, + SankeyValueScale, sankey_link_path, }; pub use stack::{Stack, StackPoint, StackSeries}; diff --git a/crates/ui/src/plot/shape/sankey.rs b/crates/ui/src/plot/shape/sankey.rs index c2b0fcb9e2..1392c9fe17 100644 --- a/crates/ui/src/plot/shape/sankey.rs +++ b/crates/ui/src/plot/shape/sankey.rs @@ -46,7 +46,9 @@ impl SankeyLink { #[derive(Clone, Debug, Default)] pub struct SankeyNodeLayout { pub index: usize, - /// The node's throughput: max(sum of incoming values, sum of outgoing values). + /// The node's throughput in the layout's value space: max(sum of incoming, + /// sum of outgoing). With a non-linear [`SankeyValueScale`] this is in + /// scaled units, not raw values — read raw values from the input links. pub value: f64, /// Topological distance from any source node (longest path). pub depth: usize, @@ -78,6 +80,8 @@ pub struct SankeyLinkLayout { pub index: usize, pub source: usize, pub target: usize, + /// The flow value in the layout's value space (scaled by + /// [`SankeyValueScale`]; equals the raw input value under `Linear`). pub value: f64, pub y0: f32, pub y1: f32, @@ -127,12 +131,35 @@ impl std::fmt::Display for SankeyError { impl std::error::Error for SankeyError {} +/// How flow values map to node heights and ribbon widths. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SankeyValueScale { + /// Height is proportional to the value (standard sankey semantics). + #[default] + Linear, + /// Height is proportional to the square root of the value, compressing a + /// wide value range so a dominant flow doesn't dwarf the small ones (and + /// the small ones stay visible) without the caller pre-transforming data. + Sqrt, +} + +impl SankeyValueScale { + fn apply(self, value: f64) -> f64 { + match self { + Self::Linear => value, + // Guard against tiny negatives from bad data. + Self::Sqrt => value.max(0.).sqrt(), + } + } +} + /// The Sankey layout generator. pub struct Sankey { node_width: f32, node_padding: f32, align: SankeyAlign, iterations: usize, + value_scale: SankeyValueScale, x0: f32, y0: f32, x1: f32, @@ -146,6 +173,7 @@ impl Default for Sankey { node_padding: 8., align: SankeyAlign::default(), iterations: 6, + value_scale: SankeyValueScale::default(), x0: 0., y0: 0., x1: 1., @@ -183,6 +211,12 @@ impl Sankey { self } + /// Set how values map to heights. Defaults to [`SankeyValueScale::Linear`]. + pub fn value_scale(mut self, value_scale: SankeyValueScale) -> Self { + self.value_scale = value_scale; + self + } + /// Set the layout bounds as `[[x0, y0], [x1, y1]]`. Defaults to `[[0, 0], [1, 1]]`. pub fn extent(mut self, x0: f32, y0: f32, x1: f32, y1: f32) -> Self { self.x0 = x0; @@ -233,7 +267,11 @@ impl Sankey { index, source: link.source, target: link.target, - value: link.value, + // Layout works in scaled value space; all downstream math + // (node values, widths, breadths) stays coherent because + // it is additive in this space, so nodes remain exactly + // filled by their ribbons regardless of the scale. + value: self.value_scale.apply(link.value), y0: 0., y1: 0., width: 0., @@ -1072,6 +1110,44 @@ mod tests { assert!((out_link.y1 - (node_c.y0 + node_c.y1) / 2.).abs() < EPSILON); } + #[test] + fn test_sankey_sqrt_scale_fills_nodes() { + // With the sqrt scale, a node's children must still exactly fill it: + // the incoming and outgoing ribbon widths each sum to the node height + // (no gaps), and the diagram is compressed vs linear. + let links = links(&[(0, 1, 90.), (1, 2, 40.), (1, 3, 50.)]); + let sqrt = Sankey::new() + .value_scale(SankeyValueScale::Sqrt) + .size(100., 100.) + .layout(4, &links) + .unwrap(); + + for node in &sqrt.nodes { + let node_height = node.y1 - node.y0; + let incoming: f32 = node + .target_links + .iter() + .map(|&l| sqrt.links[l].target_width) + .sum(); + let outgoing: f32 = node + .source_links + .iter() + .map(|&l| sqrt.links[l].source_width) + .sum(); + if !node.target_links.is_empty() { + assert!((incoming - node_height).abs() < EPSILON); + } + if !node.source_links.is_empty() { + assert!((outgoing - node_height).abs() < EPSILON); + } + } + + // Two leaf nodes (50 and 40) show the sqrt compression directly: + // their height ratio is sqrt(50/40), not the linear 50/40. + let ratio = (sqrt.nodes[3].y1 - sqrt.nodes[3].y0) / (sqrt.nodes[2].y1 - sqrt.nodes[2].y0); + assert!((ratio - (50f32 / 40.).sqrt()).abs() < 0.02); + } + #[test] fn test_sankey_value_conservation() { // Incoming 10, outgoing 7: node value takes the max. diff --git a/docs/docs/components/chart.md b/docs/docs/components/chart.md index af49897158..d42c810970 100644 --- a/docs/docs/components/chart.md +++ b/docs/docs/components/chart.md @@ -460,27 +460,16 @@ Line color defaults to the theme foreground and font size to 10; the chart keeps #### Compressing Large Value Ranges -Node heights are linear in flow value, so a large value range (e.g. 200:1) leaves the small flows nearly invisible. Feed compressed values (e.g. square roots) to the layout, and show the real value in the label: +Node heights are linear in flow value by default, so a large value range (e.g. 200:1) leaves the small flows nearly invisible and the dominant flow oversized. Set `value_scale(SankeyValueScale::Sqrt)` to compress the range — the component sizes nodes by the square root of the value, so small flows stay visible without pre-transforming the data, and labels still receive the raw values: ```rust -// Nodes carry the real value for the label. -#[derive(Clone)] -struct FlowNode { - pub name: SharedString, - pub value: f64, -} +use gpui_component::plot::shape::SankeyValueScale; -// Feed sqrt-compressed values to the layout so small flows stay visible. -let links = raw_links - .iter() - .map(|l| SankeyLink::new(l.source, l.target, l.value.sqrt())) - .collect::>(); - -SankeyChart::new(nodes, links) - // Show the real value from the datum, not the compressed throughput. - .value_label(|d: &FlowNode, _| format!("{:.1}", d.value).into()) +SankeyChart::new(nodes, links).value_scale(SankeyValueScale::Sqrt) ``` +Every node stays exactly filled by its ribbons under either scale, so children always match their parent's height. + ## Data Structures ### Example Data Types diff --git a/docs/zh-CN/docs/components/chart.md b/docs/zh-CN/docs/components/chart.md index de71aa1f55..50a2673172 100644 --- a/docs/zh-CN/docs/components/chart.md +++ b/docs/zh-CN/docs/components/chart.md @@ -440,27 +440,16 @@ SankeyChart::new(nodes, links).labels(move |d: &FlowNode, value| { #### 压缩数值跨度 -节点高度与流量值成线性关系,数值跨度很大时(如 200:1)小流量几乎不可见。可以把压缩后的值(如平方根)交给布局,标签仍显示真实值: +节点高度默认与流量值成线性关系,数值跨度很大时(如 200:1)小流量几乎不可见、主流量过大。设置 `value_scale(SankeyValueScale::Sqrt)` 即可压缩跨度——组件按值的平方根来定节点高度,小流量保持可见,且无需预处理数据,标签仍显示真实值: ```rust -// 节点自身携带用于标签的真实值。 -#[derive(Clone)] -struct FlowNode { - pub name: SharedString, - pub value: f64, -} +use gpui_component::plot::shape::SankeyValueScale; -// 把 sqrt 压缩后的值交给布局,让小流量保持可见。 -let links = raw_links - .iter() - .map(|l| SankeyLink::new(l.source, l.target, l.value.sqrt())) - .collect::>(); - -SankeyChart::new(nodes, links) - // 标签从数据里取真实值,而不是压缩后的吞吐量。 - .value_label(|d: &FlowNode, _| format!("{:.1}", d.value).into()) +SankeyChart::new(nodes, links).value_scale(SankeyValueScale::Sqrt) ``` +无论用哪种缩放,每个节点都被其连接精确填满,所以子节点高度始终与父节点匹配。 + ## 数据结构示例 ```rust From 6abebf19f70bc272b00a47716925654f05790d17 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Thu, 9 Jul 2026 17:41:04 +0800 Subject: [PATCH 13/14] chart: Cover sankey layout invariants and bound middle labels Follow-ups from an Opus review of the sankey implementation (no correctness bugs were found): - Assert link ends stay attached to their nodes after `stagger_flat_columns` (guards a source/target mix-up in `apply_layer_offsets`), and that `center_columns` keeps every node in the extent. - Test `raw_throughput`: it returns raw per-node sums and, under `Sqrt`, differs from the layout's scaled node value (the label value-leak guard). - Bound middle-column labels by the distance to the plot edge so a runaway one is truncated rather than drawn off-plot, instead of the previous unenforced "never reaches the edge" assumption. Co-Authored-By: Claude Fable 5 --- crates/ui/src/chart/sankey_chart.rs | 51 +++++++++++++++++++++++++---- crates/ui/src/plot/shape/sankey.rs | 24 ++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/chart/sankey_chart.rs b/crates/ui/src/chart/sankey_chart.rs index c7a2b28226..f294586f0e 100644 --- a/crates/ui/src/chart/sankey_chart.rs +++ b/crates/ui/src/chart/sankey_chart.rs @@ -405,12 +405,12 @@ impl Plot for SankeyChart { let is_first = node.layer == 0; let is_last = node.layer + 1 == layer_count; - // `x`/`align` place the label beside (first/last) or above - // (middle) the node. First/last labels are bounded to their - // reserved margin so a long one is truncated with an ellipsis - // instead of drawn outside the plot; middle labels sit above - // interior nodes and never reach the plot edge, so they are left - // untruncated (`INFINITY` makes the truncation a no-op). + // `x`/`align` place the label beside (first/last) or centered above + // (middle) the node, and `max_width` bounds it so a long label is + // truncated with an ellipsis instead of drawn outside the plot: + // first/last to their reserved margin, middle to twice the smaller + // gap to the plot edge (generous for interior nodes, only bites a + // label long enough to actually run off-plot). let (x, align, max_width) = if is_first { ( node.x0 - self.label_gap, @@ -424,7 +424,9 @@ impl Plot for SankeyChart { right - self.label_gap, ) } else { - ((node.x0 + node.x1) / 2., TextAlign::Center, f32::INFINITY) + let center = (node.x0 + node.x1) / 2.; + let edge_budget = 2. * center.min(width - center); + (center, TextAlign::Center, edge_budget) }; let block = block_height(lines); @@ -532,4 +534,39 @@ mod tests { ); assert_eq!(block_height(&[]), 0.); } + + #[test] + fn test_sankey_chart_raw_throughput() { + // A(out 30) -> B, B -> C(20) + D(10): B's throughput is max(in, out). + let chart = SankeyChart::new( + vec!["a", "b", "c", "d"], + vec![ + SankeyLink::new(0, 1, 30.), + SankeyLink::new(1, 2, 20.), + SankeyLink::new(1, 3, 10.), + ], + ); + let raw = chart.raw_throughput(); + assert_eq!(raw, vec![30., 30., 20., 10.]); + + // Under Sqrt the layout's node value is scaled, but raw_throughput + // (used for labels) must stay in raw units — the two must differ. + let sqrt = chart + .value_scale(SankeyValueScale::Sqrt) + .sankey() + .layout(4, &chart_links()) + .unwrap(); + // Node A: layout value is sqrt-scaled (30 -> sqrt(30)), raw is 30. + assert!((sqrt.nodes[0].value - 30f64.sqrt()).abs() < 1e-6); + assert!((raw[0] - 30.).abs() < 1e-6); + assert!(raw[0] != sqrt.nodes[0].value); + } + + fn chart_links() -> Vec { + vec![ + SankeyLink::new(0, 1, 30.), + SankeyLink::new(1, 2, 20.), + SankeyLink::new(1, 3, 10.), + ] + } } diff --git a/crates/ui/src/plot/shape/sankey.rs b/crates/ui/src/plot/shape/sankey.rs index 1392c9fe17..e4f60bb56f 100644 --- a/crates/ui/src/plot/shape/sankey.rs +++ b/crates/ui/src/plot/shape/sankey.rs @@ -1190,6 +1190,11 @@ mod tests { for l in 0..layers { assert!(((lo[l] + hi[l]) / 2. - 50.).abs() < EPSILON); } + // Centering keeps every node within the extent. + for node in &graph.nodes { + assert!(node.y0 >= 10. - EPSILON); + assert!(node.y1 <= 90. + EPSILON); + } } #[test] @@ -1222,6 +1227,25 @@ mod tests { assert!(node.y0 >= -EPSILON); assert!(node.y1 <= 100. + EPSILON); } + + // After the stagger's per-layer shift, each ribbon end must still be + // attached to its node: `y0` centered on the source node's outgoing + // stack, `y1` on the target node's incoming stack. (Guards against a + // source/target mix-up in `apply_layer_offsets`.) + for node in &graph.nodes { + let mut y = node.y0; + for &l in &node.source_links { + let link = &graph.links[l]; + assert!((link.y0 - (y + link.source_width / 2.)).abs() < EPSILON); + y += link.source_width; + } + let mut y = node.y0; + for &l in &node.target_links { + let link = &graph.links[l]; + assert!((link.y1 - (y + link.target_width / 2.)).abs() < EPSILON); + y += link.target_width; + } + } } #[test] From d0d1816ca857456b2b2f82b07f58504bb8daddd7 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Thu, 9 Jul 2026 17:50:13 +0800 Subject: [PATCH 14/14] chart: Fill sankey doc-comment gaps Follow-ups from a comment review (comments were otherwise accurate): - Document the undocumented public items: `SankeyError`, `Sankey::new`, `SankeyLink::new`, `SankeyChart::new`, `SankeyLabel::new`. - State the "unset by default" behavior on `node_label`/`value_label`/ `labels`, and that the value passed to them is the raw (unscaled) throughput. - Fix the first-pass comment: it uses only `node.layer`, not the topology's value (labels come from `raw_throughput`). Co-Authored-By: Claude Fable 5 --- crates/ui/src/chart/sankey_chart.rs | 24 +++++++++++++++--------- crates/ui/src/plot/shape/sankey.rs | 4 ++++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/chart/sankey_chart.rs b/crates/ui/src/chart/sankey_chart.rs index f294586f0e..cb0bfc6341 100644 --- a/crates/ui/src/chart/sankey_chart.rs +++ b/crates/ui/src/chart/sankey_chart.rs @@ -36,6 +36,7 @@ pub struct SankeyLabel { } impl SankeyLabel { + /// Create a label line with the default color and font size. pub fn new(text: impl Into) -> Self { Self { text: text.into(), @@ -89,6 +90,8 @@ pub struct SankeyChart { } impl SankeyChart { + /// Create a chart from nodes and links; links reference nodes by their + /// index in `nodes` (map string ids to indices before constructing). pub fn new(nodes: I, links: L) -> Self where I: IntoIterator, @@ -164,26 +167,29 @@ impl SankeyChart { self } - /// Set the name label of each node, drawn in muted foreground. + /// Set the name label of each node, drawn in muted foreground. No name + /// label is drawn unless set. pub fn node_label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self { self.node_label = Some(Rc::new(label)); self } - /// Set the value label of each node, drawn above the name label. + /// Set the value label of each node, drawn above the name label. No value + /// label is drawn unless set. /// - /// The closure receives the datum and the node's computed throughput - /// (max of incoming and outgoing flow). + /// The closure receives the datum and the node's raw computed throughput + /// (max of incoming and outgoing flow, in unscaled units). pub fn value_label(mut self, label: impl Fn(&T, f64) -> SharedString + 'static) -> Self { self.value_label = Some(Rc::new(label)); self } /// Set fully custom node labels, one [`SankeyLabel`] per line, top to - /// bottom. Takes precedence over `node_label`/`value_label` when set. + /// bottom. Takes precedence over `node_label`/`value_label` when set; + /// unset by default. /// - /// The closure receives the datum and the node's computed throughput - /// (max of incoming and outgoing flow). + /// The closure receives the datum and the node's raw computed throughput + /// (max of incoming and outgoing flow, in unscaled units). pub fn labels(mut self, labels: impl Fn(&T, f64) -> Vec + 'static) -> Self { self.labels = Some(Rc::new(labels)); self @@ -246,8 +252,8 @@ impl Plot for SankeyChart { return; } - // First pass: only the topology (layer, value) is needed to measure - // the label margins. + // First pass: only the topology (each node's `layer`) is needed to + // measure the label margins; label values come from `raw_throughput`. let Ok(topology) = self.sankey().topology(self.nodes.len(), &self.links) else { return; }; diff --git a/crates/ui/src/plot/shape/sankey.rs b/crates/ui/src/plot/shape/sankey.rs index e4f60bb56f..4add5b758c 100644 --- a/crates/ui/src/plot/shape/sankey.rs +++ b/crates/ui/src/plot/shape/sankey.rs @@ -33,6 +33,8 @@ pub struct SankeyLink { } impl SankeyLink { + /// Create a link from the `source` node index to the `target` node index + /// carrying `value`. pub fn new(source: usize, target: usize, value: f64) -> Self { Self { source, @@ -112,6 +114,7 @@ impl SankeyGraph { } } +/// A reason a Sankey layout could not be computed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SankeyError { /// A link references a node index out of range. @@ -183,6 +186,7 @@ impl Default for Sankey { } impl Sankey { + /// Create a generator with the d3-sankey defaults (see [`Sankey::default`]). pub fn new() -> Self { Self::default() }