Skip to main content

liora_components/
area_chart.rs

1use crate::chart::{
2    ChartBoundsTracker, ChartOptions, ChartPalette, ChartSeries, ChartValueLabelContent,
3    ChartValueLabelPlacement, collect_axis_labels, downsample_index_range,
4    downsample_indexed_values, format_hit_tooltip, format_value_label, has_chart_data,
5    label_domain_len, nearest_cartesian_hit_point, normalized_domain_with_baseline, series_total,
6    sparse_indices, stacked_domain,
7};
8use crate::chart_frame::{paint_chart_frame, paint_chart_label_aligned};
9use crate::chart_scale::{ScaleLinear, ScalePoint};
10use crate::chart_shape::{
11    area_path, finite_line_points, line_path, line_soft_edge_path, smooth_area_path,
12    smooth_line_path,
13};
14use crate::gpui_compat::PixelsExt;
15use crate::{Empty, Space, Text};
16use gpui::{
17    App, Bounds, Component, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels,
18    RenderOnce, SharedString, Styled, Window, canvas, div, point, px, size,
19};
20use liora_core::{Config, Placement, TooltipData, clear_tooltip, set_active_tooltip, unique_id};
21use std::cell::Cell;
22use std::rc::Rc;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum AreaChartMode {
26    Overlay,
27    Stacked,
28}
29
30#[derive(Clone)]
31pub struct AreaChart {
32    series: Vec<ChartSeries>,
33    options: ChartOptions,
34    mode: AreaChartMode,
35    line_stroke: bool,
36    smooth: bool,
37    stroke_width: Pixels,
38}
39
40impl AreaChart {
41    pub fn new(series: impl IntoIterator<Item = ChartSeries>) -> Self {
42        Self {
43            series: series.into_iter().collect(),
44            options: ChartOptions {
45                id: unique_id("area-chart"),
46                ..ChartOptions::default()
47            },
48            mode: AreaChartMode::Overlay,
49            line_stroke: true,
50            smooth: false,
51            stroke_width: px(2.0),
52        }
53    }
54
55    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
56        self.options.id = id.into();
57        self
58    }
59
60    pub fn height(mut self, height: impl Into<Pixels>) -> Self {
61        self.options.height = height.into();
62        self
63    }
64
65    pub fn show_grid(mut self, show: bool) -> Self {
66        self.options.show_grid = show;
67        self
68    }
69
70    pub fn show_axis(mut self, show: bool) -> Self {
71        self.options.show_axis = show;
72        self
73    }
74
75    pub fn show_legend(mut self, show: bool) -> Self {
76        self.options.show_legend = show;
77        self
78    }
79
80    pub fn y_domain(mut self, min: f64, max: f64) -> Self {
81        self.options.y_domain = Some((min, max));
82        self
83    }
84
85    pub fn y_format(mut self, formatter: fn(f64) -> SharedString) -> Self {
86        self.options.y_format = Some(formatter);
87        self
88    }
89
90    pub fn show_value_labels(mut self, show: bool) -> Self {
91        self.options.show_value_labels = show;
92        self
93    }
94
95    pub fn show_tooltip(mut self, show: bool) -> Self {
96        self.options.show_tooltip = show;
97        self
98    }
99
100    pub fn tooltip_hit_radius(mut self, radius: impl Into<Pixels>) -> Self {
101        self.options.tooltip_hit_radius = radius.into().max(px(0.0));
102        self
103    }
104
105    pub fn value_label_content(mut self, content: ChartValueLabelContent) -> Self {
106        self.options.value_label_options.content = content;
107        self
108    }
109
110    pub fn value_label_placement(mut self, placement: ChartValueLabelPlacement) -> Self {
111        self.options.value_label_options.placement = placement;
112        self
113    }
114
115    pub fn percentage_decimals(mut self, decimals: usize) -> Self {
116        self.options.value_label_options.percentage_decimals = decimals.min(4);
117        self
118    }
119
120    pub fn smooth(mut self, enabled: bool) -> Self {
121        self.smooth = enabled;
122        self
123    }
124
125    pub fn stroke_width(mut self, width: impl Into<Pixels>) -> Self {
126        self.stroke_width = width.into();
127        self
128    }
129
130    pub fn max_render_points(mut self, max_points: usize) -> Self {
131        self.options.max_render_points = Some(max_points.max(3));
132        self
133    }
134
135    pub fn max_axis_labels(mut self, max_labels: usize) -> Self {
136        self.options.max_axis_labels = max_labels.max(2);
137        self
138    }
139
140    pub fn max_value_labels(mut self, max_labels: usize) -> Self {
141        self.options.max_value_labels = max_labels.max(2);
142        self
143    }
144
145    pub fn disable_downsampling(mut self) -> Self {
146        self.options.max_render_points = None;
147        self
148    }
149
150    pub fn overlay(mut self) -> Self {
151        self.mode = AreaChartMode::Overlay;
152        self
153    }
154
155    pub fn stacked(mut self) -> Self {
156        self.mode = AreaChartMode::Stacked;
157        self
158    }
159
160    pub fn mode(mut self, mode: AreaChartMode) -> Self {
161        self.mode = mode;
162        self
163    }
164
165    pub fn line_stroke(mut self, enabled: bool) -> Self {
166        self.line_stroke = enabled;
167        self
168    }
169
170    pub fn series(&self) -> &[ChartSeries] {
171        &self.series
172    }
173
174    pub fn options(&self) -> &ChartOptions {
175        &self.options
176    }
177
178    pub fn area_mode(&self) -> AreaChartMode {
179        self.mode
180    }
181}
182
183impl IntoElement for AreaChart {
184    type Element = Component<Self>;
185
186    fn into_element(self) -> Self::Element {
187        Component::new(self)
188    }
189}
190
191impl RenderOnce for AreaChart {
192    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
193        let theme = cx.global::<Config>().theme.clone();
194        let palette = ChartPalette::from_config(cx.global::<Config>());
195        let has_data = has_chart_data(&self.series);
196        let height = self.options.height;
197        let id = self.options.id.clone();
198
199        let mut shell = div()
200            .id(ElementId::from(id.clone()))
201            .flex()
202            .flex_col()
203            .gap_2()
204            .w_full()
205            .p_3()
206            .rounded_md()
207            .border_1()
208            .border_color(theme.neutral.border)
209            .bg(theme.neutral.card);
210
211        if !has_data {
212            return shell
213                .h(height)
214                .items_center()
215                .justify_center()
216                .child(Empty::new().description("暂无图表数据"))
217                .into_any_element();
218        }
219
220        if self.options.show_legend {
221            shell = shell.child(render_legend(&self.series, &palette));
222        }
223
224        shell
225            .child(render_area_canvas(
226                self.series,
227                self.options,
228                palette,
229                self.mode,
230                self.line_stroke,
231                self.smooth,
232                self.stroke_width,
233            ))
234            .into_any_element()
235    }
236}
237
238fn render_legend(series: &[ChartSeries], palette: &ChartPalette) -> impl IntoElement {
239    Space::new()
240        .wrap()
241        .gap_md()
242        .children(series.iter().enumerate().map(|(index, series)| {
243            let color = series.color.unwrap_or_else(|| palette.series_color(index));
244            Space::new()
245                .gap_xs()
246                .align_center()
247                .child(
248                    div()
249                        .w(px(10.0))
250                        .h(px(10.0))
251                        .rounded_sm()
252                        .bg(color.opacity(0.72)),
253                )
254                .child(Text::new(series.name.clone()).size(px(12.0)))
255        }))
256}
257
258fn render_area_canvas(
259    series: Vec<ChartSeries>,
260    options: ChartOptions,
261    palette: ChartPalette,
262    mode: AreaChartMode,
263    line_stroke: bool,
264    smooth: bool,
265    stroke_width: Pixels,
266) -> impl IntoElement {
267    let height = options.height;
268    let bounds_cell: Rc<Cell<Bounds<Pixels>>> = Rc::new(Cell::new(Bounds::default()));
269    let tooltip_bounds = bounds_cell.clone();
270    let tooltip_series = series.clone();
271    let tooltip_options = options.clone();
272    let tooltip_mode = mode;
273    let tooltip_id: SharedString = format!("{}-tooltip", options.id).into();
274    let move_id = tooltip_id.clone();
275    let chart = canvas(
276        |_, _, _| (),
277        move |bounds, _, window, cx| {
278            let domain_len = label_domain_len(&series);
279            if domain_len == 0 {
280                return;
281            }
282            let axis_labels = collect_axis_labels(&series, options.max_axis_labels);
283
284            let padding = options.padding;
285            let left = bounds.left() + padding.left;
286            let right = bounds.right() - padding.right;
287            let top = bounds.top() + padding.top;
288            let bottom = bounds.bottom() - padding.bottom;
289            let width = (right - left).max(px(1.0));
290            let plot_height = (bottom - top).max(px(1.0));
291
292            let x = ScalePoint::from_len(domain_len, (0.0, width.as_f32()));
293            let domain = if mode == AreaChartMode::Stacked {
294                options
295                    .y_domain
296                    .or_else(|| stacked_domain(&series))
297                    .map(|domain| normalized_domain_with_baseline(Some(domain), &[], true))
298                    .unwrap_or_else(|| normalized_domain_with_baseline(None, &series, true))
299            } else {
300                normalized_domain_with_baseline(options.y_domain, &series, true)
301            };
302            let y = ScaleLinear::new(domain, (plot_height.as_f32(), 0.0));
303            if options.show_grid || options.show_axis {
304                paint_chart_frame(
305                    left,
306                    top,
307                    width,
308                    plot_height,
309                    &axis_labels,
310                    &x,
311                    &y,
312                    &palette,
313                    &options,
314                    window,
315                    cx,
316                );
317            }
318
319            match mode {
320                AreaChartMode::Overlay => paint_overlay_areas(
321                    left,
322                    top,
323                    plot_height,
324                    &series,
325                    &x,
326                    &y,
327                    &palette,
328                    &options,
329                    line_stroke,
330                    smooth,
331                    stroke_width,
332                    window,
333                    cx,
334                ),
335                AreaChartMode::Stacked => paint_stacked_areas(
336                    left,
337                    top,
338                    &series,
339                    &x,
340                    &y,
341                    &palette,
342                    &options,
343                    line_stroke,
344                    smooth,
345                    stroke_width,
346                    window,
347                    cx,
348                ),
349            }
350        },
351    )
352    .w_full()
353    .h(height);
354
355    div()
356        .relative()
357        .w_full()
358        .h(height)
359        .on_mouse_move(move |event, _, cx| {
360            if !tooltip_options.show_tooltip || tooltip_mode != AreaChartMode::Overlay {
361                clear_tooltip(&move_id, cx);
362                return;
363            }
364            let bounds = tooltip_bounds.get();
365            if bounds.size.width <= px(0.0) || bounds.size.height <= px(0.0) {
366                clear_tooltip(&move_id, cx);
367                return;
368            }
369            let padding = tooltip_options.padding;
370            let plot_width =
371                (bounds.size.width.as_f32() - padding.left.as_f32() - padding.right.as_f32())
372                    .max(1.0);
373            let plot_height =
374                (bounds.size.height.as_f32() - padding.top.as_f32() - padding.bottom.as_f32())
375                    .max(1.0);
376            let local_x = (event.position.x - bounds.left() - padding.left).as_f32();
377            let local_y = (event.position.y - bounds.top() - padding.top).as_f32();
378            let domain =
379                normalized_domain_with_baseline(tooltip_options.y_domain, &tooltip_series, true);
380            let Some(hit) = nearest_cartesian_hit_point(
381                &tooltip_series,
382                domain,
383                plot_width,
384                plot_height,
385                local_x,
386                local_y,
387                tooltip_options.tooltip_hit_radius.as_f32(),
388            ) else {
389                clear_tooltip(&move_id, cx);
390                return;
391            };
392            set_active_tooltip(
393                TooltipData {
394                    id: move_id.clone(),
395                    content: format_hit_tooltip(&hit, tooltip_options.y_format),
396                    anchor_bounds: Bounds::new(
397                        point(event.position.x - px(1.0), event.position.y - px(1.0)),
398                        size(px(2.0), px(2.0)),
399                    ),
400                    placement: Placement::Top,
401                    offset: px(8.0),
402                },
403                cx,
404            );
405        })
406        .child(ChartBoundsTracker::new(chart, bounds_cell))
407}
408
409fn sampled_point_indices(
410    labels_len: usize,
411    series: &[ChartSeries],
412    max_points: Option<usize>,
413) -> Vec<usize> {
414    downsample_index_range(
415        labels_len,
416        |index| {
417            series
418                .iter()
419                .filter_map(|series| series.points.get(index))
420                .filter(|point| point.is_finite())
421                .map(|point| point.value)
422                .sum::<f64>()
423        },
424        max_points,
425    )
426    .into_iter()
427    .map(|(index, _)| index)
428    .collect()
429}
430
431#[allow(clippy::too_many_arguments)]
432fn paint_overlay_areas(
433    left: Pixels,
434    top: Pixels,
435    plot_height: Pixels,
436    series: &[ChartSeries],
437    x: &ScalePoint,
438    y: &ScaleLinear,
439    palette: &ChartPalette,
440    options: &ChartOptions,
441    line_stroke: bool,
442    smooth: bool,
443    stroke_width: Pixels,
444    window: &mut Window,
445    cx: &mut App,
446) {
447    let baseline = y.tick(0.0).clamp(0.0, plot_height.as_f32());
448    for (series_index, current) in series.iter().enumerate() {
449        let fallback = palette.series_color(series_index);
450        let color = current.resolved_stroke_color(fallback);
451        let fill_color = current.resolved_fill_color(fallback);
452        let current_smooth = current.smooth.unwrap_or(smooth);
453        let current_stroke_width = current.stroke_width.unwrap_or(stroke_width);
454        let sampled_values = downsample_indexed_values(
455            &current.points,
456            |chart_point| chart_point.value,
457            options.max_render_points,
458        );
459        let point_data = sampled_values
460            .into_iter()
461            .filter_map(|(index, value)| {
462                let x_pos = x.tick_index(index)?;
463                Some((
464                    gpui::point(left + px(x_pos), top + px(y.tick(value))),
465                    value,
466                ))
467            })
468            .collect::<Vec<_>>();
469        let points = point_data
470            .iter()
471            .map(|(position, _)| *position)
472            .collect::<Vec<_>>();
473        let area = if current_smooth {
474            smooth_area_path(&points, top + px(baseline))
475        } else {
476            area_path(&points, top + px(baseline))
477        };
478        if let Some(path) = area {
479            window.paint_path(path, fill_color.opacity(0.26));
480        }
481        if line_stroke {
482            if let Some(path) = line_soft_edge_path(&points, current_stroke_width, current_smooth) {
483                window.paint_path(path, color.opacity(0.20));
484            }
485            let line = if current_smooth {
486                smooth_line_path(&points, current_stroke_width)
487            } else {
488                line_path(&points, current_stroke_width)
489            };
490            if let Some(path) = line {
491                window.paint_path(path, color);
492            }
493        }
494        if options.show_value_labels {
495            let value_label_indices = sparse_indices(point_data.len(), options.max_value_labels);
496            for (position, value) in value_label_indices
497                .into_iter()
498                .filter_map(|index| point_data.get(index))
499            {
500                paint_chart_label_aligned(
501                    format_value_label(
502                        *value,
503                        series_total(current),
504                        options.y_format,
505                        &options.value_label_options,
506                    ),
507                    gpui::point(position.x - px(18.0), position.y - px(20.0)),
508                    palette.label,
509                    gpui::TextAlign::Center,
510                    Some(px(36.0)),
511                    window,
512                    cx,
513                );
514            }
515        }
516    }
517}
518
519#[allow(clippy::too_many_arguments)]
520fn paint_stacked_areas(
521    left: Pixels,
522    top: Pixels,
523    series: &[ChartSeries],
524    x: &ScalePoint,
525    y: &ScaleLinear,
526    palette: &ChartPalette,
527    options: &ChartOptions,
528    line_stroke: bool,
529    _smooth: bool,
530    stroke_width: Pixels,
531    window: &mut Window,
532    cx: &mut App,
533) {
534    let labels_len = series
535        .iter()
536        .map(|series| series.points.len())
537        .max()
538        .unwrap_or(0);
539    let sampled_indices = sampled_point_indices(labels_len, series, options.max_render_points);
540    let mut previous = vec![0.0_f64; labels_len];
541    for (series_index, current) in series.iter().enumerate() {
542        let fallback = palette.series_color(series_index);
543        let color = current.resolved_stroke_color(fallback);
544        let fill_color = current.resolved_fill_color(fallback);
545        let current_stroke_width = current.stroke_width.unwrap_or(stroke_width);
546        let mut lower = Vec::new();
547        let mut upper = Vec::new();
548        for &point_index in &sampled_indices {
549            let value = current
550                .points
551                .get(point_index)
552                .filter(|point| point.is_finite())
553                .map(|point| point.value)
554                .unwrap_or(0.0);
555            let from = previous[point_index];
556            let to = from + value;
557            previous[point_index] = to;
558            if let Some(x_pos) = x.tick_index(point_index) {
559                lower.push((left.as_f32() + x_pos, top.as_f32() + y.tick(from)));
560                upper.push((left.as_f32() + x_pos, top.as_f32() + y.tick(to)));
561            }
562        }
563        let lower = finite_line_points(lower);
564        let upper = finite_line_points(upper);
565        if let Some(path) = stacked_area_path(&lower, &upper) {
566            window.paint_path(path, fill_color.opacity(0.32));
567        }
568        if line_stroke {
569            if let Some(path) = line_soft_edge_path(&upper, current_stroke_width, false) {
570                window.paint_path(path, color.opacity(0.20));
571            }
572            if let Some(path) = line_path(&upper, current_stroke_width) {
573                window.paint_path(path, color);
574            }
575        }
576        if options.show_value_labels {
577            let value_label_indices = sparse_indices(upper.len(), options.max_value_labels);
578            for sample_index in value_label_indices {
579                let Some(position) = upper.get(sample_index) else {
580                    continue;
581                };
582                let Some(&point_index) = sampled_indices.get(sample_index) else {
583                    continue;
584                };
585                let value = current
586                    .points
587                    .get(point_index)
588                    .filter(|point| point.is_finite())
589                    .map(|point| point.value)
590                    .unwrap_or(0.0);
591                paint_chart_label_aligned(
592                    format_value_label(
593                        value,
594                        series_total(current),
595                        options.y_format,
596                        &options.value_label_options,
597                    ),
598                    gpui::point(position.x - px(18.0), position.y - px(20.0)),
599                    palette.label,
600                    gpui::TextAlign::Center,
601                    Some(px(36.0)),
602                    window,
603                    cx,
604                );
605            }
606        }
607    }
608}
609
610fn stacked_area_path(
611    lower: &[gpui::Point<Pixels>],
612    upper: &[gpui::Point<Pixels>],
613) -> Option<gpui::Path<Pixels>> {
614    let first = *upper.first()?;
615    if lower.is_empty() || upper.len() != lower.len() {
616        return None;
617    }
618    let mut builder = gpui::PathBuilder::fill();
619    builder.move_to(first);
620    for point in upper.iter().skip(1) {
621        builder.line_to(*point);
622    }
623    for point in lower.iter().rev() {
624        builder.line_to(*point);
625    }
626    builder.close();
627    builder.build().ok()
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crate::chart::ChartPoint;
634
635    fn sample_series() -> Vec<ChartSeries> {
636        vec![ChartSeries::new(
637            "Visitors",
638            [ChartPoint::new("Mon", 120.0), ChartPoint::new("Tue", 180.0)],
639        )]
640    }
641
642    #[test]
643    fn area_chart_builder_tracks_options_and_mode() {
644        let chart = AreaChart::new(sample_series())
645            .id("traffic-area")
646            .height(px(320.0))
647            .show_grid(false)
648            .show_axis(false)
649            .show_legend(false)
650            .y_domain(0.0, 500.0)
651            .line_stroke(false)
652            .show_value_labels(false)
653            .show_tooltip(false)
654            .tooltip_hit_radius(px(20.0))
655            .value_label_content(ChartValueLabelContent::Percentage)
656            .value_label_placement(ChartValueLabelPlacement::OutsideFree)
657            .percentage_decimals(2)
658            .smooth(true)
659            .stroke_width(px(3.0))
660            .max_render_points(600)
661            .max_axis_labels(6)
662            .max_value_labels(10)
663            .stacked();
664
665        assert_eq!(chart.options().id, SharedString::from("traffic-area"));
666        assert_eq!(chart.options().height, px(320.0));
667        assert!(!chart.options().show_grid);
668        assert!(!chart.options().show_axis);
669        assert!(!chart.options().show_legend);
670        assert_eq!(chart.options().y_domain, Some((0.0, 500.0)));
671        assert!(!chart.options().show_value_labels);
672        assert!(!chart.options().show_tooltip);
673        assert_eq!(chart.options().tooltip_hit_radius, px(20.0));
674        assert_eq!(
675            chart.options().value_label_options.content,
676            ChartValueLabelContent::Percentage
677        );
678        assert_eq!(
679            chart.options().value_label_options.placement,
680            ChartValueLabelPlacement::OutsideFree
681        );
682        assert_eq!(chart.options().value_label_options.percentage_decimals, 2);
683        assert_eq!(chart.area_mode(), AreaChartMode::Stacked);
684        assert!(!chart.line_stroke);
685        assert!(chart.smooth);
686        assert_eq!(chart.stroke_width, px(3.0));
687        assert_eq!(chart.options().max_render_points, Some(600));
688        assert_eq!(chart.options().max_axis_labels, 6);
689        assert_eq!(chart.options().max_value_labels, 10);
690    }
691
692    #[test]
693    fn area_chart_keeps_series_data() {
694        let chart = AreaChart::new(sample_series());
695        assert_eq!(chart.series().len(), 1);
696        assert_eq!(chart.series()[0].name, SharedString::from("Visitors"));
697    }
698
699    #[test]
700    fn stacked_area_samples_indices_from_total_series_shape() {
701        let series = [
702            ChartSeries::new(
703                "a",
704                (0..1_000).map(|index| ChartPoint::new(format!("T{index}"), index as f64)),
705            ),
706            ChartSeries::new(
707                "b",
708                (0..1_000).map(|index| {
709                    let value = if index == 500 { 10_000.0 } else { 1.0 };
710                    ChartPoint::new(format!("T{index}"), value)
711                }),
712            ),
713        ];
714        let indices = sampled_point_indices(1_000, &series, Some(80));
715
716        assert!(indices.len() <= 80);
717        assert_eq!(indices.first(), Some(&0));
718        assert_eq!(indices.last(), Some(&999));
719        assert!(indices.contains(&500));
720    }
721}