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