Skip to main content

gpui_component/chart/
area_chart.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, Background, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString,
5    Window, point, px,
6};
7use gpui_component_macros::IntoPlot;
8use num_traits::{Num, ToPrimitive};
9
10use crate::{
11    ActiveTheme,
12    plot::{
13        AXIS_GAP, Grid, Plot, PlotAxis, StrokeStyle,
14        scale::{Scale, ScaleLinear, ScalePoint, Sealed},
15        shape::Area,
16        tooltip::{CrossLine, Dot, Tooltip, TooltipState},
17    },
18};
19
20use super::build_point_x_labels;
21
22#[derive(IntoPlot)]
23pub struct AreaChart<T, X, Y>
24where
25    T: 'static,
26    X: Clone + PartialEq + Into<SharedString> + 'static,
27    Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
28{
29    data: Vec<T>,
30    x: Option<Rc<dyn Fn(&T) -> X>>,
31    y: Vec<Rc<dyn Fn(&T) -> Y>>,
32    strokes: Vec<Hsla>,
33    stroke_styles: Vec<StrokeStyle>,
34    fills: Vec<Background>,
35    names: Vec<SharedString>,
36    tick_margin: usize,
37    x_axis: bool,
38    grid: bool,
39    id: Option<ElementId>,
40}
41
42impl<T, X, Y> AreaChart<T, X, Y>
43where
44    X: Clone + PartialEq + Into<SharedString> + 'static,
45    Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
46{
47    pub fn new<I>(data: I) -> Self
48    where
49        I: IntoIterator<Item = T>,
50    {
51        Self {
52            data: data.into_iter().collect(),
53            stroke_styles: vec![],
54            strokes: vec![],
55            fills: vec![],
56            names: vec![],
57            tick_margin: 1,
58            x: None,
59            y: vec![],
60            x_axis: true,
61            grid: true,
62            id: None,
63        }
64    }
65
66    /// Enable an interactive hover tooltip (crosshair + a dot and row per series).
67    ///
68    /// The `id` must be unique among sibling elements. Without it, the chart stays a
69    /// non-interactive plot.
70    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
71        self.id = Some(id.into());
72        self
73    }
74
75    /// Set the name of the most recently added series, shown in its tooltip row.
76    ///
77    /// Call after the matching [`AreaChart::y`] (e.g. `.y(..).stroke(..).name("Desktop")`).
78    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
79        self.names.push(name.into());
80        self
81    }
82
83    pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self {
84        self.x = Some(Rc::new(x));
85        self
86    }
87
88    pub fn y(mut self, y: impl Fn(&T) -> Y + 'static) -> Self {
89        self.y.push(Rc::new(y));
90        self
91    }
92
93    pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
94        self.strokes.push(stroke.into());
95        self
96    }
97
98    pub fn fill(mut self, fill: impl Into<Background>) -> Self {
99        self.fills.push(fill.into());
100        self
101    }
102
103    pub fn natural(mut self) -> Self {
104        self.stroke_styles.push(StrokeStyle::Natural);
105        self
106    }
107
108    pub fn linear(mut self) -> Self {
109        self.stroke_styles.push(StrokeStyle::Linear);
110        self
111    }
112
113    pub fn step_after(mut self) -> Self {
114        self.stroke_styles.push(StrokeStyle::StepAfter);
115        self
116    }
117
118    pub fn tick_margin(mut self, tick_margin: usize) -> Self {
119        self.tick_margin = tick_margin;
120        self
121    }
122
123    /// Show or hide the x-axis line and labels.
124    ///
125    /// Default is true.
126    pub fn x_axis(mut self, x_axis: bool) -> Self {
127        self.x_axis = x_axis;
128        self
129    }
130
131    pub fn grid(mut self, grid: bool) -> Self {
132        self.grid = grid;
133        self
134    }
135
136    /// Build the x (point) and y (linear) scales for the given bounds.
137    ///
138    /// Shared by `paint` and `tooltip_state` so the two stay in sync. Returns `None` when there
139    /// is no x accessor or no series.
140    fn scales(&self, bounds: Bounds<Pixels>) -> Option<(ScalePoint<X>, ScaleLinear<Y>)> {
141        let x_fn = self.x.as_ref()?;
142        if self.y.is_empty() {
143            return None;
144        }
145
146        let width = bounds.size.width.as_f32();
147        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
148        let height = bounds.size.height.as_f32() - axis_gap;
149
150        let x = ScalePoint::new(self.data.iter().map(|v| x_fn(v)).collect(), vec![0., width]);
151        let domain = self
152            .data
153            .iter()
154            .flat_map(|v| self.y.iter().map(|y_fn| y_fn(v)))
155            .chain(Some(Y::zero()))
156            .collect::<Vec<_>>();
157        let y = ScaleLinear::new(domain, vec![height, 10.]);
158
159        Some((x, y))
160    }
161}
162
163impl<T, X, Y> Plot for AreaChart<T, X, Y>
164where
165    X: Clone + PartialEq + Into<SharedString> + 'static,
166    Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
167{
168    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
169        let Some(x_fn) = self.x.as_ref() else {
170            return;
171        };
172        let Some((x, y)) = self.scales(bounds) else {
173            return;
174        };
175
176        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
177        let height = bounds.size.height.as_f32() - axis_gap;
178
179        // Draw X axis
180        let mut axis = PlotAxis::new().stroke(cx.theme().border);
181        if self.x_axis {
182            let labels = build_point_x_labels(
183                &self.data,
184                x_fn.as_ref(),
185                &x,
186                self.tick_margin,
187                cx.theme().muted_foreground,
188            );
189            axis = axis.x(height).x_label(labels);
190        }
191        axis.paint(&bounds, window, cx);
192
193        // Draw grid
194        if self.grid {
195            Grid::new()
196                .y((0..=3).map(|i| height * i as f32 / 4.0).collect())
197                .stroke(cx.theme().border)
198                .dash_array(&[px(4.), px(2.)])
199                .paint(&bounds, window);
200        }
201
202        // Draw area
203        for (i, y_fn) in self.y.iter().enumerate() {
204            let x = x.clone();
205            let y = y.clone();
206            let x_fn = x_fn.clone();
207            let y_fn = y_fn.clone();
208
209            let fill = *self
210                .fills
211                .get(i)
212                .unwrap_or(&cx.theme().chart_2.opacity(0.4).into());
213
214            let stroke = *self.strokes.get(i).unwrap_or(&cx.theme().chart_2);
215
216            let stroke_style = *self
217                .stroke_styles
218                .get(i)
219                .unwrap_or(self.stroke_styles.first().unwrap_or(&Default::default()));
220
221            Area::new()
222                .data(&self.data)
223                .x(move |d| x.tick(&x_fn(d)))
224                .y0(height)
225                .y1(move |d| y.tick(&y_fn(d)))
226                .stroke(stroke)
227                .stroke_style(stroke_style)
228                .fill(fill)
229                .paint(&bounds, window);
230        }
231    }
232
233    fn id(&self) -> Option<ElementId> {
234        self.id.clone()
235    }
236
237    fn tooltip_state(
238        &self,
239        position: Point<Pixels>,
240        bounds: Bounds<Pixels>,
241        _cx: &App,
242    ) -> Option<TooltipState> {
243        let x_fn = self.x.as_ref()?;
244        let (x, y) = self.scales(bounds)?;
245
246        // Ignore the x-axis label gutter so hovering the labels doesn't show a tooltip.
247        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
248        if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap {
249            return None;
250        }
251
252        let index = x.least_index(position.x.as_f32());
253        let d = self.data.get(index)?;
254        let x_tick = x.tick(&x_fn(d))?;
255
256        // One dot per series at the hovered x.
257        let dots = self
258            .y
259            .iter()
260            .filter_map(|y_fn| Some(point(px(x_tick), px(y.tick(&y_fn(d))?))))
261            .collect();
262
263        Some(TooltipState::new(
264            index,
265            point(px(x_tick), position.y),
266            dots,
267        ))
268    }
269
270    fn tooltip(
271        &self,
272        state: &TooltipState,
273        cursor: Point<Pixels>,
274        bounds: Bounds<Pixels>,
275        _window: &mut Window,
276        cx: &mut App,
277    ) -> Option<AnyElement> {
278        let x_fn = self.x.as_ref()?;
279        let d = self.data.get(state.index)?;
280        let title: SharedString = x_fn(d).into();
281
282        let default_color = cx.theme().chart_2;
283        let dot_stroke = cx.theme().background;
284        let color = |i: usize| *self.strokes.get(i).unwrap_or(&default_color);
285
286        // Follow the cursor; the crosshair and dots stay snapped to the data point.
287        let mut tooltip = Tooltip::new(cursor, bounds.size)
288            .gap(px(8.))
289            // Confine the crosshair to the plot area so it doesn't cross the x-axis.
290            .cross_line(
291                CrossLine::new(state.cross_line)
292                    .height(bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }),
293            )
294            .dots(
295                state
296                    .dots
297                    .iter()
298                    .enumerate()
299                    .map(|(i, p)| Dot::new(*p).stroke(dot_stroke).fill(color(i))),
300            )
301            .title(title);
302
303        // One row per series: swatch + label + value.
304        for (i, y_fn) in self.y.iter().enumerate() {
305            let name = self.names.get(i).cloned().unwrap_or_default();
306            let value = y_fn(d).to_f64()?;
307            tooltip = tooltip.row(color(i), name, format!("{}", value));
308        }
309
310        Some(tooltip.into_any_element())
311    }
312}