diff --git a/crates/modules/pineal/treemap/src/lib.rs b/crates/modules/pineal/treemap/src/lib.rs index 2c6b395..d96976f 100644 --- a/crates/modules/pineal/treemap/src/lib.rs +++ b/crates/modules/pineal/treemap/src/lib.rs @@ -1,12 +1,14 @@ //! `pineal-treemap` — treemap squarified. //! -//! Algoritmo en `pineal_core::squarify` (placeholder); el `Element` -//! sólo se encarga de iterar las tiles resultantes y dibujarlas. -//! Pre-scaling de valores al area total del rect es clave para -//! estabilidad numérica con rangos amplios. +//! - [`squarify`] — algoritmo de Bruls, Huizing & van Wijk (2000): +//! asigna a cada peso un rect de área proporcional minimizando el +//! peor aspect ratio. Pre-escala los pesos al área del rect destino. +//! - [`paint`] — painter agnóstico: tiles → `fill_rect` contra `Canvas`. #![forbid(unsafe_code)] -#![allow(dead_code)] -pub mod tile {} -pub mod element {} +pub mod squarify; +pub mod paint; + +pub use paint::{paint_treemap, Tile}; +pub use squarify::squarify; diff --git a/crates/modules/pineal/treemap/src/paint.rs b/crates/modules/pineal/treemap/src/paint.rs new file mode 100644 index 0000000..00c7644 --- /dev/null +++ b/crates/modules/pineal/treemap/src/paint.rs @@ -0,0 +1,74 @@ +//! Painter agnóstico del treemap: tiles → `fill_rect` contra un `Canvas`. + +use crate::squarify::squarify; +use pineal_render::{Canvas, Color, Rect}; + +/// Una tile del treemap: su peso (área relativa) y su color. +#[derive(Debug, Clone, Copy)] +pub struct Tile { + pub weight: f64, + pub color: Color, +} + +impl Tile { + pub fn new(weight: f64, color: Color) -> Self { + Self { weight, color } + } +} + +/// Dibuja un treemap de `tiles` dentro de `area`. `gap` es el margen +/// (en px) que se recorta de cada lado de cada tile, para separarlas +/// visualmente. Tiles cuya área no alcanza para el gap se omiten. +pub fn paint_treemap(tiles: &[Tile], area: Rect, gap: f32, canvas: &mut dyn Canvas) { + let weights: Vec = tiles.iter().map(|t| t.weight).collect(); + let rects = squarify(&weights, area); + for (tile, r) in tiles.iter().zip(&rects) { + let inset = Rect::new( + r.x + gap, + r.y + gap, + r.w - 2.0 * gap, + r.h - 2.0 * gap, + ); + if inset.w > 0.0 && inset.h > 0.0 { + canvas.fill_rect(inset, tile.color); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pineal_render::{PlanRecorder, RenderCmd}; + + #[test] + fn one_fill_rect_per_visible_tile() { + let tiles = [ + Tile::new(3.0, Color::WHITE), + Tile::new(2.0, Color::BLACK), + Tile::new(1.0, Color::from_hex(0x00ff00)), + ]; + let mut rec = PlanRecorder::new(); + paint_treemap(&tiles, Rect::new(0.0, 0.0, 300.0, 200.0), 1.0, &mut rec); + let n = rec + .into_plan() + .cmds + .iter() + .filter(|c| matches!(c, RenderCmd::FillRect { .. })) + .count(); + assert_eq!(n, 3); + } + + #[test] + fn zero_weight_tile_is_skipped() { + let tiles = [Tile::new(1.0, Color::WHITE), Tile::new(0.0, Color::BLACK)]; + let mut rec = PlanRecorder::new(); + paint_treemap(&tiles, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0, &mut rec); + let n = rec + .into_plan() + .cmds + .iter() + .filter(|c| matches!(c, RenderCmd::FillRect { .. })) + .count(); + assert_eq!(n, 1); + } +} diff --git a/crates/modules/pineal/treemap/src/squarify.rs b/crates/modules/pineal/treemap/src/squarify.rs new file mode 100644 index 0000000..6a82720 --- /dev/null +++ b/crates/modules/pineal/treemap/src/squarify.rs @@ -0,0 +1,157 @@ +//! Treemap squarified (Bruls, Huizing & van Wijk, 2000). +//! +//! Asigna a cada peso un rectángulo de área proporcional, minimizando +//! el peor aspect ratio (rects lo más cuadrados posible). Pre-escala los +//! pesos al área del rect destino para estabilidad numérica. + +use pineal_render::Rect; + +/// Calcula el layout: devuelve un `Rect` por peso, en el mismo orden de +/// entrada. Pesos `<= 0` o no finitos reciben un rect de área cero. +pub fn squarify(weights: &[f64], area: Rect) -> Vec { + let n = weights.len(); + let zero = Rect::new(area.x, area.y, 0.0, 0.0); + let mut out = vec![zero; n]; + + let area_px = area.w as f64 * area.h as f64; + let total: f64 = weights.iter().filter(|w| w.is_finite() && **w > 0.0).sum(); + if n == 0 || total <= 0.0 || area_px <= 0.0 { + return out; + } + let scale = area_px / total; + + // Sólo los pesos positivos participan; los demás quedan con rect cero. + // `idx` se ordena por área descendente — mejora los aspect ratios. + let areas: Vec = weights + .iter() + .map(|w| if w.is_finite() && *w > 0.0 { w * scale } else { 0.0 }) + .collect(); + let mut idx: Vec = (0..n).filter(|&i| areas[i] > 0.0).collect(); + idx.sort_by(|&a, &b| areas[b].partial_cmp(&areas[a]).unwrap_or(std::cmp::Ordering::Equal)); + + let mut free = area; + let mut row: Vec = Vec::new(); + let mut i = 0; + + while i < idx.len() { + let side = free.w.min(free.h) as f64; + let cur = worst_ratio(&row, &areas, side); + row.push(idx[i]); + let with_next = worst_ratio(&row, &areas, side); + + if cur > 0.0 && with_next > cur { + // Agregar el item empeoró el ratio: revertir, cerrar la fila. + row.pop(); + free = layout_row(&row, &areas, free, &mut out); + row.clear(); + } else { + i += 1; + } + } + if !row.is_empty() { + layout_row(&row, &areas, free, &mut out); + } + out +} + +/// Peor aspect ratio de una fila tendida sobre un lado de longitud `side`. +/// Fórmula de Bruls et al.: `max(side²·max / sum², sum² / (side²·min))`. +fn worst_ratio(row: &[usize], areas: &[f64], side: f64) -> f64 { + if row.is_empty() || side <= 0.0 { + return 0.0; + } + let mut sum = 0.0; + let mut mx = f64::MIN; + let mut mn = f64::MAX; + for &i in row { + let a = areas[i]; + sum += a; + mx = mx.max(a); + mn = mn.min(a); + } + if sum <= 0.0 || mn <= 0.0 { + return f64::INFINITY; + } + let s2 = sum * sum; + let w2 = side * side; + (w2 * mx / s2).max(s2 / (w2 * mn)) +} + +/// Tiende una fila sobre el lado corto del rect libre y devuelve el rect +/// libre restante. +fn layout_row(row: &[usize], areas: &[f64], free: Rect, out: &mut [Rect]) -> Rect { + let sum: f64 = row.iter().map(|&i| areas[i]).sum(); + if sum <= 0.0 { + return free; + } + if free.w >= free.h { + // Columna a la izquierda; items apilados verticalmente. + let col_w = (sum / free.h as f64) as f32; + let mut y = free.y; + for &i in row { + let h = (areas[i] / sum * free.h as f64) as f32; + out[i] = Rect::new(free.x, y, col_w, h); + y += h; + } + Rect::new(free.x + col_w, free.y, free.w - col_w, free.h) + } else { + // Fila arriba; items lado a lado horizontalmente. + let row_h = (sum / free.w as f64) as f32; + let mut x = free.x; + for &i in row { + let w = (areas[i] / sum * free.w as f64) as f32; + out[i] = Rect::new(x, free.y, w, row_h); + x += w; + } + Rect::new(free.x, free.y + row_h, free.w, free.h - row_h) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn area_of(r: &Rect) -> f64 { + r.w as f64 * r.h as f64 + } + + #[test] + fn empty_input() { + assert!(squarify(&[], Rect::new(0.0, 0.0, 100.0, 100.0)).is_empty()); + } + + #[test] + fn single_item_fills_rect() { + let rects = squarify(&[1.0], Rect::new(0.0, 0.0, 100.0, 50.0)); + assert_eq!(rects.len(), 1); + assert!((area_of(&rects[0]) - 5000.0).abs() < 1.0); + } + + #[test] + fn areas_proportional_to_weights() { + let area = Rect::new(0.0, 0.0, 200.0, 100.0); + let rects = squarify(&[1.0, 1.0, 2.0], area); + let total: f64 = rects.iter().map(area_of).sum(); + assert!((total - 20_000.0).abs() < 5.0, "área total ≈ rect"); + // El tercer item pesa el doble que cada uno de los otros. + assert!((area_of(&rects[2]) - 2.0 * area_of(&rects[0])).abs() < 50.0); + } + + #[test] + fn zero_and_negative_weights_get_empty_rects() { + let rects = squarify(&[1.0, 0.0, -3.0], Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(area_of(&rects[1]) == 0.0); + assert!(area_of(&rects[2]) == 0.0); + assert!((area_of(&rects[0]) - 10_000.0).abs() < 1.0); + } + + #[test] + fn all_rects_within_bounds() { + let area = Rect::new(10.0, 20.0, 300.0, 200.0); + let rects = squarify(&[5.0, 3.0, 8.0, 1.0, 2.0, 6.0], area); + for r in &rects { + assert!(r.x >= area.x - 0.01 && r.right() <= area.right() + 0.01); + assert!(r.y >= area.y - 0.01 && r.bottom() <= area.bottom() + 0.01); + } + } +}