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 self.axes.y_axis
367 && self.axes.y_axis_label_placement == AxisLabelPlacement::Outside
368 && let Some((_, _, extent)) = self.scales(bounds)
369 {
370 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
371 let height = bounds.size.height.as_f32() - axis_gap;
372 self.axes.measure_y_labels(extent, height, window);
373 }
374 vec![]
375 }
376
377 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
378 let (Some(x_fn), Some(y_fn)) = (self.x.as_ref(), self.y.as_ref()) else {
379 return;
380 };
381 let Some((x, y, extent)) = self.scales(bounds) else {
382 return;
383 };
384
385 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
386 let height = bounds.size.height.as_f32() - axis_gap;
387
388 let left = self.axes.plot_left();
392 let axis_bounds = Bounds {
393 origin: bounds.origin + point(px(left), px(0.)),
394 size: Size::new(bounds.size.width - px(left), bounds.size.height),
395 };
396 let mut axis = PlotAxis::new().stroke(cx.macos().border);
397 if self.x_axis {
398 let labeled = labeled_items(
399 axis_point_count(self.point_count, self.data.len()),
400 self.axes.x_tick_count,
401 self.tick_margin,
402 );
403 let labels = build_point_x_labels(
404 &self.data,
405 x_fn.as_ref(),
406 &x,
407 axis_point_count(self.point_count, self.data.len()),
408 &labeled,
409 cx.macos().secondary,
410 )
411 .into_iter()
412 .map(|mut label| {
413 label.tick -= px(left);
414 label
415 });
416 axis = axis.x(height).x_label(labels);
417 }
418 axis.paint(&axis_bounds, window, cx);
419
420 if self.grid {
421 self.axes.paint_grid(bounds, height, window, cx);
422 }
423
424 let stroke = self.stroke.unwrap_or(cx.macos().chart[0]);
426 let y_fn = y_fn.clone();
427 let mut line = Line::new()
428 .data(self.data.iter().enumerate())
429 .x(move |(index, _)| x.tick_at(*index))
430 .y(move |(_, d)| y.tick(&y_fn(d)))
431 .stroke(stroke)
432 .curve(self.curve)
433 .stroke_width(2.);
434
435 if self.dot {
436 line = line.dot().dot_size(8.).dot_fill(stroke);
437 }
438
439 let mask = self
440 .y_domain
441 .is_some()
442 .then(|| pinned_plot_mask(bounds, height));
443 window.with_content_mask(mask, |window| {
444 if self.interactive {
448 let caches = PathCaches::for_paint("line", window, cx);
449 caches.update(cx, |caches, _| {
450 line.paint_cached(&bounds, caches.slot(0), window);
451 });
452 } else {
453 line.paint(&bounds, window);
454 }
455 });
456
457 self.axes
458 .paint_reference_lines(extent, bounds, height, window, cx);
459 self.axes.paint_y_labels(extent, bounds, height, window, cx);
460 }
461
462 fn id(&self) -> Option<ElementId> {
463 self.interactive.then(|| self.id.clone())
464 }
465
466 fn tooltip_state(
467 &self,
468 position: Point<Pixels>,
469 bounds: Bounds<Pixels>,
470 _cx: &App,
471 ) -> Option<TooltipState> {
472 let y_fn = self.y.as_ref()?;
473 let (x, y, _) = self.scales(bounds)?;
474
475 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
477 if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap
478 || position.x.as_f32() < self.axes.plot_left()
479 {
480 return None;
481 }
482
483 let index = x.nearest_index(position.x.as_f32());
484 let d = self.data.get(index)?;
485 let x_tick = x.tick_at(index)?;
486 let y_tick = y.tick(&y_fn(d))?;
487
488 Some(TooltipState::new(
489 index,
490 point(px(x_tick), position.y),
491 vec![point(px(x_tick), px(y_tick))],
492 ))
493 }
494
495 fn tooltip(
496 &self,
497 state: &TooltipState,
498 cursor: Point<Pixels>,
499 bounds: Bounds<Pixels>,
500 window: &mut Window,
501 cx: &mut App,
502 ) -> Option<AnyElement> {
503 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
504 let d = self.data.get(state.index)?;
505 let stroke = self.stroke.unwrap_or(cx.macos().chart[0]);
506 let name = self.name.clone().unwrap_or_default();
507 let dot = *state.dots.first()?;
508
509 let tooltip = Tooltip::new(cursor, bounds.size)
511 .gap(px(8.))
512 .cross_line(
514 CrossLine::new(state.cross_line)
515 .height(bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }),
516 )
517 .dots(Some(
518 Dot::new(dot)
519 .size(HOVER_DOT_SIZE)
520 .halo(HOVER_HALO_SIZE)
521 .stroke(cx.macos().background)
522 .fill(stroke),
523 ));
524
525 let tooltip = self.tooltip_content.apply(
526 tooltip,
527 d,
528 || Some(x_fn(d).into()),
529 || Some([(stroke, name, y_fn(d).to_f64()?)]),
530 window,
531 cx,
532 )?;
533
534 Some(tooltip.into_any_element())
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use gpui_kit::{Bounds, point, px, size};
541
542 use super::LineChart;
543 use crate::plot::scale::Scale;
544
545 #[test]
546 fn test_f32_values_scale_like_f64() {
547 let bounds = Bounds::new(point(px(0.), px(0.)), size(px(100.), px(50.)));
548 let chart: LineChart<(usize, f32), String, f32> =
549 LineChart::new([2f32, 4.].into_iter().enumerate())
550 .x(|(i, _)| i.to_string())
551 .y(|(_, v)| *v)
552 .x_axis(false);
553 let (_, y, _) = chart.scales(bounds).unwrap();
554 let y64 = LineChart::new([2f64, 4.].into_iter().enumerate())
555 .x(|(i, _): &(usize, f64)| i.to_string())
556 .y(|(_, v)| *v)
557 .x_axis(false)
558 .scales(bounds)
559 .unwrap()
560 .1;
561 assert_eq!(y.tick(&4.), y64.tick(&4.));
562 assert_eq!(y.tick(&0.), y64.tick(&0.));
563 }
564}