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 ¤t.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}