1use egui::{Color32, Pos2, Rect, Ui, Vec2};
7
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10
11#[derive(Debug, Clone, Copy)]
13pub struct HeatmapCell {
14 pub bar_idx: usize,
16 pub delta: f64,
18 pub volume: f64,
20 pub intensity: f32,
22}
23
24impl HeatmapCell {
25 pub fn new(bar_idx: usize, delta: f64, volume: f64) -> Self {
27 Self {
28 bar_idx,
29 delta,
30 volume,
31 intensity: 0.5, }
33 }
34
35 pub fn with_normalized_intensity(mut self, max_abs_delta: f64) -> Self {
37 if max_abs_delta > 0.0 {
38 let normalized = (self.delta / max_abs_delta) as f32;
40 self.intensity = ((normalized + 1.0) / 2.0).clamp(0.0, 1.0); }
42 self
43 }
44
45 pub fn is_buying(&self) -> bool {
47 self.delta > 0.0
48 }
49
50 pub fn is_selling(&self) -> bool {
52 self.delta < 0.0
53 }
54}
55
56#[derive(Debug, Clone)]
58pub struct HeatmapStripConfig {
59 pub height: f32,
61 pub min_cell_width: f32,
63 pub volume_weighted: bool,
65 pub use_gradient: bool,
67 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#[derive(Debug, Clone, Default)]
85pub struct HeatmapStrip {
86 cells: Vec<HeatmapCell>,
88 max_abs_delta: f64,
89 max_volume: f64,
90 pub config: HeatmapStripConfig,
92}
93
94impl HeatmapStrip {
95 pub fn new() -> Self {
97 Self::default()
98 }
99
100 pub fn with_config(config: HeatmapStripConfig) -> Self {
102 Self {
103 config,
104 ..Self::default()
105 }
106 }
107
108 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 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 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 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 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 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 painter.rect_filled(strip_rect, 0.0, neutral);
179
180 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 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 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 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 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 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 let t = (intensity - 0.5) * 2.0; lerp_color(neutral, bullish, t)
264 } else {
265 let t = (0.5 - intensity) * 2.0; lerp_color(neutral, bearish, t)
268 }
269 }
270
271 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
283fn 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
297fn 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 #[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}