1use std::rc::Rc;
2
3use gpui_kit::{
4 AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, Size,
5 Window, point, px,
6};
7
8use crate::{
9 ActiveTheme,
10 plot::{
11 AxisLabelPlacement, Curve, PathCaches, Plot, PlotAxis,
12 scale::{PlotValue, Scale, ScaleLinear, ScalePoint},
13 shape::Line,
14 tooltip::{CrossLine, Dot, Tooltip, TooltipState},
15 },
16};
17
18use super::{
19 AXIS_GAP, HOVER_DOT_SIZE, HOVER_HALO_SIZE, PointAxes, TooltipContent, ValueExtent,
20 axis_point_count, build_point_x_labels, caller_id, labeled_items, pinned_plot_mask,
21 point_range, point_value_scale,
22};
23
24pub struct LineChart<T, X, Y>
25where
26 T: 'static,
27 X: PartialEq + Into<SharedString> + 'static,
28 Y: PlotValue,
29{
30 data: Vec<T>,
31 x: Option<Rc<dyn Fn(&T) -> X>>,
32 y: Option<Rc<dyn Fn(&T) -> Y>>,
33 stroke: Option<Hsla>,
34 curve: Curve,
35 dot: bool,
36 tick_margin: usize,
37 x_axis: bool,
38 grid: bool,
39 y_domain: Option<(Y, Y)>,
40 point_count: Option<usize>,
41 axes: PointAxes,
42 id: ElementId,
43 interactive: bool,
44 name: Option<SharedString>,
45 tooltip_content: TooltipContent<T>,
46}
47
48impl<T, X, Y> LineChart<T, X, Y>
49where
50 X: PartialEq + Into<SharedString> + 'static,
51 Y: PlotValue,
52{
53 #[track_caller]
54 pub fn new<I>(data: I) -> Self
55 where
56 I: IntoIterator<Item = T>,
57 {
58 Self {
59 data: data.into_iter().collect(),
60 stroke: None,
61 curve: Default::default(),
62 dot: false,
63 x: None,
64 y: None,
65 tick_margin: 1,
66 x_axis: true,
67 grid: true,
68 y_domain: None,
69 point_count: None,
70 axes: PointAxes::default(),
71 id: caller_id(),
72 interactive: true,
73 name: None,
74 tooltip_content: TooltipContent::default(),
75 }
76 }
77
78 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
85 self.id = id.into();
86 self
87 }
88
89 pub fn interactive(mut self, interactive: bool) -> Self {
98 self.interactive = interactive;
99 self
100 }
101
102 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
104 self.name = Some(name.into());
105 self
106 }
107
108 pub fn tooltip_title(mut self, title: impl Fn(&T) -> SharedString + 'static) -> Self {
110 self.tooltip_content.set_title(title);
111 self
112 }
113
114 pub fn tooltip_value(mut self, value: impl Fn(&T, f64) -> SharedString + 'static) -> Self {
118 self.tooltip_content.set_value(move |d, _, v| value(d, v));
119 self
120 }
121
122 pub fn tooltip_value_color<H>(mut self, color: impl Fn(&T, f64) -> H + 'static) -> Self
128 where
129 H: Into<Hsla>,
130 {
131 self.tooltip_content
132 .set_value_color(move |d, _, value| color(d, value));
133 self
134 }
135
136 pub fn tooltip_content<E>(
143 mut self,
144 content: impl Fn(&T, &mut Window, &mut App) -> E + 'static,
145 ) -> Self
146 where
147 E: IntoElement,
148 {
149 self.tooltip_content.set_content(content);
150 self
151 }
152
153 pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self {
154 self.x = Some(Rc::new(x));
155 self
156 }
157
158 pub fn y(mut self, y: impl Fn(&T) -> Y + 'static) -> Self {
159 self.y = Some(Rc::new(y));
160 self
161 }
162
163 pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
164 self.stroke = Some(stroke.into());
165 self
166 }
167
168 pub fn natural(mut self) -> Self {
169 self.curve = Curve::Natural;
170 self
171 }
172
173 pub fn linear(mut self) -> Self {
174 self.curve = Curve::Linear;
175 self
176 }
177
178 pub fn step_after(mut self) -> Self {
179 self.curve = Curve::StepAfter;
180 self
181 }
182
183 pub fn dot(mut self) -> Self {
184 self.dot = true;
185 self
186 }
187
188 pub fn tick_margin(mut self, tick_margin: usize) -> Self {
189 self.tick_margin = tick_margin;
190 self
191 }
192
193 pub fn x_axis(mut self, x_axis: bool) -> Self {
197 self.x_axis = x_axis;
198 self
199 }
200
201 pub fn grid(mut self, grid: bool) -> Self {
202 self.grid = grid;
203 self
204 }
205
206 pub fn y_domain(mut self, min: Y, max: Y) -> Self {
214 self.y_domain = Some((min, max));
215 self
216 }
217
218 pub fn point_count(mut self, count: usize) -> Self {
227 self.point_count = Some(count);
228 self
229 }
230
231 pub fn y_axis(mut self, y_axis: bool) -> Self {
235 self.axes.y_axis = y_axis;
236 self
237 }
238
239 pub fn y_axis_label_placement(mut self, placement: AxisLabelPlacement) -> Self {
244 self.axes.y_axis_label_placement = placement;
245 self
246 }
247
248 pub fn y_tick_count(mut self, count: usize) -> Self {
257 self.axes.y_tick_count = count.max(2);
258 self
259 }
260
261 pub fn y_tick_format<S>(mut self, format: impl Fn(f64) -> S + 'static) -> Self
263 where
264 S: Into<SharedString> + 'static,
265 {
266 self.axes.y_tick_format = Some(Rc::new(move |value| format(value).into()));
267 self
268 }
269
270 pub fn x_tick_count(mut self, count: usize) -> Self {
277 self.axes.x_tick_count = Some(count);
278 self
279 }
280
281 pub fn grid_columns(mut self, count: usize) -> Self {
286 self.axes.grid_columns = count;
287 self
288 }
289
290 pub fn grid_dashed(mut self, dashed: bool) -> Self {
294 self.axes.grid_dashed = dashed;
295 self
296 }
297
298 pub fn reference_line(mut self, value: Y) -> Self {
302 if let Some(value) = value.to_f64() {
303 self.axes.reference_lines.push(value);
304 }
305 self
306 }
307
308 pub fn y_padding(mut self, top: f32, bottom: f32) -> Self {
313 self.axes.y_padding = (top, bottom);
314 self
315 }
316
317 fn scales(
322 &self,
323 bounds: Bounds<Pixels>,
324 ) -> Option<(ScalePoint<X>, ScaleLinear<Y>, ValueExtent)> {
325 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
326
327 let width = bounds.size.width.as_f32();
328 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
329 let height = bounds.size.height.as_f32() - axis_gap;
330
331 let len = self.data.len();
332 let x = ScalePoint::new(
333 self.data.iter().map(|v| x_fn(v)),
334 point_range(
335 self.axes.plot_left(),
336 width - self.axes.plot_left(),
337 len,
338 axis_point_count(self.point_count, len),
339 ),
340 );
341 let (y, extent) = point_value_scale(
342 self.data.iter().map(|v| y_fn(v)),
343 self.y_domain,
344 height,
345 self.axes.y_padding,
346 );
347
348 Some((x, y, extent))
349 }
350}
351
352crate::impl_into_plot!(impl<T, X, Y> LineChart<T, X, Y> where X: PartialEq + Into<SharedString> + 'static, Y: PlotValue);
353
354impl<T, X, Y> Plot for LineChart<T, X, Y>
355where
356 X: PartialEq + Into<SharedString> + 'static,
357 Y: PlotValue,
358{
359 fn prepaint(
360 &mut self,
361 bounds: Bounds<Pixels>,
362 window: &mut Window,
363 _cx: &mut App,
364 ) -> Vec<AnyElement> {
365 if let Some((_, _, extent)) = self.scales(bounds) {
367 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
368 let height = bounds.size.height.as_f32() - axis_gap;
369 self.axes.measure_y_labels(extent, height, window);
370 }
371 vec![]
372 }
373
374 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
375 let (Some(x_fn), Some(y_fn)) = (self.x.as_ref(), self.y.as_ref()) else {
376 return;
377 };
378 let Some((x, y, extent)) = self.scales(bounds) else {
379 return;
380 };
381
382 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
383 let height = bounds.size.height.as_f32() - axis_gap;
384
385 let left = self.axes.plot_left();
389 let axis_bounds = Bounds {
390 origin: bounds.origin + point(px(left), px(0.)),
391 size: Size::new(bounds.size.width - px(left), bounds.size.height),
392 };
393 let mut axis = PlotAxis::new().stroke(cx.macos().border);
394 if self.x_axis {
395 let labeled = labeled_items(
396 axis_point_count(self.point_count, self.data.len()),
397 self.axes.x_tick_count,
398 self.tick_margin,
399 );
400 let labels = build_point_x_labels(
401 &self.data,
402 x_fn.as_ref(),
403 &x,
404 axis_point_count(self.point_count, self.data.len()),
405 &labeled,
406 cx.macos().secondary,
407 )
408 .into_iter()
409 .map(|mut label| {
410 label.tick -= px(left);
411 label
412 });
413 axis = axis.x(height).x_label(labels);
414 }
415 axis.paint(&axis_bounds, window, cx);
416
417 if self.grid {
418 self.axes.paint_grid(bounds, height, window, cx);
419 }
420
421 let stroke = self.stroke.unwrap_or(cx.macos().chart[0]);
423 let x_fn = x_fn.clone();
424 let y_fn = y_fn.clone();
425 let mut line = Line::new()
426 .data(&self.data)
427 .x(move |d| x.tick(&x_fn(d)))
428 .y(move |d| y.tick(&y_fn(d)))
429 .stroke(stroke)
430 .curve(self.curve)
431 .stroke_width(2.);
432
433 if self.dot {
434 line = line.dot().dot_size(8.).dot_fill(stroke);
435 }
436
437 let mask = self
438 .y_domain
439 .is_some()
440 .then(|| pinned_plot_mask(bounds, height));
441 window.with_content_mask(mask, |window| {
442 if self.interactive {
446 let caches = PathCaches::for_paint("line", window, cx);
447 caches.update(cx, |caches, _| {
448 line.paint_cached(&bounds, caches.slot(0), window);
449 });
450 } else {
451 line.paint(&bounds, window);
452 }
453 });
454
455 self.axes
456 .paint_reference_lines(extent, bounds, height, window, cx);
457 self.axes.paint_y_labels(extent, bounds, height, window, cx);
458 }
459
460 fn id(&self) -> Option<ElementId> {
461 self.interactive.then(|| self.id.clone())
462 }
463
464 fn tooltip_state(
465 &self,
466 position: Point<Pixels>,
467 bounds: Bounds<Pixels>,
468 _cx: &App,
469 ) -> Option<TooltipState> {
470 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
471 let (x, y, _) = self.scales(bounds)?;
472
473 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
475 if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap
476 || position.x.as_f32() < self.axes.plot_left()
477 {
478 return None;
479 }
480
481 let index = x.nearest_index(position.x.as_f32());
482 let d = self.data.get(index)?;
483 let x_tick = x.tick(&x_fn(d))?;
484 let y_tick = y.tick(&y_fn(d))?;
485
486 Some(TooltipState::new(
487 index,
488 point(px(x_tick), position.y),
489 vec![point(px(x_tick), px(y_tick))],
490 ))
491 }
492
493 fn tooltip(
494 &self,
495 state: &TooltipState,
496 cursor: Point<Pixels>,
497 bounds: Bounds<Pixels>,
498 window: &mut Window,
499 cx: &mut App,
500 ) -> Option<AnyElement> {
501 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
502 let d = self.data.get(state.index)?;
503 let stroke = self.stroke.unwrap_or(cx.macos().chart[0]);
504 let name = self.name.clone().unwrap_or_default();
505 let dot = *state.dots.first()?;
506
507 let tooltip = Tooltip::new(cursor, bounds.size)
509 .gap(px(8.))
510 .cross_line(
512 CrossLine::new(state.cross_line)
513 .height(bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }),
514 )
515 .dots(Some(
516 Dot::new(dot)
517 .size(HOVER_DOT_SIZE)
518 .halo(HOVER_HALO_SIZE)
519 .stroke(cx.macos().background)
520 .fill(stroke),
521 ));
522
523 let tooltip = self.tooltip_content.apply(
524 tooltip,
525 d,
526 || Some(x_fn(d).into()),
527 || Some([(stroke, name, y_fn(d).to_f64()?)]),
528 window,
529 cx,
530 )?;
531
532 Some(tooltip.into_any_element())
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use gpui_kit::{Bounds, point, px, size};
539
540 use super::LineChart;
541 use crate::plot::scale::Scale;
542
543 #[test]
544 fn test_f32_values_scale_like_f64() {
545 let bounds = Bounds::new(point(px(0.), px(0.)), size(px(100.), px(50.)));
546 let chart: LineChart<(usize, f32), String, f32> =
547 LineChart::new([2f32, 4.].into_iter().enumerate())
548 .x(|(i, _)| i.to_string())
549 .y(|(_, v)| *v)
550 .x_axis(false);
551 let (_, y, _) = chart.scales(bounds).unwrap();
552 let y64 = LineChart::new([2f64, 4.].into_iter().enumerate())
553 .x(|(i, _): &(usize, f64)| i.to_string())
554 .y(|(_, v)| *v)
555 .x_axis(false)
556 .scales(bounds)
557 .unwrap()
558 .1;
559 assert_eq!(y.tick(&4.), y64.tick(&4.));
560 assert_eq!(y.tick(&0.), y64.tick(&0.));
561 }
562}