1use crate::chart::{
2 ChartBoundsTracker, ChartHitPoint, ChartOptions, ChartPalette, ChartSeries,
3 ChartValueLabelContent, ChartValueLabelPlacement, collect_axis_labels, collect_labels,
4 format_hit_tooltip, format_value_label, has_chart_data, normalized_domain, series_total,
5 stacked_domain,
6};
7use crate::chart_frame::{paint_chart_frame, paint_chart_label_aligned};
8use crate::chart_scale::{ScaleBand, ScaleLinear, ScalePoint};
9use crate::gpui_compat::PixelsExt;
10use crate::{Empty, Space, Text};
11use gpui::{
12 App, Background, BorderStyle, Bounds, Component, Corners, Edges, ElementId, Hsla,
13 InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, Styled,
14 Window, canvas, div, fill, linear_color_stop, linear_gradient, point, prelude::*, px, quad,
15 size,
16};
17use liora_core::{Config, Placement, TooltipData, clear_tooltip, set_active_tooltip, unique_id};
18use std::cell::Cell;
19use std::rc::Rc;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum BarChartMode {
23 Grouped,
24 Stacked,
25}
26
27#[derive(Clone, Debug, PartialEq)]
28pub struct BarChartHitBox {
29 pub series_index: usize,
30 pub point_index: usize,
31 pub series_name: SharedString,
32 pub label: SharedString,
33 pub value: f64,
34 pub x: f32,
35 pub y: f32,
36 pub width: f32,
37 pub height: f32,
38}
39
40impl BarChartHitBox {
41 pub fn center_x(&self) -> f32 {
42 self.x + self.width / 2.0
43 }
44
45 pub fn center_y(&self) -> f32 {
46 self.y + self.height / 2.0
47 }
48}
49
50pub fn bar_chart_hit_boxes(
51 series: &[ChartSeries],
52 mode: BarChartMode,
53 domain: (f64, f64),
54 plot_width: f32,
55 plot_height: f32,
56 bar_gap_ratio: f32,
57 bar_width: Option<Pixels>,
58 bar_gap: Option<Pixels>,
59) -> Vec<BarChartHitBox> {
60 if series.is_empty()
61 || !domain.0.is_finite()
62 || !domain.1.is_finite()
63 || (domain.1 - domain.0).abs() < f64::EPSILON
64 || !plot_width.is_finite()
65 || !plot_height.is_finite()
66 || plot_width <= 0.0
67 || plot_height <= 0.0
68 {
69 return Vec::new();
70 }
71
72 let labels = collect_labels(series);
73 if labels.is_empty() {
74 return Vec::new();
75 }
76
77 let band = ScaleBand::new(labels.clone(), (0.0, plot_width))
78 .padding_inner(bar_gap_ratio)
79 .padding_outer((bar_gap_ratio * 0.58).max(0.02));
80 let y = ScaleLinear::new(domain, (plot_height, 0.0));
81 match mode {
82 BarChartMode::Grouped => {
83 grouped_bar_hit_boxes(series, &band, &y, plot_height, bar_width, bar_gap)
84 }
85 BarChartMode::Stacked => stacked_bar_hit_boxes(series, &band, &y, plot_height, bar_width),
86 }
87}
88
89pub fn nearest_bar_chart_hit_point(
90 series: &[ChartSeries],
91 mode: BarChartMode,
92 domain: (f64, f64),
93 plot_width: f32,
94 plot_height: f32,
95 bar_gap_ratio: f32,
96 bar_width: Option<Pixels>,
97 bar_gap: Option<Pixels>,
98 pointer_x: f32,
99 pointer_y: f32,
100 hit_radius: f32,
101) -> Option<ChartHitPoint> {
102 if !pointer_x.is_finite()
103 || !pointer_y.is_finite()
104 || !hit_radius.is_finite()
105 || hit_radius < 0.0
106 {
107 return None;
108 }
109 let hit_boxes = bar_chart_hit_boxes(
110 series,
111 mode,
112 domain,
113 plot_width,
114 plot_height,
115 bar_gap_ratio,
116 bar_width,
117 bar_gap,
118 );
119
120 let mut nearest: Option<(&BarChartHitBox, f32)> = None;
121 for hit_box in &hit_boxes {
122 let inside_x = pointer_x >= hit_box.x && pointer_x <= hit_box.x + hit_box.width;
123 let inside_y = pointer_y >= hit_box.y && pointer_y <= hit_box.y + hit_box.height;
124 let dx = if inside_x {
125 0.0
126 } else if pointer_x < hit_box.x {
127 hit_box.x - pointer_x
128 } else {
129 pointer_x - (hit_box.x + hit_box.width)
130 };
131 let dy = if inside_y {
132 0.0
133 } else if pointer_y < hit_box.y {
134 hit_box.y - pointer_y
135 } else {
136 pointer_y - (hit_box.y + hit_box.height)
137 };
138 let distance = (dx * dx + dy * dy).sqrt();
139 if distance <= hit_radius && nearest.is_none_or(|(_, best)| distance < best) {
140 nearest = Some((hit_box, distance));
141 }
142 }
143
144 nearest.map(|(hit_box, distance)| ChartHitPoint {
145 series_index: hit_box.series_index,
146 point_index: hit_box.point_index,
147 series_name: hit_box.series_name.clone(),
148 label: hit_box.label.clone(),
149 value: hit_box.value,
150 x: hit_box.center_x(),
151 y: hit_box.center_y(),
152 distance,
153 })
154}
155
156fn grouped_bar_hit_boxes(
157 series: &[ChartSeries],
158 band: &ScaleBand,
159 y: &ScaleLinear,
160 plot_height: f32,
161 configured_bar_width: Option<Pixels>,
162 configured_gap: Option<Pixels>,
163) -> Vec<BarChartHitBox> {
164 let baseline = y.tick(0.0).clamp(0.0, plot_height);
165 let series_count = series.len().max(1) as f32;
166 let group_width = band.band_width().max(1.0);
167 let default_width = (group_width / series_count * 0.82).max(1.0);
168 let bar_width = configured_bar_width
169 .map(|width| width.as_f32().min(group_width / series_count).max(1.0))
170 .unwrap_or(default_width);
171 let gap = configured_gap
172 .map(|gap| gap.as_f32())
173 .unwrap_or_else(|| (group_width / series_count - bar_width).max(0.0));
174 let mut boxes = Vec::new();
175
176 for (series_index, current) in series.iter().enumerate() {
177 for (point_index, chart_point) in current.points.iter().enumerate() {
178 if !chart_point.is_finite() {
179 continue;
180 }
181 let Some(group_x) = band.tick_index(point_index) else {
182 continue;
183 };
184 let value_y = y.tick(chart_point.value).clamp(0.0, plot_height);
185 let top_y = baseline.min(value_y);
186 let height = (baseline - value_y).abs().max(1.0);
187 let x = group_x + series_index as f32 * (bar_width + gap) + gap * 0.5;
188 boxes.push(BarChartHitBox {
189 series_index,
190 point_index,
191 series_name: current.name.clone(),
192 label: chart_point.label.clone(),
193 value: chart_point.value,
194 x,
195 y: top_y,
196 width: bar_width,
197 height,
198 });
199 }
200 }
201 boxes
202}
203
204fn stacked_bar_hit_boxes(
205 series: &[ChartSeries],
206 band: &ScaleBand,
207 y: &ScaleLinear,
208 plot_height: f32,
209 configured_bar_width: Option<Pixels>,
210) -> Vec<BarChartHitBox> {
211 let labels_len = series
212 .iter()
213 .map(|series| series.points.len())
214 .max()
215 .unwrap_or(0);
216 let mut boxes = Vec::new();
217 for point_index in 0..labels_len {
218 let Some(group_x) = band.tick_index(point_index) else {
219 continue;
220 };
221 let mut positive_base = 0.0_f64;
222 let mut negative_base = 0.0_f64;
223 for (series_index, current) in series.iter().enumerate() {
224 let Some(chart_point) = current.points.get(point_index) else {
225 continue;
226 };
227 if !chart_point.is_finite() {
228 continue;
229 }
230 let (from, to) = if chart_point.value >= 0.0 {
231 let from = positive_base;
232 positive_base += chart_point.value;
233 (from, positive_base)
234 } else {
235 let from = negative_base;
236 negative_base += chart_point.value;
237 (from, negative_base)
238 };
239 let y0 = y.tick(from).clamp(0.0, plot_height);
240 let y1 = y.tick(to).clamp(0.0, plot_height);
241 let top_y = y0.min(y1);
242 let height = (y0 - y1).abs().max(1.0);
243 let width = configured_bar_width
244 .map(|width| width.as_f32().min(band.band_width()).max(1.0))
245 .unwrap_or_else(|| band.band_width().max(1.0));
246 let x = group_x + (band.band_width().max(1.0) - width) * 0.5;
247 boxes.push(BarChartHitBox {
248 series_index,
249 point_index,
250 series_name: current.name.clone(),
251 label: chart_point.label.clone(),
252 value: chart_point.value,
253 x,
254 y: top_y,
255 width,
256 height,
257 });
258 }
259 }
260 boxes
261}
262
263#[derive(Clone, Debug, PartialEq)]
264pub enum BarChartFill {
265 Solid(Hsla),
266 Gradient(BarChartGradient),
267}
268
269impl BarChartFill {
270 pub fn solid(color: Hsla) -> Self {
271 Self::Solid(color)
272 }
273
274 pub fn vertical_gradient(from: Hsla, to: Hsla) -> Self {
275 Self::Gradient(BarChartGradient::vertical(from, to))
276 }
277
278 pub fn horizontal_gradient(from: Hsla, to: Hsla) -> Self {
279 Self::Gradient(BarChartGradient::horizontal(from, to))
280 }
281
282 fn into_background(self) -> Background {
283 match self {
284 Self::Solid(color) => Background::from(color),
285 Self::Gradient(gradient) => gradient.into_background(),
286 }
287 }
288}
289
290impl From<Hsla> for BarChartFill {
291 fn from(color: Hsla) -> Self {
292 Self::Solid(color)
293 }
294}
295
296#[derive(Clone, Debug, PartialEq)]
297pub struct BarChartGradient {
298 pub angle: f32,
299 pub stops: Vec<(Hsla, f32)>,
300}
301
302impl BarChartGradient {
303 pub fn new(angle: f32, stops: impl IntoIterator<Item = (Hsla, f32)>) -> Self {
304 let mut stops = stops
305 .into_iter()
306 .map(|(color, offset)| (color, offset.clamp(0.0, 1.0)))
307 .collect::<Vec<_>>();
308 if stops.is_empty() {
309 stops.push((gpui::blue(), 0.0));
310 }
311 Self { angle, stops }
312 }
313
314 pub fn vertical(from: Hsla, to: Hsla) -> Self {
315 Self::new(180.0, [(from, 0.0), (to, 1.0)])
316 }
317
318 pub fn horizontal(from: Hsla, to: Hsla) -> Self {
319 Self::new(90.0, [(from, 0.0), (to, 1.0)])
320 }
321
322 fn into_background(self) -> Background {
323 let mut stops = self.stops.into_iter();
324 let (first_color, first_offset) = stops.next().unwrap_or((gpui::blue(), 0.0));
325 let (second_color, second_offset) = stops.next().unwrap_or((first_color, 1.0));
326 linear_gradient(
327 self.angle,
328 linear_color_stop(first_color, first_offset),
329 linear_color_stop(second_color, second_offset),
330 )
331 }
332}
333
334#[derive(Clone, Debug, PartialEq)]
335pub struct BarChartValueFillRange {
336 pub min: f64,
337 pub max: f64,
338 pub fill: BarChartFill,
339}
340
341impl BarChartValueFillRange {
342 pub fn new(min: f64, max: f64, fill: impl Into<BarChartFill>) -> Self {
343 Self {
344 min,
345 max,
346 fill: fill.into(),
347 }
348 }
349
350 fn contains(&self, value: f64) -> bool {
351 value >= self.min && value <= self.max
352 }
353}
354
355#[derive(Clone, Copy, Debug, PartialEq)]
356pub struct BarChartValueColorRange {
357 pub min: f64,
358 pub max: f64,
359 pub color: Hsla,
360}
361
362impl BarChartValueColorRange {
363 pub fn new(min: f64, max: f64, color: Hsla) -> Self {
364 Self { min, max, color }
365 }
366
367 fn into_fill_range(self) -> BarChartValueFillRange {
368 BarChartValueFillRange::new(self.min, self.max, self.color)
369 }
370}
371
372#[derive(Clone)]
373pub struct BarChart {
374 series: Vec<ChartSeries>,
375 options: ChartOptions,
376 mode: BarChartMode,
377 bar_gap_ratio: f32,
378 standalone: bool,
379 bar_radius: Pixels,
380 bar_width: Option<Pixels>,
381 bar_gap: Option<Pixels>,
382 value_fill_ranges: Vec<BarChartValueFillRange>,
383 bar_fills: Vec<BarChartFill>,
384}
385
386impl BarChart {
387 pub fn new(series: impl IntoIterator<Item = ChartSeries>) -> Self {
388 Self {
389 series: series.into_iter().collect(),
390 options: ChartOptions {
391 id: unique_id("bar-chart"),
392 ..ChartOptions::default()
393 },
394 mode: BarChartMode::Grouped,
395 bar_gap_ratio: 0.18,
396 standalone: false,
397 bar_radius: px(0.0),
398 bar_width: None,
399 bar_gap: None,
400 value_fill_ranges: Vec::new(),
401 bar_fills: Vec::new(),
402 }
403 }
404
405 pub fn id(mut self, id: impl Into<SharedString>) -> Self {
406 self.options.id = id.into();
407 self
408 }
409
410 pub fn height(mut self, height: impl Into<Pixels>) -> Self {
411 self.options.height = height.into();
412 self
413 }
414
415 pub fn show_grid(mut self, show: bool) -> Self {
416 self.options.show_grid = show;
417 self
418 }
419
420 pub fn show_axis(mut self, show: bool) -> Self {
421 self.options.show_axis = show;
422 self
423 }
424
425 pub fn show_legend(mut self, show: bool) -> Self {
426 self.options.show_legend = show;
427 self
428 }
429
430 pub fn y_domain(mut self, min: f64, max: f64) -> Self {
431 self.options.y_domain = Some((min, max));
432 self
433 }
434
435 pub fn y_format(mut self, formatter: fn(f64) -> SharedString) -> Self {
436 self.options.y_format = Some(formatter);
437 self
438 }
439
440 pub fn show_value_labels(mut self, show: bool) -> Self {
441 self.options.show_value_labels = show;
442 self
443 }
444
445 pub fn show_tooltip(mut self, show: bool) -> Self {
446 self.options.show_tooltip = show;
447 self
448 }
449
450 pub fn tooltip_hit_radius(mut self, radius: impl Into<Pixels>) -> Self {
451 self.options.tooltip_hit_radius = radius.into().max(px(0.0));
452 self
453 }
454
455 pub fn value_label_content(mut self, content: ChartValueLabelContent) -> Self {
456 self.options.value_label_options.content = content;
457 self
458 }
459
460 pub fn value_label_placement(mut self, placement: ChartValueLabelPlacement) -> Self {
461 self.options.value_label_options.placement = placement;
462 self
463 }
464
465 pub fn percentage_decimals(mut self, decimals: usize) -> Self {
466 self.options.value_label_options.percentage_decimals = decimals.min(4);
467 self
468 }
469
470 pub fn bar_gap_ratio(mut self, ratio: f32) -> Self {
471 self.bar_gap_ratio = ratio.clamp(0.0, 0.8);
472 self
473 }
474
475 pub fn max_axis_labels(mut self, max_labels: usize) -> Self {
476 self.options.max_axis_labels = max_labels.max(2);
477 self
478 }
479
480 pub fn max_value_labels(mut self, max_labels: usize) -> Self {
481 self.options.max_value_labels = max_labels.max(2);
482 self
483 }
484
485 pub fn standalone(mut self) -> Self {
486 self.standalone = true;
487 self.options.show_grid = false;
488 self.options.show_axis = false;
489 self.options.show_legend = false;
490 self.options.show_value_labels = false;
491 self.options.padding = crate::chart::ChartPadding {
492 top: px(6.0),
493 right: px(6.0),
494 bottom: px(6.0),
495 left: px(6.0),
496 };
497 self.options.height = px(86.0);
498 self.bar_radius = px(4.0);
499 self
500 }
501
502 pub fn bar_radius(mut self, radius: impl Into<Pixels>) -> Self {
503 self.bar_radius = radius.into().max(px(0.0));
504 self
505 }
506
507 pub fn bar_width(mut self, width: impl Into<Pixels>) -> Self {
508 self.bar_width = Some(width.into().max(px(1.0)));
509 self
510 }
511
512 pub fn bar_gap(mut self, gap: impl Into<Pixels>) -> Self {
513 self.bar_gap = Some(gap.into().max(px(0.0)));
514 self
515 }
516
517 pub fn value_color_ranges(
518 mut self,
519 ranges: impl IntoIterator<Item = BarChartValueColorRange>,
520 ) -> Self {
521 self.value_fill_ranges = ranges
522 .into_iter()
523 .map(BarChartValueColorRange::into_fill_range)
524 .collect();
525 self
526 }
527
528 pub fn value_fill_ranges(
529 mut self,
530 ranges: impl IntoIterator<Item = BarChartValueFillRange>,
531 ) -> Self {
532 self.value_fill_ranges = ranges.into_iter().collect();
533 self
534 }
535
536 pub fn bar_fills(mut self, fills: impl IntoIterator<Item = impl Into<BarChartFill>>) -> Self {
537 self.bar_fills = fills.into_iter().map(Into::into).collect();
538 self
539 }
540
541 pub fn bar_vertical_gradient(mut self, from: Hsla, to: Hsla) -> Self {
542 self.bar_fills = vec![BarChartFill::vertical_gradient(from, to)];
543 self
544 }
545
546 pub fn grouped(mut self) -> Self {
547 self.mode = BarChartMode::Grouped;
548 self
549 }
550
551 pub fn stacked(mut self) -> Self {
552 self.mode = BarChartMode::Stacked;
553 self
554 }
555
556 pub fn mode(mut self, mode: BarChartMode) -> Self {
557 self.mode = mode;
558 self
559 }
560
561 pub fn series(&self) -> &[ChartSeries] {
562 &self.series
563 }
564
565 pub fn options(&self) -> &ChartOptions {
566 &self.options
567 }
568
569 pub fn bar_mode(&self) -> BarChartMode {
570 self.mode
571 }
572
573 pub fn is_standalone(&self) -> bool {
574 self.standalone
575 }
576
577 pub fn bar_radius_value(&self) -> Pixels {
578 self.bar_radius
579 }
580
581 pub fn value_fill_ranges_config(&self) -> &[BarChartValueFillRange] {
582 &self.value_fill_ranges
583 }
584
585 pub fn bar_fills_config(&self) -> &[BarChartFill] {
586 &self.bar_fills
587 }
588}
589
590#[derive(Clone)]
591struct BarPaintOptions {
592 radius: Pixels,
593 width: Option<Pixels>,
594 gap: Option<Pixels>,
595 value_fill_ranges: Vec<BarChartValueFillRange>,
596 bar_fills: Vec<BarChartFill>,
597 compact_width: bool,
598}
599
600impl BarPaintOptions {
601 fn resolve_fill(&self, value: f64, fallback: Hsla, point_index: usize) -> BarChartFill {
602 self.value_fill_ranges
603 .iter()
604 .find(|range| range.contains(value))
605 .map(|range| range.fill.clone())
606 .or_else(|| {
607 (!self.bar_fills.is_empty())
608 .then(|| self.bar_fills[point_index % self.bar_fills.len()].clone())
609 })
610 .unwrap_or(BarChartFill::Solid(fallback))
611 }
612
613 fn preferred_width(
614 &self,
615 series: &[ChartSeries],
616 mode: BarChartMode,
617 padding: crate::chart::ChartPadding,
618 ) -> Option<Pixels> {
619 if !self.compact_width {
620 return None;
621 }
622 let labels_len = series.iter().map(|series| series.points.len()).max()?;
623 let bar_width = self.width?;
624 let gap = self.gap.unwrap_or(px(4.0));
625 let series_count = match mode {
626 BarChartMode::Grouped => series.len().max(1),
627 BarChartMode::Stacked => 1,
628 };
629 let group_width =
630 bar_width * series_count as f32 + gap * series_count.saturating_sub(1) as f32;
631 Some(
632 padding.left
633 + padding.right
634 + group_width * labels_len as f32
635 + gap * labels_len.saturating_sub(1) as f32,
636 )
637 }
638}
639
640fn paint_bar(
641 window: &mut Window,
642 bounds: Bounds<Pixels>,
643 fill_style: BarChartFill,
644 radius: Pixels,
645) {
646 let background = fill_style.into_background();
647 if radius > px(0.0) {
648 window.paint_quad(quad(
649 bounds,
650 Corners::all(radius).clamp_radii_for_quad_size(bounds.size),
651 background,
652 Edges::all(px(0.0)),
653 gpui::transparent_black(),
654 BorderStyle::Solid,
655 ));
656 } else {
657 window.paint_quad(fill(bounds, background));
658 }
659}
660
661impl IntoElement for BarChart {
662 type Element = Component<Self>;
663
664 fn into_element(self) -> Self::Element {
665 Component::new(self)
666 }
667}
668
669impl RenderOnce for BarChart {
670 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
671 let theme = cx.global::<Config>().theme.clone();
672 let palette = ChartPalette::from_config(cx.global::<Config>());
673 let has_data = has_chart_data(&self.series);
674 let height = self.options.height;
675 let id = self.options.id.clone();
676
677 let mut shell = div()
678 .id(ElementId::from(id.clone()))
679 .flex()
680 .flex_col()
681 .gap_2()
682 .when(!self.standalone, |s| s.w_full())
683 .when(!self.standalone, |s| {
684 s.p_3()
685 .rounded_md()
686 .border_1()
687 .border_color(theme.neutral.border)
688 .bg(theme.neutral.card)
689 });
690
691 if !has_data {
692 return shell
693 .h(height)
694 .items_center()
695 .justify_center()
696 .child(Empty::new().description("暂无图表数据"))
697 .into_any_element();
698 }
699
700 if self.options.show_legend {
701 shell = shell.child(render_legend(&self.series, &palette));
702 }
703
704 shell
705 .child(render_bar_canvas(
706 self.series,
707 self.options,
708 palette,
709 theme.neutral.inverted,
710 self.mode,
711 self.bar_gap_ratio,
712 BarPaintOptions {
713 radius: self.bar_radius,
714 width: self.bar_width,
715 gap: self.bar_gap,
716 value_fill_ranges: self.value_fill_ranges,
717 bar_fills: self.bar_fills,
718 compact_width: self.standalone,
719 },
720 ))
721 .into_any_element()
722 }
723}
724
725fn render_legend(series: &[ChartSeries], palette: &ChartPalette) -> impl IntoElement {
726 Space::new()
727 .wrap()
728 .gap_md()
729 .children(series.iter().enumerate().map(|(index, series)| {
730 let color = series.color.unwrap_or_else(|| palette.series_color(index));
731 Space::new()
732 .gap_xs()
733 .align_center()
734 .child(div().w(px(10.0)).h(px(10.0)).rounded_sm().bg(color))
735 .child(Text::new(series.name.clone()).size(px(12.0)))
736 }))
737}
738
739fn render_bar_canvas(
740 series: Vec<ChartSeries>,
741 options: ChartOptions,
742 palette: ChartPalette,
743 label_on_fill: Hsla,
744 mode: BarChartMode,
745 bar_gap_ratio: f32,
746 paint_options: BarPaintOptions,
747) -> impl IntoElement {
748 let height = options.height;
749 let preferred_width = paint_options.preferred_width(&series, mode, options.padding);
750 let tooltip_bar_width = paint_options.width;
751 let tooltip_bar_gap = paint_options.gap;
752 let bounds_cell: Rc<Cell<Bounds<Pixels>>> = Rc::new(Cell::new(Bounds::default()));
753 let tooltip_bounds = bounds_cell.clone();
754 let tooltip_series = series.clone();
755 let tooltip_options = options.clone();
756 let tooltip_id: SharedString = format!("{}-tooltip", options.id).into();
757 let move_id = tooltip_id.clone();
758 let chart = canvas(
759 |_, _, _| (),
760 move |bounds, _, window, cx| {
761 let labels = collect_labels(&series);
762 if labels.is_empty() {
763 return;
764 }
765
766 let padding = options.padding;
767 let left = bounds.left() + padding.left;
768 let right = bounds.right() - padding.right;
769 let top = bounds.top() + padding.top;
770 let bottom = bounds.bottom() - padding.bottom;
771 let width = (right - left).max(px(1.0));
772 let plot_height = (bottom - top).max(px(1.0));
773
774 let frame_x = ScalePoint::new(labels.clone(), (0.0, width.as_f32()));
775 let band = ScaleBand::new(labels.clone(), (0.0, width.as_f32()))
776 .padding_inner(bar_gap_ratio)
777 .padding_outer((bar_gap_ratio * 0.58).max(0.02));
778 let domain = if mode == BarChartMode::Stacked {
779 options
780 .y_domain
781 .or_else(|| stacked_domain(&series))
782 .map(|domain| normalized_domain(Some(domain), &[]))
783 .unwrap_or_else(|| normalized_domain(None, &series))
784 } else {
785 normalized_domain(options.y_domain, &series)
786 };
787 let y = ScaleLinear::new(domain, (plot_height.as_f32(), 0.0));
788 if options.show_grid || options.show_axis {
789 paint_chart_frame(
790 left,
791 top,
792 width,
793 plot_height,
794 &collect_axis_labels(&series, options.max_axis_labels),
795 &frame_x,
796 &y,
797 &palette,
798 &options,
799 window,
800 cx,
801 );
802 }
803
804 match mode {
805 BarChartMode::Grouped => paint_grouped_bars(
806 left,
807 top,
808 plot_height,
809 &series,
810 &band,
811 &y,
812 &palette,
813 &options,
814 &paint_options,
815 window,
816 cx,
817 ),
818 BarChartMode::Stacked => paint_stacked_bars(
819 left,
820 top,
821 plot_height,
822 &series,
823 &band,
824 &y,
825 &palette,
826 label_on_fill,
827 &options,
828 &paint_options,
829 window,
830 cx,
831 ),
832 }
833 },
834 )
835 .when_some(preferred_width, |style, width| style.w(width))
836 .when(preferred_width.is_none(), |style| style.w_full())
837 .h(height);
838
839 div()
840 .relative()
841 .when_some(preferred_width, |style, width| style.w(width))
842 .when(preferred_width.is_none(), |style| style.w_full())
843 .h(height)
844 .on_mouse_move(move |event, _, cx| {
845 if !tooltip_options.show_tooltip {
846 clear_tooltip(&move_id, cx);
847 return;
848 }
849 let bounds = tooltip_bounds.get();
850 if bounds.size.width <= px(0.0) || bounds.size.height <= px(0.0) {
851 clear_tooltip(&move_id, cx);
852 return;
853 }
854 let padding = tooltip_options.padding;
855 let plot_width =
856 (bounds.size.width.as_f32() - padding.left.as_f32() - padding.right.as_f32())
857 .max(1.0);
858 let plot_height =
859 (bounds.size.height.as_f32() - padding.top.as_f32() - padding.bottom.as_f32())
860 .max(1.0);
861 let local_x = (event.position.x - bounds.left() - padding.left).as_f32();
862 let local_y = (event.position.y - bounds.top() - padding.top).as_f32();
863 let domain = if mode == BarChartMode::Stacked {
864 tooltip_options
865 .y_domain
866 .or_else(|| stacked_domain(&tooltip_series))
867 .map(|domain| normalized_domain(Some(domain), &[]))
868 .unwrap_or_else(|| normalized_domain(None, &tooltip_series))
869 } else {
870 normalized_domain(tooltip_options.y_domain, &tooltip_series)
871 };
872 let Some(hit) = nearest_bar_chart_hit_point(
873 &tooltip_series,
874 mode,
875 domain,
876 plot_width,
877 plot_height,
878 bar_gap_ratio,
879 tooltip_bar_width,
880 tooltip_bar_gap,
881 local_x,
882 local_y,
883 tooltip_options.tooltip_hit_radius.as_f32(),
884 ) else {
885 clear_tooltip(&move_id, cx);
886 return;
887 };
888 set_active_tooltip(
889 TooltipData {
890 id: move_id.clone(),
891 content: format_hit_tooltip(&hit, tooltip_options.y_format),
892 anchor_bounds: Bounds::new(
893 point(event.position.x - px(1.0), event.position.y - px(1.0)),
894 size(px(2.0), px(2.0)),
895 ),
896 placement: Placement::Top,
897 offset: px(8.0),
898 },
899 cx,
900 );
901 })
902 .child(ChartBoundsTracker::new(chart, bounds_cell))
903}
904
905fn paint_grouped_bars(
906 left: Pixels,
907 top: Pixels,
908 plot_height: Pixels,
909 series: &[ChartSeries],
910 band: &ScaleBand,
911 y: &ScaleLinear,
912 palette: &ChartPalette,
913 options: &ChartOptions,
914 paint_options: &BarPaintOptions,
915 window: &mut Window,
916 cx: &mut App,
917) {
918 let baseline = y.tick(0.0).clamp(0.0, plot_height.as_f32());
919 let series_count = series.len().max(1) as f32;
920 let group_width = band.band_width().max(1.0);
921 let default_width = (group_width / series_count * 0.82).max(1.0);
922 let bar_width = paint_options
923 .width
924 .map(|width| width.as_f32().min(group_width / series_count).max(1.0))
925 .unwrap_or(default_width);
926 let gap = paint_options
927 .gap
928 .map(|gap| gap.as_f32())
929 .unwrap_or_else(|| (group_width / series_count - bar_width).max(0.0));
930
931 for (series_index, current) in series.iter().enumerate() {
932 for (point_index, chart_point) in current.points.iter().enumerate() {
933 if !chart_point.is_finite() {
934 continue;
935 }
936 let Some(group_x) = band.tick_index(point_index) else {
937 continue;
938 };
939 let fill = paint_options.resolve_fill(
940 chart_point.value,
941 current.resolved_fill_color(palette.series_color(series_index)),
942 point_index,
943 );
944 let value_y = y.tick(chart_point.value).clamp(0.0, plot_height.as_f32());
945 let top_y = baseline.min(value_y);
946 let height = (baseline - value_y).abs().max(1.0);
947 let x = group_x + series_index as f32 * (bar_width + gap) + gap * 0.5;
948 paint_bar(
949 window,
950 Bounds::new(
951 point(left + px(x), top + px(top_y)),
952 size(px(bar_width), px(height)),
953 ),
954 fill,
955 paint_options.radius,
956 );
957 if options.show_value_labels {
958 let label_y = if chart_point.value >= 0.0 {
959 top_y - 17.0
960 } else {
961 top_y + height + 3.0
962 };
963 paint_chart_label_aligned(
964 format_value_label(
965 chart_point.value,
966 series_total(current),
967 options.y_format,
968 &options.value_label_options,
969 ),
970 point(left + px(x + bar_width * 0.5 - 24.0), top + px(label_y)),
971 palette.label,
972 gpui::TextAlign::Center,
973 Some(px(48.0)),
974 window,
975 cx,
976 );
977 }
978 }
979 }
980}
981
982fn paint_stacked_bars(
983 left: Pixels,
984 top: Pixels,
985 plot_height: Pixels,
986 series: &[ChartSeries],
987 band: &ScaleBand,
988 y: &ScaleLinear,
989 palette: &ChartPalette,
990 label_on_fill: Hsla,
991 options: &ChartOptions,
992 paint_options: &BarPaintOptions,
993 window: &mut Window,
994 cx: &mut App,
995) {
996 let baseline = y.tick(0.0).clamp(0.0, plot_height.as_f32());
997 let labels_len = series
998 .iter()
999 .map(|series| series.points.len())
1000 .max()
1001 .unwrap_or(0);
1002 for point_index in 0..labels_len {
1003 let Some(group_x) = band.tick_index(point_index) else {
1004 continue;
1005 };
1006 let mut positive_base = 0.0_f64;
1007 let mut negative_base = 0.0_f64;
1008 for (series_index, current) in series.iter().enumerate() {
1009 let Some(chart_point) = current.points.get(point_index) else {
1010 continue;
1011 };
1012 if !chart_point.is_finite() {
1013 continue;
1014 }
1015 let fill = paint_options.resolve_fill(
1016 chart_point.value,
1017 current.resolved_fill_color(palette.series_color(series_index)),
1018 point_index,
1019 );
1020 let (from, to) = if chart_point.value >= 0.0 {
1021 let from = positive_base;
1022 positive_base += chart_point.value;
1023 (from, positive_base)
1024 } else {
1025 let from = negative_base;
1026 negative_base += chart_point.value;
1027 (from, negative_base)
1028 };
1029 let y0 = y.tick(from).clamp(0.0, plot_height.as_f32());
1030 let y1 = y.tick(to).clamp(0.0, plot_height.as_f32());
1031 let top_y = y0.min(y1).min(baseline.max(y1));
1032 let height = (y0 - y1).abs().max(1.0);
1033 let width = paint_options
1034 .width
1035 .map(|width| width.as_f32().min(band.band_width()).max(1.0))
1036 .unwrap_or_else(|| band.band_width().max(1.0));
1037 let x = group_x + (band.band_width().max(1.0) - width) * 0.5;
1038 paint_bar(
1039 window,
1040 Bounds::new(
1041 point(left + px(x), top + px(top_y)),
1042 size(px(width), px(height)),
1043 ),
1044 fill,
1045 paint_options.radius,
1046 );
1047 if options.show_value_labels {
1048 paint_chart_label_aligned(
1049 format_value_label(
1050 chart_point.value,
1051 series_total(current),
1052 options.y_format,
1053 &options.value_label_options,
1054 ),
1055 point(
1056 left + px(group_x + band.band_width().max(1.0) * 0.5 - 24.0),
1057 top + px(top_y + height * 0.5 - 7.0),
1058 ),
1059 label_on_fill,
1060 gpui::TextAlign::Center,
1061 Some(px(48.0)),
1062 window,
1063 cx,
1064 );
1065 }
1066 }
1067 }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073 use crate::chart::ChartPoint;
1074
1075 fn sample_series() -> Vec<ChartSeries> {
1076 vec![
1077 ChartSeries::new(
1078 "Revenue",
1079 [ChartPoint::new("Q1", 12.0), ChartPoint::new("Q2", 18.0)],
1080 ),
1081 ChartSeries::new(
1082 "Cost",
1083 [ChartPoint::new("Q1", 7.0), ChartPoint::new("Q2", 9.0)],
1084 ),
1085 ]
1086 }
1087
1088 #[test]
1089 fn bar_chart_builder_tracks_options_and_mode() {
1090 let chart = BarChart::new(sample_series())
1091 .id("sales-bars")
1092 .height(px(320.0))
1093 .show_grid(false)
1094 .show_axis(false)
1095 .show_legend(false)
1096 .y_domain(0.0, 100.0)
1097 .show_value_labels(false)
1098 .show_tooltip(false)
1099 .tooltip_hit_radius(px(20.0))
1100 .value_label_content(ChartValueLabelContent::ValueAndPercentage)
1101 .value_label_placement(ChartValueLabelPlacement::Inside)
1102 .percentage_decimals(2)
1103 .bar_gap_ratio(0.3)
1104 .bar_radius(px(3.0))
1105 .bar_width(px(8.0))
1106 .bar_gap(px(4.0))
1107 .value_color_ranges([BarChartValueColorRange::new(0.0, 50.0, gpui::green())])
1108 .stacked();
1109
1110 assert_eq!(chart.options().id, SharedString::from("sales-bars"));
1111 assert_eq!(chart.options().height, px(320.0));
1112 assert!(!chart.options().show_grid);
1113 assert!(!chart.options().show_axis);
1114 assert!(!chart.options().show_legend);
1115 assert_eq!(chart.options().y_domain, Some((0.0, 100.0)));
1116 assert!(!chart.options().show_value_labels);
1117 assert!(!chart.options().show_tooltip);
1118 assert_eq!(chart.options().tooltip_hit_radius, px(20.0));
1119 assert_eq!(
1120 chart.options().value_label_options.content,
1121 ChartValueLabelContent::ValueAndPercentage
1122 );
1123 assert_eq!(
1124 chart.options().value_label_options.placement,
1125 ChartValueLabelPlacement::Inside
1126 );
1127 assert_eq!(chart.options().value_label_options.percentage_decimals, 2);
1128 assert_eq!(chart.bar_gap_ratio, 0.3);
1129 assert_eq!(chart.bar_radius_value(), px(3.0));
1130 assert_eq!(chart.bar_width, Some(px(8.0)));
1131 assert_eq!(chart.bar_gap, Some(px(4.0)));
1132 assert_eq!(chart.value_fill_ranges.len(), 1);
1133 assert_eq!(chart.bar_mode(), BarChartMode::Stacked);
1134 }
1135
1136 #[test]
1137 fn bar_chart_keeps_series_data() {
1138 let chart = BarChart::new(sample_series());
1139 assert_eq!(chart.series().len(), 2);
1140 assert_eq!(chart.series()[0].name, SharedString::from("Revenue"));
1141 }
1142
1143 #[test]
1144 fn bar_chart_tracks_gradient_fill_options() {
1145 let chart = BarChart::new(sample_series())
1146 .bar_fills([BarChartFill::vertical_gradient(gpui::blue(), gpui::green())])
1147 .value_fill_ranges([BarChartValueFillRange::new(
1148 0.0,
1149 20.0,
1150 BarChartFill::horizontal_gradient(gpui::red(), gpui::blue()),
1151 )]);
1152
1153 assert_eq!(chart.bar_fills_config().len(), 1);
1154 assert_eq!(chart.value_fill_ranges_config().len(), 1);
1155 }
1156
1157 #[test]
1158 fn grouped_bar_hit_testing_returns_the_bar_under_pointer() {
1159 let domain = normalized_domain(Some((0.0, 20.0)), &[]);
1160 let boxes = bar_chart_hit_boxes(
1161 &sample_series(),
1162 BarChartMode::Grouped,
1163 domain,
1164 240.0,
1165 120.0,
1166 0.18,
1167 None,
1168 None,
1169 );
1170 assert_eq!(boxes.len(), 4);
1171 assert_eq!(boxes[0].series_index, 0);
1172 assert_eq!(boxes[0].point_index, 0);
1173 assert!(boxes[0].width > 1.0);
1174 assert!(boxes[0].height > 1.0);
1175 assert!(boxes[1].x > boxes[0].x);
1176
1177 let target = &boxes[3];
1178 let hit = nearest_bar_chart_hit_point(
1179 &sample_series(),
1180 BarChartMode::Grouped,
1181 domain,
1182 240.0,
1183 120.0,
1184 0.18,
1185 None,
1186 None,
1187 target.center_x(),
1188 target.center_y(),
1189 0.0,
1190 )
1191 .expect("pointer inside grouped bar should hit");
1192
1193 assert_eq!(hit.series_index, target.series_index);
1194 assert_eq!(hit.point_index, target.point_index);
1195 assert_eq!(hit.series_name, target.series_name);
1196 assert_eq!(hit.label, target.label);
1197 assert_eq!(hit.value, target.value);
1198 }
1199
1200 #[test]
1201 fn stacked_bar_hit_testing_returns_the_stacked_segment_under_pointer() {
1202 let domain = normalized_domain(stacked_domain(&sample_series()), &[]);
1203 let boxes = bar_chart_hit_boxes(
1204 &sample_series(),
1205 BarChartMode::Stacked,
1206 domain,
1207 240.0,
1208 120.0,
1209 0.18,
1210 None,
1211 None,
1212 );
1213 assert_eq!(boxes.len(), 4);
1214
1215 let second_series_q1 = boxes
1216 .iter()
1217 .find(|hit_box| hit_box.series_index == 1 && hit_box.point_index == 0)
1218 .expect("stacked Q1 second segment should exist");
1219 let hit = nearest_bar_chart_hit_point(
1220 &sample_series(),
1221 BarChartMode::Stacked,
1222 domain,
1223 240.0,
1224 120.0,
1225 0.18,
1226 None,
1227 None,
1228 second_series_q1.center_x(),
1229 second_series_q1.center_y(),
1230 0.0,
1231 )
1232 .expect("pointer inside stacked segment should hit");
1233
1234 assert_eq!(hit.series_index, 1);
1235 assert_eq!(hit.point_index, 0);
1236 assert_eq!(hit.series_name, SharedString::from("Cost"));
1237 assert_eq!(hit.label, SharedString::from("Q1"));
1238 assert_eq!(hit.value, 7.0);
1239 }
1240
1241 #[test]
1242 fn standalone_mode_disables_chart_chrome() {
1243 let chart = BarChart::new(sample_series()).standalone();
1244 assert!(chart.is_standalone());
1245 assert!(!chart.options().show_grid);
1246 assert!(!chart.options().show_axis);
1247 assert!(!chart.options().show_legend);
1248 assert!(!chart.options().show_value_labels);
1249 assert_eq!(chart.bar_radius_value(), px(4.0));
1250 }
1251
1252 #[test]
1253 fn standalone_fixed_width_uses_compact_content_width() {
1254 let chart = BarChart::new(sample_series())
1255 .standalone()
1256 .bar_width(px(8.0))
1257 .bar_gap(px(4.0));
1258 let options = BarPaintOptions {
1259 radius: chart.bar_radius,
1260 width: chart.bar_width,
1261 gap: chart.bar_gap,
1262 value_fill_ranges: chart.value_fill_ranges.clone(),
1263 bar_fills: chart.bar_fills.clone(),
1264 compact_width: chart.standalone,
1265 };
1266
1267 assert_eq!(
1268 options.preferred_width(chart.series(), chart.bar_mode(), chart.options().padding),
1269 Some(px(56.0))
1270 );
1271 }
1272}