Skip to main content

densa_chart/
bars.rs

1use densa_core::{Color, Rect};
2use densa_draw::Canvas;
3
4use crate::series::{candles_within, points_within, with_neighbours};
5use crate::{Candles, Plot, Points};
6
7/// How candlesticks are drawn: `CandleStyle::new(up, down)`.
8#[derive(Debug, Clone, Copy, PartialEq)]
9#[non_exhaustive]
10pub struct CandleStyle {
11    /// Candles that closed at or above their open.
12    pub up: Color,
13    /// Candles that closed below their open.
14    pub down: Color,
15    /// How much of the distance between two candles a body takes (0.1 to 1).
16    pub body: f32,
17}
18
19impl CandleStyle {
20    /// Candles in `up` and `down`, bodies taking 70% of the distance between candles.
21    pub const fn new(up: Color, down: Color) -> Self {
22        Self {
23            up,
24            down,
25            body: DEFAULT_BODY,
26        }
27    }
28
29    /// The style with bodies taking `fraction` of the distance between candles.
30    #[must_use]
31    pub fn with_body(mut self, fraction: f32) -> Self {
32        self.body = usable_body(fraction);
33        self
34    }
35
36    /// The color of a period that opened at `open` and closed at `close`.
37    fn color(&self, open: f64, close: f64) -> Color {
38        if close >= open {
39            self.up
40        } else {
41            self.down
42        }
43    }
44}
45
46/// How bars are drawn: `BarStyle::new()`, [`with_body`](Self::with_body) for wider or
47/// narrower ones.
48#[derive(Debug, Clone, Copy, PartialEq)]
49#[non_exhaustive]
50pub struct BarStyle {
51    /// How much of the distance between two bars a bar takes (0.1 to 1).
52    pub body: f32,
53}
54
55impl BarStyle {
56    /// Bars taking 70% of the distance between bars.
57    pub const fn new() -> Self {
58        Self { body: DEFAULT_BODY }
59    }
60
61    /// The style with bars taking `fraction` of the distance between bars.
62    #[must_use]
63    pub fn with_body(mut self, fraction: f32) -> Self {
64        self.body = usable_body(fraction);
65        self
66    }
67}
68
69impl Default for BarStyle {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75/// The share of the distance between bars or candles a body takes unless told otherwise.
76const DEFAULT_BODY: f32 = 0.7;
77
78/// `fraction` held to 0.1..=1; the default if it is not a number.
79fn usable_body(fraction: f32) -> f32 {
80    if fraction.is_finite() {
81        fraction.clamp(0.1, 1.0)
82    } else {
83        DEFAULT_BODY
84    }
85}
86
87/// Bodies narrower than this many pixels are drawn as one bar from low to high, without a
88/// separate wick.
89const MIN_BODY_WITH_WICK: f64 = 3.0;
90
91/// Narrow candles that land on one pixel column, merged into one: the first open, the last
92/// close, and the highest high and lowest low (as pixels).
93#[derive(Debug, Clone, Copy)]
94struct Period {
95    left: f64,
96    top: f64,
97    bottom: f64,
98    open: f64,
99    close: f64,
100}
101
102/// Draws `candles` across `plot`, clipped to its area; `interval` is the distance between
103/// two candles in `x` units (60 for one-minute candles on a time axis), which sets their
104/// width.
105///
106/// Each candle is a body from open to close with a wick from low to high, in whole pixels;
107/// candles narrower than three pixels are a single bar from low to high, and those that land
108/// on the same pixel column are merged into one (first open, last close, highest high,
109/// lowest low), so a frame costs at most two rectangles per candle and one per column
110/// however many candles there are. Candles with a value that is not finite are skipped.
111/// Allocation-free.
112pub fn paint_candles<C: Candles + ?Sized>(
113    canvas: &mut Canvas<'_>,
114    plot: &Plot,
115    candles: &C,
116    interval: f64,
117    style: CandleStyle,
118) {
119    let area = plot.area();
120    if area.is_empty() {
121        return;
122    }
123    let state = canvas.state();
124    canvas.clip(area);
125    let width = bar_width(plot, interval, style.body);
126    let mut merged: Option<Period> = None;
127    for index in with_neighbours(candles_within(candles, &plot.x()), candles.len()) {
128        let candle = candles.candle(index);
129        if !candle.is_finite() {
130            continue;
131        }
132        let left = (plot.x_at(candles.x(index)) - width / 2.0).round();
133        let high = plot.clamp_y(plot.y_at(candle.high));
134        let low = plot.clamp_y(plot.y_at(candle.low));
135        if width < MIN_BODY_WITH_WICK {
136            match merged.as_mut() {
137                Some(period) if period.left == left => {
138                    period.top = period.top.min(high);
139                    period.bottom = period.bottom.max(low);
140                    period.close = candle.close;
141                }
142                _ => {
143                    if let Some(period) = merged.take() {
144                        fill_period(canvas, period, width, style);
145                    }
146                    merged = Some(Period {
147                        left,
148                        top: high,
149                        bottom: low,
150                        open: candle.open,
151                        close: candle.close,
152                    });
153                }
154            }
155            continue;
156        }
157        let color = style.color(candle.open, candle.close);
158        let wick = left + (width / 2.0).floor();
159        fill_bar(canvas, wick, 1.0, high, low, color);
160        let open = plot.clamp_y(plot.y_at(candle.open));
161        let close = plot.clamp_y(plot.y_at(candle.close));
162        fill_bar(canvas, left, width, open.min(close), open.max(close), color);
163    }
164    if let Some(period) = merged {
165        fill_period(canvas, period, width, style);
166    }
167    canvas.restore(state);
168}
169
170/// Fills merged narrow candles as one bar, colored by the period's direction.
171fn fill_period(canvas: &mut Canvas<'_>, period: Period, width: f64, style: CandleStyle) {
172    let color = style.color(period.open, period.close);
173    fill_bar(canvas, period.left, width, period.top, period.bottom, color);
174}
175
176/// Bars that land on one left edge, merged into one: their whole extent, in the color of
177/// the tallest.
178#[derive(Debug, Clone, Copy)]
179struct Stack {
180    left: f64,
181    top: f64,
182    bottom: f64,
183    color: Color,
184    height: f64,
185}
186
187/// Draws `bars` across `plot` as columns from zero to each `y`, clipped to its area;
188/// `interval` is the distance between two bars in `x` units, and `color` gives each bar's
189/// color by index (to color volume by its candle's direction, say).
190///
191/// Bars that land on the same left edge (more bars than pixels) are merged into one, in the
192/// color of the tallest, so a frame costs at most one rectangle per bar and per pixel
193/// column. Bars whose `y` is not finite are skipped. Allocation-free.
194pub fn paint_bars<P: Points + ?Sized>(
195    canvas: &mut Canvas<'_>,
196    plot: &Plot,
197    bars: &P,
198    interval: f64,
199    style: BarStyle,
200    color: impl Fn(usize) -> Color,
201) {
202    let area = plot.area();
203    if area.is_empty() {
204        return;
205    }
206    let state = canvas.state();
207    canvas.clip(area);
208    let width = bar_width(plot, interval, style.body);
209    let base = plot.clamp_y(plot.y_at(0.0));
210    let mut merged: Option<Stack> = None;
211    for index in with_neighbours(points_within(bars, &plot.x()), bars.len()) {
212        let y = bars.y(index);
213        if !y.is_finite() {
214            continue;
215        }
216        let left = (plot.x_at(bars.x(index)) - width / 2.0).round();
217        let end = plot.clamp_y(plot.y_at(y));
218        let (top, bottom) = (end.min(base), end.max(base));
219        match merged.as_mut() {
220            Some(stack) if stack.left == left => {
221                stack.top = stack.top.min(top);
222                stack.bottom = stack.bottom.max(bottom);
223                if bottom - top > stack.height {
224                    stack.height = bottom - top;
225                    stack.color = color(index);
226                }
227            }
228            _ => {
229                if let Some(stack) = merged.take() {
230                    fill_stack(canvas, stack, width);
231                }
232                merged = Some(Stack {
233                    left,
234                    top,
235                    bottom,
236                    color: color(index),
237                    height: bottom - top,
238                });
239            }
240        }
241    }
242    if let Some(stack) = merged {
243        fill_stack(canvas, stack, width);
244    }
245    canvas.restore(state);
246}
247
248/// Fills merged bars as one.
249fn fill_stack(canvas: &mut Canvas<'_>, stack: Stack, width: f64) {
250    fill_bar(
251        canvas,
252        stack.left,
253        width,
254        stack.top,
255        stack.bottom,
256        stack.color,
257    );
258}
259
260/// Kept off a width before rounding it down, so 70% of 10 pixels (0.7 as an `f32` is a
261/// little less) is 7.
262const WIDTH_EPSILON: f64 = 1e-4;
263
264/// Whole pixels a bar `fraction` of `interval` wide takes, at least one.
265fn bar_width(plot: &Plot, interval: f64, fraction: f32) -> f64 {
266    let spacing = plot.x_length(interval).abs();
267    let width = (spacing * f64::from(usable_body(fraction)) + WIDTH_EPSILON).floor();
268    if width.is_finite() {
269        // Never wider than the plot: a huge interval would only make huge rects.
270        width.clamp(1.0, f64::from(plot.area().width()).max(1.0))
271    } else {
272        1.0
273    }
274}
275
276/// Fills `width` pixels from `left`, `top` to `bottom` rounded to whole pixels, at least
277/// one pixel tall.
278fn fill_bar(canvas: &mut Canvas<'_>, left: f64, width: f64, top: f64, bottom: f64, color: Color) {
279    let top = top.round();
280    let bottom = bottom.round().max(top + 1.0);
281    // Lossless enough: pixels within a pixel of the plot.
282    let rect = Rect::from_edges(
283        left as f32,
284        top as f32,
285        (left + width) as f32,
286        bottom as f32,
287    );
288    canvas.fill_rect(rect, color);
289}