1use std::rc::Rc;
2
3use gpui::{
4 AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, Window,
5 point, px,
6};
7use gpui_component_macros::IntoPlot;
8use num_traits::{Num, ToPrimitive};
9
10use crate::{
11 ActiveTheme,
12 plot::{
13 AXIS_GAP, Grid, Plot, PlotAxis, StrokeStyle,
14 scale::{Scale, ScaleLinear, ScalePoint, Sealed},
15 shape::Line,
16 tooltip::{CrossLine, Dot, Tooltip, TooltipState},
17 },
18};
19
20use super::build_point_x_labels;
21
22#[derive(IntoPlot)]
23pub struct LineChart<T, X, Y>
24where
25 T: 'static,
26 X: PartialEq + Into<SharedString> + 'static,
27 Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
28{
29 data: Vec<T>,
30 x: Option<Rc<dyn Fn(&T) -> X>>,
31 y: Option<Rc<dyn Fn(&T) -> Y>>,
32 stroke: Option<Hsla>,
33 stroke_style: StrokeStyle,
34 dot: bool,
35 tick_margin: usize,
36 x_axis: bool,
37 grid: bool,
38 id: Option<ElementId>,
39 name: Option<SharedString>,
40}
41
42impl<T, X, Y> LineChart<T, X, Y>
43where
44 X: PartialEq + Into<SharedString> + 'static,
45 Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
46{
47 pub fn new<I>(data: I) -> Self
48 where
49 I: IntoIterator<Item = T>,
50 {
51 Self {
52 data: data.into_iter().collect(),
53 stroke: None,
54 stroke_style: Default::default(),
55 dot: false,
56 x: None,
57 y: None,
58 tick_margin: 1,
59 x_axis: true,
60 grid: true,
61 id: None,
62 name: None,
63 }
64 }
65
66 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
71 self.id = Some(id.into());
72 self
73 }
74
75 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
77 self.name = Some(name.into());
78 self
79 }
80
81 pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self {
82 self.x = Some(Rc::new(x));
83 self
84 }
85
86 pub fn y(mut self, y: impl Fn(&T) -> Y + 'static) -> Self {
87 self.y = Some(Rc::new(y));
88 self
89 }
90
91 pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
92 self.stroke = Some(stroke.into());
93 self
94 }
95
96 pub fn natural(mut self) -> Self {
97 self.stroke_style = StrokeStyle::Natural;
98 self
99 }
100
101 pub fn linear(mut self) -> Self {
102 self.stroke_style = StrokeStyle::Linear;
103 self
104 }
105
106 pub fn step_after(mut self) -> Self {
107 self.stroke_style = StrokeStyle::StepAfter;
108 self
109 }
110
111 pub fn dot(mut self) -> Self {
112 self.dot = true;
113 self
114 }
115
116 pub fn tick_margin(mut self, tick_margin: usize) -> Self {
117 self.tick_margin = tick_margin;
118 self
119 }
120
121 pub fn x_axis(mut self, x_axis: bool) -> Self {
125 self.x_axis = x_axis;
126 self
127 }
128
129 pub fn grid(mut self, grid: bool) -> Self {
130 self.grid = grid;
131 self
132 }
133
134 fn scales(&self, bounds: Bounds<Pixels>) -> Option<(ScalePoint<X>, ScaleLinear<Y>)> {
139 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
140
141 let width = bounds.size.width.as_f32();
142 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
143 let height = bounds.size.height.as_f32() - axis_gap;
144
145 let x = ScalePoint::new(self.data.iter().map(|v| x_fn(v)).collect(), vec![0., width]);
146 let y = ScaleLinear::new(
148 self.data
149 .iter()
150 .map(|v| y_fn(v))
151 .chain(Some(Y::zero()))
152 .collect(),
153 vec![height, 10.],
154 );
155
156 Some((x, y))
157 }
158}
159
160impl<T, X, Y> Plot for LineChart<T, X, Y>
161where
162 X: PartialEq + Into<SharedString> + 'static,
163 Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
164{
165 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
166 let (Some(x_fn), Some(y_fn)) = (self.x.as_ref(), self.y.as_ref()) else {
167 return;
168 };
169 let Some((x, y)) = self.scales(bounds) else {
170 return;
171 };
172
173 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
174 let height = bounds.size.height.as_f32() - axis_gap;
175
176 let mut axis = PlotAxis::new().stroke(cx.theme().border);
178 if self.x_axis {
179 let labels = build_point_x_labels(
180 &self.data,
181 x_fn.as_ref(),
182 &x,
183 self.tick_margin,
184 cx.theme().muted_foreground,
185 );
186 axis = axis.x(height).x_label(labels);
187 }
188 axis.paint(&bounds, window, cx);
189
190 if self.grid {
192 Grid::new()
193 .y((0..=3).map(|i| height * i as f32 / 4.0).collect())
194 .stroke(cx.theme().border)
195 .dash_array(&[px(4.), px(2.)])
196 .paint(&bounds, window);
197 }
198
199 let stroke = self.stroke.unwrap_or(cx.theme().chart_2);
201 let x_fn = x_fn.clone();
202 let y_fn = y_fn.clone();
203 let mut line = Line::new()
204 .data(&self.data)
205 .x(move |d| x.tick(&x_fn(d)))
206 .y(move |d| y.tick(&y_fn(d)))
207 .stroke(stroke)
208 .stroke_style(self.stroke_style)
209 .stroke_width(2.);
210
211 if self.dot {
212 line = line.dot().dot_size(8.).dot_fill_color(stroke);
213 }
214
215 line.paint(&bounds, window);
216 }
217
218 fn id(&self) -> Option<ElementId> {
219 self.id.clone()
220 }
221
222 fn tooltip_state(
223 &self,
224 position: Point<Pixels>,
225 bounds: Bounds<Pixels>,
226 _cx: &App,
227 ) -> Option<TooltipState> {
228 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
229 let (x, y) = self.scales(bounds)?;
230
231 let axis_gap = if self.x_axis { AXIS_GAP } else { 0. };
233 if position.y.as_f32() > bounds.size.height.as_f32() - axis_gap {
234 return None;
235 }
236
237 let index = x.least_index(position.x.as_f32());
238 let d = self.data.get(index)?;
239 let x_tick = x.tick(&x_fn(d))?;
240 let y_tick = y.tick(&y_fn(d))?;
241
242 Some(TooltipState::new(
243 index,
244 point(px(x_tick), position.y),
245 vec![point(px(x_tick), px(y_tick))],
246 ))
247 }
248
249 fn tooltip(
250 &self,
251 state: &TooltipState,
252 cursor: Point<Pixels>,
253 bounds: Bounds<Pixels>,
254 _window: &mut Window,
255 cx: &mut App,
256 ) -> Option<AnyElement> {
257 let (x_fn, y_fn) = (self.x.as_ref()?, self.y.as_ref()?);
258 let d = self.data.get(state.index)?;
259 let title: SharedString = x_fn(d).into();
260 let value = y_fn(d).to_f64()?;
261 let stroke = self.stroke.unwrap_or(cx.theme().chart_2);
262 let name = self.name.clone().unwrap_or_default();
263
264 Some(
265 Tooltip::new(cursor, bounds.size)
267 .gap(px(8.))
268 .cross_line(
270 CrossLine::new(state.cross_line).height(
271 bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. },
272 ),
273 )
274 .dots(
275 state
276 .dots
277 .iter()
278 .map(|p| Dot::new(*p).stroke(cx.theme().background).fill(stroke)),
279 )
280 .title(title)
281 .row(stroke, name, format!("{}", value))
282 .into_any_element(),
283 )
284 }
285}