Skip to main content

liora_components/
line_chart.rs

1use crate::chart::{
2    ChartBoundsTracker, ChartOptions, ChartPalette, ChartSeries, ChartValueLabelContent,
3    ChartValueLabelPlacement, collect_axis_labels, downsample_indexed_values, format_hit_tooltip,
4    format_value_label, has_chart_data, label_domain_len, nearest_cartesian_hit_point,
5    normalized_domain, series_total, sparse_indices,
6};
7use crate::chart_frame::{paint_chart_frame, paint_chart_label_aligned};
8use crate::chart_scale::{ScaleLinear, ScalePoint};
9use crate::chart_shape::{
10    area_path, line_path_with_style, line_soft_edge_path_with_style, smooth_area_path,
11    smooth_line_path_with_style,
12};
13use crate::gpui_compat::PixelsExt;
14use crate::{Empty, Space, Text};
15use gpui::{
16    App, Background, Bounds, Component, ElementId, Hsla, InteractiveElement, IntoElement,
17    ParentElement, Pixels, RenderOnce, SharedString, Styled, Window, canvas, div, fill, point, px,
18    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)]
25pub struct LineChart {
26    series: Vec<ChartSeries>,
27    options: ChartOptions,
28    point_markers: bool,
29    smooth: bool,
30    area_fill: bool,
31    stroke_width: Pixels,
32}
33
34impl LineChart {
35    pub fn new(series: impl IntoIterator<Item = ChartSeries>) -> Self {
36        Self {
37            series: series.into_iter().collect(),
38            options: ChartOptions {
39                id: unique_id("line-chart"),
40                ..ChartOptions::default()
41            },
42            point_markers: true,
43            smooth: true,
44            area_fill: true,
45            stroke_width: px(2.4),
46        }
47    }
48
49    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
50        self.options.id = id.into();
51        self
52    }
53
54    pub fn height(mut self, height: impl Into<Pixels>) -> Self {
55        self.options.height = height.into();
56        self
57    }
58
59    pub fn show_grid(mut self, show: bool) -> Self {
60        self.options.show_grid = show;
61        self
62    }
63
64    pub fn show_axis(mut self, show: bool) -> Self {
65        self.options.show_axis = show;
66        self
67    }
68
69    pub fn show_legend(mut self, show: bool) -> Self {
70        self.options.show_legend = show;
71        self
72    }
73
74    pub fn y_domain(mut self, min: f64, max: f64) -> Self {
75        self.options.y_domain = Some((min, max));
76        self
77    }
78
79    pub fn y_format(mut self, formatter: fn(f64) -> SharedString) -> Self {
80        self.options.y_format = Some(formatter);
81        self
82    }
83
84    pub fn point_markers(mut self, enabled: bool) -> Self {
85        self.point_markers = enabled;
86        self
87    }
88
89    pub fn smooth(mut self, enabled: bool) -> Self {
90        self.smooth = enabled;
91        self
92    }
93
94    pub fn area_fill(mut self, enabled: bool) -> Self {
95        self.area_fill = enabled;
96        self
97    }
98
99    pub fn show_value_labels(mut self, show: bool) -> Self {
100        self.options.show_value_labels = show;
101        self
102    }
103
104    pub fn show_tooltip(mut self, show: bool) -> Self {
105        self.options.show_tooltip = show;
106        self
107    }
108
109    pub fn tooltip_hit_radius(mut self, radius: impl Into<Pixels>) -> Self {
110        self.options.tooltip_hit_radius = radius.into().max(px(0.0));
111        self
112    }
113
114    pub fn value_label_content(mut self, content: ChartValueLabelContent) -> Self {
115        self.options.value_label_options.content = content;
116        self
117    }
118
119    pub fn value_label_placement(mut self, placement: ChartValueLabelPlacement) -> Self {
120        self.options.value_label_options.placement = placement;
121        self
122    }
123
124    pub fn percentage_decimals(mut self, decimals: usize) -> Self {
125        self.options.value_label_options.percentage_decimals = decimals.min(4);
126        self
127    }
128
129    pub fn stroke_width(mut self, width: impl Into<Pixels>) -> Self {
130        self.stroke_width = width.into();
131        self
132    }
133
134    pub fn max_render_points(mut self, max_points: usize) -> Self {
135        self.options.max_render_points = Some(max_points.max(3));
136        self
137    }
138
139    pub fn max_axis_labels(mut self, max_labels: usize) -> Self {
140        self.options.max_axis_labels = max_labels.max(2);
141        self
142    }
143
144    pub fn max_value_labels(mut self, max_labels: usize) -> Self {
145        self.options.max_value_labels = max_labels.max(2);
146        self
147    }
148
149    pub fn disable_downsampling(mut self) -> Self {
150        self.options.max_render_points = None;
151        self
152    }
153
154    pub fn series(&self) -> &[ChartSeries] {
155        &self.series
156    }
157
158    pub fn options(&self) -> &ChartOptions {
159        &self.options
160    }
161}
162
163impl IntoElement for LineChart {
164    type Element = Component<Self>;
165
166    fn into_element(self) -> Self::Element {
167        Component::new(self)
168    }
169}
170
171impl RenderOnce for LineChart {
172    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
173        let theme = cx.global::<Config>().theme.clone();
174        let palette = ChartPalette::from_config(cx.global::<Config>());
175        let has_data = has_chart_data(&self.series);
176        let height = self.options.height;
177        let id = self.options.id.clone();
178
179        let mut shell = div()
180            .id(ElementId::from(id.clone()))
181            .flex()
182            .flex_col()
183            .gap_2()
184            .w_full()
185            .p_3()
186            .rounded_md()
187            .border_1()
188            .border_color(theme.neutral.border)
189            .bg(theme.neutral.card);
190
191        if !has_data {
192            return shell
193                .h(height)
194                .items_center()
195                .justify_center()
196                .child(Empty::new().description("暂无图表数据"))
197                .into_any_element();
198        }
199
200        if self.options.show_legend {
201            shell = shell.child(render_legend(&self.series, &palette));
202        }
203
204        shell
205            .child(render_line_canvas(
206                self.series,
207                self.options,
208                palette,
209                self.point_markers,
210                self.smooth,
211                self.area_fill,
212                self.stroke_width,
213            ))
214            .into_any_element()
215    }
216}
217
218fn gradient_for_series(color: Hsla) -> gpui::Background {
219    // GPUI uses CSS-like linear gradient angles. 180deg keeps the strongest
220    // color on the curve edge and fades vertically toward the chart baseline.
221    gpui::linear_gradient(
222        180.0,
223        gpui::linear_color_stop(color.opacity(0.28), 0.0),
224        gpui::linear_color_stop(color.opacity(0.0), 1.0),
225    )
226}
227
228fn render_legend(series: &[ChartSeries], palette: &ChartPalette) -> impl IntoElement {
229    Space::new()
230        .wrap()
231        .gap_md()
232        .children(series.iter().enumerate().map(|(index, series)| {
233            let color = series.color.unwrap_or_else(|| palette.series_color(index));
234            Space::new()
235                .gap_xs()
236                .align_center()
237                .child(div().w(px(10.0)).h(px(10.0)).rounded_full().bg(color))
238                .child(Text::new(series.name.clone()).size(px(12.0)))
239        }))
240}
241
242fn render_line_canvas(
243    series: Vec<ChartSeries>,
244    options: ChartOptions,
245    palette: ChartPalette,
246    point_markers: bool,
247    smooth: bool,
248    area_fill: bool,
249    stroke_width: Pixels,
250) -> impl IntoElement {
251    let height = options.height;
252    let bounds_cell: Rc<Cell<Bounds<Pixels>>> = Rc::new(Cell::new(Bounds::default()));
253    let tooltip_bounds = bounds_cell.clone();
254    let tooltip_series = series.clone();
255    let tooltip_options = options.clone();
256    let tooltip_id: SharedString = format!("{}-tooltip", options.id).into();
257    let move_id = tooltip_id.clone();
258    let chart = canvas(
259        |_, _, _| (),
260        move |bounds, _, window, cx| {
261            let domain_len = label_domain_len(&series);
262            if domain_len == 0 {
263                return;
264            }
265            let axis_labels = collect_axis_labels(&series, options.max_axis_labels);
266
267            let padding = options.padding;
268            let left = bounds.left() + padding.left;
269            let right = bounds.right() - padding.right;
270            let top = bounds.top() + padding.top;
271            let bottom = bounds.bottom() - padding.bottom;
272            let width = (right - left).max(px(1.0));
273            let plot_height = (bottom - top).max(px(1.0));
274
275            let x = ScalePoint::from_len(domain_len, (0.0, width.as_f32()));
276            let domain = normalized_domain(options.y_domain, &series);
277            let y = ScaleLinear::new(domain, (plot_height.as_f32(), 0.0));
278            if options.show_grid || options.show_axis {
279                paint_chart_frame(
280                    left,
281                    top,
282                    width,
283                    plot_height,
284                    &axis_labels,
285                    &x,
286                    &y,
287                    &palette,
288                    &options,
289                    window,
290                    cx,
291                );
292            }
293
294            for (series_index, current) in series.iter().enumerate() {
295                let fallback = palette.series_color(series_index);
296                let color = current.resolved_stroke_color(fallback);
297                let fill_color = current.resolved_fill_color(fallback);
298                let current_smooth = current.smooth.unwrap_or(smooth);
299                let current_stroke_width = current.stroke_width.unwrap_or(stroke_width);
300                let current_line_style = current
301                    .line_style
302                    .unwrap_or(crate::chart::ChartLineStyle::Solid);
303                let current_dash_pattern = current.dash_pattern.as_deref();
304                let sampled_values = downsample_indexed_values(
305                    &current.points,
306                    |chart_point| chart_point.value,
307                    options.max_render_points,
308                );
309                let point_data = sampled_values
310                    .into_iter()
311                    .filter_map(|(index, value)| {
312                        let x_pos = x.tick_index(index)?;
313                        let position = point(
314                            left + px(x_pos),
315                            top + px(y.tick(value).clamp(0.0, plot_height.as_f32())),
316                        );
317                        Some((position, value))
318                    })
319                    .collect::<Vec<_>>();
320                let points = point_data
321                    .iter()
322                    .map(|(position, _)| *position)
323                    .collect::<Vec<_>>();
324                if area_fill {
325                    let baseline_y = top + px(plot_height.as_f32());
326                    let area = if current_smooth {
327                        smooth_area_path(&points, baseline_y)
328                    } else {
329                        area_path(&points, baseline_y)
330                    };
331                    if let Some(path) = area {
332                        let gradient = gradient_for_series(fill_color);
333                        window.paint_path(path, gradient);
334                    }
335                }
336                if let Some(path) = line_soft_edge_path_with_style(
337                    &points,
338                    current_stroke_width,
339                    current_smooth,
340                    current_line_style,
341                    current_dash_pattern,
342                ) {
343                    window.paint_path(path, color.opacity(0.20));
344                }
345                if let Some(path) = if current_smooth {
346                    smooth_line_path_with_style(
347                        &points,
348                        current_stroke_width,
349                        current_line_style,
350                        current_dash_pattern,
351                    )
352                } else {
353                    line_path_with_style(
354                        &points,
355                        current_stroke_width,
356                        current_line_style,
357                        current_dash_pattern,
358                    )
359                } {
360                    window.paint_path(path, color);
361                }
362                if point_markers {
363                    for (point_pos, _) in &point_data {
364                        window.paint_quad(fill(
365                            gpui::Bounds::new(
366                                point(point_pos.x - px(3.0), point_pos.y - px(3.0)),
367                                size(px(6.0), px(6.0)),
368                            ),
369                            Background::from(color),
370                        ));
371                    }
372                }
373                if options.show_value_labels {
374                    let value_label_indices =
375                        sparse_indices(point_data.len(), options.max_value_labels);
376                    for (point_pos, value) in value_label_indices
377                        .into_iter()
378                        .filter_map(|index| point_data.get(index))
379                    {
380                        paint_chart_label_aligned(
381                            format_value_label(
382                                *value,
383                                series_total(current),
384                                options.y_format,
385                                &options.value_label_options,
386                            ),
387                            point(point_pos.x - px(18.0), point_pos.y - px(20.0)),
388                            palette.label,
389                            gpui::TextAlign::Center,
390                            Some(px(36.0)),
391                            window,
392                            cx,
393                        );
394                    }
395                }
396            }
397        },
398    )
399    .w_full()
400    .h(height);
401
402    div()
403        .relative()
404        .w_full()
405        .h(height)
406        .on_mouse_move(move |event, _, cx| {
407            if !tooltip_options.show_tooltip {
408                clear_tooltip(&move_id, cx);
409                return;
410            }
411            let bounds = tooltip_bounds.get();
412            if bounds.size.width <= px(0.0) || bounds.size.height <= px(0.0) {
413                clear_tooltip(&move_id, cx);
414                return;
415            }
416            let padding = tooltip_options.padding;
417            let plot_width =
418                (bounds.size.width.as_f32() - padding.left.as_f32() - padding.right.as_f32())
419                    .max(1.0);
420            let plot_height =
421                (bounds.size.height.as_f32() - padding.top.as_f32() - padding.bottom.as_f32())
422                    .max(1.0);
423            let local_x = (event.position.x - bounds.left() - padding.left).as_f32();
424            let local_y = (event.position.y - bounds.top() - padding.top).as_f32();
425            let domain = normalized_domain(tooltip_options.y_domain, &tooltip_series);
426            let Some(hit) = nearest_cartesian_hit_point(
427                &tooltip_series,
428                domain,
429                plot_width,
430                plot_height,
431                local_x,
432                local_y,
433                tooltip_options.tooltip_hit_radius.as_f32(),
434            ) else {
435                clear_tooltip(&move_id, cx);
436                return;
437            };
438            let anchor = Bounds::new(
439                point(event.position.x - px(1.0), event.position.y - px(1.0)),
440                size(px(2.0), px(2.0)),
441            );
442            set_active_tooltip(
443                TooltipData {
444                    id: move_id.clone(),
445                    content: format_hit_tooltip(&hit, tooltip_options.y_format),
446                    anchor_bounds: anchor,
447                    placement: Placement::Top,
448                    offset: px(8.0),
449                },
450                cx,
451            );
452        })
453        .child(ChartBoundsTracker::new(chart, bounds_cell))
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    fn sample_series() -> Vec<ChartSeries> {
461        vec![ChartSeries::new(
462            "CPU",
463            [
464                ChartPoint::new("10:00", 20.0),
465                ChartPoint::new("10:05", 35.0),
466                ChartPoint::new("10:10", 28.0),
467            ],
468        )]
469    }
470
471    use crate::chart::ChartPoint;
472
473    #[test]
474    fn line_chart_builder_tracks_options() {
475        let chart = LineChart::new(sample_series())
476            .id("cpu-line")
477            .height(px(320.0))
478            .show_grid(false)
479            .show_axis(false)
480            .show_legend(false)
481            .y_domain(0.0, 100.0)
482            .point_markers(false)
483            .show_value_labels(false)
484            .show_tooltip(false)
485            .tooltip_hit_radius(px(18.0))
486            .value_label_content(ChartValueLabelContent::ValueAndPercentage)
487            .value_label_placement(ChartValueLabelPlacement::OutsideFree)
488            .percentage_decimals(2)
489            .stroke_width(px(3.0))
490            .max_render_points(1200)
491            .max_axis_labels(6)
492            .max_value_labels(10);
493
494        assert_eq!(chart.options().id, SharedString::from("cpu-line"));
495        assert_eq!(chart.options().height, px(320.0));
496        assert!(!chart.options().show_grid);
497        assert!(!chart.options().show_axis);
498        assert!(!chart.options().show_legend);
499        assert_eq!(chart.options().y_domain, Some((0.0, 100.0)));
500        assert!(!chart.point_markers);
501        assert!(!chart.options().show_value_labels);
502        assert!(!chart.options().show_tooltip);
503        assert_eq!(chart.options().tooltip_hit_radius, px(18.0));
504        assert_eq!(
505            chart.options().value_label_options.content,
506            ChartValueLabelContent::ValueAndPercentage
507        );
508        assert_eq!(
509            chart.options().value_label_options.placement,
510            ChartValueLabelPlacement::OutsideFree
511        );
512        assert_eq!(chart.options().value_label_options.percentage_decimals, 2);
513        assert_eq!(chart.stroke_width, px(3.0));
514        assert_eq!(chart.options().max_render_points, Some(1200));
515        assert_eq!(chart.options().max_axis_labels, 6);
516        assert_eq!(chart.options().max_value_labels, 10);
517    }
518
519    #[test]
520    fn line_chart_keeps_series_data() {
521        let chart = LineChart::new(sample_series());
522        assert_eq!(chart.series().len(), 1);
523        assert_eq!(chart.series()[0].name, SharedString::from("CPU"));
524        assert_eq!(chart.series()[0].points.len(), 3);
525    }
526}