Skip to main content

egui_charts/chart/overlays/
heatmap_strip.rs

1//! Order Flow Heatmap Strip
2//!
3//! A horizontal strip visualization below the chart showing volume flow
4//! over time, color-coded by delta (buy/sell pressure).
5
6use egui::{Color32, Pos2, Rect, Ui, Vec2};
7
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10
11/// A single cell in the heatmap strip
12#[derive(Debug, Clone, Copy)]
13pub struct HeatmapCell {
14    /// Bar index this cell corresponds to
15    pub bar_idx: usize,
16    /// Delta value (positive = buying, negative = selling)
17    pub delta: f64,
18    /// Total volume at this bar
19    pub volume: f64,
20    /// Normalized intensity (0.0-1.0) for color mapping
21    pub intensity: f32,
22}
23
24impl HeatmapCell {
25    /// Create a new heatmap cell
26    pub fn new(bar_idx: usize, delta: f64, volume: f64) -> Self {
27        Self {
28            bar_idx,
29            delta,
30            volume,
31            intensity: 0.5, // Default mid-intensity
32        }
33    }
34
35    /// Set intensity based on delta relative to max delta
36    pub fn with_normalized_intensity(mut self, max_abs_delta: f64) -> Self {
37        if max_abs_delta > 0.0 {
38            // Normalize to 0.0-1.0 range where 0.5 is neutral
39            let normalized = (self.delta / max_abs_delta) as f32;
40            self.intensity = ((normalized + 1.0) / 2.0).clamp(0.0, 1.0); // Map -1..1 to 0..1
41        }
42        self
43    }
44
45    /// Check if this is a buying cell (positive delta)
46    pub fn is_buying(&self) -> bool {
47        self.delta > 0.0
48    }
49
50    /// Check if this is a selling cell (negative delta)
51    pub fn is_selling(&self) -> bool {
52        self.delta < 0.0
53    }
54}
55
56/// Configuration for the heatmap strip
57#[derive(Debug, Clone)]
58pub struct HeatmapStripConfig {
59    /// Height of the strip in pixels
60    pub height: f32,
61    /// Minimum painted cell width; bar positions still follow the caller's mapping.
62    pub min_cell_width: f32,
63    /// Whether to show volume-based intensity
64    pub volume_weighted: bool,
65    /// Whether to show gradient or solid colors
66    pub use_gradient: bool,
67    /// Opacity of the strip (0.0-1.0)
68    pub opacity: f32,
69}
70
71impl Default for HeatmapStripConfig {
72    fn default() -> Self {
73        Self {
74            height: DESIGN_TOKENS.sizing.charts_ext.heatmap_strip_height,
75            min_cell_width: DESIGN_TOKENS.spacing.sm,
76            volume_weighted: true,
77            use_gradient: true,
78            opacity: 0.85,
79        }
80    }
81}
82
83/// Heatmap strip visualization
84#[derive(Debug, Clone, Default)]
85pub struct HeatmapStrip {
86    /// Raw cells ordered by bar index; intensities are normalized when read or rendered.
87    cells: Vec<HeatmapCell>,
88    max_abs_delta: f64,
89    max_volume: f64,
90    /// Configuration
91    pub config: HeatmapStripConfig,
92}
93
94impl HeatmapStrip {
95    /// Create a new empty heatmap strip
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Create with custom configuration
101    pub fn with_config(config: HeatmapStripConfig) -> Self {
102        Self {
103            config,
104            ..Self::default()
105        }
106    }
107
108    /// Set the cells from delta/volume data, sorting them by bar index.
109    pub fn set_data(&mut self, data: impl IntoIterator<Item = (usize, f64, f64)>) {
110        self.clear();
111        for (idx, delta, volume) in data {
112            self.max_abs_delta = self.max_abs_delta.max(delta.abs());
113            self.max_volume = self.max_volume.max(volume);
114            self.cells.push(HeatmapCell::new(idx, delta, volume));
115        }
116        self.cells.sort_by_key(|cell| cell.bar_idx);
117    }
118
119    /// Add a single cell, keeping the cells ordered by bar index. Appending in bar-index
120    /// order takes amortized constant time.
121    pub fn add_cell(&mut self, bar_idx: usize, delta: f64, volume: f64) {
122        self.max_abs_delta = self.max_abs_delta.max(delta.abs());
123        self.max_volume = self.max_volume.max(volume);
124        let at = self.cells.partition_point(|cell| cell.bar_idx <= bar_idx);
125        self.cells
126            .insert(at, HeatmapCell::new(bar_idx, delta, volume));
127    }
128
129    /// Cells in bar-index order, with intensities normalized against all held deltas.
130    pub fn cells(&self) -> impl ExactSizeIterator<Item = HeatmapCell> + '_ {
131        self.cells
132            .iter()
133            .map(|cell| cell.with_normalized_intensity(self.max_abs_delta))
134    }
135
136    /// Clear all cells and their normalization maxima.
137    pub fn clear(&mut self) {
138        self.cells.clear();
139        self.max_abs_delta = 0.0;
140        self.max_volume = 0.0;
141    }
142
143    /// Render the heatmap strip
144    ///
145    /// # Arguments
146    /// * `ui` - The egui UI context
147    /// * `strip_rect` - The rectangle where the strip should be drawn
148    /// * `visible_range` - The inclusive visible bar index range (start, end)
149    /// * `bar_width` - Spacing between bar centres in logical points
150    /// * `x` - The screen x coordinate of each absolute bar index's centre
151    pub fn render(
152        &self,
153        ui: &mut Ui,
154        strip_rect: Rect,
155        visible_range: (usize, usize),
156        bar_width: f32,
157        x: impl Fn(usize) -> f32,
158    ) {
159        if self.cells.is_empty()
160            || visible_range.0 > visible_range.1
161            || !bar_width.is_finite()
162            || bar_width <= 0.0
163        {
164            return;
165        }
166
167        let painter = ui
168            .painter()
169            .with_clip_rect(strip_rect.intersect(ui.clip_rect()));
170
171        // Get theme colors
172        let colors = ui.active_theme().chart;
173        let bullish = colors.candle_up;
174        let bearish = colors.candle_down;
175        let neutral = colors.bg;
176
177        // Draw background
178        painter.rect_filled(strip_rect, 0.0, neutral);
179
180        // Calculate cell dimensions
181        let cell_width = bar_width.max(self.config.min_cell_width);
182
183        let max_volume = self.max_volume;
184        let first = self
185            .cells
186            .partition_point(|cell| cell.bar_idx < visible_range.0);
187        let end = self
188            .cells
189            .partition_point(|cell| cell.bar_idx <= visible_range.1);
190        for at in first..end {
191            let cell = &self.cells[at];
192            // Widen the paint to the right without changing the next bar's position, and stop it
193            // at the next cell so translucent cells never stack.
194            let left = x(cell.bar_idx) - bar_width * 0.5;
195            let width = self.cells.get(at + 1).map_or(cell_width, |next| {
196                cell_width.min(x(next.bar_idx) - bar_width * 0.5 - left)
197            });
198            let cell_rect = Rect::from_min_size(
199                Pos2::new(left, strip_rect.top()),
200                Vec2::new(width, strip_rect.height()),
201            )
202            .intersect(strip_rect);
203            if !left.is_finite() || !cell_rect.is_positive() {
204                continue;
205            }
206
207            // Calculate color based on delta and intensity
208            let color = if self.config.use_gradient {
209                let mut shown = cell.with_normalized_intensity(self.max_abs_delta);
210                if self.config.volume_weighted {
211                    shown.intensity = if max_volume > 0.0 {
212                        (0.5 + 0.5 * cell.delta.signum() * cell.volume / max_volume).clamp(0.0, 1.0)
213                            as f32
214                    } else {
215                        0.5
216                    };
217                }
218                self.gradient_color(&shown, bullish, bearish, neutral)
219            } else {
220                self.solid_color(cell, bullish, bearish)
221            };
222
223            // Apply opacity
224            let weight = if self.config.volume_weighted && !self.config.use_gradient {
225                if max_volume > 0.0 {
226                    (cell.volume / max_volume).clamp(0.0, 1.0) as f32
227                } else {
228                    0.0
229                }
230            } else {
231                1.0
232            };
233            let final_color = color.gamma_multiply(self.config.opacity * weight);
234
235            painter.rect_filled(cell_rect, 0.0, final_color);
236        }
237
238        // Draw border
239        painter.rect_stroke(
240            strip_rect,
241            0.0,
242            egui::Stroke::new(
243                DESIGN_TOKENS.stroke.hairline,
244                ui.visuals().widgets.noninteractive.bg_stroke.color,
245            ),
246            egui::StrokeKind::Inside,
247        );
248    }
249
250    /// Calculate gradient color based on intensity
251    fn gradient_color(
252        &self,
253        cell: &HeatmapCell,
254        bullish: Color32,
255        bearish: Color32,
256        neutral: Color32,
257    ) -> Color32 {
258        let intensity = cell.intensity;
259
260        if intensity > 0.5 {
261            // Bullish (green) gradient
262            let t = (intensity - 0.5) * 2.0; // 0.0 to 1.0
263            lerp_color(neutral, bullish, t)
264        } else {
265            // Bearish (red) gradient
266            let t = (0.5 - intensity) * 2.0; // 0.0 to 1.0
267            lerp_color(neutral, bearish, t)
268        }
269    }
270
271    /// Calculate solid color (no gradient)
272    fn solid_color(&self, cell: &HeatmapCell, bullish: Color32, bearish: Color32) -> Color32 {
273        if cell.is_buying() {
274            bullish
275        } else if cell.is_selling() {
276            bearish
277        } else {
278            Color32::TRANSPARENT
279        }
280    }
281}
282
283/// Linear interpolation between two colors
284fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
285    let t = t.clamp(0.0, 1.0);
286    let [ar, ag, ab, aa] = a.to_array();
287    let [br, bg, bb, ba] = b.to_array();
288
289    Color32::from_rgba_unmultiplied(
290        lerp_u8(ar, br, t),
291        lerp_u8(ag, bg, t),
292        lerp_u8(ab, bb, t),
293        lerp_u8(aa, ba, t),
294    )
295}
296
297/// Linear interpolation between two u8 values
298fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
299    (a as f32 + (b as f32 - a as f32) * t).round() as u8
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_strip_data_setting() {
308        let mut strip = HeatmapStrip::new();
309        strip.set_data(vec![(0, 100.0, 500.0), (1, -50.0, 300.0), (2, 75.0, 400.0)]);
310
311        assert_eq!(strip.cells().len(), 3);
312        assert!(strip.cells().next().unwrap().is_buying());
313        assert!(strip.cells().nth(1).unwrap().is_selling());
314        strip.add_cell(3, -200.0, 600.0);
315        assert_eq!(strip.cells().nth(3).unwrap().intensity, 0.0);
316        assert_eq!(strip.cells().next().unwrap().intensity, 0.75);
317
318        strip.add_cell(4, -100.0, 500.0);
319        assert_eq!(strip.cells().nth(4).unwrap().intensity, 0.25);
320
321        let data = [
322            (0, 0.0, 5.0),
323            (1, 100.0, 500.0),
324            (2, -50.0, 300.0),
325            (3, 75.0, 400.0),
326            (4, -200.0, 600.0),
327        ];
328        let mut appended = HeatmapStrip::new();
329        for (n, &(idx, delta, volume)) in data.iter().enumerate() {
330            appended.add_cell(idx, delta, volume);
331            strip.set_data(data[..=n].iter().copied().rev());
332            let expected = data[..=n]
333                .iter()
334                .map(|(_, delta, _)| delta.abs())
335                .fold(0.0, f64::max);
336            let reference: Vec<_> = data[..=n]
337                .iter()
338                .map(|&(idx, delta, volume)| {
339                    let cell =
340                        HeatmapCell::new(idx, delta, volume).with_normalized_intensity(expected);
341                    (cell.bar_idx, cell.intensity)
342                })
343                .collect();
344            for built in [&strip, &appended] {
345                assert_eq!(
346                    built
347                        .cells()
348                        .map(|c| (c.bar_idx, c.intensity))
349                        .collect::<Vec<_>>(),
350                    reference
351                );
352            }
353        }
354        appended.clear();
355        appended.add_cell(0, 1.0, 1.0);
356        assert_eq!(appended.cells().next().unwrap().intensity, 1.0);
357        appended.add_cell(5, 1.0, 1.0);
358        appended.add_cell(2, 1.0, 1.0);
359        assert_eq!(
360            appended.cells().map(|c| c.bar_idx).collect::<Vec<_>>(),
361            [0, 2, 5]
362        );
363
364        strip.set_data([(0, 10.0, 1.0), (1, -10.0, 10.0), (2, 0.0, 5.0)]);
365        strip.config.use_gradient = false;
366        strip.config.opacity = 1.0;
367        let ctx = egui::Context::default();
368        for (weighted, expected) in [(false, [255, 255, 0]), (true, [26, 255, 0])] {
369            strip.config.volume_weighted = weighted;
370            let mut output = ctx.run_ui(Default::default(), |ui| {
371                strip.render(
372                    ui,
373                    Rect::from_min_size(Pos2::ZERO, Vec2::new(150.0, 24.0)),
374                    (0, 2),
375                    50.0,
376                    |i| 25.0 + i as f32 * 50.0,
377                );
378            });
379            let alphas: Vec<_> = output
380                .shapes
381                .iter()
382                .filter_map(|shape| match &shape.shape {
383                    egui::Shape::Rect(rect) if rect.rect.width() == 50.0 => Some(rect.fill.a()),
384                    _ => None,
385                })
386                .collect();
387            output.textures_delta.clear();
388            assert_eq!(alphas, expected, "solid volume weighting: {weighted}");
389        }
390    }
391
392    /// At two points per bar, x = 401 is the centre of bar 200, and 300 bars
393    /// occupy exactly 600 points even when the minimum painted width is four.
394    #[test]
395    fn dense_cells_follow_the_bars_and_stay_inside_the_strip() {
396        let mut strip = HeatmapStrip::new();
397        strip.set_data((0..300).map(|i| (i, if i < 150 { 1.0 } else { -1.0 }, 1.0)));
398        let rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 24.0));
399        for (range, offset, probe, expected) in [
400            ((0, 299), 0.0, 401.0, 200),
401            ((0, 299), -0.7, 400.3, 200),
402            ((100, 299), 20.0, 221.0, 100),
403        ] {
404            let mut output = egui::Context::default().run_ui(
405                egui::RawInput {
406                    screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 100.0))),
407                    ..Default::default()
408                },
409                |ui| {
410                    strip.render(ui, rect, range, 2.0, |i| {
411                        1.0 + 2.0 * (i - range.0) as f32 + offset
412                    })
413                },
414            );
415            output.textures_delta.clear();
416            let cells: Vec<_> = output
417                .shapes
418                .iter()
419                .filter_map(|s| match &s.shape {
420                    egui::Shape::Rect(r) if r.rect.width() <= 4.01 && r.stroke.width == 0.0 => {
421                        Some(r.rect)
422                    }
423                    _ => None,
424                })
425                .collect();
426            assert_eq!(
427                cells.len(),
428                range.1 - range.0 + 1,
429                "range {range:?}, offset {offset}"
430            );
431            assert_eq!(
432                cells
433                    .iter()
434                    .rposition(|r| r.left() <= probe && r.right() > probe),
435                Some(expected)
436            );
437            assert!(cells.iter().all(|r| rect.contains_rect(*r)));
438            assert!(
439                cells
440                    .windows(2)
441                    .all(|pair| pair[0].right() <= pair[1].left() + 1e-3),
442                "translucent cells do not stack, range {range:?}, offset {offset}"
443            );
444        }
445    }
446
447    #[test]
448    fn test_color_lerp() {
449        let white = Color32::WHITE;
450        let black = Color32::BLACK;
451
452        let mid = lerp_color(white, black, 0.5);
453        assert_eq!(mid.r(), 128);
454        assert_eq!(mid.g(), 128);
455        assert_eq!(mid.b(), 128);
456    }
457}