Skip to main content

gpui_component/chart/
line_chart.rs

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