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::{hash::Hash, panic::Location, rc::Rc};
18
19use gpui::{
20 AnyElement, App, Bounds, ContentMask, ElementId, Hsla, IntoElement, ParentElement as _, Pixels,
21 SharedString, Size, TextAlign, Window, point, px,
22};
23
24use crate::{
25 ActiveTheme,
26 plot::{
27 AxisLabelPlacement, AxisText, Grid, PlotLabel,
28 label::{TEXT_GAP, TEXT_HEIGHT, TEXT_SIZE, Text, measure_text_width},
29 scale::{PlotValue, Scale, ScaleBand, ScaleLinear, ScalePoint},
30 tooltip::Tooltip,
31 },
32};
33
34#[track_caller]
49pub(crate) fn caller_id() -> ElementId {
50 ElementId::CodeLocation(*Location::caller())
51}
52
53pub(crate) const HOVER_DOT_SIZE: Pixels = px(8.);
55
56pub(crate) const HOVER_HALO_SIZE: Pixels = px(20.);
61
62pub(crate) fn axis_point_count(point_count: Option<usize>, data_len: usize) -> usize {
66 point_count.unwrap_or(data_len).max(data_len)
67}
68
69pub(crate) fn point_range(start: f32, width: f32, data_len: usize, point_count: usize) -> [f32; 2] {
74 let end = if point_count > 1 {
75 width * data_len.saturating_sub(1) as f32 / (point_count - 1) as f32
76 } else {
77 width
78 };
79 [start, start + end]
80}
81
82#[derive(Clone, Copy)]
85pub(crate) struct ValueExtent {
86 lo: f64,
87 hi: f64,
88 bottom: f32,
89 top: f32,
90}
91
92impl ValueExtent {
93 pub(crate) fn value_at(&self, y: f32) -> f64 {
95 if self.bottom == self.top {
96 return self.lo;
97 }
98 self.lo + (self.hi - self.lo) * ((self.bottom - y) / (self.bottom - self.top)) as f64
99 }
100
101 pub(crate) fn position_of(&self, value: f64) -> Option<f32> {
103 if self.hi == self.lo {
104 return None;
105 }
106 Some(
107 self.bottom
108 - ((value - self.lo) / (self.hi - self.lo)) as f32 * (self.bottom - self.top),
109 )
110 }
111}
112
113pub(crate) fn point_value_scale<Y>(
119 values: impl IntoIterator<Item = Y>,
120 domain: Option<(Y, Y)>,
121 height: f32,
122 (top, bottom): (f32, f32),
123) -> (ScaleLinear<Y>, ValueExtent)
124where
125 Y: PlotValue,
126{
127 let domain: Vec<Y> = match domain {
128 Some((min, max)) => vec![min, max],
129 None => values.into_iter().chain(Some(Y::zero())).collect(),
130 };
131 let (lo, hi) = domain
132 .iter()
133 .filter_map(|v| v.to_f64())
134 .fold((f64::MAX, f64::MIN), |(lo, hi), v| (lo.min(v), hi.max(v)));
135 let extent = ValueExtent {
136 lo,
137 hi,
138 bottom: height - bottom,
139 top,
140 };
141 (ScaleLinear::new(domain, [height - bottom, top]), extent)
142}
143
144pub(crate) const AXIS_GAP: f32 = TEXT_SIZE + TEXT_GAP * 4.;
148
149pub(crate) const MAX_BAND_WIDTH: f32 = 30.;
152
153pub(crate) const VALUE_AXIS_GAP: f32 = 32.;
156
157pub(crate) fn value_axis_gap(
163 labels: impl IntoIterator<Item = SharedString>,
164 window: &mut Window,
165) -> f32 {
166 labels
167 .into_iter()
168 .map(|label| measure_text_width(&label, px(TEXT_SIZE), window) + TEXT_GAP * 2.)
169 .fold(VALUE_AXIS_GAP, f32::max)
170}
171
172pub(crate) type TickFormat = Rc<dyn Fn(f64) -> SharedString>;
174
175pub(crate) fn format_tick(value: f64) -> SharedString {
177 if (value - value.round()).abs() < 0.001 {
178 format!("{:.0}", value).into()
179 } else {
180 format!("{:.1}", value).into()
181 }
182}
183
184pub(crate) fn labeled_items(
187 len: usize,
188 label_count: Option<usize>,
189 tick_margin: usize,
190) -> Vec<bool> {
191 match label_count {
192 Some(count) => {
193 let mut labeled = vec![false; len];
194 match count {
195 0 => {}
196 1 => labeled.iter_mut().take(1).for_each(|l| *l = true),
197 count if count >= len => labeled.iter_mut().for_each(|l| *l = true),
198 count => {
199 for k in 0..count {
200 let ix =
201 (k as f32 * (len - 1) as f32 / (count - 1) as f32).round() as usize;
202 labeled[ix] = true;
203 }
204 }
205 }
206 labeled
207 }
208 None => (0..len).map(|i| (i + 1) % tick_margin == 0).collect(),
209 }
210}
211
212pub(crate) struct TooltipContent<T> {
216 title: Option<Rc<dyn Fn(&T) -> SharedString>>,
217 value: Option<Rc<dyn Fn(&T, usize, f64) -> SharedString>>,
218 value_color: Option<Rc<dyn Fn(&T, usize, f64) -> Hsla>>,
219 content: Option<Rc<dyn Fn(&T, &mut Window, &mut App) -> AnyElement>>,
220}
221
222impl<T> Default for TooltipContent<T> {
223 fn default() -> Self {
224 Self {
225 title: None,
226 value: None,
227 value_color: None,
228 content: None,
229 }
230 }
231}
232
233impl<T: 'static> TooltipContent<T> {
234 pub(crate) fn set_title(&mut self, title: impl Fn(&T) -> SharedString + 'static) {
235 self.title = Some(Rc::new(title));
236 }
237
238 pub(crate) fn set_value(&mut self, value: impl Fn(&T, usize, f64) -> SharedString + 'static) {
239 self.value = Some(Rc::new(value));
240 }
241
242 pub(crate) fn set_value_color<H: Into<Hsla>>(
243 &mut self,
244 color: impl Fn(&T, usize, f64) -> H + 'static,
245 ) {
246 self.value_color = Some(Rc::new(move |d, ix, value| color(d, ix, value).into()));
247 }
248
249 pub(crate) fn set_content<E: IntoElement>(
250 &mut self,
251 content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
252 ) {
253 self.content = Some(Rc::new(move |d, window, cx| {
254 content(d, window, cx).into_any_element()
255 }));
256 }
257
258 fn title_text(&self, d: &T, fallback: Option<SharedString>) -> Option<SharedString> {
261 match self.title.as_ref() {
262 Some(title) => Some(title(d)),
263 None => fallback,
264 }
265 }
266
267 fn value_text(&self, d: &T, ix: usize, value: f64) -> SharedString {
269 match self.value.as_ref() {
270 Some(text) => text(d, ix, value),
271 None => format!("{}", value).into(),
272 }
273 }
274
275 pub(crate) fn apply<R>(
280 &self,
281 tooltip: Tooltip,
282 d: &T,
283 title: impl FnOnce() -> Option<SharedString>,
284 rows: impl FnOnce() -> Option<R>,
285 window: &mut Window,
286 cx: &mut App,
287 ) -> Option<Tooltip>
288 where
289 R: IntoIterator<Item = (Hsla, SharedString, f64)>,
290 {
291 if let Some(content) = self.content.as_ref() {
292 return Some(tooltip.child(content(d, window, cx)));
293 }
294 let mut tooltip = match self.title_text(d, title()) {
295 Some(title) => tooltip.title(title),
296 None => tooltip,
297 };
298 for (ix, (swatch, name, value)) in rows()?.into_iter().enumerate() {
299 tooltip = tooltip.row(swatch, name, self.value_text(d, ix, value));
300 if let Some(color) = self.value_color.as_ref() {
301 tooltip = tooltip.value_color(color(d, ix, value));
302 }
303 }
304 Some(tooltip)
305 }
306}
307
308pub(crate) struct PointAxes {
311 pub(crate) y_axis: bool,
312 pub(crate) y_axis_label_placement: AxisLabelPlacement,
313 pub(crate) y_tick_count: usize,
314 pub(crate) y_tick_format: Option<TickFormat>,
315 pub(crate) x_tick_count: Option<usize>,
316 pub(crate) grid_columns: usize,
317 pub(crate) grid_dashed: bool,
318 pub(crate) y_padding: (f32, f32),
319 pub(crate) reference_lines: Vec<f64>,
320 y_label_gap: f32,
322}
323
324impl Default for PointAxes {
325 fn default() -> Self {
326 Self {
327 y_axis: false,
328 y_axis_label_placement: AxisLabelPlacement::default(),
329 y_tick_count: 5,
330 y_tick_format: None,
331 x_tick_count: None,
332 grid_columns: 0,
333 grid_dashed: true,
334 y_padding: (10., 0.),
335 reference_lines: vec![],
336 y_label_gap: VALUE_AXIS_GAP,
337 }
338 }
339}
340
341impl PointAxes {
342 pub(crate) fn plot_left(&self) -> f32 {
345 if self.y_axis && self.y_axis_label_placement == AxisLabelPlacement::Outside {
346 self.y_label_gap
347 } else {
348 0.
349 }
350 }
351
352 pub(crate) fn measure_y_labels(
355 &mut self,
356 extent: ValueExtent,
357 height: f32,
358 window: &mut Window,
359 ) {
360 if self.y_axis && self.y_axis_label_placement == AxisLabelPlacement::Outside {
361 let labels = self
362 .y_tick_labels(extent, height)
363 .into_iter()
364 .map(|(_, text)| text);
365 self.y_label_gap = value_axis_gap(labels, window);
366 }
367 }
368
369 fn y_tick_labels(&self, extent: ValueExtent, height: f32) -> Vec<(f32, SharedString)> {
371 self.tick_positions(height)
372 .into_iter()
373 .map(|y| {
374 let value = extent.value_at(y);
375 let text = match self.y_tick_format.as_ref() {
376 Some(format) => format(value),
377 None => format_tick(value),
378 };
379 (y, text)
380 })
381 .collect()
382 }
383
384 pub(crate) fn plot_bounds(&self, bounds: Bounds<Pixels>, height: f32) -> Bounds<Pixels> {
387 let left = self.plot_left();
388 Bounds {
389 origin: bounds.origin + point(px(left), px(0.)),
390 size: Size::new(bounds.size.width - px(left), px(height)),
391 }
392 }
393
394 fn tick_positions(&self, height: f32) -> Vec<f32> {
397 let count = self.y_tick_count.max(2);
398 (0..count)
399 .map(|i| height * i as f32 / (count - 1) as f32)
400 .collect()
401 }
402
403 pub(crate) fn paint_grid(
406 &self,
407 bounds: Bounds<Pixels>,
408 height: f32,
409 window: &mut Window,
410 cx: &mut App,
411 ) {
412 let plot = self.plot_bounds(bounds, height);
413 let mut rows = self.tick_positions(height);
414 rows.pop();
415 let width = plot.size.width.as_f32();
416 let columns: Vec<f32> = (0..self.grid_columns)
417 .map(|i| width * i as f32 / self.grid_columns as f32)
418 .collect();
419 let grid = Grid::new().y(rows).x(columns).stroke(cx.theme().chart_grid);
420 let grid = if self.grid_dashed {
421 grid.dash_array(&[px(4.), px(2.)])
422 } else {
423 grid
424 };
425 grid.paint(&plot, window);
426 }
427
428 pub(crate) fn paint_reference_lines(
431 &self,
432 extent: ValueExtent,
433 bounds: Bounds<Pixels>,
434 height: f32,
435 window: &mut Window,
436 cx: &mut App,
437 ) {
438 let rows: Vec<f32> = self
439 .reference_lines
440 .iter()
441 .filter_map(|v| extent.position_of(*v))
442 .filter(|y| (0. ..=height).contains(y))
443 .collect();
444 if rows.is_empty() {
445 return;
446 }
447 Grid::new()
448 .y(rows)
449 .stroke(cx.theme().muted_foreground)
450 .dash_array(&[px(4.), px(2.)])
451 .paint(&self.plot_bounds(bounds, height), window);
452 }
453
454 pub(crate) fn paint_y_labels(
456 &self,
457 extent: ValueExtent,
458 bounds: Bounds<Pixels>,
459 height: f32,
460 window: &mut Window,
461 cx: &mut App,
462 ) {
463 if !self.y_axis {
464 return;
465 }
466 let color = cx.theme().muted_foreground;
467 let labels = self
468 .y_tick_labels(extent, height)
469 .into_iter()
470 .map(|(y, text)| {
471 match self.y_axis_label_placement {
472 AxisLabelPlacement::Inside => {
475 let top = if y < TEXT_HEIGHT {
476 y + TEXT_GAP
477 } else {
478 y - TEXT_HEIGHT
479 };
480 Text::new(text, point(TEXT_GAP, top), color)
481 }
482 AxisLabelPlacement::Outside => {
483 let top = (y - TEXT_SIZE / 2.).clamp(0., (height - TEXT_SIZE).max(0.));
484 Text::new(text, point(self.y_label_gap - TEXT_GAP * 2., top), color)
485 .align(TextAlign::Right)
486 }
487 }
488 })
489 .collect();
490 PlotLabel::new(labels).paint(&bounds, window, cx);
491 }
492}
493
494pub(crate) fn pinned_plot_mask(bounds: Bounds<Pixels>, height: f32) -> ContentMask<Pixels> {
499 let bleed = HOVER_DOT_SIZE / 2.;
500 ContentMask {
501 bounds: Bounds::from_corners(
502 bounds.origin - gpui::point(bleed, bleed),
503 gpui::point(bounds.right() + bleed, bounds.top() + px(height) + bleed),
504 ),
505 }
506}
507
508pub(crate) fn build_point_x_labels<T, X>(
514 data: &[T],
515 x_fn: &dyn Fn(&T) -> X,
516 x_scale: &ScalePoint<X>,
517 point_count: usize,
518 labeled: &[bool],
519 color: Hsla,
520) -> Vec<AxisText>
521where
522 X: PartialEq + Into<SharedString>,
523{
524 data.iter()
525 .enumerate()
526 .filter_map(|(i, d)| {
527 if !labeled.get(i).copied().unwrap_or(false) {
528 return None;
529 }
530 x_scale.tick_at(i).map(|x_tick| {
531 let align = match i {
532 0 if point_count == 1 => TextAlign::Center,
533 0 => TextAlign::Left,
534 i if i == point_count - 1 => TextAlign::Right,
535 _ => TextAlign::Center,
536 };
537 AxisText::new(x_fn(d).into(), x_tick, color).align(align)
538 })
539 })
540 .collect()
541}
542
543pub(crate) fn build_band_labels<T, X>(
550 data: &[T],
551 x_fn: &dyn Fn(&T) -> X,
552 x_scale: &ScaleBand<X>,
553 band_width: f32,
554 labeled: &[bool],
555 color: Hsla,
556) -> Vec<AxisText>
557where
558 X: Eq + Hash + Into<SharedString>,
559{
560 data.iter()
561 .enumerate()
562 .filter_map(|(i, d)| {
563 if !labeled.get(i).copied().unwrap_or(false) {
564 return None;
565 }
566 x_scale.tick(&x_fn(d)).map(|x_tick| {
567 AxisText::new(x_fn(d).into(), x_tick + band_width / 2., color)
569 .align(TextAlign::Center)
570 })
571 })
572 .collect()
573}
574
575#[cfg(test)]
576mod tests {
577 use std::cell::Cell;
578
579 use gpui::{Hsla, SharedString, TestAppContext, blue, div, green, point, px, red, size};
580
581 use super::TooltipContent;
582 use crate::{
583 chart::PieChart,
584 plot::{Plot, tooltip::Tooltip},
585 };
586
587 fn chart() -> PieChart<f32> {
588 PieChart::new([1., 2.])
589 }
590
591 #[test]
594 fn a_chart_is_interactive_without_being_given_an_id() {
595 assert!(Plot::id(&chart()).is_some());
596 }
597
598 #[test]
600 fn charts_built_at_different_sites_get_different_ids() {
601 assert_ne!(
602 Plot::id(&PieChart::new([1.])),
603 Plot::id(&PieChart::new([1.]))
604 );
605 }
606
607 #[test]
609 fn charts_built_at_one_site_share_an_id() {
610 assert_eq!(Plot::id(&chart()), Plot::id(&chart()));
611 }
612
613 #[test]
616 fn a_chart_turned_off_has_no_id_to_key_anything_on() {
617 assert!(Plot::id(&chart().interactive(false)).is_none());
618 assert!(Plot::id(&chart().id("pie").interactive(false)).is_none());
619 }
620
621 #[test]
624 fn only_the_last_point_right_aligns_its_label() {
625 use gpui::{Hsla, TextAlign};
626
627 use super::{build_point_x_labels, point_range};
628 use crate::plot::scale::ScalePoint;
629
630 let data = ["a", "b", "c"];
631 let align = |point_count| {
632 let x = ScalePoint::new(
633 data.to_vec(),
634 point_range(0., 100., data.len(), point_count),
635 );
636 build_point_x_labels(
637 &data,
638 &|d: &&'static str| *d,
639 &x,
640 point_count,
641 &[true; 3],
642 Hsla::default(),
643 )
644 .into_iter()
645 .map(|label| label.align)
646 .collect::<Vec<_>>()
647 };
648
649 assert_eq!(
650 align(3),
651 [TextAlign::Left, TextAlign::Center, TextAlign::Right]
652 );
653 assert_eq!(
654 align(5),
655 [TextAlign::Left, TextAlign::Center, TextAlign::Center]
656 );
657 }
658
659 #[test]
660 fn a_named_id_replaces_the_default() {
661 assert_eq!(
662 Plot::id(&chart().id("pie")),
663 Some(gpui::ElementId::Name("pie".into()))
664 );
665 }
666
667 #[test]
670 fn the_default_ticks_keep_the_grid_in_place() {
671 let axes = super::PointAxes::default();
672 let mut rows = axes.tick_positions(100.);
673 rows.pop();
674 assert_eq!(rows, vec![0., 25., 50., 75.]);
675 }
676
677 #[test]
678 fn a_label_count_spreads_labels_from_the_first_item_to_the_last() {
679 use super::labeled_items;
680
681 let shown = |len, count| {
682 labeled_items(len, Some(count), 1)
683 .iter()
684 .enumerate()
685 .filter_map(|(i, &on)| on.then_some(i))
686 .collect::<Vec<_>>()
687 };
688 assert_eq!(shown(11, 3), vec![0, 5, 10]);
689 assert_eq!(shown(10, 2), vec![0, 9]);
690 assert_eq!(shown(3, 5), vec![0, 1, 2]);
691 assert_eq!(shown(4, 1), vec![0]);
692 assert!(shown(4, 0).is_empty());
693
694 assert_eq!(labeled_items(4, None, 2), vec![false, true, false, true]);
696 }
697
698 #[test]
701 fn a_tick_reads_the_value_at_its_height() {
702 use super::point_value_scale;
703
704 let (_, extent) = point_value_scale([10., 20.], None, 110., (10., 0.));
705 assert_eq!(extent.value_at(110.), 0.);
706 assert_eq!(extent.value_at(10.), 20.);
707 assert!((extent.value_at(0.) - 22.).abs() < 1e-4);
708 assert_eq!(extent.position_of(20.), Some(10.));
709
710 let (_, extent) = point_value_scale([0.], Some((100., 200.)), 100., (0., 0.));
711 assert_eq!(extent.value_at(0.), 200.);
712 assert_eq!(extent.position_of(150.), Some(50.));
713 }
714
715 #[test]
718 fn tooltip_text_falls_back_to_the_chart_own() {
719 let mut content = TooltipContent::<f64>::default();
720 assert_eq!(
721 content.title_text(&1., Some("Jan".into())),
722 Some("Jan".into())
723 );
724 assert_eq!(content.title_text(&1., None), None);
725 assert_eq!(content.value_text(&1., 0, 1234.5).as_ref(), "1234.5");
726
727 content.set_title(|d| format!("Day {d}").into());
728 content.set_value(|_, _, value| format!("${value:.2}").into());
729 assert_eq!(content.title_text(&3., None), Some("Day 3".into()));
730 assert_eq!(content.value_text(&3., 0, 1234.5).as_ref(), "$1234.50");
731 }
732
733 #[gpui::test]
736 fn tooltip_fill_writes_each_row_with_the_value_color(cx: &mut TestAppContext) {
737 let mut content = TooltipContent::<f64>::default();
738 content.set_value(|_, ix, value| format!("{ix}: {value:+}").into());
739 content.set_value_color(|_, _, value| if value >= 0. { green() } else { red() });
740 let cx = cx.add_empty_window();
741 let tooltip = cx
742 .update(|window, cx| {
743 content.apply(
744 Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
745 &1.,
746 || Some("Jan".into()),
747 || Some([(blue(), "Open".into(), 2.), (blue(), "Close".into(), -1.)]),
748 window,
749 cx,
750 )
751 })
752 .expect("rows are given");
753
754 assert_eq!(tooltip.title_for_test().map(|t| t.as_ref()), Some("Jan"));
755 assert_eq!(
756 tooltip.rows_for_test(),
757 vec![
758 ("0: +2".into(), Some(green())),
759 ("1: -1".into(), Some(red()))
760 ]
761 );
762 }
763
764 #[gpui::test]
767 fn tooltip_fill_leaves_the_title_off_without_one(cx: &mut TestAppContext) {
768 let content = TooltipContent::<f64>::default();
769 let cx = cx.add_empty_window();
770 let tooltip = cx
771 .update(|window, cx| {
772 content.apply(
773 Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
774 &1.,
775 || None,
776 || Some([(blue(), "Alpha".into(), 80.)]),
777 window,
778 cx,
779 )
780 })
781 .expect("rows are given");
782
783 assert!(tooltip.title_for_test().is_none());
784 assert_eq!(tooltip.rows_for_test(), vec![("80".into(), None)]);
785 }
786
787 #[gpui::test]
790 fn tooltip_fill_renders_the_caller_content_without_building_rows(cx: &mut TestAppContext) {
791 let mut content = TooltipContent::<f64>::default();
792 content.set_title(|_| "Caller".into());
793 content.set_content(|_, _, _| div());
794 let built = Cell::new(false);
795 let cx = cx.add_empty_window();
796 let tooltip = cx.update(|window, cx| {
797 content.apply(
798 Tooltip::new(point(px(0.), px(0.)), size(px(100.), px(100.))),
799 &1.,
800 || {
801 built.set(true);
802 Some("Jan".into())
803 },
804 || -> Option<[(Hsla, SharedString, f64); 0]> {
805 built.set(true);
806 None
807 },
808 window,
809 cx,
810 )
811 });
812
813 let tooltip = tooltip.expect("the caller renders");
814 assert!(!built.get());
815 assert!(tooltip.title_for_test().is_none());
816 assert!(tooltip.rows_for_test().is_empty());
817 }
818}