Skip to main content

gpui_component/chart/
candlestick_chart.rs

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/// The hover a candlestick chart paints, sampled once per frame in [`Plot::hover`].
24#[derive(Clone, Copy)]
25struct CandlestickHover {
26    /// Center of the highlight band along the x axis, springing between candles.
27    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    /// Enable an interactive hover tooltip (a highlight band and the open, high,
81    /// low and close of the hovered candle) for this chart.
82    ///
83    /// The `id` must be unique among sibling elements. Without it, the chart stays a
84    /// non-interactive plot.
85    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    /// Show or hide the x-axis line and labels.
126    ///
127    /// Default is true.
128    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    /// Set the color of a candle that closed above its open.
139    ///
140    /// Defaults to the theme's `chart.bullish` color. Markets that read a rise
141    /// as red set this and [`Self::bearish`] the other way round.
142    pub fn bullish(mut self, color: impl Into<Hsla>) -> Self {
143        self.bullish = Some(color.into());
144        self
145    }
146
147    /// Set the color of a candle that closed at or below its open.
148    ///
149    /// Defaults to the theme's `chart.bearish` color.
150    pub fn bearish(mut self, color: impl Into<Hsla>) -> Self {
151        self.bearish = Some(color.into());
152        self
153    }
154
155    /// The candle colors, `(bullish, bearish)`, set or from the theme.
156    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    /// The band scale along the x axis. Shared by `paint` and `tooltip_state` so
164    /// the candles and the hover band stay aligned.
165    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    /// The height of the plot area above the x-axis labels.
178    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        // X scale
202        let Some(x) = self.x_scale(bounds) else {
203            return;
204        };
205        let band_width = x.band_width();
206
207        // Y scale
208        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        // Draw X axis
216        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        // Draw grid
231        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        // Draw candlesticks
240        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            // Get OHLC values for the current data point
255            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            // Convert values to pixel coordinates
261            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            // Determine if bullish (close > open) or bearish (close < open)
273            let is_bullish = close > open;
274            let color = if is_bullish { bullish } else { bearish };
275
276            // Calculate candlestick body dimensions
277            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            // Draw wick (high to low line)
283            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            // Draw body (open to close rectangle)
292            // For bullish: top is close, bottom is open
293            // For bearish: top is open, bottom is close
294            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        // Ignore the x-axis label gutter so hovering the labels doesn't show a tooltip.
323        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            // The band slides to the hovered candle; on the first hovered frame it
341            // adopts the candle instead of travelling from where the last hover ended.
342            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        // Highlight the hovered candle with a translucent band the width of its
375        // slot, centered where the band spring has reached rather than snapped to
376        // the candle, and confined to the plot area above the axis labels.
377        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}