Skip to main content

guise/chart/
line.rs

1//! `LineChart` — one or more line series with optional axis, legend, area
2//! fill, and hover value readouts.
3//!
4//! ```ignore
5//! use guise::chart::LineChart;
6//!
7//! LineChart::new([12.0, 18.0, 9.0, 24.0]).fill().height(180.0)
8//!
9//! LineChart::series("Revenue", [12.0, 18.0, 24.0])
10//!     .add_series("Costs", [8.0, 11.0, 13.0])
11//!     .axis()
12//!     .labels(["Q1", "Q2", "Q3"])
13//!     .hover()
14//! ```
15
16use gpui::prelude::*;
17use gpui::{
18    canvas, div, fill, point, px, size, App, Bounds, Hsla, IntoElement, SharedString, Window,
19};
20
21use crate::style::ColorValue;
22use crate::theme::theme;
23
24use super::axis::nice_ticks;
25use super::frame::{hover_slots, legend_row, x_label_row, y_axis_column};
26use super::{
27    min_max, normalize_between, paint_polyline, paint_polyline_ys, resolve_color, series_color,
28    tick_label,
29};
30use crate::devtools::Probed;
31
32/// How many horizontal gridlines an axis-free `LineChart` paints.
33const GRIDLINES: usize = 4;
34
35/// A line chart. One series (`new`) or several (`series`, chained); values
36/// are min/max normalized, or scaled to nice axis ticks with [`LineChart::axis`].
37#[derive(IntoElement)]
38pub struct LineChart {
39    series: Vec<(Option<SharedString>, Vec<f32>)>,
40    colors: Vec<ColorValue>,
41    stroke: f32,
42    fill: bool,
43    axis: bool,
44    hover: bool,
45    labels: Vec<SharedString>,
46    width: Option<f32>,
47    height: f32,
48}
49
50impl LineChart {
51    pub fn new(values: impl IntoIterator<Item = f32>) -> Self {
52        LineChart {
53            series: vec![(None, values.into_iter().collect())],
54            colors: Vec::new(),
55            stroke: 2.0,
56            fill: false,
57            axis: false,
58            hover: false,
59            labels: Vec::new(),
60            width: None,
61            height: 140.0,
62        }
63    }
64
65    /// Start a named multi-series chart; extend with
66    /// [`add_series`](Self::add_series). Named series show in the legend and
67    /// every series shares the y scale.
68    pub fn series(label: impl Into<SharedString>, values: impl IntoIterator<Item = f32>) -> Self {
69        let mut chart = LineChart::new(values);
70        chart.series[0].0 = Some(label.into());
71        chart
72    }
73
74    /// Add another named series.
75    pub fn add_series(
76        mut self,
77        label: impl Into<SharedString>,
78        values: impl IntoIterator<Item = f32>,
79    ) -> Self {
80        self.series
81            .push((Some(label.into()), values.into_iter().collect()));
82        self
83    }
84
85    /// One color for every line (single-series) — defaults to theme primary.
86    pub fn color(mut self, color: impl Into<ColorValue>) -> Self {
87        self.colors = vec![color.into()];
88        self
89    }
90
91    /// Per-series colors, cycled when shorter than the series list.
92    pub fn colors(mut self, colors: impl IntoIterator<Item = impl Into<ColorValue>>) -> Self {
93        self.colors = colors.into_iter().map(Into::into).collect();
94        self
95    }
96
97    /// Stroke width in px (default 2).
98    pub fn stroke(mut self, width: f32) -> Self {
99        self.stroke = width.max(0.5);
100        self
101    }
102
103    /// Fill the area under each line (line color at 0.15 alpha).
104    pub fn fill(mut self) -> Self {
105        self.fill = true;
106        self
107    }
108
109    /// Show a y-axis: nice-number tick labels on the left, gridlines aligned
110    /// to them, and the lines scaled against the tick range.
111    pub fn axis(mut self) -> Self {
112        self.axis = true;
113        self
114    }
115
116    /// Show per-point values in a tooltip as the pointer moves across.
117    pub fn hover(mut self) -> Self {
118        self.hover = true;
119        self
120    }
121
122    /// Category labels under the plot, one per data point.
123    pub fn labels(mut self, labels: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
124        self.labels = labels.into_iter().map(Into::into).collect();
125        self
126    }
127
128    /// Fixed width in px. Defaults to the parent's full width.
129    pub fn width(mut self, width: f32) -> Self {
130        self.width = Some(width);
131        self
132    }
133
134    /// Plot height in px (default 140), excluding labels and legend.
135    pub fn height(mut self, height: f32) -> Self {
136        self.height = height;
137        self
138    }
139}
140
141impl RenderOnce for LineChart {
142    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
143        let t = theme(cx);
144        let multi = self.series.len() > 1;
145        let line_colors: Vec<Hsla> = (0..self.series.len())
146            .map(|i| {
147                if !multi && self.colors.is_empty() {
148                    t.primary().hsla()
149                } else if self.colors.len() == 1 && !multi {
150                    resolve_color(t, self.colors[0])
151                } else {
152                    series_color(t, &self.colors, i)
153                }
154            })
155            .collect();
156        let grid = t.border().alpha(0.5);
157        let stroke = self.stroke;
158        let filled = self.fill;
159
160        // Shared scale across every series.
161        let all: Vec<f32> = self
162            .series
163            .iter()
164            .flat_map(|(_, values)| values.iter().copied())
165            .collect();
166        let ticks = if self.axis {
167            let (lo, hi) = min_max(&all).unwrap_or((0.0, 1.0));
168            nice_ticks(lo, hi, 4)
169        } else {
170            Vec::new()
171        };
172        let scale = (!ticks.is_empty()).then(|| (*ticks.first().unwrap(), *ticks.last().unwrap()));
173
174        let point_count = self.series.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
175        let gridline_count = if self.axis {
176            ticks.len().max(2)
177        } else {
178            GRIDLINES
179        };
180
181        let series = self.series.clone();
182        let paint_colors = line_colors.clone();
183        let plot = canvas(
184            |_, _, _| (),
185            move |bounds, _, window, _cx| {
186                let w = f32::from(bounds.size.width);
187                let h = f32::from(bounds.size.height);
188                if w <= 0.0 || h <= 0.0 {
189                    return;
190                }
191                for i in 0..gridline_count {
192                    let y = (h - 1.0) * (i as f32 / (gridline_count - 1) as f32);
193                    window.paint_quad(fill(
194                        Bounds::new(bounds.origin + point(px(0.0), px(y)), size(px(w), px(1.0))),
195                        grid,
196                    ));
197                }
198                for (i, (_, values)) in series.iter().enumerate() {
199                    let color = paint_colors[i];
200                    let area = filled.then_some(Hsla { a: 0.15, ..color });
201                    match scale {
202                        Some((lo, hi)) => paint_polyline_ys(
203                            window,
204                            bounds,
205                            &normalize_between(values, lo, hi),
206                            stroke,
207                            color,
208                            area,
209                        ),
210                        None => paint_polyline(window, bounds, values, stroke, color, area),
211                    }
212                }
213            },
214        )
215        .w_full()
216        .h(px(self.height));
217
218        // Plot area, with optional hover readout slots layered above.
219        let mut plot_wrap = div().relative().flex_1().child(plot);
220        if self.hover && point_count > 0 {
221            let texts: Vec<SharedString> = (0..point_count)
222                .map(|i| {
223                    let parts: Vec<String> = self
224                        .series
225                        .iter()
226                        .map(|(label, values)| {
227                            let value = values
228                                .get(i)
229                                .map(|v| tick_label(*v))
230                                .unwrap_or_else(|| "–".into());
231                            match label {
232                                Some(name) => format!("{name}: {value}"),
233                                None => value,
234                            }
235                        })
236                        .collect();
237                    let text = match self.labels.get(i) {
238                        Some(cat) => format!("{cat} — {}", parts.join("  ")),
239                        None => parts.join("  "),
240                    };
241                    text.into()
242                })
243                .collect();
244            plot_wrap = plot_wrap.child(hover_slots("guise-linechart-hover".into(), texts));
245        }
246
247        let mut body = div().flex().flex_row().w_full();
248        if self.axis {
249            body = body.child(y_axis_column(t, &ticks, self.height));
250        }
251        let mut plot_column = div()
252            .flex_1()
253            .flex()
254            .flex_col()
255            .gap(px(4.0))
256            .child(plot_wrap);
257        if !self.labels.is_empty() {
258            plot_column = plot_column.child(x_label_row(t, &self.labels));
259        }
260        body = body.child(plot_column);
261
262        let legend: Vec<(SharedString, Hsla)> = self
263            .series
264            .iter()
265            .enumerate()
266            .filter_map(|(i, (label, _))| label.clone().map(|l| (l, line_colors[i])))
267            .collect();
268
269        let mut root = div().flex().flex_col();
270        root = match self.width {
271            Some(w) => root.w(px(w)),
272            None => root.w_full(),
273        };
274        root = root.child(body);
275        if !legend.is_empty() {
276            root = root.child(legend_row(t, &legend));
277        }
278        root.probe("LineChart")
279    }
280}