Skip to main content

liora_components/
bar_chart.rs

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}