feat(pineal): cierra stub heatmap — matrix + viridis + encoder + paint
Fase F: segundo stub de pineal cerrado.
- matrix — HeatmapMatrix densa width×height de f32, con revision para
invalidación de textura; get/set/min_max/replace_data.
- palette — Ramp::{Viridis, Grayscale}; Viridis por interpolación
lineal de 5 control points perceptualmente uniformes.
- encoder — encode_argb: normaliza por min/max + rampa + pack 0xAARRGGBB
para subir como textura (camino de matrices grandes).
- paint — painter agnóstico: un fill_rect por celda contra un Canvas
(camino de matrices chicas + export SVG).
12 tests verdes. cargo check --workspace verde.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<u32> {
|
||||
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]));
|
||||
}
|
||||
}
|
||||
@@ -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<f32>, 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;
|
||||
|
||||
@@ -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<f32>,
|
||||
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<f32>, width: usize, height: usize) -> Option<Self> {
|
||||
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<f32>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user