diff --git a/crates/modules/pineal/heatmap/src/encoder.rs b/crates/modules/pineal/heatmap/src/encoder.rs new file mode 100644 index 0000000..67bcc44 --- /dev/null +++ b/crates/modules/pineal/heatmap/src/encoder.rs @@ -0,0 +1,57 @@ +//! Encoder: `HeatmapMatrix` → buffer ARGB para subir como textura. + +use crate::matrix::HeatmapMatrix; +use crate::palette::Ramp; + +/// Normaliza cada celda a `[0,1]` por min/max, la mapea por la rampa y +/// empaqueta el resultado como `u32` ARGB (0xAARRGGBB), fila por fila. +/// +/// El backend GPUI sube este buffer como una textura y la rendea con un +/// solo `drawImageRect`, en vez de N draw calls. +pub fn encode_argb(matrix: &HeatmapMatrix, ramp: Ramp) -> Vec { + let (min, max) = matrix.min_max(); + let span = max - min; + let mut out = Vec::with_capacity(matrix.width() * matrix.height()); + for &v in matrix.data() { + let t = if span > 0.0 { (v - min) / span } else { 0.0 }; + let c = ramp.sample(t); + out.push(pack_argb(c.a, c.r, c.g, c.b)); + } + out +} + +/// Empaqueta 4 canales `f32` `[0,1]` en `0xAARRGGBB`. +fn pack_argb(a: f32, r: f32, g: f32, b: f32) -> u32 { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32; + (q(a) << 24) | (q(r) << 16) | (q(g) << 8) | q(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_one_pixel_per_cell() { + let m = HeatmapMatrix::from_data(vec![0.0, 1.0, 2.0, 3.0], 2, 2).unwrap(); + let buf = encode_argb(&m, Ramp::Grayscale); + assert_eq!(buf.len(), 4); + } + + #[test] + fn normalizes_min_to_ramp_start() { + // Grayscale: min → negro (0x000000), max → blanco (0xffffff). + let m = HeatmapMatrix::from_data(vec![10.0, 20.0], 2, 1).unwrap(); + let buf = encode_argb(&m, Ramp::Grayscale); + assert_eq!(buf[0] & 0x00ff_ffff, 0x0000_0000); // min + assert_eq!(buf[1] & 0x00ff_ffff, 0x00ff_ffff); // max + assert_eq!(buf[0] >> 24, 0xff); // alpha opaco + } + + #[test] + fn flat_matrix_does_not_divide_by_zero() { + let m = HeatmapMatrix::from_data(vec![5.0; 4], 2, 2).unwrap(); + let buf = encode_argb(&m, Ramp::Viridis); + assert_eq!(buf.len(), 4); + assert!(buf.iter().all(|&p| p == buf[0])); + } +} diff --git a/crates/modules/pineal/heatmap/src/lib.rs b/crates/modules/pineal/heatmap/src/lib.rs index f8575fd..4179864 100644 --- a/crates/modules/pineal/heatmap/src/lib.rs +++ b/crates/modules/pineal/heatmap/src/lib.rs @@ -1,21 +1,23 @@ -//! `pineal-heatmap` — matriz `[width × height]` de `f32` → imagen. +//! `pineal-heatmap` — matriz `width × height` de `f32` → visualización. //! -//! Para matrices grandes (4096² = 67 MB de pixels), encodear la -//! imagen una vez al cambiar la data y renderear con un solo -//! `drawImageRect` (o equivalente GPUI). Eso convierte el coste -//! de cada frame en "blit de una textura", sub-millisecond. +//! Dos caminos de render: +//! - [`paint`] — agnóstico, un `fill_rect` por celda contra un `Canvas`. +//! Apto para matrices chicas y export SVG. +//! - [`encoder::encode_argb`] — empaqueta la matriz como buffer ARGB para +//! que un backend lo suba como textura y la rendee con un solo blit. +//! Apto para matrices grandes (4096² sin sudar). //! -//! - **`matrix`** — `HeatmapMatrix { data: Vec, width, height, -//! revision }`. -//! - **`palette`** — color ramps (viridis, plasma, gray…). -//! - **`encoder`** — convierte la matrix a un buffer ARGB para -//! subir como textura. -//! - **`element`** — `Element` GPUI. +//! - [`matrix`] — `HeatmapMatrix` con `revision` para invalidación. +//! - [`palette`] — color ramps (Viridis, Grayscale). #![forbid(unsafe_code)] -#![allow(dead_code)] -pub mod matrix {} -pub mod palette {} -pub mod encoder {} -pub mod element {} +pub mod matrix; +pub mod palette; +pub mod encoder; +pub mod paint; + +pub use encoder::encode_argb; +pub use matrix::HeatmapMatrix; +pub use paint::paint; +pub use palette::Ramp; diff --git a/crates/modules/pineal/heatmap/src/matrix.rs b/crates/modules/pineal/heatmap/src/matrix.rs new file mode 100644 index 0000000..acaeba0 --- /dev/null +++ b/crates/modules/pineal/heatmap/src/matrix.rs @@ -0,0 +1,93 @@ +//! `HeatmapMatrix` — matriz densa `width × height` de `f32`. + +/// Matriz de valores para un heatmap. `revision` se incrementa en cada +/// mutación — los backends lo usan para invalidar la textura cacheada. +#[derive(Debug, Clone)] +pub struct HeatmapMatrix { + data: Vec, + width: usize, + height: usize, + revision: u64, +} + +impl HeatmapMatrix { + /// Matriz de ceros de `width × height`. + pub fn new(width: usize, height: usize) -> Self { + Self { data: vec![0.0; width * height], width, height, revision: 0 } + } + + /// Construye desde datos crudos. `None` si `data.len() != width*height`. + pub fn from_data(data: Vec, width: usize, height: usize) -> Option { + if data.len() != width * height { + return None; + } + Some(Self { data, width, height, revision: 0 }) + } + + pub fn width(&self) -> usize { self.width } + pub fn height(&self) -> usize { self.height } + pub fn revision(&self) -> u64 { self.revision } + pub fn data(&self) -> &[f32] { &self.data } + + /// Valor en `(x, y)`. `0.0` si está fuera de rango. + pub fn get(&self, x: usize, y: usize) -> f32 { + if x >= self.width || y >= self.height { + return 0.0; + } + self.data[y * self.width + x] + } + + /// Fija el valor en `(x, y)` e incrementa `revision`. No-op si está + /// fuera de rango. + pub fn set(&mut self, x: usize, y: usize, v: f32) { + if x >= self.width || y >= self.height { + return; + } + self.data[y * self.width + x] = v; + self.revision += 1; + } + + /// Reemplaza todos los datos (mismas dimensiones) e incrementa + /// `revision`. No-op si la longitud no coincide. + pub fn replace_data(&mut self, data: Vec) { + if data.len() == self.width * self.height { + self.data = data; + self.revision += 1; + } + } + + /// `(min, max)` de los valores. `(0.0, 0.0)` si la matriz está vacía. + pub fn min_max(&self) -> (f32, f32) { + let mut it = self.data.iter().copied(); + let Some(first) = it.next() else { return (0.0, 0.0) }; + it.fold((first, first), |(lo, hi), v| (lo.min(v), hi.max(v))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_data_checks_length() { + assert!(HeatmapMatrix::from_data(vec![1.0; 6], 2, 3).is_some()); + assert!(HeatmapMatrix::from_data(vec![1.0; 5], 2, 3).is_none()); + } + + #[test] + fn get_set_and_revision() { + let mut m = HeatmapMatrix::new(3, 2); + assert_eq!(m.revision(), 0); + m.set(1, 1, 4.5); + assert_eq!(m.get(1, 1), 4.5); + assert_eq!(m.revision(), 1); + m.set(99, 99, 1.0); // fuera de rango → no-op + assert_eq!(m.revision(), 1); + } + + #[test] + fn min_max_over_values() { + let m = HeatmapMatrix::from_data(vec![3.0, -1.0, 7.0, 2.0], 2, 2).unwrap(); + assert_eq!(m.min_max(), (-1.0, 7.0)); + } +} diff --git a/crates/modules/pineal/heatmap/src/paint.rs b/crates/modules/pineal/heatmap/src/paint.rs new file mode 100644 index 0000000..4d0f909 --- /dev/null +++ b/crates/modules/pineal/heatmap/src/paint.rs @@ -0,0 +1,61 @@ +//! Painter agnóstico: dibuja una `HeatmapMatrix` contra un `Canvas`. +//! +//! Emite un `fill_rect` por celda. Apto para matrices chicas y para +//! export SVG. Para matrices grandes el backend GPUI usa +//! [`crate::encoder`] + textura en vez de este camino. + +use crate::matrix::HeatmapMatrix; +use crate::palette::Ramp; +use pineal_render::{Canvas, Rect}; + +/// Dibuja `matrix` dentro de `area`, una celda = un `fill_rect`. +/// Los valores se normalizan por min/max de la matriz. +pub fn paint(matrix: &HeatmapMatrix, ramp: Ramp, area: Rect, canvas: &mut dyn Canvas) { + let (w, h) = (matrix.width(), matrix.height()); + if w == 0 || h == 0 { + return; + } + let (min, max) = matrix.min_max(); + let span = max - min; + let cell_w = area.w / w as f32; + let cell_h = area.h / h as f32; + + for y in 0..h { + for x in 0..w { + let v = matrix.get(x, y); + let t = if span > 0.0 { (v - min) / span } else { 0.0 }; + let color = ramp.sample(t); + let rect = Rect::new( + area.x + x as f32 * cell_w, + area.y + y as f32 * cell_h, + cell_w, + cell_h, + ); + canvas.fill_rect(rect, color); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pineal_render::{PlanRecorder, RenderCmd}; + + #[test] + fn emits_one_fill_rect_per_cell() { + let m = HeatmapMatrix::from_data(vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0], 3, 2).unwrap(); + let mut rec = PlanRecorder::new(); + paint(&m, Ramp::Viridis, Rect::new(0.0, 0.0, 300.0, 200.0), &mut rec); + let plan = rec.into_plan(); + assert_eq!(plan.cmds.len(), 6); + assert!(plan.cmds.iter().all(|c| matches!(c, RenderCmd::FillRect { .. }))); + } + + #[test] + fn empty_matrix_emits_nothing() { + let m = HeatmapMatrix::new(0, 0); + let mut rec = PlanRecorder::new(); + paint(&m, Ramp::Viridis, Rect::new(0.0, 0.0, 10.0, 10.0), &mut rec); + assert!(rec.into_plan().cmds.is_empty()); + } +} diff --git a/crates/modules/pineal/heatmap/src/palette.rs b/crates/modules/pineal/heatmap/src/palette.rs new file mode 100644 index 0000000..14b3ef1 --- /dev/null +++ b/crates/modules/pineal/heatmap/src/palette.rs @@ -0,0 +1,94 @@ +//! Color ramps para heatmaps. Interpolación lineal entre control points. + +use pineal_render::Color; + +/// Rampa de color. `sample(t)` mapea `t ∈ [0,1]` a un `Color`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ramp { + /// Viridis — perceptualmente uniforme, dark-purple → yellow. + Viridis, + /// Escala de grises lineal, negro → blanco. + Grayscale, +} + +impl Ramp { + /// Mapea `t` (se clampa a `[0,1]`) a un color de la rampa. + pub fn sample(&self, t: f32) -> Color { + let t = t.clamp(0.0, 1.0); + match self { + Ramp::Grayscale => Color::rgb(t, t, t), + Ramp::Viridis => lerp_stops(t, VIRIDIS), + } + } +} + +/// Control points de Viridis (aproximación de 5 stops del colormap real). +const VIRIDIS: &[(f32, u32)] = &[ + (0.00, 0x440154), + (0.25, 0x3b528b), + (0.50, 0x21918c), + (0.75, 0x5ec962), + (1.00, 0xfde725), +]; + +/// Interpola linealmente entre los stops `(pos, hex)` ordenados por `pos`. +fn lerp_stops(t: f32, stops: &[(f32, u32)]) -> Color { + if stops.is_empty() { + return Color::BLACK; + } + if t <= stops[0].0 { + return Color::from_hex(stops[0].1); + } + let last = stops[stops.len() - 1]; + if t >= last.0 { + return Color::from_hex(last.1); + } + for w in stops.windows(2) { + let (p0, c0) = w[0]; + let (p1, c1) = w[1]; + if t >= p0 && t <= p1 { + let local = (t - p0) / (p1 - p0); + return lerp_color(Color::from_hex(c0), Color::from_hex(c1), local); + } + } + Color::from_hex(last.1) +} + +fn lerp_color(a: Color, b: Color, t: f32) -> Color { + Color::rgba( + a.r + (b.r - a.r) * t, + a.g + (b.g - a.g) * t, + a.b + (b.b - a.b) * t, + a.a + (b.a - a.a) * t, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grayscale_endpoints() { + assert_eq!(Ramp::Grayscale.sample(0.0), Color::BLACK); + assert_eq!(Ramp::Grayscale.sample(1.0), Color::WHITE); + } + + #[test] + fn viridis_endpoints_match_control_points() { + assert_eq!(Ramp::Viridis.sample(0.0), Color::from_hex(0x440154)); + assert_eq!(Ramp::Viridis.sample(1.0), Color::from_hex(0xfde725)); + } + + #[test] + fn sample_clamps_out_of_range() { + assert_eq!(Ramp::Viridis.sample(-5.0), Ramp::Viridis.sample(0.0)); + assert_eq!(Ramp::Viridis.sample(5.0), Ramp::Viridis.sample(1.0)); + } + + #[test] + fn viridis_midpoint_is_between() { + let mid = Ramp::Viridis.sample(0.5); + // El stop de 0.5 es 0x21918c. + assert_eq!(mid, Color::from_hex(0x21918c)); + } +}