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