Skip to main content

gpui_component/chart/
mod.rs

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