1use std::{hash::Hash, ops::RangeInclusive, rc::Rc};
2
3use gpui::{
4 AnyElement, App, Background, Bounds, Corners, ElementId, Hsla, IntoElement, LinearColorStop,
5 Pixels, Point, SharedString, Size, TextAlign, Window, linear_gradient, point, px,
6};
7use gpui_base::motion::spring;
8use gpui_component_macros::IntoPlot;
9use num_traits::{Num, ToPrimitive};
10
11use crate::{
12 ActiveTheme,
13 plot::{
14 AXIS_GAP, AxisLabelSide, AxisText, Grid, Plot, PlotAxis, PlotLabel,
15 label::{TEXT_GAP, TEXT_SIZE, Text, measure_text_width},
16 scale::{Scale, ScaleBand, ScaleLinear, Sealed},
17 shape::{Bar, BarAlignment},
18 tooltip::{CrossLine, PlotHover, Tooltip, TooltipState},
19 },
20};
21
22use super::{build_band_labels, pointer_spring};
23
24const VALUE_AXIS_GAP: f32 = 32.;
30
31const HOVER_DIM: f32 = 0.45;
33
34#[derive(Clone, Copy)]
36struct BarHover {
37 center: f32,
39 focus: f32,
41}
42
43#[derive(IntoPlot)]
44pub struct BarChart<T, B, V>
45where
46 T: 'static,
47 B: Eq + Hash + Into<SharedString> + 'static,
48 V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
49{
50 data: Vec<T>,
51 band: Option<Rc<dyn Fn(&T) -> B>>,
52 value: Option<Rc<dyn Fn(&T) -> V>>,
53 fill: Option<Rc<dyn Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Background>>,
54 #[allow(clippy::type_complexity)]
55 fill_gradient:
56 Option<Rc<dyn Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2]>>,
57 tick_margin: usize,
58 label: Option<Rc<dyn Fn(&T) -> SharedString>>,
59 label_axis: bool,
60 value_axis: bool,
61 value_tick_count: usize,
62 grid: bool,
63 alignment: BarAlignment,
64 corner_radii: Corners<Pixels>,
65 id: Option<ElementId>,
66 name: Option<SharedString>,
67 horizontal_gaps: (f32, f32),
70 hover: Option<BarHover>,
71}
72
73impl<T, B, V> BarChart<T, B, V>
74where
75 B: Eq + Hash + Into<SharedString> + 'static,
76 V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
77{
78 pub fn new<I>(data: I) -> Self
79 where
80 I: IntoIterator<Item = T>,
81 {
82 Self {
83 data: data.into_iter().collect(),
84 band: None,
85 value: None,
86 fill: None,
87 fill_gradient: None,
88 tick_margin: 1,
89 label: None,
90 label_axis: true,
91 value_axis: false,
92 value_tick_count: 4,
93 grid: true,
94 alignment: BarAlignment::default(),
95 corner_radii: Corners::all(px(0.)),
96 id: None,
97 name: None,
98 horizontal_gaps: (0., 0.),
99 hover: None,
100 }
101 }
102
103 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
109 self.id = Some(id.into());
110 self
111 }
112
113 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
115 self.name = Some(name.into());
116 self
117 }
118
119 pub fn band(mut self, band: impl Fn(&T) -> B + 'static) -> Self {
121 self.band = Some(Rc::new(band));
122 self
123 }
124
125 pub fn value(mut self, value: impl Fn(&T) -> V + 'static) -> Self {
127 self.value = Some(Rc::new(value));
128 self
129 }
130
131 pub fn fill<Bg>(
151 mut self,
152 fill: impl Fn(&T, Bounds<f32>, Bounds<f32>, BarAlignment) -> Bg + 'static,
153 ) -> Self
154 where
155 Bg: Into<Background> + 'static,
156 {
157 self.fill = Some(Rc::new(move |t, bar_bounds, chart_bounds, alignment| {
158 fill(t, bar_bounds, chart_bounds, alignment).into()
159 }));
160 self.fill_gradient = None;
161 self
162 }
163
164 pub fn fill_gradient(
200 mut self,
201 fill: impl Fn(&T, RangeInclusive<f32>, &dyn Fn(f32) -> f32) -> [LinearColorStop; 2] + 'static,
202 ) -> Self {
203 self.fill_gradient = Some(Rc::new(fill));
204 self.fill = None;
205 self
206 }
207
208 pub fn tick_margin(mut self, tick_margin: usize) -> Self {
209 self.tick_margin = tick_margin;
210 self
211 }
212
213 pub fn label<S>(mut self, label: impl Fn(&T) -> S + 'static) -> Self
214 where
215 S: Into<SharedString> + 'static,
216 {
217 self.label = Some(Rc::new(move |t| label(t).into()));
218 self
219 }
220
221 pub fn label_axis(mut self, label_axis: bool) -> Self {
225 self.label_axis = label_axis;
226 self
227 }
228
229 pub fn value_axis(mut self, value_axis: bool) -> Self {
236 self.value_axis = value_axis;
237 self
238 }
239
240 pub fn value_tick_count(mut self, value_tick_count: usize) -> Self {
248 self.value_tick_count = value_tick_count.max(1);
249 self
250 }
251
252 pub fn grid(mut self, grid: bool) -> Self {
253 self.grid = grid;
254 self
255 }
256
257 pub fn alignment(mut self, alignment: BarAlignment) -> Self {
261 self.alignment = alignment;
262 self
263 }
264
265 pub fn corner_radii(mut self, corner_radii: impl Into<Corners<Pixels>>) -> Self {
270 self.corner_radii = corner_radii.into();
271 self
272 }
273
274 fn band_scale(&self, bounds: Bounds<Pixels>) -> Option<ScaleBand<B>> {
277 let band_fn = self.band.as_ref()?;
278 let band_extent = if self.alignment.is_horizontal() {
279 bounds.size.height.as_f32()
280 } else {
281 bounds.size.width.as_f32()
282 };
283 let gap = if self.value_axis { VALUE_AXIS_GAP } else { 0. };
286 Some(
287 ScaleBand::new(
288 self.data.iter().map(|v| band_fn(v)).collect(),
289 vec![0., (band_extent - gap).max(0.)],
290 )
291 .padding_inner(0.4)
292 .padding_outer(0.2),
293 )
294 }
295
296 fn band_offset(&self) -> f32 {
303 if self.value_axis && !self.alignment.is_horizontal() {
304 VALUE_AXIS_GAP
305 } else {
306 0.
307 }
308 }
309
310 fn measure_horizontal_gaps(&self, window: &mut Window) -> (f32, f32) {
315 let Some(band_fn) = self.band.as_ref() else {
316 return (0., 0.);
317 };
318 let font_size = px(TEXT_SIZE);
319 let band_gap = if self.label_axis {
320 self.data
321 .iter()
322 .map(|v| {
323 let s: SharedString = band_fn(v).into();
324 measure_text_width(&s, font_size, window)
325 })
326 .fold(0f32, f32::max)
327 + TEXT_GAP * 2.
328 } else {
329 0.
330 };
331 let value_end_gap = if let Some(label_fn) = self.label.as_ref() {
332 self.data
333 .iter()
334 .map(|v| measure_text_width(&label_fn(v), font_size, window))
335 .fold(0f32, f32::max)
336 + TEXT_GAP * 2.
337 } else {
338 TEXT_GAP * 4.
339 };
340 (band_gap, value_end_gap)
341 }
342
343 fn value_extent(&self, bounds: Bounds<Pixels>) -> (f32, f32) {
346 if self.alignment.is_horizontal() {
347 let (band_gap, value_end_gap) = self.horizontal_gaps;
348 let length = (bounds.size.width.as_f32() - band_gap - value_end_gap).max(0.);
349 let start = if matches!(self.alignment, BarAlignment::Left) {
350 band_gap
351 } else {
352 value_end_gap
353 };
354 (start, length)
355 } else {
356 let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
357 let length = bounds.size.height.as_f32() - axis_gap;
358 let start = if matches!(self.alignment, BarAlignment::Top) {
359 axis_gap
360 } else {
361 0.
362 };
363 (start, length)
364 }
365 }
366
367 fn is_over_bars(&self, position: Point<Pixels>, bounds: Bounds<Pixels>) -> bool {
369 let (start, length) = self.value_extent(bounds);
370 if self.alignment.is_horizontal() {
371 let value_labels_top = bounds.size.height.as_f32() - VALUE_AXIS_GAP;
372 (start..=start + length).contains(&position.x.as_f32())
373 && !(self.value_axis && position.y.as_f32() > value_labels_top)
374 } else {
375 (start..=start + length).contains(&position.y.as_f32())
376 && position.x.as_f32() >= self.band_offset()
377 }
378 }
379}
380
381impl<T, B, V> Plot for BarChart<T, B, V>
382where
383 B: Eq + Hash + Into<SharedString> + 'static,
384 V: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
385{
386 fn prepaint(
387 &mut self,
388 _bounds: Bounds<Pixels>,
389 window: &mut Window,
390 _cx: &mut App,
391 ) -> Vec<AnyElement> {
392 self.horizontal_gaps = if self.alignment.is_horizontal() {
393 self.measure_horizontal_gaps(window)
394 } else {
395 (0., 0.)
396 };
397 vec![]
398 }
399
400 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
401 let (Some(band_fn), Some(value_fn)) = (self.band.as_ref(), self.value.as_ref()) else {
402 return;
403 };
404
405 let total_width = bounds.size.width.as_f32();
406 let total_height = bounds.size.height.as_f32();
407 let axis_gap = if self.label_axis { AXIS_GAP } else { 0. };
408 let alignment = self.alignment;
409 let is_horizontal = alignment.is_horizontal();
410
411 let Some(band_scale) = self.band_scale(bounds) else {
414 return;
415 };
416 let band_width = band_scale.band_width();
417
418 let value_dim = if is_horizontal {
419 total_width
420 } else {
421 total_height
422 };
423 let (band_gap, value_end_gap) = if is_horizontal {
429 self.horizontal_gaps
430 } else {
431 (axis_gap, 10.)
432 };
433 let (range, baseline) = match alignment {
434 BarAlignment::Bottom => {
435 let baseline = value_dim - axis_gap;
436 (vec![baseline, 10.], baseline)
437 }
438 BarAlignment::Top => {
439 let baseline = axis_gap;
440 (vec![baseline, value_dim - 10.], baseline)
441 }
442 BarAlignment::Left => {
443 let baseline = band_gap;
444 (vec![baseline, value_dim - value_end_gap], baseline)
445 }
446 BarAlignment::Right => {
447 let baseline = value_dim - band_gap;
448 (vec![baseline, value_end_gap], baseline)
449 }
450 };
451 let value_scale = ScaleLinear::new(
452 self.data
453 .iter()
454 .map(|v| value_fn(v))
455 .chain(Some(V::zero()))
456 .collect(),
457 range,
458 );
459
460 let zero_pixel = value_scale.tick(&V::zero()).unwrap_or(baseline);
464 let band_offset = self.band_offset();
465
466 let value_axis_gap = if self.value_axis { VALUE_AXIS_GAP } else { 0. };
470 let plot_bounds = if is_horizontal {
471 Bounds {
472 origin: bounds.origin,
473 size: Size::new(bounds.size.width, bounds.size.height - px(value_axis_gap)),
474 }
475 } else {
476 Bounds {
477 origin: bounds.origin + point(px(value_axis_gap), px(0.)),
478 size: Size::new(bounds.size.width - px(value_axis_gap), bounds.size.height),
479 }
480 };
481
482 let (domain_lo, domain_hi) = self.data.iter().fold((0.0_f32, 0.0_f32), |(lo, hi), v| {
485 let f = value_fn(v).to_f32().unwrap_or(0.);
486 (lo.min(f), hi.max(f))
487 });
488
489 let mut axis = PlotAxis::new().stroke(cx.theme().border);
491 if self.label_axis {
492 match alignment {
493 BarAlignment::Bottom | BarAlignment::Top => {
494 axis = axis.x(zero_pixel);
495
496 let labels = self
501 .data
502 .iter()
503 .enumerate()
504 .filter(|(i, _)| (i + 1) % self.tick_margin == 0)
505 .filter_map(|(_, d)| {
506 let band_x = band_scale.tick(&band_fn(d))?;
507 let value = value_fn(d).to_f32().unwrap_or(0.);
508 let label_y = if label_below_zero_line(value, alignment) {
509 zero_pixel + TEXT_GAP
510 } else {
511 zero_pixel - TEXT_GAP - TEXT_SIZE
512 };
513
514 Some(
515 Text::new(
516 band_fn(d).into(),
517 point(px(band_x + band_offset + band_width / 2.), px(label_y)),
518 cx.theme().muted_foreground,
519 )
520 .align(TextAlign::Center),
521 )
522 })
523 .collect();
524 PlotLabel::new(labels).paint(&bounds, window, cx);
525 }
526 BarAlignment::Left | BarAlignment::Right => {
527 let labels = build_band_labels(
528 &self.data,
529 band_fn.as_ref(),
530 &band_scale,
531 band_width,
532 self.tick_margin,
533 cx.theme().muted_foreground,
534 );
535 let (side, align) = if matches!(alignment, BarAlignment::Left) {
536 (AxisLabelSide::Start, TextAlign::Right)
537 } else {
538 (AxisLabelSide::End, TextAlign::Left)
539 };
540 axis = axis
541 .y(zero_pixel)
542 .y_label_side(side)
543 .y_label(labels.into_iter().map(|t| t.align(align)));
544 }
545 }
546 }
547 axis.paint(&plot_bounds, window, cx);
548
549 let far = match alignment {
551 BarAlignment::Bottom => 10.,
552 BarAlignment::Top => value_dim - 10.,
553 BarAlignment::Left => value_dim - value_end_gap,
554 BarAlignment::Right => value_end_gap,
555 };
556
557 let steps = self.value_tick_count;
558 let value_ticks = value_tick_positions(far, baseline, steps);
559
560 if self.grid {
562 let grid = Grid::new()
563 .stroke(cx.theme().border)
564 .dash_array(&[px(4.), px(2.)]);
565 let lines = value_ticks[..steps].to_vec();
566 let grid = if is_horizontal {
567 grid.x(lines)
568 } else {
569 grid.y(lines)
570 };
571 grid.paint(&plot_bounds, window);
572 }
573
574 if self.value_axis {
575 let labels = value_ticks.iter().enumerate().map(|(i, &tick)| {
578 let value = domain_hi - (domain_hi - domain_lo) * i as f32 / steps as f32;
579 AxisText::new(format_tick(value), px(tick), cx.theme().muted_foreground)
580 });
581
582 let value_axis = if is_horizontal {
585 PlotAxis::new()
586 .x_axis(false)
587 .x(px(total_height - VALUE_AXIS_GAP))
588 .x_label(labels.map(|t| t.align(TextAlign::Center)))
589 } else {
590 PlotAxis::new()
591 .y_axis(false)
592 .y(px(VALUE_AXIS_GAP - TEXT_GAP * 2.))
593 .y_label(labels.map(|t| t.align(TextAlign::Right)))
594 };
595 value_axis.paint(&bounds, window, cx);
596 }
597
598 let band_fn_cloned = band_fn.clone();
600 let value_fn_cloned = value_fn.clone();
601 let default_fill: Background = cx.theme().chart_2.into();
602 let fill = self.fill.clone();
603 let fill_gradient = self.fill_gradient.clone();
604 let label_color = cx.theme().foreground;
605
606 let chart_bounds: Bounds<f32> = Bounds {
610 origin: Point::new(0., 0.),
611 size: Size::new(total_width, total_height),
612 };
613
614 let chart_range = {
617 let mut lo = 0.0_f32;
618 let mut hi = 0.0_f32;
619 for v in &self.data {
620 if let Some(f) = value_fn(v).to_f32() {
621 lo = lo.min(f);
622 hi = hi.max(f);
623 }
624 }
625 lo..=hi
626 };
627
628 let hover = self.hover;
632 let step = band_scale.step().max(f32::EPSILON);
633 let emphasis = move |frame: Bounds<f32>| -> f32 {
634 let Some(hover) = hover else {
635 return 1.;
636 };
637 let center = if is_horizontal {
638 frame.origin.y + frame.size.height / 2.
639 } else {
640 frame.origin.x + frame.size.width / 2.
641 };
642 let distance = ((center - hover.center).abs() / step).min(1.);
643 1. - HOVER_DIM * hover.focus * distance
644 };
645
646 let mut bar = Bar::new()
647 .data(&self.data)
648 .alignment(alignment)
649 .band_width(band_width)
650 .cross(move |d| band_scale.tick(&band_fn_cloned(d)).map(|t| t + band_offset))
651 .base(move |_| zero_pixel)
652 .value(move |d| value_scale.tick(&value_fn_cloned(d)))
653 .corner_radii(self.corner_radii);
654
655 bar = match (fill, fill_gradient) {
656 (_, Some(fg)) => {
657 let value_fn_for_grad = value_fn.clone();
658 bar.fill(move |d, frame, alignment| {
659 let v = value_fn_for_grad(d).to_f32().unwrap_or(0.);
660 let base_v = 0.0_f32;
661 let bar_lo = base_v.min(v);
662 let bar_hi = base_v.max(v);
663 let bar_span = (bar_hi - bar_lo).max(f32::EPSILON);
664 let chart_to_bar = |chart_value: f32| (chart_value - bar_lo) / bar_span;
665 let stops = fg(d, chart_range.clone(), &chart_to_bar);
666 let [s0, s1] = clip_stops_to_bar(stops);
667 let bg: Background = linear_gradient(alignment.gradient_angle(), s0, s1);
668 bg.opacity(emphasis(frame))
669 })
670 }
671 (Some(f), _) => bar.fill(move |d, frame, alignment| {
672 f(d, frame, chart_bounds, alignment).opacity(emphasis(frame))
673 }),
674 _ => bar.fill(move |_, frame, _| default_fill.opacity(emphasis(frame))),
675 };
676
677 if let Some(label) = self.label.as_ref() {
678 let label = label.clone();
679 let text_align = match alignment {
680 BarAlignment::Bottom | BarAlignment::Top => TextAlign::Center,
681 BarAlignment::Left => TextAlign::Left,
682 BarAlignment::Right => TextAlign::Right,
683 };
684 bar =
685 bar.label(move |d, p| vec![Text::new(label(d), p, label_color).align(text_align)]);
686 }
687
688 bar.paint(&bounds, window, cx);
689 }
690
691 fn id(&self) -> Option<ElementId> {
692 self.id.clone()
693 }
694
695 fn tooltip_state(
696 &self,
697 position: Point<Pixels>,
698 bounds: Bounds<Pixels>,
699 _cx: &App,
700 ) -> Option<TooltipState> {
701 let band_fn = self.band.as_ref()?;
702 self.value.as_ref()?;
703
704 if !self.is_over_bars(position, bounds) {
706 return None;
707 }
708
709 let is_horizontal = self.alignment.is_horizontal();
712 let band_scale = self.band_scale(bounds)?;
713 let band_width = band_scale.band_width();
714
715 let band_offset = self.band_offset();
716 let cursor_band = if is_horizontal {
717 position.y
718 } else {
719 position.x
720 };
721 let index = band_scale.least_index(cursor_band.as_f32() - band_offset);
722 let d = self.data.get(index)?;
723 let center = band_scale.tick(&band_fn(d))? + band_offset + band_width / 2.;
724
725 let cross_line = if is_horizontal {
728 point(position.x, px(center))
729 } else {
730 point(px(center), position.y)
731 };
732
733 Some(TooltipState::new(index, cross_line, vec![]))
734 }
735
736 fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
737 self.hover = hover.map(|hover| {
738 let target = if self.alignment.is_horizontal() {
741 hover.state().cross_line.y
742 } else {
743 hover.state().cross_line.x
744 };
745 let center = spring(
746 ("bar-chart", "band"),
747 target,
748 pointer_spring(cx).with_travel(!hover.is_entering()),
749 window,
750 cx,
751 );
752 BarHover {
753 center: center.as_f32(),
754 focus: hover.focus(),
755 }
756 });
757 }
758
759 fn tooltip(
760 &self,
761 state: &TooltipState,
762 cursor: Point<Pixels>,
763 bounds: Bounds<Pixels>,
764 _window: &mut Window,
765 cx: &mut App,
766 ) -> Option<AnyElement> {
767 let (band_fn, value_fn) = (self.band.as_ref()?, self.value.as_ref()?);
768 let d = self.data.get(state.index)?;
769 let title: SharedString = band_fn(d).into();
770 let value = value_fn(d).to_f64()?;
771 let name = self.name.clone().unwrap_or_default();
772
773 let band_width = self.band_scale(bounds)?.band_width();
777 let center = self.hover.map_or(state.cross_line, |hover| {
778 if self.alignment.is_horizontal() {
779 point(state.cross_line.x, px(hover.center))
780 } else {
781 point(px(hover.center), state.cross_line.y)
782 }
783 });
784 let (start, length) = self.value_extent(bounds);
785 let cross_line = if self.alignment.is_horizontal() {
786 CrossLine::new(center)
787 .horizontal()
788 .h_span(start, length)
789 .band(px(band_width))
790 } else {
791 CrossLine::new(center)
792 .span(start, length)
793 .band(px(band_width))
794 };
795
796 Some(
797 Tooltip::new(cursor, bounds.size)
799 .gap(px(8.))
800 .cross_line(cross_line)
801 .title(title)
802 .row(cx.theme().chart_2, name, format!("{}", value))
803 .into_any_element(),
804 )
805 }
806}
807
808fn clip_stops_to_bar(stops: [LinearColorStop; 2]) -> [LinearColorStop; 2] {
819 let [a, b] = stops;
820 let p0 = a.percentage;
821 let p1 = b.percentage;
822 let lerp = |t: f32| -> Hsla {
823 Hsla {
824 h: a.color.h + (b.color.h - a.color.h) * t,
825 s: a.color.s + (b.color.s - a.color.s) * t,
826 l: a.color.l + (b.color.l - a.color.l) * t,
827 a: a.color.a + (b.color.a - a.color.a) * t,
828 }
829 };
830 let span = p1 - p0;
831 let sample = |target: f32| -> Hsla {
832 if span.abs() < f32::EPSILON {
833 a.color
834 } else {
835 lerp((target - p0) / span)
836 }
837 };
838 let new_a = if (0. ..=1.).contains(&p0) {
839 a
840 } else {
841 LinearColorStop {
842 color: sample(p0.clamp(0., 1.)),
843 percentage: p0.clamp(0., 1.),
844 }
845 };
846 let new_b = if (0. ..=1.).contains(&p1) {
847 b
848 } else {
849 LinearColorStop {
850 color: sample(p1.clamp(0., 1.)),
851 percentage: p1.clamp(0., 1.),
852 }
853 };
854 [new_a, new_b]
855}
856
857fn format_tick(v: f32) -> String {
859 if (v - v.round()).abs() < 0.001 {
860 format!("{:.0}", v)
861 } else {
862 format!("{:.1}", v)
863 }
864}
865
866fn label_below_zero_line(value: f32, alignment: BarAlignment) -> bool {
873 (value < 0.) == (alignment == BarAlignment::Top)
874}
875
876fn value_tick_positions(far: f32, baseline: f32, steps: usize) -> Vec<f32> {
882 (0..=steps)
883 .map(|i| far + (baseline - far) * i as f32 / steps as f32)
884 .collect()
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890
891 #[test]
892 fn test_label_below_zero_line() {
893 assert!(label_below_zero_line(5., BarAlignment::Bottom));
895 assert!(label_below_zero_line(0., BarAlignment::Bottom));
896 assert!(!label_below_zero_line(-5., BarAlignment::Bottom));
897
898 assert!(!label_below_zero_line(5., BarAlignment::Top));
900 assert!(!label_below_zero_line(0., BarAlignment::Top));
901 assert!(label_below_zero_line(-5., BarAlignment::Top));
902 }
903
904 #[test]
905 fn test_value_tick_positions() {
906 assert_eq!(
908 value_tick_positions(10., 110., 4),
909 vec![10., 35., 60., 85., 110.]
910 );
911
912 assert_eq!(value_tick_positions(110., 10., 2), vec![110., 60., 10.]);
914
915 assert_eq!(value_tick_positions(0., 50., 1), vec![0., 50.]);
916 }
917}