1use std::rc::Rc;
2
3use gpui::{
4 AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, Window,
5 point, px,
6};
7use gpui_base::motion::spring;
8use gpui_component_macros::IntoPlot;
9use num_traits::{Num, ToPrimitive};
10
11use crate::{
12 ActiveTheme,
13 plot::{
14 AXIS_GAP, Grid, PathCaches, Plot, PlotAxis, StrokeStyle,
15 scale::{Scale, ScaleLinear, ScalePoint, Sealed},
16 shape::Line,
17 tooltip::{CrossLine, Dot, PlotHover, Tooltip, TooltipState},
18 },
19};
20
21use super::{HOVER_DOT_SIZE, build_point_x_labels, hover_halo_size, pointer_spring};
22
23#[derive(Clone, Copy)]
25struct LineHover {
26 dot: Point<Pixels>,
28 focus: f32,
30}
31
32#[derive(IntoPlot)]
33pub struct LineChart<T, X, Y>
34where
35 T: 'static,
36 X: PartialEq + Into<SharedString> + 'static,
37 Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
38{
39 data: Vec<T>,
40 x: Option<Rc<dyn Fn(&T) -> X>>,
41 y: Option<Rc<dyn Fn(&T) -> Y>>,
42 stroke: Option<Hsla>,
43 stroke_style: StrokeStyle,
44 dot: bool,
45 tick_margin: usize,
46 x_axis: bool,
47 grid: bool,
48 id: Option<ElementId>,
49 name: Option<SharedString>,
50 hover: Option<LineHover>,
51}
52
53impl<T, X, Y> LineChart<T, X, Y>
54where
55 X: PartialEq + Into<SharedString> + 'static,
56 Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
57{
58 pub fn new<I>(data: I) -> Self
59 where
60 I: IntoIterator<Item = T>,
61 {
62 Self {
63 data: data.into_iter().collect(),
64 stroke: None,
65 stroke_style: Default::default(),
66 dot: false,
67 x: None,
68 y: None,
69 tick_margin: 1,
70 x_axis: true,
71 grid: true,
72 id: None,
73 name: None,
74 hover: None,
75 }
76 }
77
78 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
83 self.id = Some(id.into());
84 self
85 }
86
87 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
89 self.name = Some(name.into());
90 self
91 }
92
93 pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self {
94 self.x = Some(Rc::new(x));
95 self
96 }
97
98 pub fn y(mut self, y: impl Fn(&T) -> Y + 'static) -> Self {
99 self.y = Some(Rc::new(y));
100 self
101 }
102
103 pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
104 self.stroke = Some(stroke.into());
105 self
106 }
107
108 pub fn natural(mut self) -> Self {
109 self.stroke_style = StrokeStyle::Natural;
110 self
111 }
112
113 pub fn linear(mut self) -> Self {
114 self.stroke_style = StrokeStyle::Linear;
115 self
116 }
117
118 pub fn step_after(mut self) -> Self {
119 self.stroke_style = StrokeStyle::StepAfter;
120 self
121 }
122
123 pub fn dot(mut self) -> Self {
124 self.dot = true;
125 self
126 }
127
128 pub fn tick_margin(mut self, tick_margin: usize) -> Self {
129 self.tick_margin = tick_margin;
130 self
131 }
132
133 pub fn x_axis(mut self, x_axis: bool) -> Self {
137 self.x_axis = x_axis;
138 self
139 }
140
141 pub fn grid(mut self, grid: bool) -> Self {
142 self.grid = grid;
143 self
144 }
145
146 fn scales(&self, bounds: Bounds<Pixels>) -> Option<(ScalePoint<X>, ScaleLinear<Y>)> {
151 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
152
153 let width = bounds.size.width.as_f32();
154 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
155 let height = bounds.size.height.as_f32() - axis_gap;
156
157 let x = ScalePoint::new(self.data.iter().map(|v| x_fn(v)).collect(), vec![0., width]);
158 let y = ScaleLinear::new(
160 self.data
161 .iter()
162 .map(|v| y_fn(v))
163 .chain(Some(Y::zero()))
164 .collect(),
165 vec![height, 10.],
166 );
167
168 Some((x, y))
169 }
170}
171
172impl<T, X, Y> Plot for LineChart<T, X, Y>
173where
174 X: PartialEq + Into<SharedString> + 'static,
175 Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
176{
177 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
178 let (Some(x_fn), Some(y_fn)) = (self.x.as_ref(), self.y.as_ref()) else {
179 return;
180 };
181 let Some((x, y)) = self.scales(bounds) else {
182 return;
183 };
184
185 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
186 let height = bounds.size.height.as_f32() - axis_gap;
187
188 let mut axis = PlotAxis::new().stroke(cx.theme().border);
190 if self.x_axis {
191 let labels = build_point_x_labels(
192 &self.data,
193 x_fn.as_ref(),
194 &x,
195 self.tick_margin,
196 cx.theme().muted_foreground,
197 );
198 axis = axis.x(height).x_label(labels);
199 }
200 axis.paint(&bounds, window, cx);
201
202 if self.grid {
204 Grid::new()
205 .y((0..=3).map(|i| height * i as f32 / 4.0).collect())
206 .stroke(cx.theme().border)
207 .dash_array(&[px(4.), px(2.)])
208 .paint(&bounds, window);
209 }
210
211 let stroke = self.stroke.unwrap_or(cx.theme().chart_2);
213 let x_fn = x_fn.clone();
214 let y_fn = y_fn.clone();
215 let mut line = Line::new()
216 .data(&self.data)
217 .x(move |d| x.tick(&x_fn(d)))
218 .y(move |d| y.tick(&y_fn(d)))
219 .stroke(stroke)
220 .stroke_style(self.stroke_style)
221 .stroke_width(2.);
222
223 if self.dot {
224 line = line.dot().dot_size(8.).dot_fill_color(stroke);
225 }
226
227 if self.id.is_some() {
230 let caches = PathCaches::for_paint("line", window, cx);
231 caches.update(cx, |caches, _| {
232 line.paint_cached(&bounds, caches.slot(0), window);
233 });
234 } else {
235 line.paint(&bounds, window);
236 }
237 }
238
239 fn id(&self) -> Option<ElementId> {
240 self.id.clone()
241 }
242
243 fn tooltip_state(
244 &self,
245 position: Point<Pixels>,
246 bounds: Bounds<Pixels>,
247 _cx: &App,
248 ) -> Option<TooltipState> {
249 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
250 let (x, y) = self.scales(bounds)?;
251
252 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
254 if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap {
255 return None;
256 }
257
258 let index = x.least_index(position.x.as_f32());
259 let d = self.data.get(index)?;
260 let x_tick = x.tick(&x_fn(d))?;
261 let y_tick = y.tick(&y_fn(d))?;
262
263 Some(TooltipState::new(
264 index,
265 point(px(x_tick), position.y),
266 vec![point(px(x_tick), px(y_tick))],
267 ))
268 }
269
270 fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
271 self.hover = hover.and_then(|hover| {
272 let target = *hover.state().dots.first()?;
276 let policy = pointer_spring(cx).with_travel(!hover.is_entering());
277 let dot = point(
278 spring(("line-chart", "x"), target.x, policy, window, cx),
279 spring(("line-chart", "y"), target.y, policy, window, cx),
280 );
281 Some(LineHover {
282 dot,
283 focus: hover.focus(),
284 })
285 });
286 }
287
288 fn tooltip(
289 &self,
290 state: &TooltipState,
291 cursor: Point<Pixels>,
292 bounds: Bounds<Pixels>,
293 _window: &mut Window,
294 cx: &mut App,
295 ) -> Option<AnyElement> {
296 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
297 let d = self.data.get(state.index)?;
298 let title: SharedString = x_fn(d).into();
299 let value = y_fn(d).to_f64()?;
300 let stroke = self.stroke.unwrap_or(cx.theme().chart_2);
301 let name = self.name.clone().unwrap_or_default();
302
303 let (dot, focus) = match self.hover {
306 Some(hover) => (hover.dot, hover.focus),
307 None => (*state.dots.first()?, 1.),
308 };
309
310 Some(
311 Tooltip::new(cursor, bounds.size)
313 .gap(px(8.))
314 .cross_line(
316 CrossLine::new(point(dot.x, state.cross_line.y)).height(
317 bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. },
318 ),
319 )
320 .dots(Some(
321 Dot::new(dot)
322 .size(HOVER_DOT_SIZE)
323 .halo(hover_halo_size(focus))
324 .stroke(cx.theme().background)
325 .fill(stroke),
326 ))
327 .title(title)
328 .row(stroke, name, format!("{}", value))
329 .into_any_element(),
330 )
331 }
332}