use egui::{Color32, Pos2, Rect, Ui, Vec2};
use crate::ext::HasDesignTokens;
use crate::tokens::DESIGN_TOKENS;
#[derive(Debug, Clone, Copy)]
pub struct HeatmapCell {
pub bar_idx: usize,
pub delta: f64,
pub volume: f64,
pub intensity: f32,
}
impl HeatmapCell {
pub fn new(bar_idx: usize, delta: f64, volume: f64) -> Self {
Self {
bar_idx,
delta,
volume,
intensity: 0.5, }
}
pub fn with_normalized_intensity(mut self, max_abs_delta: f64) -> Self {
if max_abs_delta > 0.0 {
let normalized = (self.delta / max_abs_delta) as f32;
self.intensity = (normalized + 1.0) / 2.0; }
self
}
pub fn is_buying(&self) -> bool {
self.delta > 0.0
}
pub fn is_selling(&self) -> bool {
self.delta < 0.0
}
}
#[derive(Debug, Clone)]
pub struct HeatmapStripConfig {
pub height: f32,
pub min_cell_width: f32,
pub volume_weighted: bool,
pub use_gradient: bool,
pub opacity: f32,
}
impl Default for HeatmapStripConfig {
fn default() -> Self {
Self {
height: DESIGN_TOKENS.sizing.charts_ext.heatmap_strip_height,
min_cell_width: DESIGN_TOKENS.spacing.sm,
volume_weighted: true,
use_gradient: true,
opacity: 0.85,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct HeatmapStrip {
pub cells: Vec<HeatmapCell>,
pub config: HeatmapStripConfig,
}
impl HeatmapStrip {
pub fn new() -> Self {
Self {
cells: Vec::new(),
config: HeatmapStripConfig::default(),
}
}
pub fn with_config(config: HeatmapStripConfig) -> Self {
Self {
cells: Vec::new(),
config,
}
}
pub fn set_data(&mut self, data: impl IntoIterator<Item = (usize, f64, f64)>) {
self.cells.clear();
let cells: Vec<HeatmapCell> = data
.into_iter()
.map(|(idx, delta, volume)| HeatmapCell::new(idx, delta, volume))
.collect();
let max_abs_delta = cells.iter().map(|c| c.delta.abs()).fold(0.0f64, f64::max);
self.cells = cells
.into_iter()
.map(|c| c.with_normalized_intensity(max_abs_delta))
.collect();
}
pub fn add_cell(&mut self, bar_idx: usize, delta: f64, volume: f64) {
self.cells.push(HeatmapCell::new(bar_idx, delta, volume));
}
pub fn clear(&mut self) {
self.cells.clear();
}
pub fn render(
&self,
ui: &mut Ui,
strip_rect: Rect,
visible_range: (usize, usize),
bar_width: f32,
) {
if self.cells.is_empty() {
return;
}
let painter = ui.painter();
let bullish = ui.bullish_color();
let bearish = ui.bearish_color();
let neutral = ui.chart_bg();
painter.rect_filled(strip_rect, 0.0, neutral);
let cell_width = bar_width.max(self.config.min_cell_width);
for cell in &self.cells {
if cell.bar_idx < visible_range.0 || cell.bar_idx > visible_range.1 {
continue;
}
let relative_idx = cell.bar_idx - visible_range.0;
let x = strip_rect.left() + (relative_idx as f32 * cell_width);
if x + cell_width < strip_rect.left() || x > strip_rect.right() {
continue;
}
let cell_rect = Rect::from_min_size(
Pos2::new(x, strip_rect.top()),
Vec2::new(cell_width, strip_rect.height()),
);
let color = if self.config.use_gradient {
self.gradient_color(cell, bullish, bearish, neutral)
} else {
self.solid_color(cell, bullish, bearish)
};
let [r, g, b, _] = color.to_array();
let alpha = (255.0 * self.config.opacity) as u8;
let final_color = Color32::from_rgba_unmultiplied(r, g, b, alpha);
painter.rect_filled(cell_rect, 0.0, final_color);
}
painter.rect_stroke(
strip_rect,
0.0,
egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.border_color()),
egui::StrokeKind::Inside,
);
}
fn gradient_color(
&self,
cell: &HeatmapCell,
bullish: Color32,
bearish: Color32,
neutral: Color32,
) -> Color32 {
let intensity = cell.intensity;
if intensity > 0.5 {
let t = (intensity - 0.5) * 2.0; lerp_color(neutral, bullish, t)
} else {
let t = (0.5 - intensity) * 2.0; lerp_color(neutral, bearish, t)
}
}
fn solid_color(&self, cell: &HeatmapCell, bullish: Color32, bearish: Color32) -> Color32 {
if cell.is_buying() {
bullish
} else if cell.is_selling() {
bearish
} else {
Color32::TRANSPARENT
}
}
}
fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
let t = t.clamp(0.0, 1.0);
let [ar, ag, ab, aa] = a.to_array();
let [br, bg, bb, ba] = b.to_array();
Color32::from_rgba_unmultiplied(
lerp_u8(ar, br, t),
lerp_u8(ag, bg, t),
lerp_u8(ab, bb, t),
lerp_u8(aa, ba, t),
)
}
fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
(a as f32 + (b as f32 - a as f32) * t).round() as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_heatmap_cell_creation() {
let cell = HeatmapCell::new(0, 100.0, 500.0);
assert!(cell.is_buying());
assert!(!cell.is_selling());
let cell = HeatmapCell::new(1, -50.0, 300.0);
assert!(!cell.is_buying());
assert!(cell.is_selling());
}
#[test]
fn test_intensity_normalization() {
let cell = HeatmapCell::new(0, 100.0, 500.0).with_normalized_intensity(200.0);
assert!((cell.intensity - 0.75).abs() < 0.01);
let cell = HeatmapCell::new(0, -100.0, 500.0).with_normalized_intensity(200.0);
assert!((cell.intensity - 0.25).abs() < 0.01);
}
#[test]
fn test_strip_data_setting() {
let mut strip = HeatmapStrip::new();
strip.set_data(vec![(0, 100.0, 500.0), (1, -50.0, 300.0), (2, 75.0, 400.0)]);
assert_eq!(strip.cells.len(), 3);
assert!(strip.cells[0].is_buying());
assert!(strip.cells[1].is_selling());
}
#[test]
fn test_color_lerp() {
let white = Color32::WHITE;
let black = Color32::BLACK;
let mid = lerp_color(white, black, 0.5);
assert_eq!(mid.r(), 128);
assert_eq!(mid.g(), 128);
assert_eq!(mid.b(), 128);
}
}