Skip to main content

gpui_macos/chart/
mod.rs

1#![allow(clippy::type_complexity)]
2
3mod area_chart;
4mod bar_chart;
5mod candlestick_chart;
6mod line_chart;
7mod pie_chart;
8mod radar_chart;
9mod sankey_chart;
10
11pub use area_chart::AreaChart;
12pub use bar_chart::BarChart;
13pub use candlestick_chart::CandlestickChart;
14pub use line_chart::LineChart;
15pub use pie_chart::PieChart;
16pub use radar_chart::{RadarChart, RadarLabel};
17pub use sankey_chart::{SankeyChart, SankeyLabel};
18
19pub use crate::plot::shape::{BarAlignment, SankeyAlign, SankeyLink, SankeyValueScale};
20
21use std::{hash::Hash, panic::Location, rc::Rc};
22
23use gpui_kit::{
24    AnyElement, App, Bounds, ContentMask, ElementId, Hsla, IntoElement, ParentElement as _, Pixels,
25    SharedString, Size, TextAlign, Window, point, px,
26};
27
28use crate::{
29    ActiveTheme,
30    plot::{
31        AxisLabelPlacement, AxisText, Grid, PlotLabel,
32        label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width},
33        scale::{PlotValue, Scale, ScaleBand, ScaleLinear, ScalePoint},
34        tooltip::Tooltip,
35    },
36};
37
38/// Gentle spring motion for a hovered pie slice, honoring Reduce motion.
39pub(crate) fn slice_spring() -> gpui_kit::base::Spring {
40    gpui_kit::base::Spring::new(std::time::Duration::from_millis(140))
41}
42
43/// The [`ElementId`] a chart carries when the caller names none: the source
44/// location it was constructed at.
45///
46/// The crosshair, the hover lift, the tooltip and the path cache all need an id
47/// unique among siblings, and a chart that has to be handed one per call site is
48/// a chart every caller leaves static. One construction site written out once,
49/// which is nearly every chart, is unique by construction.
50///
51/// The exception is one site rendering several charts as siblings, where every
52/// copy shares this location and therefore one hover state and one path cache.
53/// A `GlobalElementId` is the whole id stack, so rows that carry their own id —
54/// which `List` and `uniform_list` give them — already separate the copies
55/// underneath them; only id-less siblings collide, and those name an id with
56/// `id`. GPUI takes this same trade-off for [`gpui_kit::Window::use_state`].
57#[track_caller]
58pub(crate) fn caller_id() -> ElementId {
59    ElementId::CodeLocation(*Location::caller())
60}
61
62/// The size of the dot marking the hovered data point.
63pub(crate) const HOVER_DOT_SIZE: Pixels = px(8.);
64
65/// The ring behind the hovered dot at full focus, extending 2px per side;
66/// a [`Tooltip`] grows it out of the dot as the hover fades in. This compact
67/// chart-specific size keeps the halo subordinate to the data point.
68///
69/// [`Tooltip`]: crate::plot::tooltip::Tooltip
70pub(crate) const HOVER_HALO_SIZE: Pixels = px(12.);
71
72/// How many points the x axis of a point chart (`LineChart`, `AreaChart`) is
73/// laid out for: `point_count`, or the data's own length when that is unset or
74/// smaller.
75pub(crate) fn axis_point_count(point_count: Option<usize>, data_len: usize) -> usize {
76    point_count.unwrap_or(data_len).max(data_len)
77}
78
79/// The x range a point scale spreads `data_len` points over, when the axis is
80/// laid out for `point_count` of them across `width` pixels from `start`.
81///
82/// The data takes the leading points, so each keeps its place as the data grows.
83pub(crate) fn point_range(start: f32, width: f32, data_len: usize, point_count: usize) -> [f32; 2] {
84    let end = if point_count > 1 {
85        width * data_len.saturating_sub(1) as f32 / (point_count - 1) as f32
86    } else {
87        width
88    };
89    [start, start + end]
90}
91
92/// The value range a y scale spans and the pixel range it maps onto, kept in
93/// `f64` so tick labels can read the value at any height of the plot.
94#[derive(Clone, Copy)]
95pub(crate) struct ValueExtent {
96    lo: f64,
97    hi: f64,
98    bottom: f32,
99    top: f32,
100}
101
102impl ValueExtent {
103    /// The value the scale puts at pixel `y`.
104    pub(crate) fn value_at(&self, y: f32) -> f64 {
105        if self.bottom == self.top {
106            return self.lo;
107        }
108        self.lo + (self.hi - self.lo) * ((self.bottom - y) / (self.bottom - self.top)) as f64
109    }
110
111    /// The pixel the scale puts `value` at, or `None` for a scale with no extent.
112    pub(crate) fn position_of(&self, value: f64) -> Option<f32> {
113        if self.hi == self.lo {
114            return None;
115        }
116        Some(
117            self.bottom
118                - ((value - self.lo) / (self.hi - self.lo)) as f32 * (self.bottom - self.top),
119        )
120    }
121}
122
123/// The y scale of a point chart, from `height` less the bottom `padding` up to
124/// the top `padding`.
125///
126/// A pinned `domain` maps its ends onto that range; otherwise the scale fits
127/// `values` from zero.
128pub(crate) fn point_value_scale<Y>(
129    values: impl IntoIterator<Item = Y>,
130    domain: Option<(Y, Y)>,
131    height: f32,
132    (top, bottom): (f32, f32),
133) -> (ScaleLinear<Y>, ValueExtent)
134where
135    Y: PlotValue,
136{
137    let (min, max) = match domain {
138        Some((min, max)) if min > max => (max, min),
139        Some(domain) => domain,
140        None => values
141            .into_iter()
142            .fold((Y::zero(), Y::zero()), |(min, max), value| {
143                (
144                    if value < min { value } else { min },
145                    if value > max { value } else { max },
146                )
147            }),
148    };
149    let extent = ValueExtent {
150        lo: min.to_f64().unwrap_or(0.),
151        hi: max.to_f64().unwrap_or(0.),
152        bottom: height - bottom,
153        top,
154    };
155    (ScaleLinear::new([min, max], [height - bottom, top]), extent)
156}
157
158/// The height the charts reserve under the plot for x-axis labels, which they
159/// draw at the default [`TEXT_SIZE`]: [`axis_gutter`](crate::plot::axis_gutter)
160/// for that size.
161pub(crate) const AXIS_GAP: f32 = TEXT_SIZE + TEXT_GAP * 4.;
162
163/// The widest a bar or candle is by default, in pixels, however few bands
164/// share the width; see `BarChart::max_band_width`.
165pub(crate) const MAX_BAND_WIDTH: f32 = 30.;
166
167/// The least space kept beside the plot for value-axis tick labels drawn
168/// outside it, in pixels; wider labels widen it (see [`value_axis_gap`]).
169pub(crate) const VALUE_AXIS_GAP: f32 = 32.;
170
171/// The gutter value-axis tick `labels` drawn outside the plot need: the widest
172/// label and the gap before the plot, and never less than [`VALUE_AXIS_GAP`].
173///
174/// Measured in `prepaint`, which runs before `tooltip_state` and `paint`, so
175/// hit-testing and painting share one gutter.
176pub(crate) fn value_axis_gap(
177    labels: impl IntoIterator<Item = SharedString>,
178    window: &mut Window,
179) -> f32 {
180    labels
181        .into_iter()
182        .map(|label| measure_text_width(&label, px(TEXT_SIZE), window) + TEXT_GAP * 2.)
183        .fold(VALUE_AXIS_GAP, f32::max)
184}
185
186/// A caller's tick label text for a value.
187pub(crate) type TickFormat = Rc<dyn Fn(f64) -> SharedString>;
188
189/// The default tick label: whole numbers bare, the rest to one decimal.
190pub(crate) fn format_tick(value: f64) -> SharedString {
191    if (value - value.round()).abs() < 0.001 {
192        format!("{:.0}", value).into()
193    } else {
194        format!("{:.1}", value).into()
195    }
196}
197
198/// Which of `len` items carry a category label: `label_count` of them evenly
199/// spread from the first to the last when set, otherwise every `tick_margin`-th.
200pub(crate) fn labeled_items(
201    len: usize,
202    label_count: Option<usize>,
203    tick_margin: usize,
204) -> Vec<bool> {
205    match label_count {
206        Some(count) => {
207            let mut labeled = vec![false; len];
208            match count {
209                0 => {}
210                1 => labeled.iter_mut().take(1).for_each(|l| *l = true),
211                count if count >= len => labeled.iter_mut().for_each(|l| *l = true),
212                count => {
213                    for k in 0..count {
214                        let ix =
215                            (k as f32 * (len - 1) as f32 / (count - 1) as f32).round() as usize;
216                        labeled[ix] = true;
217                    }
218                }
219            }
220            labeled
221        }
222        None => (0..len)
223            .map(|i| (i + 1) % tick_margin.max(1) == 0)
224            .collect(),
225    }
226}
227
228/// What a series chart (`LineChart`, `AreaChart`, `BarChart`, `RadarChart`,
229/// `CandlestickChart`) writes in its hover tooltip, and the builders the five
230/// charts forward to it.
231pub(crate) struct TooltipContent<T> {
232    title: Option<Rc<dyn Fn(&T) -> SharedString>>,
233    value: Option<Rc<dyn Fn(&T, usize, f64) -> SharedString>>,
234    value_color: Option<Rc<dyn Fn(&T, usize, f64) -> Hsla>>,
235    content: Option<Rc<dyn Fn(&T, &mut Window, &mut App) -> AnyElement>>,
236}
237
238impl<T> Default for TooltipContent<T> {
239    fn default() -> Self {
240        Self {
241            title: None,
242            value: None,
243            value_color: None,
244            content: None,
245        }
246    }
247}
248
249impl<T: 'static> TooltipContent<T> {
250    pub(crate) fn set_title(&mut self, title: impl Fn(&T) -> SharedString + 'static) {
251        self.title = Some(Rc::new(title));
252    }
253
254    pub(crate) fn set_value(&mut self, value: impl Fn(&T, usize, f64) -> SharedString + 'static) {
255        self.value = Some(Rc::new(value));
256    }
257
258    pub(crate) fn set_value_color<H: Into<Hsla>>(
259        &mut self,
260        color: impl Fn(&T, usize, f64) -> H + 'static,
261    ) {
262        self.value_color = Some(Rc::new(move |d, ix, value| color(d, ix, value).into()));
263    }
264
265    pub(crate) fn set_content<E: IntoElement>(
266        &mut self,
267        content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
268    ) {
269        self.content = Some(Rc::new(move |d, window, cx| {
270            content(d, window, cx).into_any_element()
271        }));
272    }
273
274    /// The title for datum `d`: the caller's, or `fallback`, the chart's own,
275    /// which a chart may not have.
276    fn title_text(&self, d: &T, fallback: Option<SharedString>) -> Option<SharedString> {
277        match self.title.as_ref() {
278            Some(title) => Some(title(d)),
279            None => fallback,
280        }
281    }
282
283    /// The value text of row `ix`: the caller's, or the raw number.
284    fn value_text(&self, d: &T, ix: usize, value: f64) -> SharedString {
285        match self.value.as_ref() {
286            Some(text) => text(d, ix, value),
287            None => format!("{}", value).into(),
288        }
289    }
290
291    /// Write the content of `tooltip` for datum `d`: the caller's own content when it renders
292    /// one, otherwise the chart's `title`, if it has one, and one row per
293    /// `(swatch, name, value)`. Neither is built when the caller renders, and
294    /// `None` from `rows` means a row has no value to show.
295    pub(crate) fn apply<R>(
296        &self,
297        tooltip: Tooltip,
298        d: &T,
299        title: impl FnOnce() -> Option<SharedString>,
300        rows: impl FnOnce() -> Option<R>,
301        window: &mut Window,
302        cx: &mut App,
303    ) -> Option<Tooltip>
304    where
305        R: IntoIterator<Item = (Hsla, SharedString, f64)>,
306    {
307        if let Some(content) = self.content.as_ref() {
308            return Some(tooltip.child(content(d, window, cx)));
309        }
310        let mut tooltip = match self.title_text(d, title()) {
311            Some(title) => tooltip.title(title),
312            None => tooltip,
313        };
314        for (ix, (swatch, name, value)) in rows()?.into_iter().enumerate() {
315            tooltip = tooltip.row(swatch, name, self.value_text(d, ix, value));
316            if let Some(color) = self.value_color.as_ref() {
317                tooltip = tooltip.value_color(color(d, ix, value));
318            }
319        }
320        Some(tooltip)
321    }
322}
323
324/// The grid, value-axis labels and reference lines a point chart (`LineChart`,
325/// `AreaChart`) draws, and the builders both charts forward to it.
326pub(crate) struct PointAxes {
327    pub(crate) y_axis: bool,
328    pub(crate) y_axis_label_placement: AxisLabelPlacement,
329    pub(crate) y_tick_count: usize,
330    pub(crate) y_tick_format: Option<TickFormat>,
331    pub(crate) x_tick_count: Option<usize>,
332    pub(crate) grid_columns: usize,
333    pub(crate) grid_dashed: bool,
334    pub(crate) y_padding: (f32, f32),
335    pub(crate) reference_lines: Vec<f64>,
336    /// The value-axis gutter measured in `prepaint`; see [`value_axis_gap`].
337    y_label_gap: f32,
338}
339
340impl Default for PointAxes {
341    fn default() -> Self {
342        Self {
343            y_axis: false,
344            y_axis_label_placement: AxisLabelPlacement::default(),
345            y_tick_count: 5,
346            y_tick_format: None,
347            x_tick_count: None,
348            grid_columns: 0,
349            grid_dashed: false,
350            y_padding: (10., 0.),
351            reference_lines: vec![],
352            y_label_gap: VALUE_AXIS_GAP,
353        }
354    }
355}
356
357impl PointAxes {
358    /// Where the plot starts along x: past the value-axis gutter when the labels
359    /// sit outside it.
360    pub(crate) fn plot_left(&self) -> f32 {
361        if self.y_axis && self.y_axis_label_placement == AxisLabelPlacement::Outside {
362            self.y_label_gap
363        } else {
364            0.
365        }
366    }
367
368    /// Measure the gutter the y labels need outside the plot, before the x
369    /// scale is laid out past it.
370    pub(crate) fn measure_y_labels(
371        &mut self,
372        extent: ValueExtent,
373        height: f32,
374        window: &mut Window,
375    ) {
376        if self.y_axis && self.y_axis_label_placement == AxisLabelPlacement::Outside {
377            let labels = self
378                .y_tick_labels(extent, height)
379                .into_iter()
380                .map(|(_, text)| text);
381            self.y_label_gap = value_axis_gap(labels, window);
382        }
383    }
384
385    /// Each y tick's position and the label text for the value there.
386    fn y_tick_labels(&self, extent: ValueExtent, height: f32) -> Vec<(f32, SharedString)> {
387        self.tick_positions(height)
388            .into_iter()
389            .map(|y| {
390                let value = extent.value_at(y);
391                let text = match self.y_tick_format.as_ref() {
392                    Some(format) => format(value),
393                    None => format_tick(value),
394                };
395                (y, text)
396            })
397            .collect()
398    }
399
400    /// The plot area within `bounds`: past the value-axis gutter and above the
401    /// x axis at `height`.
402    pub(crate) fn plot_bounds(&self, bounds: Bounds<Pixels>, height: f32) -> Bounds<Pixels> {
403        let left = self.plot_left();
404        Bounds {
405            origin: bounds.origin + point(px(left), px(0.)),
406            size: Size::new(bounds.size.width - px(left), px(height)),
407        }
408    }
409
410    /// The y ticks, evenly spaced in pixels from the top edge (0) to the
411    /// baseline (`height`), both included.
412    fn tick_positions(&self, height: f32) -> Vec<f32> {
413        let count = self.y_tick_count.max(2);
414        (0..count)
415            .map(|i| height * i as f32 / (count - 1) as f32)
416            .collect()
417    }
418
419    /// Paint the grid: a line at every y tick but the baseline, which the x axis
420    /// draws, and `grid_columns` evenly spaced vertical lines from the left edge.
421    pub(crate) fn paint_grid(
422        &self,
423        bounds: Bounds<Pixels>,
424        height: f32,
425        window: &mut Window,
426        cx: &mut App,
427    ) {
428        let plot = self.plot_bounds(bounds, height);
429        let mut rows = self.tick_positions(height);
430        rows.pop();
431        let width = plot.size.width.as_f32();
432        let columns: Vec<f32> = (0..self.grid_columns)
433            .map(|i| width * i as f32 / self.grid_columns as f32)
434            .collect();
435        let grid = Grid::new().y(rows).x(columns).stroke(cx.macos().divider());
436        let grid = if self.grid_dashed {
437            grid.dash_array(&[px(4.), px(2.)])
438        } else {
439            grid
440        };
441        grid.paint(&plot, window);
442    }
443
444    /// Paint a dashed line across the plot at each reference value, darker than
445    /// the grid so it reads apart from a dashed grid line.
446    pub(crate) fn paint_reference_lines(
447        &self,
448        extent: ValueExtent,
449        bounds: Bounds<Pixels>,
450        height: f32,
451        window: &mut Window,
452        cx: &mut App,
453    ) {
454        let rows: Vec<f32> = self
455            .reference_lines
456            .iter()
457            .filter_map(|v| extent.position_of(*v))
458            .filter(|y| (0. ..=height).contains(y))
459            .collect();
460        if rows.is_empty() {
461            return;
462        }
463        Grid::new()
464            .y(rows)
465            .stroke(cx.macos().secondary)
466            .dash_array(&[px(4.), px(2.)])
467            .paint(&self.plot_bounds(bounds, height), window);
468    }
469
470    /// Paint a tick label at every y tick, reading the value the scale puts there.
471    pub(crate) fn paint_y_labels(
472        &self,
473        extent: ValueExtent,
474        bounds: Bounds<Pixels>,
475        height: f32,
476        window: &mut Window,
477        cx: &mut App,
478    ) {
479        if !self.y_axis {
480            return;
481        }
482        let color = cx.macos().secondary;
483        let labels = self
484            .y_tick_labels(extent, height)
485            .into_iter()
486            .map(|(y, text)| {
487                match self.y_axis_label_placement {
488                    // Beside its grid line, above it but for the top one, which
489                    // would leave the plot.
490                    AxisLabelPlacement::Inside => {
491                        let top = if y < TEXT_HEIGHT {
492                            y + TEXT_GAP
493                        } else {
494                            y - TEXT_HEIGHT
495                        };
496                        Text::new(text, point(TEXT_GAP, top), color)
497                    }
498                    AxisLabelPlacement::Outside => {
499                        let top = (y - TEXT_SIZE / 2.).clamp(0., (height - TEXT_SIZE).max(0.));
500                        Text::new(text, point(self.y_label_gap - TEXT_GAP * 2., top), color)
501                            .align(TextAlign::Right)
502                    }
503                }
504            })
505            .collect();
506        PlotLabel::new(labels).paint(&bounds, window, cx);
507    }
508}
509
510/// The mask a point chart paints its series under once its y axis is pinned,
511/// so a value outside the pinned domain stops at the plot area instead of
512/// running over the x-axis labels. It bleeds by half a hover dot, keeping
513/// strokes and dots on the plot's edges whole.
514pub(crate) fn pinned_plot_mask(bounds: Bounds<Pixels>, height: f32) -> ContentMask<Pixels> {
515    let bleed = HOVER_DOT_SIZE / 2.;
516    ContentMask {
517        bounds: Bounds::from_corners(
518            bounds.origin - gpui_kit::point(bleed, bleed),
519            gpui_kit::point(bounds.right() + bleed, bounds.top() + px(height) + bleed),
520        ),
521    }
522}
523
524/// Build x-axis labels for point-based scales (`LineChart`, `AreaChart`).
525///
526/// Point scales place items at evenly spaced positions, on an axis laid out for
527/// `point_count` of them. A label on the first point is left-aligned, one on
528/// the last is right-aligned, and the rest are centered.
529pub(crate) fn build_point_x_labels<T, X>(
530    data: &[T],
531    x_fn: &dyn Fn(&T) -> X,
532    x_scale: &ScalePoint<X>,
533    point_count: usize,
534    labeled: &[bool],
535    color: Hsla,
536) -> Vec<AxisText>
537where
538    X: PartialEq + Into<SharedString>,
539{
540    data.iter()
541        .enumerate()
542        .filter_map(|(i, d)| {
543            if !labeled.get(i).copied().unwrap_or(false) {
544                return None;
545            }
546            x_scale.tick_at(i).map(|x_tick| {
547                let align = match i {
548                    0 if point_count == 1 => TextAlign::Center,
549                    0 => TextAlign::Left,
550                    i if i == point_count - 1 => TextAlign::Right,
551                    _ => TextAlign::Center,
552                };
553                AxisText::new(x_fn(d).into(), x_tick, color).align(align)
554            })
555        })
556        .collect()
557}
558
559/// Build axis labels for band-based scales (`BarChart`, `CandlestickChart`).
560///
561/// Band scales place items in evenly sized bands. The returned `tick`
562/// coordinate is the centre of each band along the band axis; the caller
563/// decides whether to feed the result to `PlotAxis::x_label` (vertical
564/// charts) or `PlotAxis::y_label` (horizontal charts).
565pub(crate) fn build_band_labels<T, X>(
566    data: &[T],
567    x_fn: &dyn Fn(&T) -> X,
568    x_scale: &ScaleBand<X>,
569    band_width: f32,
570    labeled: &[bool],
571    color: Hsla,
572) -> Vec<AxisText>
573where
574    X: Eq + Hash + Into<SharedString>,
575{
576    data.iter()
577        .enumerate()
578        .filter_map(|(i, d)| {
579            if !labeled.get(i).copied().unwrap_or(false) {
580                return None;
581            }
582            let label = x_fn(d);
583            x_scale.tick(&label).map(|x_tick| {
584                AxisText::new(label.into(), x_tick + band_width / 2., color)
585                    .align(TextAlign::Center)
586            })
587        })
588        .collect()
589}
590
591#[cfg(test)]
592mod tests {
593    use std::cell::Cell;
594
595    use gpui_kit::{Hsla, SharedString, TestAppContext, blue, div, green, point, px, red, size};
596
597    use super::TooltipContent;
598    use crate::{
599        chart::PieChart,
600        plot::{Plot, tooltip::Tooltip},
601    };
602
603    fn chart() -> PieChart<f32> {
604        PieChart::new([1., 2.])
605    }
606
607    /// The whole point of the default: a chart nobody gave an id to is still
608    /// interactive, because every caller forgot to ask for it.
609    #[test]
610    fn a_chart_is_interactive_without_being_given_an_id() {
611        assert!(Plot::id(&chart()).is_some());
612    }
613
614    /// Two construction sites must not share hover state or a path cache.
615    #[test]
616    fn charts_built_at_different_sites_get_different_ids() {
617        assert_ne!(
618            Plot::id(&PieChart::new([1.])),
619            Plot::id(&PieChart::new([1.]))
620        );
621    }
622
623    /// One site reached twice is one id — the caveat `id` exists for.
624    #[test]
625    fn charts_built_at_one_site_share_an_id() {
626        assert_eq!(Plot::id(&chart()), Plot::id(&chart()));
627    }
628
629    /// The escape hatch: no id means no hitbox, so nothing above the chart has
630    /// to fight it for the cursor.
631    #[test]
632    fn a_chart_turned_off_has_no_id_to_key_anything_on() {
633        assert!(Plot::id(&chart().interactive(false)).is_none());
634        assert!(Plot::id(&chart().id("pie").interactive(false)).is_none());
635    }
636
637    /// Only a label on the axis's last point hugs the right edge; the last item
638    /// of data laid out for more points sits mid-axis and stays centered.
639    #[test]
640    fn only_the_last_point_right_aligns_its_label() {
641        use gpui_kit::{Hsla, TextAlign};
642
643        use super::{build_point_x_labels, point_range};
644        use crate::plot::scale::ScalePoint;
645
646        let data = ["a", "b", "c"];
647        let align = |point_count| {
648            let x = ScalePoint::new(
649                data.to_vec(),
650                point_range(0., 100., data.len(), point_count),
651            );
652            build_point_x_labels(
653                &data,
654                &|d: &&'static str| *d,
655                &x,
656                point_count,
657                &[true; 3],
658                Hsla::default(),
659            )
660            .into_iter()
661            .map(|label| label.align)
662            .collect::<Vec<_>>()
663        };
664
665        assert_eq!(
666            align(3),
667            [TextAlign::Left, TextAlign::Center, TextAlign::Right]
668        );
669        assert_eq!(
670            align(5),
671            [TextAlign::Left, TextAlign::Center, TextAlign::Center]
672        );
673    }
674
675    #[test]
676    fn a_named_id_replaces_the_default() {
677        assert_eq!(
678            Plot::id(&chart().id("pie")),
679            Some(gpui_kit::ElementId::Name("pie".into()))
680        );
681    }
682
683    /// The default five ticks put the grid where it always was: four lines
684    /// splitting the plot, the baseline left to the x axis.
685    #[test]
686    fn the_default_ticks_keep_the_grid_in_place() {
687        let axes = super::PointAxes::default();
688        let mut rows = axes.tick_positions(100.);
689        rows.pop();
690        assert_eq!(rows, vec![0., 25., 50., 75.]);
691    }
692
693    #[test]
694    fn a_label_count_spreads_labels_from_the_first_item_to_the_last() {
695        use super::labeled_items;
696
697        let shown = |len, count| {
698            labeled_items(len, Some(count), 1)
699                .iter()
700                .enumerate()
701                .filter_map(|(i, &on)| on.then_some(i))
702                .collect::<Vec<_>>()
703        };
704        assert_eq!(shown(11, 3), vec![0, 5, 10]);
705        assert_eq!(shown(10, 2), vec![0, 9]);
706        assert_eq!(shown(3, 5), vec![0, 1, 2]);
707        assert_eq!(shown(4, 1), vec![0]);
708        assert!(shown(4, 0).is_empty());
709
710        // Without a count the stride still decides.
711        assert_eq!(labeled_items(4, None, 2), vec![false, true, false, true]);
712        assert_eq!(labeled_items(4, None, 0), vec![true; 4]);
713        assert!(labeled_items(0, None, 0).is_empty());
714    }
715
716    /// A tick label reads the value its height stands for, so the top one reads
717    /// past the highest value by the padding above it.
718    #[test]
719    fn a_tick_reads_the_value_at_its_height() {
720        use super::point_value_scale;
721
722        let (_, extent) = point_value_scale([10., 20.], None, 110., (10., 0.));
723        assert_eq!(extent.value_at(110.), 0.);
724        assert_eq!(extent.value_at(10.), 20.);
725        assert!((extent.value_at(0.) - 22.).abs() < 1e-4);
726        assert_eq!(extent.position_of(20.), Some(10.));
727
728        let (_, extent) = point_value_scale([0.], Some((100., 200.)), 100., (0., 0.));
729        assert_eq!(extent.value_at(0.), 200.);
730        assert_eq!(extent.position_of(150.), Some(50.));
731
732        let (scale, extent) = point_value_scale([0.], Some((200., 100.)), 100., (0., 0.));
733        use crate::plot::scale::Scale;
734        assert_eq!(extent.position_of(150.), scale.tick(&150.));
735        assert_eq!(extent.value_at(0.), 200.);
736    }
737
738    #[derive(Clone)]
739    struct CountedCategory {
740        index: usize,
741        comparisons: std::rc::Rc<Cell<usize>>,
742    }
743
744    impl PartialEq for CountedCategory {
745        fn eq(&self, other: &Self) -> bool {
746            self.comparisons.set(self.comparisons.get() + 1);
747            self.index == other.index
748        }
749    }
750
751    impl From<CountedCategory> for SharedString {
752        fn from(value: CountedCategory) -> Self {
753            value.index.to_string().into()
754        }
755    }
756
757    struct LargePointChart {
758        area: bool,
759        comparisons: std::rc::Rc<Cell<usize>>,
760        value_reads: std::rc::Rc<Cell<usize>>,
761    }
762
763    impl gpui_kit::Render for LargePointChart {
764        fn render(
765            &mut self,
766            _: &mut gpui_kit::Window,
767            _: &mut gpui_kit::Context<Self>,
768        ) -> impl gpui_kit::IntoElement {
769            use gpui_kit::{IntoElement, ParentElement, Styled};
770            let comparisons = self.comparisons.clone();
771            let x = move |&(index, _): &(usize, f64)| CountedCategory {
772                index,
773                comparisons: comparisons.clone(),
774            };
775            let data = (0..2048).map(|index| (index, (index % 17 + 1) as f64));
776            let value_reads = self.value_reads.clone();
777            let y = move |d: &(usize, f64)| {
778                value_reads.set(value_reads.get() + 1);
779                d.1
780            };
781            let chart = if self.area {
782                super::AreaChart::new(data)
783                    .x(x)
784                    .y(y)
785                    .x_tick_count(8)
786                    .into_any_element()
787            } else {
788                super::LineChart::new(data)
789                    .x(x)
790                    .y(y)
791                    .x_tick_count(8)
792                    .into_any_element()
793            };
794            div().w(px(640.)).h(px(320.)).child(chart)
795        }
796    }
797
798    #[gpui_kit::test]
799    fn point_chart_rendering_does_not_search_the_domain_per_point(cx: &mut TestAppContext) {
800        cx.update(crate::init);
801        for area in [false, true] {
802            let comparisons = std::rc::Rc::new(Cell::new(0));
803            let value_reads = std::rc::Rc::new(Cell::new(0));
804            let (_, cx) = cx.add_window_view({
805                let comparisons = comparisons.clone();
806                let value_reads = value_reads.clone();
807                move |_, _| LargePointChart {
808                    area,
809                    comparisons,
810                    value_reads,
811                }
812            });
813            cx.update(|window, cx| window.draw(cx).clear(cx));
814            assert!(
815                value_reads.get() >= 2048,
816                "The regression check must exercise the complete series"
817            );
818            assert_eq!(
819                comparisons.get(),
820                0,
821                "Point positions must be projected by index in linear time"
822            );
823        }
824    }
825
826    #[gpui_kit::test]
827    fn repeated_point_labels_keep_distinct_positions_and_hover_targets(cx: &mut TestAppContext) {
828        use crate::plot::scale::ScalePoint;
829        cx.update(|cx| {
830            crate::init(cx);
831            let bounds = gpui_kit::Bounds::new(point(px(0.), px(0.)), size(px(100.), px(100.)));
832            let line = super::LineChart::new([1., 2., 3.])
833                .x(|_| "Repeated")
834                .y(|d| *d)
835                .x_axis(false);
836            let area = super::AreaChart::new([1., 2., 3.])
837                .x(|_| "Repeated")
838                .y(|d| *d)
839                .x_axis(false);
840            for state in [
841                line.tooltip_state(point(px(100.), px(50.)), bounds, cx),
842                area.tooltip_state(point(px(100.), px(50.)), bounds, cx),
843            ] {
844                let state = state.unwrap();
845                assert_eq!(state.index, 2);
846                assert_eq!(state.cross_line.x, px(100.));
847                assert_eq!(state.dots[0].x, px(100.));
848            }
849            let data = ["Repeated"; 3];
850            let scale = ScalePoint::new(data, [0., 100.]);
851            let labels =
852                super::build_point_x_labels(&data, &|d| *d, &scale, 3, &[true; 3], Hsla::default());
853            assert_eq!(
854                labels.iter().map(|label| label.tick).collect::<Vec<_>>(),
855                [px(0.), px(50.), px(100.)]
856            );
857        });
858    }
859
860    /// Unset, the tooltip reads the chart's own title and the raw number;
861    /// set, the caller's text replaces both.
862    #[test]
863    fn tooltip_text_falls_back_to_the_chart_own() {
864        let mut content = TooltipContent::<f64>::default();
865        assert_eq!(
866            content.title_text(&1., Some("Jan".into())),
867            Some("Jan".into())
868        );
869        assert_eq!(content.title_text(&1., None), None);
870        assert_eq!(content.value_text(&1., 0, 1234.5).as_ref(), "1234.5");
871
872        content.set_title(|d| format!("Day {d}").into());
873        content.set_value(|_, _, value| format!("${value:.2}").into());
874        assert_eq!(content.title_text(&3., None), Some("Day 3".into()));
875        assert_eq!(content.value_text(&3., 0, 1234.5).as_ref(), "$1234.50");
876    }
877
878    /// Rows read the caller's value text and color, one per series, each told
879    /// which row it is.
880    #[gpui_kit::test]
881    fn tooltip_fill_writes_each_row_with_the_value_color(cx: &mut TestAppContext) {
882        let mut content = TooltipContent::<f64>::default();
883        content.set_value(|_, ix, value| format!("{ix}: {value:+}").into());
884        content.set_value_color(|_, _, value| if value >= 0. { green() } else { red() });
885        let cx = cx.add_empty_window();
886        let tooltip = cx
887            .update(|window, cx| {
888                content.apply(
889                    Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
890                    &1.,
891                    || Some("Jan".into()),
892                    || Some([(blue(), "Open".into(), 2.), (blue(), "Close".into(), -1.)]),
893                    window,
894                    cx,
895                )
896            })
897            .expect("rows are given");
898
899        assert_eq!(tooltip.title_for_test().map(|t| t.as_ref()), Some("Jan"));
900        assert_eq!(
901            tooltip.rows_for_test(),
902            vec![
903                ("0: +2".into(), Some(green())),
904                ("1: -1".into(), Some(red()))
905            ]
906        );
907    }
908
909    /// Without a title of the chart's or the caller's, the tooltip has none, as a
910    /// radar with element labels shows.
911    #[gpui_kit::test]
912    fn tooltip_fill_leaves_the_title_off_without_one(cx: &mut TestAppContext) {
913        let content = TooltipContent::<f64>::default();
914        let cx = cx.add_empty_window();
915        let tooltip = cx
916            .update(|window, cx| {
917                content.apply(
918                    Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
919                    &1.,
920                    || None,
921                    || Some([(blue(), "Alpha".into(), 80.)]),
922                    window,
923                    cx,
924                )
925            })
926            .expect("rows are given");
927
928        assert!(tooltip.title_for_test().is_none());
929        assert_eq!(tooltip.rows_for_test(), vec![("80".into(), None)]);
930    }
931
932    /// A caller's own content replaces the title and rows, which are never built,
933    /// so a series without a value doesn't hide it.
934    #[gpui_kit::test]
935    fn tooltip_fill_renders_the_caller_content_without_building_rows(cx: &mut TestAppContext) {
936        let mut content = TooltipContent::<f64>::default();
937        content.set_title(|_| "Caller".into());
938        content.set_content(|_, _, _| div());
939        let built = Cell::new(false);
940        let cx = cx.add_empty_window();
941        let tooltip = cx.update(|window, cx| {
942            content.apply(
943                Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
944                &1.,
945                || {
946                    built.set(true);
947                    Some("Jan".into())
948                },
949                || -> Option<[(Hsla, SharedString, f64); 0]> {
950                    built.set(true);
951                    None
952                },
953                window,
954                cx,
955            )
956        });
957
958        let tooltip = tooltip.expect("the caller renders");
959        assert!(!built.get());
960        assert!(tooltip.title_for_test().is_none());
961        assert!(tooltip.rows_for_test().is_empty());
962    }
963}