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_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::Line,
16        tooltip::{CrossLine, Dot, Tooltip, TooltipState},
17    },
18};
19
20use super::build_point_x_labels;
21
22#[derive(IntoPlot)]
23pub struct LineChart<T, X, Y>
24where
25    T: 'static,
26    X: PartialEq + Into<SharedString> + 'static,
27    Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
28{
29    data: Vec<T>,
30    x: Option<Rc<dyn Fn(&T) -> X>>,
31    y: Option<Rc<dyn Fn(&T) -> Y>>,
32    stroke: Option<Hsla>,
33    stroke_style: StrokeStyle,
34    dot: bool,
35    tick_margin: usize,
36    x_axis: bool,
37    grid: bool,
38    id: Option<ElementId>,
39    name: Option<SharedString>,
40}
41
42impl<T, X, Y> LineChart<T, X, Y>
43where
44    X: PartialEq + Into<SharedString> + 'static,
45    Y: 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: None,
54            stroke_style: Default::default(),
55            dot: false,
56            x: None,
57            y: None,
58            tick_margin: 1,
59            x_axis: true,
60            grid: true,
61            id: None,
62            name: None,
63        }
64    }
65
66    /// Enable an interactive hover tooltip (with crosshair and a data dot) for this chart.
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 series name shown in the hover tooltip row (e.g. "Desktop").
76    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
77        self.name = Some(name.into());
78        self
79    }
80
81    pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self {
82        self.x = Some(Rc::new(x));
83        self
84    }
85
86    pub fn y(mut self, y: impl Fn(&T) -> Y + 'static) -> Self {
87        self.y = Some(Rc::new(y));
88        self
89    }
90
91    pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
92        self.stroke = Some(stroke.into());
93        self
94    }
95
96    pub fn natural(mut self) -> Self {
97        self.stroke_style = StrokeStyle::Natural;
98        self
99    }
100
101    pub fn linear(mut self) -> Self {
102        self.stroke_style = StrokeStyle::Linear;
103        self
104    }
105
106    pub fn step_after(mut self) -> Self {
107        self.stroke_style = StrokeStyle::StepAfter;
108        self
109    }
110
111    pub fn dot(mut self) -> Self {
112        self.dot = true;
113        self
114    }
115
116    pub fn tick_margin(mut self, tick_margin: usize) -> Self {
117        self.tick_margin = tick_margin;
118        self
119    }
120
121    /// Show or hide the x-axis line and labels.
122    ///
123    /// Default is true.
124    pub fn x_axis(mut self, x_axis: bool) -> Self {
125        self.x_axis = x_axis;
126        self
127    }
128
129    pub fn grid(mut self, grid: bool) -> Self {
130        self.grid = grid;
131        self
132    }
133
134    /// Build the x (point) and y (linear) scales for the given bounds.
135    ///
136    /// Shared by `paint` and `tooltip_state` so the two stay in sync. Returns `None` when the
137    /// x/y accessors have not been set.
138    fn scales(&self, bounds: Bounds<Pixels>) -> Option<(ScalePoint<X>, ScaleLinear<Y>)> {
139        let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
140
141        let width = bounds.size.width.as_f32();
142        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
143        let height = bounds.size.height.as_f32() - axis_gap;
144
145        let x = ScalePoint::new(self.data.iter().map(|v| x_fn(v)).collect(), vec![0., width]);
146        // Y scale, ensure start from 0.
147        let y = ScaleLinear::new(
148            self.data
149                .iter()
150                .map(|v| y_fn(v))
151                .chain(Some(Y::zero()))
152                .collect(),
153            vec![height, 10.],
154        );
155
156        Some((x, y))
157    }
158}
159
160impl<T, X, Y> Plot for LineChart<T, X, Y>
161where
162    X: PartialEq + Into<SharedString> + 'static,
163    Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
164{
165    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
166        let (Some(x_fn), Some(y_fn)) = (self.x.as_ref(), self.y.as_ref()) else {
167            return;
168        };
169        let Some((x, y)) = self.scales(bounds) else {
170            return;
171        };
172
173        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
174        let height = bounds.size.height.as_f32() - axis_gap;
175
176        // Draw X axis
177        let mut axis = PlotAxis::new().stroke(cx.theme().border);
178        if self.x_axis {
179            let labels = build_point_x_labels(
180                &self.data,
181                x_fn.as_ref(),
182                &x,
183                self.tick_margin,
184                cx.theme().muted_foreground,
185            );
186            axis = axis.x(height).x_label(labels);
187        }
188        axis.paint(&bounds, window, cx);
189
190        // Draw grid
191        if self.grid {
192            Grid::new()
193                .y((0..=3).map(|i| height * i as f32 / 4.0).collect())
194                .stroke(cx.theme().border)
195                .dash_array(&[px(4.), px(2.)])
196                .paint(&bounds, window);
197        }
198
199        // Draw line
200        let stroke = self.stroke.unwrap_or(cx.theme().chart_2);
201        let x_fn = x_fn.clone();
202        let y_fn = y_fn.clone();
203        let mut line = Line::new()
204            .data(&self.data)
205            .x(move |d| x.tick(&x_fn(d)))
206            .y(move |d| y.tick(&y_fn(d)))
207            .stroke(stroke)
208            .stroke_style(self.stroke_style)
209            .stroke_width(2.);
210
211        if self.dot {
212            line = line.dot().dot_size(8.).dot_fill_color(stroke);
213        }
214
215        line.paint(&bounds, window);
216    }
217
218    fn id(&self) -> Option<ElementId> {
219        self.id.clone()
220    }
221
222    fn tooltip_state(
223        &self,
224        position: Point<Pixels>,
225        bounds: Bounds<Pixels>,
226        _cx: &App,
227    ) -> Option<TooltipState> {
228        let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
229        let (x, y) = self.scales(bounds)?;
230
231        // Ignore the x-axis label gutter so hovering the labels doesn't show a tooltip.
232        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
233        if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap {
234            return None;
235        }
236
237        let index = x.least_index(position.x.as_f32());
238        let d = self.data.get(index)?;
239        let x_tick = x.tick(&x_fn(d))?;
240        let y_tick = y.tick(&y_fn(d))?;
241
242        Some(TooltipState::new(
243            index,
244            point(px(x_tick), position.y),
245            vec![point(px(x_tick), px(y_tick))],
246        ))
247    }
248
249    fn tooltip(
250        &self,
251        state: &TooltipState,
252        cursor: Point<Pixels>,
253        bounds: Bounds<Pixels>,
254        _window: &mut Window,
255        cx: &mut App,
256    ) -> Option<AnyElement> {
257        let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
258        let d = self.data.get(state.index)?;
259        let title: SharedString = x_fn(d).into();
260        let value = y_fn(d).to_f64()?;
261        let stroke = self.stroke.unwrap_or(cx.theme().chart_2);
262        let name = self.name.clone().unwrap_or_default();
263
264        Some(
265            // Follow the cursor; the crosshair and dot stay snapped to the data point.
266            Tooltip::new(cursor, bounds.size)
267                .gap(px(8.))
268                // Confine the crosshair to the plot area so it doesn't cross the x-axis.
269                .cross_line(
270                    CrossLine::new(state.cross_line).height(
271                        bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. },
272                    ),
273                )
274                .dots(
275                    state
276                        .dots
277                        .iter()
278                        .map(|p| Dot::new(*p).stroke(cx.theme().background).fill(stroke)),
279                )
280                .title(title)
281                .row(stroke, name, format!("{}", value))
282                .into_any_element(),
283        )
284    }
285}