Skip to main content

liora_components/
chart.rs

1use gpui::{
2    AnyElement, App, Bounds, Element, ElementId, GlobalElementId, Hsla, InspectorElementId,
3    IntoElement, LayoutId, Pixels, SharedString, Window, px,
4};
5use liora_core::{Config, unique_id};
6use std::cell::Cell;
7use std::rc::Rc;
8
9pub struct ChartBoundsTracker {
10    pub child: AnyElement,
11    pub bounds: Rc<Cell<Bounds<Pixels>>>,
12}
13
14impl ChartBoundsTracker {
15    pub fn new(child: impl IntoElement, bounds: Rc<Cell<Bounds<Pixels>>>) -> Self {
16        Self {
17            child: child.into_any_element(),
18            bounds,
19        }
20    }
21}
22
23impl IntoElement for ChartBoundsTracker {
24    type Element = Self;
25
26    fn into_element(self) -> Self::Element {
27        self
28    }
29}
30
31impl Element for ChartBoundsTracker {
32    type RequestLayoutState = ();
33    type PrepaintState = ();
34
35    fn id(&self) -> Option<ElementId> {
36        None
37    }
38
39    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
40        None
41    }
42
43    fn request_layout(
44        &mut self,
45        _id: Option<&GlobalElementId>,
46        _inspector_id: Option<&InspectorElementId>,
47        window: &mut Window,
48        cx: &mut App,
49    ) -> (LayoutId, Self::RequestLayoutState) {
50        (self.child.request_layout(window, cx), ())
51    }
52
53    fn prepaint(
54        &mut self,
55        _id: Option<&GlobalElementId>,
56        _inspector_id: Option<&InspectorElementId>,
57        _bounds: Bounds<Pixels>,
58        _request_layout: &mut Self::RequestLayoutState,
59        window: &mut Window,
60        cx: &mut App,
61    ) -> Self::PrepaintState {
62        self.child.prepaint(window, cx);
63    }
64
65    fn paint(
66        &mut self,
67        _id: Option<&GlobalElementId>,
68        _inspector_id: Option<&InspectorElementId>,
69        bounds: Bounds<Pixels>,
70        _request_layout: &mut Self::RequestLayoutState,
71        _prepaint: &mut Self::PrepaintState,
72        window: &mut Window,
73        cx: &mut App,
74    ) {
75        self.bounds.set(bounds);
76        self.child.paint(window, cx);
77    }
78}
79
80#[derive(Clone, Debug, PartialEq)]
81pub struct ChartPoint {
82    pub label: SharedString,
83    pub value: f64,
84}
85
86impl ChartPoint {
87    pub fn new(label: impl Into<SharedString>, value: f64) -> Self {
88        Self {
89            label: label.into(),
90            value,
91        }
92    }
93
94    pub fn is_finite(&self) -> bool {
95        self.value.is_finite()
96    }
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum ChartLineStyle {
101    Solid,
102    Dashed,
103    Dotted,
104}
105
106#[derive(Clone, Debug)]
107pub struct ChartSeries {
108    pub name: SharedString,
109    pub points: Vec<ChartPoint>,
110    pub color: Option<Hsla>,
111    pub fill_color: Option<Hsla>,
112    pub stroke_color: Option<Hsla>,
113    pub stroke_width: Option<Pixels>,
114    pub line_style: Option<ChartLineStyle>,
115    pub dash_pattern: Option<Vec<Pixels>>,
116    pub smooth: Option<bool>,
117}
118
119impl ChartSeries {
120    pub fn new(
121        name: impl Into<SharedString>,
122        points: impl IntoIterator<Item = ChartPoint>,
123    ) -> Self {
124        Self {
125            name: name.into(),
126            points: points.into_iter().collect(),
127            color: None,
128            fill_color: None,
129            stroke_color: None,
130            stroke_width: None,
131            line_style: None,
132            dash_pattern: None,
133            smooth: None,
134        }
135    }
136
137    pub fn color(mut self, color: Hsla) -> Self {
138        self.color = Some(color);
139        self
140    }
141
142    pub fn fill_color(mut self, color: Hsla) -> Self {
143        self.fill_color = Some(color);
144        self
145    }
146
147    pub fn stroke_color(mut self, color: Hsla) -> Self {
148        self.stroke_color = Some(color);
149        self
150    }
151
152    pub fn stroke_width(mut self, width: impl Into<Pixels>) -> Self {
153        self.stroke_width = Some(width.into());
154        self
155    }
156
157    pub fn line_style(mut self, style: ChartLineStyle) -> Self {
158        self.line_style = Some(style);
159        self
160    }
161
162    pub fn dashed(self) -> Self {
163        self.line_style(ChartLineStyle::Dashed)
164    }
165
166    pub fn dotted(self) -> Self {
167        self.line_style(ChartLineStyle::Dotted)
168    }
169
170    pub fn solid(self) -> Self {
171        self.line_style(ChartLineStyle::Solid)
172    }
173
174    pub fn dash_pattern(mut self, pattern: impl IntoIterator<Item = impl Into<Pixels>>) -> Self {
175        self.dash_pattern = Some(
176            pattern
177                .into_iter()
178                .map(|value| value.into().max(px(0.1)))
179                .collect(),
180        );
181        self.line_style = Some(ChartLineStyle::Dashed);
182        self
183    }
184
185    pub fn smooth(mut self, enabled: bool) -> Self {
186        self.smooth = Some(enabled);
187        self
188    }
189
190    pub fn resolved_fill_color(&self, fallback: Hsla) -> Hsla {
191        self.fill_color.or(self.color).unwrap_or(fallback)
192    }
193
194    pub fn resolved_stroke_color(&self, fallback: Hsla) -> Hsla {
195        self.stroke_color.or(self.color).unwrap_or(fallback)
196    }
197
198    pub fn finite_points(&self) -> impl Iterator<Item = &ChartPoint> {
199        self.points.iter().filter(|point| point.is_finite())
200    }
201
202    pub fn is_empty(&self) -> bool {
203        self.finite_points().next().is_none()
204    }
205}
206
207#[derive(Clone, Copy, Debug, PartialEq)]
208pub struct ChartPadding {
209    pub top: Pixels,
210    pub right: Pixels,
211    pub bottom: Pixels,
212    pub left: Pixels,
213}
214
215impl Default for ChartPadding {
216    fn default() -> Self {
217        Self {
218            top: px(18.0),
219            right: px(18.0),
220            bottom: px(34.0),
221            left: px(44.0),
222        }
223    }
224}
225
226#[derive(Clone, Debug)]
227pub struct ChartPalette {
228    pub series: Vec<Hsla>,
229    pub axis: Hsla,
230    pub grid: Hsla,
231    pub label: Hsla,
232}
233
234impl ChartPalette {
235    pub fn from_config(config: &Config) -> Self {
236        let theme = &config.theme;
237        Self {
238            series: vec![
239                theme.primary.base,
240                theme.info.base,
241                theme.success.base,
242                theme.warning.base,
243                theme.danger.base,
244                theme.primary.hover,
245                theme.info.hover,
246                theme.warning.hover,
247            ],
248            axis: theme.neutral.border,
249            grid: theme.neutral.divider.opacity(0.72),
250            label: theme.neutral.text_3,
251        }
252    }
253
254    pub fn series_color(&self, index: usize) -> Hsla {
255        self.series
256            .get(index % self.series.len().max(1))
257            .copied()
258            .unwrap_or_else(|| gpui::blue())
259    }
260}
261
262#[derive(Clone, Copy, Debug, PartialEq, Eq)]
263pub enum ChartValueLabelContent {
264    Value,
265    Percentage,
266    ValueAndPercentage,
267    ValueOverTotal,
268    ValueOverTotalAndPercentage,
269}
270
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272pub enum ChartValueLabelPlacement {
273    Auto,
274    Inside,
275    OutsideFree,
276    OutsideAligned,
277}
278
279#[derive(Clone, Copy, Debug, PartialEq, Eq)]
280pub struct ChartValueLabelOptions {
281    pub content: ChartValueLabelContent,
282    pub placement: ChartValueLabelPlacement,
283    pub percentage_decimals: usize,
284    pub outside_threshold_degrees: u16,
285}
286
287impl Default for ChartValueLabelOptions {
288    fn default() -> Self {
289        Self {
290            content: ChartValueLabelContent::Value,
291            placement: ChartValueLabelPlacement::Auto,
292            percentage_decimals: 1,
293            outside_threshold_degrees: 28,
294        }
295    }
296}
297
298#[derive(Clone)]
299pub struct ChartOptions {
300    pub id: SharedString,
301    pub height: Pixels,
302    pub padding: ChartPadding,
303    pub show_grid: bool,
304    pub show_axis: bool,
305    pub show_legend: bool,
306    pub y_domain: Option<(f64, f64)>,
307    pub y_tick_count: usize,
308    pub y_format: Option<fn(f64) -> SharedString>,
309    pub show_value_labels: bool,
310    pub value_label_options: ChartValueLabelOptions,
311    pub max_render_points: Option<usize>,
312    pub max_axis_labels: usize,
313    pub max_value_labels: usize,
314    pub show_tooltip: bool,
315    pub tooltip_hit_radius: Pixels,
316}
317
318impl Default for ChartOptions {
319    fn default() -> Self {
320        Self {
321            id: unique_id("chart"),
322            height: px(280.0),
323            padding: ChartPadding::default(),
324            show_grid: true,
325            show_axis: true,
326            show_legend: true,
327            y_domain: None,
328            y_tick_count: 4,
329            y_format: None,
330            show_value_labels: true,
331            value_label_options: ChartValueLabelOptions::default(),
332            max_render_points: Some(800),
333            max_axis_labels: 8,
334            max_value_labels: 32,
335            show_tooltip: true,
336            tooltip_hit_radius: px(12.0),
337        }
338    }
339}
340
341#[derive(Clone, Debug, PartialEq)]
342pub struct ChartHitPoint {
343    pub series_index: usize,
344    pub point_index: usize,
345    pub series_name: SharedString,
346    pub label: SharedString,
347    pub value: f64,
348    pub x: f32,
349    pub y: f32,
350    pub distance: f32,
351}
352
353pub fn nearest_cartesian_hit_point(
354    series: &[ChartSeries],
355    domain: (f64, f64),
356    plot_width: f32,
357    plot_height: f32,
358    pointer_x: f32,
359    pointer_y: f32,
360    max_distance: f32,
361) -> Option<ChartHitPoint> {
362    if series.is_empty()
363        || !plot_width.is_finite()
364        || !plot_height.is_finite()
365        || plot_width <= 0.0
366        || plot_height <= 0.0
367        || !pointer_x.is_finite()
368        || !pointer_y.is_finite()
369        || !max_distance.is_finite()
370        || max_distance < 0.0
371        || pointer_x < 0.0
372        || pointer_y < 0.0
373        || pointer_x > plot_width
374        || pointer_y > plot_height
375    {
376        return None;
377    }
378
379    let domain_len = label_domain_len(series);
380    if domain_len == 0 {
381        return None;
382    }
383
384    let span = domain.1 - domain.0;
385    if !domain.0.is_finite() || !domain.1.is_finite() || span.abs() < f64::EPSILON {
386        return None;
387    }
388
389    let x_for_index = |index: usize| -> Option<f32> {
390        if index >= domain_len {
391            return None;
392        }
393        if domain_len == 1 {
394            Some(plot_width / 2.0)
395        } else {
396            Some(plot_width * index as f32 / (domain_len - 1) as f32)
397        }
398    };
399    let y_for_value = |value: f64| -> Option<f32> {
400        if !value.is_finite() {
401            return None;
402        }
403        let t = ((value - domain.0) / span) as f32;
404        Some((plot_height - plot_height * t).clamp(0.0, plot_height))
405    };
406
407    let mut best: Option<ChartHitPoint> = None;
408    let mut best_distance_sq = max_distance * max_distance;
409
410    for (series_index, current) in series.iter().enumerate() {
411        for (point_index, point) in current.points.iter().enumerate() {
412            if !point.is_finite() {
413                continue;
414            }
415            let Some(x) = x_for_index(point_index) else {
416                continue;
417            };
418            let Some(y) = y_for_value(point.value) else {
419                continue;
420            };
421            let dx = x - pointer_x;
422            let dy = y - pointer_y;
423            let distance_sq = dx * dx + dy * dy;
424            if distance_sq <= best_distance_sq {
425                best_distance_sq = distance_sq;
426                best = Some(ChartHitPoint {
427                    series_index,
428                    point_index,
429                    series_name: current.name.clone(),
430                    label: point.label.clone(),
431                    value: point.value,
432                    x,
433                    y,
434                    distance: distance_sq.sqrt(),
435                });
436            }
437        }
438    }
439
440    best
441}
442
443pub fn format_hit_tooltip(
444    hit: &ChartHitPoint,
445    formatter: Option<fn(f64) -> SharedString>,
446) -> SharedString {
447    let format_value = formatter.unwrap_or(default_y_format);
448    format!(
449        "{} · {}: {}",
450        hit.series_name,
451        hit.label,
452        format_value(hit.value)
453    )
454    .into()
455}
456
457pub fn default_y_format(value: f64) -> SharedString {
458    if value.abs() >= 1000.0 {
459        format!("{value:.0}").into()
460    } else if value.fract().abs() < f64::EPSILON {
461        format!("{value:.0}").into()
462    } else {
463        format!("{value:.1}").into()
464    }
465}
466
467pub fn format_value_label(
468    value: f64,
469    total: f64,
470    formatter: Option<fn(f64) -> SharedString>,
471    options: &ChartValueLabelOptions,
472) -> SharedString {
473    let format_value = formatter.unwrap_or(default_y_format);
474    let value_text = format_value(value);
475    let total_text = format_value(total);
476    let percentage = if total.abs() > f64::EPSILON {
477        value / total * 100.0
478    } else {
479        0.0
480    };
481    match options.content {
482        ChartValueLabelContent::Value => value_text,
483        ChartValueLabelContent::Percentage => {
484            format!("{:.*}%", options.percentage_decimals, percentage).into()
485        }
486        ChartValueLabelContent::ValueAndPercentage => format!(
487            "{} ({:.*}%)",
488            value_text, options.percentage_decimals, percentage
489        )
490        .into(),
491        ChartValueLabelContent::ValueOverTotal => format!("{} / {}", value_text, total_text).into(),
492        ChartValueLabelContent::ValueOverTotalAndPercentage => format!(
493            "{} / {} ({:.*}%)",
494            value_text, total_text, options.percentage_decimals, percentage
495        )
496        .into(),
497    }
498}
499
500pub fn series_total(series: &ChartSeries) -> f64 {
501    series
502        .finite_points()
503        .map(|point| point.value.max(0.0))
504        .sum()
505}
506
507pub fn finite_domain(series: &[ChartSeries]) -> Option<(f64, f64)> {
508    let mut min = f64::INFINITY;
509    let mut max = f64::NEG_INFINITY;
510    for value in series
511        .iter()
512        .flat_map(|series| series.finite_points().map(|point| point.value))
513    {
514        min = min.min(value);
515        max = max.max(value);
516    }
517    if min.is_finite() && max.is_finite() {
518        Some((min, max))
519    } else {
520        None
521    }
522}
523
524pub fn normalized_domain(domain: Option<(f64, f64)>, series: &[ChartSeries]) -> (f64, f64) {
525    normalized_domain_with_baseline(domain, series, true)
526}
527
528pub fn normalized_domain_with_baseline(
529    domain: Option<(f64, f64)>,
530    series: &[ChartSeries],
531    include_zero: bool,
532) -> (f64, f64) {
533    let (mut min, mut max) = domain
534        .filter(|(min, max)| min.is_finite() && max.is_finite())
535        .or_else(|| finite_domain(series))
536        .unwrap_or((0.0, 1.0));
537
538    if include_zero && min > 0.0 {
539        min = 0.0;
540    }
541    if (max - min).abs() < f64::EPSILON {
542        let pad = if max.abs() < f64::EPSILON {
543            1.0
544        } else {
545            max.abs() * 0.1
546        };
547        min -= pad;
548        max += pad;
549    }
550    (min, max)
551}
552
553pub fn stacked_domain(series: &[ChartSeries]) -> Option<(f64, f64)> {
554    let labels_len = label_domain_len(series);
555    if labels_len == 0 {
556        return finite_domain(series);
557    }
558
559    let mut max_total = 0.0_f64;
560    let mut min_total = 0.0_f64;
561    let mut seen = false;
562    for index in 0..labels_len {
563        let mut positive = 0.0_f64;
564        let mut negative = 0.0_f64;
565        for point in series.iter().filter_map(|series| series.points.get(index)) {
566            if !point.is_finite() {
567                continue;
568            }
569            seen = true;
570            if point.value >= 0.0 {
571                positive += point.value;
572            } else {
573                negative += point.value;
574            }
575        }
576        max_total = max_total.max(positive);
577        min_total = min_total.min(negative);
578    }
579
580    if seen {
581        Some((min_total, max_total))
582    } else {
583        None
584    }
585}
586
587#[derive(Clone, Debug, PartialEq)]
588pub struct ChartAxisLabel {
589    pub index: usize,
590    pub label: SharedString,
591}
592
593impl ChartAxisLabel {
594    pub fn new(index: usize, label: impl Into<SharedString>) -> Self {
595        Self {
596            index,
597            label: label.into(),
598        }
599    }
600}
601
602pub fn collect_labels(series: &[ChartSeries]) -> Vec<SharedString> {
603    series
604        .iter()
605        .max_by_key(|series| series.points.len())
606        .map(|series| {
607            series
608                .points
609                .iter()
610                .map(|point| point.label.clone())
611                .collect::<Vec<_>>()
612        })
613        .unwrap_or_default()
614}
615
616pub fn label_domain_len(series: &[ChartSeries]) -> usize {
617    series
618        .iter()
619        .map(|series| series.points.len())
620        .max()
621        .unwrap_or(0)
622}
623
624pub fn collect_axis_labels(series: &[ChartSeries], max_labels: usize) -> Vec<ChartAxisLabel> {
625    let Some(longest) = series.iter().max_by_key(|series| series.points.len()) else {
626        return Vec::new();
627    };
628    sparse_axis_labels(&longest.points, max_labels)
629}
630
631pub fn sparse_indices(len: usize, max_count: usize) -> Vec<usize> {
632    if len == 0 {
633        return Vec::new();
634    }
635    let max_count = max_count.max(2);
636    if len <= max_count {
637        return (0..len).collect();
638    }
639
640    let last = len - 1;
641    let intervals = max_count - 1;
642    let mut indices = Vec::with_capacity(max_count);
643    let mut previous = None;
644    for slot in 0..=intervals {
645        let mut index = ((slot * last) + intervals / 2) / intervals;
646        if slot == 0 {
647            index = 0;
648        } else if slot == intervals {
649            index = last;
650        }
651        if previous == Some(index) {
652            continue;
653        }
654        indices.push(index);
655        previous = Some(index);
656    }
657    indices
658}
659
660pub fn sparse_axis_labels(points: &[ChartPoint], max_labels: usize) -> Vec<ChartAxisLabel> {
661    sparse_indices(points.len(), max_labels)
662        .into_iter()
663        .map(|index| ChartAxisLabel::new(index, points[index].label.clone()))
664        .collect()
665}
666
667pub fn has_chart_data(series: &[ChartSeries]) -> bool {
668    series.iter().any(|series| !series.is_empty())
669}
670
671/// Downsample an indexed value slice without first allocating every finite
672/// `(index, value)` pair. It makes one cheap count pass and one bucket pass,
673/// returning only the bounded render set while preserving first/last finite
674/// values and local min/max extrema.
675pub fn downsample_index_range<F>(
676    len: usize,
677    value_at: F,
678    max_points: Option<usize>,
679) -> Vec<(usize, f64)>
680where
681    F: Fn(usize) -> f64,
682{
683    let collect_finite = || {
684        (0..len)
685            .filter_map(|index| {
686                let value = value_at(index);
687                value.is_finite().then_some((index, value))
688            })
689            .collect::<Vec<_>>()
690    };
691
692    let Some(max_points) = max_points.filter(|max| *max >= 3) else {
693        return collect_finite();
694    };
695
696    let finite_count = (0..len)
697        .map(&value_at)
698        .filter(|value| value.is_finite())
699        .count();
700    if finite_count == 0 {
701        return Vec::new();
702    }
703    if finite_count <= max_points {
704        return collect_finite();
705    }
706
707    let bucket_count = ((max_points.saturating_sub(2)) / 2).max(1);
708    let middle_len = finite_count.saturating_sub(2);
709    let bucket_size = (middle_len as f64 / bucket_count as f64).ceil() as usize;
710    let mut sampled = Vec::with_capacity(max_points.min(finite_count));
711    let mut finite_ordinal = 0usize;
712    let mut first = None;
713    let mut last = None;
714    let mut bucket_start = 1usize;
715    let mut bucket_end = (bucket_start + bucket_size).min(finite_count - 1);
716    let mut bucket_min: Option<(usize, f64, usize)> = None;
717    let mut bucket_max: Option<(usize, f64, usize)> = None;
718
719    let flush_bucket = |sampled: &mut Vec<(usize, f64)>,
720                        bucket_min: &mut Option<(usize, f64, usize)>,
721                        bucket_max: &mut Option<(usize, f64, usize)>| {
722        let (Some(min), Some(max)) = (*bucket_min, *bucket_max) else {
723            return;
724        };
725        if min.2 <= max.2 {
726            sampled.push((min.0, min.1));
727            if min.2 != max.2 && sampled.len() + 1 < max_points {
728                sampled.push((max.0, max.1));
729            }
730        } else {
731            sampled.push((max.0, max.1));
732            if sampled.len() + 1 < max_points {
733                sampled.push((min.0, min.1));
734            }
735        }
736        *bucket_min = None;
737        *bucket_max = None;
738    };
739
740    for index in 0..len {
741        let current_value = value_at(index);
742        if !current_value.is_finite() {
743            continue;
744        }
745
746        if finite_ordinal == 0 {
747            first = Some((index, current_value));
748        }
749        if finite_ordinal == finite_count - 1 {
750            last = Some((index, current_value));
751        } else if finite_ordinal >= bucket_start && finite_ordinal < finite_count - 1 {
752            while finite_ordinal >= bucket_end && sampled.len() + 1 < max_points {
753                flush_bucket(&mut sampled, &mut bucket_min, &mut bucket_max);
754                bucket_start = bucket_end;
755                bucket_end = (bucket_start + bucket_size).min(finite_count - 1);
756            }
757            let candidate = (index, current_value, finite_ordinal);
758            if bucket_min
759                .as_ref()
760                .is_none_or(|(_, min_value, _)| current_value < *min_value)
761            {
762                bucket_min = Some(candidate);
763            }
764            if bucket_max
765                .as_ref()
766                .is_none_or(|(_, max_value, _)| current_value > *max_value)
767            {
768                bucket_max = Some(candidate);
769            }
770        }
771        finite_ordinal += 1;
772    }
773
774    if let Some(first) = first {
775        sampled.insert(0, first);
776    }
777    if sampled.len() + 1 < max_points {
778        flush_bucket(&mut sampled, &mut bucket_min, &mut bucket_max);
779    }
780    if sampled.len() >= max_points {
781        sampled.pop();
782    }
783    if let Some(last) = last {
784        sampled.push(last);
785    }
786    sampled
787}
788
789pub fn downsample_indexed_values<T, F>(
790    items: &[T],
791    value: F,
792    max_points: Option<usize>,
793) -> Vec<(usize, f64)>
794where
795    F: Fn(&T) -> f64,
796{
797    downsample_index_range(items.len(), |index| value(&items[index]), max_points)
798}
799
800/// Downsample a finite point stream while preserving first/last points and
801/// local min/max extrema in each bucket. This keeps long native path rendering
802/// bounded without hiding short spikes in monitoring-style charts.
803pub fn downsample_points<T>(points: &[(T, f64)], max_points: Option<usize>) -> Vec<(T, f64)>
804where
805    T: Copy,
806{
807    let finite = points
808        .iter()
809        .copied()
810        .filter(|(_, value)| value.is_finite())
811        .collect::<Vec<_>>();
812    let Some(max_points) = max_points.filter(|max| *max >= 3) else {
813        return finite;
814    };
815    if finite.len() <= max_points {
816        return finite;
817    }
818
819    let bucket_count = ((max_points.saturating_sub(2)) / 2).max(1);
820    let middle_len = finite.len().saturating_sub(2);
821    let bucket_size = (middle_len as f64 / bucket_count as f64).ceil() as usize;
822    let mut sampled = Vec::with_capacity(max_points.min(finite.len()));
823    sampled.push(finite[0]);
824
825    let mut start = 1;
826    while start < finite.len() - 1 && sampled.len() + 1 < max_points {
827        let end = (start + bucket_size).min(finite.len() - 1);
828        let bucket = &finite[start..end];
829        if !bucket.is_empty() {
830            if let (Some((min_offset, _)), Some((max_offset, _))) = (
831                bucket
832                    .iter()
833                    .enumerate()
834                    .min_by(|(_, a), (_, b)| a.1.total_cmp(&b.1)),
835                bucket
836                    .iter()
837                    .enumerate()
838                    .max_by(|(_, a), (_, b)| a.1.total_cmp(&b.1)),
839            ) {
840                if min_offset <= max_offset {
841                    sampled.push(bucket[min_offset]);
842                    if min_offset != max_offset && sampled.len() + 1 < max_points {
843                        sampled.push(bucket[max_offset]);
844                    }
845                } else {
846                    sampled.push(bucket[max_offset]);
847                    if sampled.len() + 1 < max_points {
848                        sampled.push(bucket[min_offset]);
849                    }
850                }
851            }
852        }
853        start = end;
854    }
855
856    let Some(last) = finite.last().copied() else {
857        return sampled;
858    };
859    if sampled.len() >= max_points {
860        sampled.pop();
861    }
862    sampled.push(last);
863    sampled
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869
870    #[test]
871    fn chart_series_builder_tracks_visual_overrides() {
872        let series = ChartSeries::new("metrics", [ChartPoint::new("a", 1.0)])
873            .fill_color(gpui::red())
874            .stroke_color(gpui::blue())
875            .stroke_width(px(3.0))
876            .smooth(false);
877        assert_eq!(series.fill_color, Some(gpui::red()));
878        assert_eq!(series.stroke_color, Some(gpui::blue()));
879        assert_eq!(series.stroke_width, Some(px(3.0)));
880        assert_eq!(series.smooth, Some(false));
881    }
882
883    #[test]
884    fn value_labels_format_content_variants() {
885        let options = ChartValueLabelOptions {
886            content: ChartValueLabelContent::ValueOverTotalAndPercentage,
887            percentage_decimals: 2,
888            ..ChartValueLabelOptions::default()
889        };
890        assert_eq!(
891            format_value_label(1.0, 4.0, None, &options),
892            SharedString::from("1 / 4 (25.00%)")
893        );
894    }
895
896    #[test]
897    fn chart_options_enable_tooltip_by_default() {
898        let options = ChartOptions::default();
899        assert!(options.show_tooltip);
900        assert_eq!(options.tooltip_hit_radius, px(12.0));
901    }
902
903    #[test]
904    fn hit_tooltip_uses_series_label_and_formatter() {
905        let hit = ChartHitPoint {
906            series_index: 0,
907            point_index: 1,
908            series_name: "CPU".into(),
909            label: "10:05".into(),
910            value: 42.25,
911            x: 10.0,
912            y: 20.0,
913            distance: 2.0,
914        };
915
916        assert_eq!(
917            format_hit_tooltip(&hit, Some(|value| format!("{value:.1}%").into())),
918            SharedString::from("CPU · 10:05: 42.2%")
919        );
920    }
921
922    #[test]
923    fn chart_series_filters_non_finite_points() {
924        let series = ChartSeries::new(
925            "metrics",
926            [
927                ChartPoint::new("a", 1.0),
928                ChartPoint::new("bad", f64::NAN),
929                ChartPoint::new("b", 2.0),
930                ChartPoint::new("inf", f64::INFINITY),
931            ],
932        );
933
934        let values = series
935            .finite_points()
936            .map(|point| point.value)
937            .collect::<Vec<_>>();
938        assert_eq!(values, vec![1.0, 2.0]);
939    }
940
941    #[test]
942    fn normalized_domain_includes_zero_and_expands_single_value() {
943        let series = [ChartSeries::new("one", [ChartPoint::new("a", 10.0)])];
944        assert_eq!(normalized_domain(None, &series), (0.0, 10.0));
945
946        let negative = [ChartSeries::new("negative", [ChartPoint::new("a", -4.0)])];
947        assert_eq!(normalized_domain(None, &negative), (-4.4, -3.6));
948    }
949
950    #[test]
951    fn stacked_domain_sums_same_index_values() {
952        let series = [
953            ChartSeries::new(
954                "a",
955                [ChartPoint::new("Q1", 2.0), ChartPoint::new("Q2", -1.0)],
956            ),
957            ChartSeries::new(
958                "b",
959                [ChartPoint::new("Q1", 3.0), ChartPoint::new("Q2", -4.0)],
960            ),
961        ];
962        assert_eq!(stacked_domain(&series), Some((-5.0, 5.0)));
963    }
964
965    #[test]
966    fn collect_labels_uses_longest_series() {
967        let labels = collect_labels(&[
968            ChartSeries::new("a", [ChartPoint::new("Q1", 1.0)]),
969            ChartSeries::new(
970                "b",
971                [ChartPoint::new("Q1", 2.0), ChartPoint::new("Q2", 3.0)],
972            ),
973        ]);
974        assert_eq!(
975            labels,
976            vec![SharedString::from("Q1"), SharedString::from("Q2")]
977        );
978    }
979
980    #[test]
981    fn sparse_indices_preserve_edges_and_cap_count() {
982        let indices = sparse_indices(100, 8);
983        assert_eq!(indices.len(), 8);
984        assert_eq!(indices.first(), Some(&0));
985        assert_eq!(indices.last(), Some(&99));
986    }
987
988    #[test]
989    fn collect_axis_labels_caps_dense_domains() {
990        let series = [ChartSeries::new(
991            "dense",
992            (0..100).map(|index| ChartPoint::new(format!("T{index}"), index as f64)),
993        )];
994        let labels = collect_axis_labels(&series, 8);
995
996        assert_eq!(labels.len(), 8);
997        assert_eq!(labels.first().map(|label| label.index), Some(0));
998        assert_eq!(labels.last().map(|label| label.index), Some(99));
999        assert_eq!(label_domain_len(&series), 100);
1000    }
1001
1002    #[test]
1003    fn downsample_index_range_preserves_edges_and_extrema_without_dense_output() {
1004        let sampled = downsample_index_range(
1005            10_000,
1006            |index| {
1007                if index == 5_432 {
1008                    999_999.0
1009                } else {
1010                    index as f64
1011                }
1012            },
1013            Some(101),
1014        );
1015
1016        assert!(sampled.len() <= 101);
1017        assert_eq!(sampled.first(), Some(&(0, 0.0)));
1018        assert_eq!(sampled.last(), Some(&(9_999, 9_999.0)));
1019        assert!(sampled.contains(&(5_432, 999_999.0)));
1020    }
1021
1022    #[test]
1023    fn downsample_indexed_values_preserves_edges_and_extrema_without_dense_output() {
1024        let values = (0..10_000)
1025            .map(|index| {
1026                if index == 5_432 {
1027                    999_999.0
1028                } else {
1029                    index as f64
1030                }
1031            })
1032            .collect::<Vec<_>>();
1033        let sampled = downsample_indexed_values(&values, |value| *value, Some(101));
1034
1035        assert!(sampled.len() <= 101);
1036        assert_eq!(sampled.first(), Some(&(0, 0.0)));
1037        assert_eq!(sampled.last(), Some(&(9_999, 9_999.0)));
1038        assert!(sampled.contains(&(5_432, 999_999.0)));
1039    }
1040
1041    #[test]
1042    fn downsample_indexed_values_filters_non_finite_values() {
1043        let values = [0.0, f64::NAN, 2.0, f64::INFINITY, 4.0];
1044        assert_eq!(
1045            downsample_indexed_values(&values, |value| *value, Some(10)),
1046            vec![(0, 0.0), (2, 2.0), (4, 4.0)]
1047        );
1048    }
1049
1050    #[test]
1051    fn downsample_points_preserves_edges_and_extrema() {
1052        let points = (0..100)
1053            .map(|index| {
1054                let value = if index == 42 { 1000.0 } else { index as f64 };
1055                (index, value)
1056            })
1057            .collect::<Vec<_>>();
1058        let sampled = downsample_points(&points, Some(21));
1059
1060        assert!(sampled.len() <= 21);
1061        assert_eq!(sampled.first(), Some(&(0, 0.0)));
1062        assert_eq!(sampled.last(), Some(&(99, 99.0)));
1063        assert!(sampled.contains(&(42, 1000.0)));
1064    }
1065
1066    #[test]
1067    fn downsample_points_can_be_disabled() {
1068        let points = (0..10)
1069            .map(|index| (index, index as f64))
1070            .collect::<Vec<_>>();
1071        assert_eq!(downsample_points(&points, None), points);
1072        assert_eq!(downsample_points(&points, Some(2)), points);
1073    }
1074
1075    #[test]
1076    fn nearest_cartesian_hit_point_returns_closest_finite_point() {
1077        let series = [
1078            ChartSeries::new(
1079                "cpu",
1080                [
1081                    ChartPoint::new("t0", 0.0),
1082                    ChartPoint::new("t1", 50.0),
1083                    ChartPoint::new("t2", f64::NAN),
1084                ],
1085            ),
1086            ChartSeries::new(
1087                "mem",
1088                [
1089                    ChartPoint::new("t0", 10.0),
1090                    ChartPoint::new("t1", 80.0),
1091                    ChartPoint::new("t2", 100.0),
1092                ],
1093            ),
1094        ];
1095
1096        let hit = nearest_cartesian_hit_point(&series, (0.0, 100.0), 200.0, 100.0, 198.0, 2.0, 8.0)
1097            .expect("pointer near last mem point should hit");
1098
1099        assert_eq!(hit.series_index, 1);
1100        assert_eq!(hit.point_index, 2);
1101        assert_eq!(hit.series_name, SharedString::from("mem"));
1102        assert_eq!(hit.label, SharedString::from("t2"));
1103        assert_eq!(hit.value, 100.0);
1104        assert!(hit.distance <= 8.0);
1105    }
1106
1107    #[test]
1108    fn nearest_cartesian_hit_point_respects_threshold_and_bounds() {
1109        let series = [ChartSeries::new(
1110            "cpu",
1111            [ChartPoint::new("t0", 0.0), ChartPoint::new("t1", 100.0)],
1112        )];
1113
1114        assert_eq!(
1115            nearest_cartesian_hit_point(&series, (0.0, 100.0), 100.0, 100.0, 50.0, 50.0, 10.0),
1116            None
1117        );
1118        assert_eq!(
1119            nearest_cartesian_hit_point(&series, (0.0, 100.0), 100.0, 100.0, -1.0, 0.0, 10.0),
1120            None
1121        );
1122    }
1123}