1use std::{hash::Hash, rc::Rc};
2
3use gpui::{
4 AnyElement, App, Bounds, ElementId, Hsla, IntoElement, PathBuilder, Pixels, Point,
5 SharedString, Window, fill, point, px,
6};
7use gpui_base::motion::spring;
8use gpui_component_macros::IntoPlot;
9use num_traits::{Num, ToPrimitive};
10use rust_i18n::t;
11
12use crate::{
13 ActiveTheme,
14 plot::{
15 AXIS_GAP, Grid, Plot, PlotAxis, origin_point,
16 scale::{Scale, ScaleBand, ScaleLinear, Sealed},
17 tooltip::{CrossLine, PlotHover, Tooltip, TooltipState},
18 },
19};
20
21use super::{build_band_labels, pointer_spring};
22
23#[derive(Clone, Copy)]
25struct CandlestickHover {
26 center: Pixels,
28}
29
30#[derive(IntoPlot)]
31pub struct CandlestickChart<T, X, Y>
32where
33 T: 'static,
34 X: Eq + Hash + Into<SharedString> + 'static,
35 Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
36{
37 data: Vec<T>,
38 x: Option<Rc<dyn Fn(&T) -> X>>,
39 open: Option<Rc<dyn Fn(&T) -> Y>>,
40 high: Option<Rc<dyn Fn(&T) -> Y>>,
41 low: Option<Rc<dyn Fn(&T) -> Y>>,
42 close: Option<Rc<dyn Fn(&T) -> Y>>,
43 tick_margin: usize,
44 body_width_ratio: f32,
45 x_axis: bool,
46 grid: bool,
47 bullish: Option<Hsla>,
48 bearish: Option<Hsla>,
49 id: Option<ElementId>,
50 hover: Option<CandlestickHover>,
51}
52
53impl<T, X, Y> CandlestickChart<T, X, Y>
54where
55 X: Eq + Hash + 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 x: None,
65 open: None,
66 high: None,
67 low: None,
68 close: None,
69 tick_margin: 1,
70 body_width_ratio: 0.8,
71 x_axis: true,
72 grid: true,
73 bullish: None,
74 bearish: None,
75 id: None,
76 hover: None,
77 }
78 }
79
80 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
86 self.id = Some(id.into());
87 self
88 }
89
90 pub fn x(mut self, x: impl Fn(&T) -> X + 'static) -> Self {
91 self.x = Some(Rc::new(x));
92 self
93 }
94
95 pub fn open(mut self, open: impl Fn(&T) -> Y + 'static) -> Self {
96 self.open = Some(Rc::new(open));
97 self
98 }
99
100 pub fn high(mut self, high: impl Fn(&T) -> Y + 'static) -> Self {
101 self.high = Some(Rc::new(high));
102 self
103 }
104
105 pub fn low(mut self, low: impl Fn(&T) -> Y + 'static) -> Self {
106 self.low = Some(Rc::new(low));
107 self
108 }
109
110 pub fn close(mut self, close: impl Fn(&T) -> Y + 'static) -> Self {
111 self.close = Some(Rc::new(close));
112 self
113 }
114
115 pub fn tick_margin(mut self, tick_margin: usize) -> Self {
116 self.tick_margin = tick_margin;
117 self
118 }
119
120 pub fn body_width_ratio(mut self, ratio: f32) -> Self {
121 self.body_width_ratio = ratio;
122 self
123 }
124
125 pub fn x_axis(mut self, x_axis: bool) -> Self {
129 self.x_axis = x_axis;
130 self
131 }
132
133 pub fn grid(mut self, grid: bool) -> Self {
134 self.grid = grid;
135 self
136 }
137
138 pub fn bullish(mut self, color: impl Into<Hsla>) -> Self {
143 self.bullish = Some(color.into());
144 self
145 }
146
147 pub fn bearish(mut self, color: impl Into<Hsla>) -> Self {
151 self.bearish = Some(color.into());
152 self
153 }
154
155 fn candle_colors(&self, cx: &App) -> (Hsla, Hsla) {
157 (
158 self.bullish.unwrap_or(cx.theme().chart_bullish),
159 self.bearish.unwrap_or(cx.theme().chart_bearish),
160 )
161 }
162
163 fn x_scale(&self, bounds: Bounds<Pixels>) -> Option<ScaleBand<X>> {
166 let x_fn = self.x.as_ref()?;
167 Some(
168 ScaleBand::new(
169 self.data.iter().map(|v| x_fn(v)).collect(),
170 vec![0., bounds.size.width.as_f32()],
171 )
172 .padding_inner(0.4)
173 .padding_outer(0.2),
174 )
175 }
176
177 fn plot_height(&self, bounds: Bounds<Pixels>) -> f32 {
179 bounds.size.height.as_f32() - if self.x_axis { AXIS_GAP } else { 0. }
180 }
181}
182
183impl<T, X, Y> Plot for CandlestickChart<T, X, Y>
184where
185 X: Eq + Hash + Into<SharedString> + 'static,
186 Y: Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
187{
188 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
189 let (Some(x_fn), Some(open_fn), Some(high_fn), Some(low_fn), Some(close_fn)) = (
190 self.x.as_ref(),
191 self.open.as_ref(),
192 self.high.as_ref(),
193 self.low.as_ref(),
194 self.close.as_ref(),
195 ) else {
196 return;
197 };
198
199 let height = self.plot_height(bounds);
200
201 let Some(x) = self.x_scale(bounds) else {
203 return;
204 };
205 let band_width = x.band_width();
206
207 let all_values: Vec<Y> = self
209 .data
210 .iter()
211 .flat_map(|d| vec![high_fn(d), low_fn(d), open_fn(d), close_fn(d)])
212 .collect();
213 let y = ScaleLinear::new(all_values, vec![height, 10.]);
214
215 let mut axis = PlotAxis::new().stroke(cx.theme().border);
217 if self.x_axis {
218 let labels = build_band_labels(
219 &self.data,
220 x_fn.as_ref(),
221 &x,
222 band_width,
223 self.tick_margin,
224 cx.theme().muted_foreground,
225 );
226 axis = axis.x(height).x_label(labels);
227 }
228 axis.paint(&bounds, window, cx);
229
230 if self.grid {
232 Grid::new()
233 .y((0..=3).map(|i| height * i as f32 / 4.0).collect())
234 .stroke(cx.theme().border)
235 .dash_array(&[px(4.), px(2.)])
236 .paint(&bounds, window);
237 }
238
239 let (bullish, bearish) = self.candle_colors(cx);
241 let origin = bounds.origin;
242 let x_fn = x_fn.clone();
243 let open_fn = open_fn.clone();
244 let high_fn = high_fn.clone();
245 let low_fn = low_fn.clone();
246 let close_fn = close_fn.clone();
247
248 for d in &self.data {
249 let x_tick = x.tick(&x_fn(d));
250 let Some(x_tick) = x_tick else {
251 continue;
252 };
253
254 let open = open_fn(d);
256 let high = high_fn(d);
257 let low = low_fn(d);
258 let close = close_fn(d);
259
260 let open_y = y.tick(&open);
262 let high_y = y.tick(&high);
263 let low_y = y.tick(&low);
264 let close_y = y.tick(&close);
265
266 let (Some(open_y), Some(high_y), Some(low_y), Some(close_y)) =
267 (open_y, high_y, low_y, close_y)
268 else {
269 continue;
270 };
271
272 let is_bullish = close > open;
274 let color = if is_bullish { bullish } else { bearish };
275
276 let center_x = x_tick + band_width / 2.;
278 let body_width = band_width * self.body_width_ratio;
279 let body_left = center_x - body_width / 2.;
280 let body_right = center_x + body_width / 2.;
281
282 let mut wick_builder = PathBuilder::stroke(px(1.));
284 wick_builder.move_to(origin_point(px(center_x), px(high_y), origin));
285 wick_builder.line_to(origin_point(px(center_x), px(low_y), origin));
286
287 if let Ok(path) = wick_builder.build() {
288 window.paint_path(path, color);
289 }
290
291 let (top, bottom) = if is_bullish {
295 (close_y, open_y)
296 } else {
297 (open_y, close_y)
298 };
299
300 let body_bounds = Bounds::from_corners(
301 origin_point(px(body_left), px(top), origin),
302 origin_point(px(body_right), px(bottom), origin),
303 );
304
305 window.paint_quad(fill(body_bounds, color));
306 }
307 }
308
309 fn id(&self) -> Option<ElementId> {
310 self.id.clone()
311 }
312
313 fn tooltip_state(
314 &self,
315 position: Point<Pixels>,
316 bounds: Bounds<Pixels>,
317 _cx: &App,
318 ) -> Option<TooltipState> {
319 let x_fn = self.x.as_ref()?;
320 let x = self.x_scale(bounds)?;
321
322 if position.y.as_f32() > self.plot_height(bounds) {
324 return None;
325 }
326
327 let index = x.least_index(position.x.as_f32());
328 let d = self.data.get(index)?;
329 let center = x.tick(&x_fn(d))? + x.band_width() / 2.;
330
331 Some(TooltipState::new(
332 index,
333 point(px(center), position.y),
334 vec![],
335 ))
336 }
337
338 fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
339 self.hover = hover.map(|hover| {
340 let center = spring(
343 ("candlestick-chart", "band"),
344 hover.state().cross_line.x,
345 pointer_spring(cx).with_travel(!hover.is_entering()),
346 window,
347 cx,
348 );
349 CandlestickHover { center }
350 });
351 }
352
353 fn tooltip(
354 &self,
355 state: &TooltipState,
356 cursor: Point<Pixels>,
357 bounds: Bounds<Pixels>,
358 _window: &mut Window,
359 cx: &mut App,
360 ) -> Option<AnyElement> {
361 let (x_fn, open_fn, high_fn, low_fn, close_fn) = (
362 self.x.as_ref()?,
363 self.open.as_ref()?,
364 self.high.as_ref()?,
365 self.low.as_ref()?,
366 self.close.as_ref()?,
367 );
368 let d = self.data.get(state.index)?;
369 let title: SharedString = x_fn(d).into();
370 let (open, close) = (open_fn(d), close_fn(d));
371 let (bullish, bearish) = self.candle_colors(cx);
372 let color = if close > open { bullish } else { bearish };
373
374 let center = self.hover.map_or(state.cross_line.x, |hover| hover.center);
378 let band_width = self.x_scale(bounds)?.band_width();
379 let cross_line = CrossLine::new(point(center, state.cross_line.y))
380 .span(0., self.plot_height(bounds))
381 .band(px(band_width));
382
383 let rows = [
384 (t!("Chart.open"), open),
385 (t!("Chart.high"), high_fn(d)),
386 (t!("Chart.low"), low_fn(d)),
387 (t!("Chart.close"), close),
388 ];
389 let mut tooltip = Tooltip::new(cursor, bounds.size)
390 .gap(px(8.))
391 .cross_line(cross_line)
392 .title(title);
393 for (label, value) in rows {
394 tooltip = tooltip.row(color, label.to_string(), format!("{}", value.to_f64()?));
395 }
396
397 Some(tooltip.into_any_element())
398 }
399}