Skip to main content

gpui_macos/chart/
area_chart.rs

1use std::rc::Rc;
2
3use gpui_kit::{
4    AnyElement, App, Background, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString,
5    Size, Window, point, px,
6};
7
8use crate::{
9    ActiveTheme,
10    plot::{
11        AxisLabelPlacement, Curve, PathCaches, Plot, PlotAxis,
12        scale::{PlotValue, Scale, ScaleLinear, ScalePoint},
13        shape::Area,
14        tooltip::{CrossLine, Dot, Tooltip, TooltipState},
15    },
16};
17
18use super::{
19    AXIS_GAP, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent,
20    axis_point_count, build_point_x_labels, caller_id, labeled_items, pinned_plot_mask,
21    point_range, point_value_scale,
22};
23
24pub struct AreaChart<T, X, Y>
25where
26    T: 'static,
27    X: Clone + PartialEq + Into<SharedString> + 'static,
28    Y: PlotValue,
29{
30    data: Vec<T>,
31    x: Option<Rc<dyn Fn(&T) -> X>>,
32    y: Vec<Rc<dyn Fn(&T) -> Y>>,
33    strokes: Vec<Hsla>,
34    curves: Vec<Curve>,
35    fills: Vec<Background>,
36    names: Vec<SharedString>,
37    tooltip_content: TooltipContent<T>,
38    tick_margin: usize,
39    x_axis: bool,
40    grid: bool,
41    y_domain: Option<(Y, Y)>,
42    point_count: Option<usize>,
43    axes: PointAxes,
44    id: ElementId,
45    interactive: bool,
46}
47
48impl<T, X, Y> AreaChart<T, X, Y>
49where
50    X: Clone + PartialEq + Into<SharedString> + 'static,
51    Y: PlotValue,
52{
53    #[track_caller]
54    pub fn new<I>(data: I) -> Self
55    where
56        I: IntoIterator<Item = T>,
57    {
58        Self {
59            data: data.into_iter().collect(),
60            curves: vec![],
61            strokes: vec![],
62            fills: vec![],
63            names: vec![],
64            tooltip_content: TooltipContent::default(),
65            tick_margin: 1,
66            x: None,
67            y: vec![],
68            x_axis: true,
69            grid: true,
70            y_domain: None,
71            point_count: None,
72            axes: PointAxes::default(),
73            id: caller_id(),
74            interactive: true,
75        }
76    }
77
78    /// Name this chart's [`ElementId`], replacing the default taken from the
79    /// construction site.
80    ///
81    /// Pass one where a single construction site renders several of these
82    /// charts as siblings: they share the default id, and with it one hover
83    /// state and one path cache. The id must be unique among those siblings.
84    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
85        self.id = id.into();
86        self
87    }
88
89    /// Turn this chart's interactive layer on or off. On by default.
90    ///
91    /// The layer is the hitbox under the cursor and what it drives: a crosshair
92    /// and a dot per series mark the hovered point, and a tooltip shows a row
93    /// each. Turn it off for a chart that only decorates, or one an element above
94    /// it wants the cursor for: without a hitbox it neither answers the mouse nor
95    /// takes the hover from what sits over it. A chart that is off also drops its
96    /// path cache, which is keyed on the same id.
97    pub fn interactive(mut self, interactive: bool) -> Self {
98        self.interactive = interactive;
99        self
100    }
101
102    /// Set the name of the most recently added series, shown in its tooltip row.
103    ///
104    /// Call after the matching [`AreaChart::y`] (e.g. `.y(..).stroke(..).name("Desktop")`).
105    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
106        self.names.push(name.into());
107        self
108    }
109
110    /// Set the hover tooltip's title for a datum, instead of its x value.
111    pub fn tooltip_title(mut self, title: impl Fn(&T) -> SharedString + 'static) -> Self {
112        self.tooltip_content.set_title(title);
113        self
114    }
115
116    /// Set the text of each tooltip row's value; the raw number by default.
117    ///
118    /// The closure receives the datum, the row's index (the series' index in the order `y` added
119    /// them) and the value the row reads.
120    pub fn tooltip_value(
121        mut self,
122        value: impl Fn(&T, usize, f64) -> SharedString + 'static,
123    ) -> Self {
124        self.tooltip_content.set_value(value);
125        self
126    }
127
128    /// Color each tooltip row's value, such as green or red by its sign; the
129    /// tooltip's text color by default.
130    ///
131    /// The closure receives the same arguments as
132    /// [`tooltip_value`](Self::tooltip_value).
133    pub fn tooltip_value_color<H>(mut self, color: impl Fn(&T, usize, f64) -> H + 'static) -> Self
134    where
135        H: Into<Hsla>,
136    {
137        self.tooltip_content.set_value_color(color);
138        self
139    }
140
141    /// Draw the tooltip box's content for a datum yourself, in place of the
142    /// title and rows, for a layout they cannot express such as a table.
143    ///
144    /// The crosshair, the dots and where the box sits stay the chart's, and
145    /// [`tooltip_title`](Self::tooltip_title), [`tooltip_value`](Self::tooltip_value)
146    /// and [`tooltip_value_color`](Self::tooltip_value_color) no longer apply.
147    pub fn tooltip_content<E>(
148        mut self,
149        content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
150    ) -> Self
151    where
152        E: IntoElement,
153    {
154        self.tooltip_content.set_content(content);
155        self
156    }
157
158    pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self {
159        self.x = Some(Rc::new(x));
160        self
161    }
162
163    pub fn y(mut self, y: impl Fn(&T) -> Y + 'static) -> Self {
164        self.y.push(Rc::new(y));
165        self
166    }
167
168    pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
169        self.strokes.push(stroke.into());
170        self
171    }
172
173    pub fn fill(mut self, fill: impl Into<Background>) -> Self {
174        self.fills.push(fill.into());
175        self
176    }
177
178    pub fn natural(mut self) -> Self {
179        self.curves.push(Curve::Natural);
180        self
181    }
182
183    pub fn linear(mut self) -> Self {
184        self.curves.push(Curve::Linear);
185        self
186    }
187
188    pub fn step_after(mut self) -> Self {
189        self.curves.push(Curve::StepAfter);
190        self
191    }
192
193    pub fn tick_margin(mut self, tick_margin: usize) -> Self {
194        self.tick_margin = tick_margin;
195        self
196    }
197
198    /// Show or hide the x-axis line and labels.
199    ///
200    /// Default is true.
201    pub fn x_axis(mut self, x_axis: bool) -> Self {
202        self.x_axis = x_axis;
203        self
204    }
205
206    pub fn grid(mut self, grid: bool) -> Self {
207        self.grid = grid;
208        self
209    }
210
211    /// Pin the y axis to `min..=max` instead of fitting every series from zero.
212    ///
213    /// Pin it where zero is not a meaningful baseline, such as a price line
214    /// that would otherwise be pressed flat against the top. The range keeps
215    /// the `y_padding` headroom above `max`, 10px by default,
216    /// and the series are clipped to the plot, so a value outside the range
217    /// stops at its edge. Nothing is drawn when `min` equals `max`.
218    pub fn y_domain(mut self, min: Y, max: Y) -> Self {
219        self.y_domain = Some((min, max));
220        self
221    }
222
223    /// Lay the x axis out for `count` evenly spaced points instead of the
224    /// data's own length.
225    ///
226    /// The data takes the leading points in order, the i-th item on the i-th
227    /// point, and the rest stay empty, as an intraday chart does before the
228    /// close. The data has to be contiguous from the first point: a missing
229    /// item shifts every later one a point to the left. A `count` below the
230    /// data's length has no effect.
231    pub fn point_count(mut self, count: usize) -> Self {
232        self.point_count = Some(count);
233        self
234    }
235
236    /// Show the y axis's tick labels, one at each of the `y_tick_count` ticks.
237    ///
238    /// Default is false.
239    pub fn y_axis(mut self, y_axis: bool) -> Self {
240        self.axes.y_axis = y_axis;
241        self
242    }
243
244    /// Set where the y-axis tick labels sit: in a gutter left of the plot, or
245    /// inside it beside their grid lines.
246    ///
247    /// Default is [`AxisLabelPlacement::Outside`].
248    pub fn y_axis_label_placement(mut self, placement: AxisLabelPlacement) -> Self {
249        self.axes.y_axis_label_placement = placement;
250        self
251    }
252
253    /// Set how many ticks the y axis carries, evenly spaced from the baseline
254    /// to the top edge with both ends included.
255    ///
256    /// The ticks place the horizontal grid lines and the tick labels, and each
257    /// label reads the value the scale puts at its height. Values below 2 are
258    /// raised to 2.
259    ///
260    /// Default is 5.
261    pub fn y_tick_count(mut self, count: usize) -> Self {
262        self.axes.y_tick_count = count.max(2);
263        self
264    }
265
266    /// Set the text of each y-axis tick label from the value at its tick.
267    pub fn y_tick_format<S>(mut self, format: impl Fn(f64) -> S + 'static) -> Self
268    where
269        S: Into<SharedString> + 'static,
270    {
271        self.axes.y_tick_format = Some(Rc::new(move |value| format(value).into()));
272        self
273    }
274
275    /// Label `count` of the x values, spread evenly from the first to the
276    /// last, instead of every `tick_margin`-th.
277    ///
278    /// With [`Self::point_count`] set, the labels spread over all the points the
279    /// axis is laid out for, so they keep their places as the data grows; one
280    /// that falls past the data is not drawn yet.
281    pub fn x_tick_count(mut self, count: usize) -> Self {
282        self.axes.x_tick_count = Some(count);
283        self
284    }
285
286    /// Divide the plot into `count` columns with vertical grid lines, the first
287    /// on its left edge.
288    ///
289    /// Default is 0, no vertical lines.
290    pub fn grid_columns(mut self, count: usize) -> Self {
291        self.axes.grid_columns = count;
292        self
293    }
294
295    /// Draw the grid dashed or solid.
296    ///
297    /// Default is false: a solid divider hairline, like the shell's separators.
298    pub fn grid_dashed(mut self, dashed: bool) -> Self {
299        self.axes.grid_dashed = dashed;
300        self
301    }
302
303    /// Draw a dashed line across the plot at `value`, such as a previous close.
304    ///
305    /// Call again for more lines. A value outside the y axis is not drawn.
306    pub fn reference_line(mut self, value: Y) -> Self {
307        if let Some(value) = value.to_f64() {
308            self.axes.reference_lines.push(value);
309        }
310        self
311    }
312
313    /// Set the space kept clear above the highest value and below the lowest,
314    /// in pixels.
315    ///
316    /// Default is 10px above and none below.
317    pub fn y_padding(mut self, top: f32, bottom: f32) -> Self {
318        self.axes.y_padding = (top, bottom);
319        self
320    }
321
322    /// Build the x (point) and y (linear) scales for the given bounds.
323    ///
324    /// Shared by `paint` and `tooltip_state` so the two stay in sync. Returns `None` when there
325    /// is no x accessor or no series.
326    fn scales(
327        &self,
328        bounds: Bounds<Pixels>,
329    ) -> Option<(ScalePoint<X>, ScaleLinear<Y>, ValueExtent)> {
330        let x_fn = self.x.as_ref()?;
331        if self.y.is_empty() {
332            return None;
333        }
334
335        let width = bounds.size.width.as_f32();
336        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
337        let height = bounds.size.height.as_f32() - axis_gap;
338
339        let len = self.data.len();
340        let x = ScalePoint::new(
341            self.data.iter().map(|v| x_fn(v)),
342            point_range(
343                self.axes.plot_left(),
344                width - self.axes.plot_left(),
345                len,
346                axis_point_count(self.point_count, len),
347            ),
348        );
349        let (y, extent) = point_value_scale(
350            self.data
351                .iter()
352                .flat_map(|v| self.y.iter().map(|y_fn| y_fn(v))),
353            self.y_domain,
354            height,
355            self.axes.y_padding,
356        );
357
358        Some((x, y, extent))
359    }
360}
361
362crate::impl_into_plot!(impl<T, X, Y> AreaChart<T, X, Y> where X: Clone + PartialEq + Into<SharedString> + 'static, Y: PlotValue);
363
364impl<T, X, Y> Plot for AreaChart<T, X, Y>
365where
366    X: Clone + PartialEq + Into<SharedString> + 'static,
367    Y: PlotValue,
368{
369    fn prepaint(
370        &mut self,
371        bounds: Bounds<Pixels>,
372        window: &mut Window,
373        _cx: &mut App,
374    ) -> Vec<AnyElement> {
375        // The y labels' gutter is measured before the x scale is laid out past it.
376        if self.axes.y_axis
377            && self.axes.y_axis_label_placement == AxisLabelPlacement::Outside
378            && let Some((_, _, extent)) = self.scales(bounds)
379        {
380            let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
381            let height = bounds.size.height.as_f32() - axis_gap;
382            self.axes.measure_y_labels(extent, height, window);
383        }
384        vec![]
385    }
386
387    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
388        let Some(x_fn) = self.x.as_ref() else {
389            return;
390        };
391        let Some((x, y, extent)) = self.scales(bounds) else {
392            return;
393        };
394
395        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
396        let height = bounds.size.height.as_f32() - axis_gap;
397
398        // Draw X axis
399        // The axis runs under the plot only, clear of a value-axis gutter, so
400        // its labels shift back by the gutter the x scale already includes.
401        let left = self.axes.plot_left();
402        let axis_bounds = Bounds {
403            origin: bounds.origin + point(px(left), px(0.)),
404            size: Size::new(bounds.size.width - px(left), bounds.size.height),
405        };
406        let mut axis = PlotAxis::new().stroke(cx.macos().border);
407        if self.x_axis {
408            let labeled = labeled_items(
409                axis_point_count(self.point_count, self.data.len()),
410                self.axes.x_tick_count,
411                self.tick_margin,
412            );
413            let labels = build_point_x_labels(
414                &self.data,
415                x_fn.as_ref(),
416                &x,
417                axis_point_count(self.point_count, self.data.len()),
418                &labeled,
419                cx.macos().secondary,
420            )
421            .into_iter()
422            .map(|mut label| {
423                label.tick -= px(left);
424                label
425            });
426            axis = axis.x(height).x_label(labels);
427        }
428        axis.paint(&axis_bounds, window, cx);
429
430        if self.grid {
431            self.axes.paint_grid(bounds, height, window, cx);
432        }
433
434        // Draw area
435        // Unstyled series take the theme's series colors in turn, over a quiet fill.
436        let chart = cx.macos().chart;
437        let x = Rc::new(x);
438        let areas = self.y.iter().enumerate().map(|(i, y_fn)| {
439            let x = x.clone();
440            let y = y.clone();
441            let y_fn = y_fn.clone();
442
443            let stroke = self
444                .strokes
445                .get(i)
446                .copied()
447                .unwrap_or(chart[i % chart.len()]);
448            let fill = self
449                .fills
450                .get(i)
451                .copied()
452                .unwrap_or_else(|| stroke.opacity(0.2).into());
453            let curve = *self
454                .curves
455                .get(i)
456                .unwrap_or(self.curves.first().unwrap_or(&Default::default()));
457
458            Area::new()
459                .data(self.data.iter().enumerate())
460                .x(move |(index, _)| x.tick_at(*index))
461                .y0(height)
462                .y1(move |(_, d)| y.tick(&y_fn(d)))
463                .stroke(stroke)
464                .curve(curve)
465                .fill(fill)
466        });
467
468        let mask = self
469            .y_domain
470            .is_some()
471            .then(|| pinned_plot_mask(bounds, height));
472        window.with_content_mask(mask, |window| {
473            // Caching hangs off the chart's own id, which only an interactive chart
474            // puts on the stack; without one, siblings would share a slot and thrash
475            // it, so a chart that is off tessellates afresh each paint.
476            if self.interactive {
477                let caches = PathCaches::for_paint("areas", window, cx);
478                caches.update(cx, |caches, _| {
479                    for (i, area) in areas.enumerate() {
480                        let (fill, line) = caches.slot_pair(i);
481                        area.paint_cached(&bounds, fill, line, window);
482                    }
483                });
484            } else {
485                for area in areas {
486                    area.paint(&bounds, window);
487                }
488            }
489        });
490
491        self.axes
492            .paint_reference_lines(extent, bounds, height, window, cx);
493        self.axes.paint_y_labels(extent, bounds, height, window, cx);
494    }
495
496    fn id(&self) -> Option<ElementId> {
497        self.interactive.then(|| self.id.clone())
498    }
499
500    fn tooltip_state(
501        &self,
502        position: Point<Pixels>,
503        bounds: Bounds<Pixels>,
504        _cx: &App,
505    ) -> Option<TooltipState> {
506        let (x, y, _) = self.scales(bounds)?;
507
508        // Ignore the x-axis label gutter so hovering the labels doesn't show a tooltip.
509        let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
510        if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap
511            || position.x.as_f32() < self.axes.plot_left()
512        {
513            return None;
514        }
515
516        let index = x.nearest_index(position.x.as_f32());
517        let d = self.data.get(index)?;
518        let x_tick = x.tick_at(index)?;
519
520        // One dot per series at the hovered x.
521        let dots = self
522            .y
523            .iter()
524            .filter_map(|y_fn| Some(point(px(x_tick), px(y.tick(&y_fn(d))?))))
525            .collect();
526
527        Some(TooltipState::new(
528            index,
529            point(px(x_tick), position.y),
530            dots,
531        ))
532    }
533
534    fn tooltip(
535        &self,
536        state: &TooltipState,
537        cursor: Point<Pixels>,
538        bounds: Bounds<Pixels>,
539        window: &mut Window,
540        cx: &mut App,
541    ) -> Option<AnyElement> {
542        let x_fn = self.x.as_ref()?;
543        let d = self.data.get(state.index)?;
544
545        let chart = cx.macos().chart;
546        let dot_stroke = cx.macos().background;
547        let color = |i: usize| {
548            self.strokes
549                .get(i)
550                .copied()
551                .unwrap_or(chart[i % chart.len()])
552        };
553
554        // Follow the cursor; the crosshair and dots glide to the data point.
555        let tooltip = Tooltip::new(cursor, bounds.size)
556            .gap(px(8.))
557            // Confine the crosshair to the plot area so it doesn't cross the x-axis.
558            .cross_line(
559                CrossLine::new(state.cross_line)
560                    .height(bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }),
561            )
562            .dots(state.dots.iter().enumerate().map(|(i, p)| {
563                Dot::new(*p)
564                    .size(HOVER_DOT_SIZE)
565                    .halo(HOVER_HALO_SIZE)
566                    .stroke(dot_stroke)
567                    .fill(color(i))
568            }));
569
570        let tooltip = self.tooltip_content.apply(
571            tooltip,
572            d,
573            || Some(x_fn(d).into()),
574            // One row per series: swatch + label + value.
575            || {
576                self.y
577                    .iter()
578                    .enumerate()
579                    .map(|(i, y_fn)| {
580                        let name = self.names.get(i).cloned().unwrap_or_default();
581                        Some((color(i), name, y_fn(d).to_f64()?))
582                    })
583                    .collect::<Option<Vec<_>>>()
584            },
585            window,
586            cx,
587        )?;
588
589        Some(tooltip.into_any_element())
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use gpui_kit::{Bounds, point, px, size};
596
597    use super::AreaChart;
598    use crate::plot::scale::Scale;
599
600    fn bounds() -> Bounds<gpui_kit::Pixels> {
601        Bounds::new(point(px(0.), px(0.)), size(px(100.), px(50.)))
602    }
603
604    fn chart(data: Vec<f64>) -> AreaChart<(usize, f64), String, f64> {
605        AreaChart::new(data.into_iter().enumerate())
606            .x(|(i, _)| i.to_string())
607            .y(|(_, v)| *v)
608            .x_axis(false)
609    }
610
611    #[test]
612    fn test_point_count_fills_the_leading_part() {
613        let (x, _, _) = chart(vec![1., 2., 3.])
614            .point_count(5)
615            .scales(bounds())
616            .unwrap();
617        assert_eq!(x.tick(&"0".to_string()), Some(0.));
618        assert_eq!(x.tick(&"2".to_string()), Some(50.));
619
620        let (x, _, _) = chart(vec![1., 2., 3.])
621            .point_count(2)
622            .scales(bounds())
623            .unwrap();
624        assert_eq!(x.tick(&"2".to_string()), Some(100.));
625    }
626
627    #[test]
628    fn test_y_domain_replaces_the_fit_from_zero() {
629        let (_, y, _) = chart(vec![10., 20.])
630            .y_domain(10., 20.)
631            .scales(bounds())
632            .unwrap();
633        assert_eq!(y.tick(&10.), Some(50.));
634        assert_eq!(y.tick(&20.), Some(10.));
635
636        let (_, y, _) = chart(vec![10., 20.]).scales(bounds()).unwrap();
637        assert_eq!(y.tick(&0.), Some(50.));
638        assert_eq!(y.tick(&20.), Some(10.));
639    }
640}