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
671pub 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
800pub 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}