feat(pineal): cierra stub treemap — squarified
Fase F: cuarto stub de pineal cerrado.
- squarify — algoritmo de Bruls, Huizing & van Wijk (2000): asigna a
cada peso un rect de área proporcional minimizando el peor aspect
ratio (rects lo más cuadrados posible). Pre-escala pesos al área del
rect; ordena descendente; tiende filas sobre el lado corto cerrándolas
cuando agregar un item empeora el ratio. Pesos <=0 → rect vacío.
- paint — painter agnóstico: tiles → fill_rect con gap configurable.
7 tests verdes (proporcionalidad, bounds, edge cases). cargo check
--workspace verde.
Pineal: 4/6 stubs cerrados (export, heatmap, polar, treemap).
Restan flow (sankey) y mesh (graph layout: force-directed/Sugiyama) —
ambos requieren algoritmos de layout sustantivos, foco dedicado.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
590572b5bb7ef0a2077169c50f88406f1ec076d4 · by sergio · 2026-05-20T14:16:31Z (5 months ago) · parent 370a593ad · browse the tree at this commit
Semantic delta · 20
crates/modules/pineal/treemap/src/lib.rs
crates/modules/pineal/treemap/src/paint.rs
crates/modules/pineal/treemap/src/squarify.rs
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<f64> = 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<Rect> {
+ 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<f64> = weights
+ .iter()
+ .map(|w| if w.is_finite() && *w > 0.0 { w * scale } else { 0.0 })
+ .collect();
+ let mut idx: Vec<usize> = (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<usize> = 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);
+ }
+ }
+}