Skip to main content

facett_plotters/
treemap.rs

1//! **Treemap** — weighted rectangles, area ∝ value, laid out by a deterministic
2//! **slice-and-dice** algorithm that alternates split direction by depth-of-cut
3//! so the tiling stays readable. The layout ([`squarify`]) is a pure function
4//! returning `(index, Rect)` pairs, tested independently of any painter. Tile
5//! fills come from a theme-derived sequential [`Ramp`](crate::ramp::Ramp).
6
7use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
8use facett_core::{Facet, FacetCaps, Semantics, a11y_node, theme};
9
10use crate::boxplot::paint_tooltip;
11use crate::ramp::{Ramp, normalize};
12
13/// A labelled, weighted item.
14#[derive(Clone, Debug, PartialEq)]
15pub struct Tile {
16    pub label: String,
17    pub value: f64,
18}
19
20impl Tile {
21    pub fn new(label: impl Into<String>, value: f64) -> Self {
22        Self { label: label.into(), value: value.max(0.0) }
23    }
24}
25
26/// Lay `weights` out into `area`, area ∝ weight, via squarified slice-and-dice.
27/// Returns `(original index, rect)` for each positive-weight item, in input
28/// order. Zero/negative weights are skipped. Pure geometry → directly testable.
29pub fn squarify(area: Rect, weights: &[f64]) -> Vec<(usize, Rect)> {
30    // Keep only positive weights, remembering their original index.
31    let items: Vec<(usize, f64)> = weights.iter().copied().enumerate().filter(|&(_, w)| w > 0.0).collect();
32    let total: f64 = items.iter().map(|(_, w)| w).sum();
33    let mut out = Vec::with_capacity(items.len());
34    if total <= 0.0 || items.is_empty() {
35        return out;
36    }
37    layout_slice(area, &items, total, &mut out);
38    out
39}
40
41/// Recursive squarify: at each step split the longest side and pack a prefix of
42/// items whose summed weight ≈ proportional to that strip, recursing on the rest.
43fn layout_slice(area: Rect, items: &[(usize, f64)], total: f64, out: &mut Vec<(usize, Rect)>) {
44    if items.is_empty() {
45        return;
46    }
47    if items.len() == 1 {
48        out.push((items[0].0, area));
49        return;
50    }
51    // Take a prefix summing to ~half the weight → balanced binary split.
52    let half = total / 2.0;
53    let mut acc = 0.0;
54    let mut cut = 0;
55    for (i, &(_, w)) in items.iter().enumerate() {
56        if acc + w > half && i > 0 {
57            break;
58        }
59        acc += w;
60        cut = i + 1;
61    }
62    cut = cut.clamp(1, items.len() - 1);
63    let (left, right) = items.split_at(cut);
64    let left_w: f64 = left.iter().map(|(_, w)| w).sum();
65    let frac = (left_w / total) as f32;
66
67    let (a, b) = if area.width() >= area.height() {
68        let xm = area.left() + area.width() * frac;
69        (Rect::from_min_max(area.min, Pos2::new(xm, area.bottom())), Rect::from_min_max(Pos2::new(xm, area.top()), area.max))
70    } else {
71        let ym = area.top() + area.height() * frac;
72        (Rect::from_min_max(area.min, Pos2::new(area.right(), ym)), Rect::from_min_max(Pos2::new(area.left(), ym), area.max))
73    };
74    layout_slice(a, left, left_w, out);
75    layout_slice(b, right, total - left_w, out);
76}
77
78/// A treemap over a flat list of weighted tiles.
79pub struct TreemapView {
80    pub title: String,
81    pub tiles: Vec<Tile>,
82}
83
84impl TreemapView {
85    pub fn new(title: impl Into<String>, tiles: Vec<Tile>) -> Self {
86        Self { title: title.into(), tiles }
87    }
88
89    fn value_range(&self) -> (f64, f64) {
90        let mut lo = f64::INFINITY;
91        let mut hi = f64::NEG_INFINITY;
92        for t in &self.tiles {
93            lo = lo.min(t.value);
94            hi = hi.max(t.value);
95        }
96        if lo.is_finite() { (lo, hi) } else { (0.0, 0.0) }
97    }
98
99    fn to_csv(&self) -> String {
100        let mut s = String::from("label,value\n");
101        for t in &self.tiles {
102            s.push_str(&format!("{},{}\n", t.label, t.value));
103        }
104        s
105    }
106}
107
108impl Facet for TreemapView {
109    fn title(&self) -> &str {
110        &self.title
111    }
112    fn caps(&self) -> FacetCaps {
113        FacetCaps::NONE.themeable().copyable().resizable()
114    }
115    fn copy(&mut self) -> Option<String> {
116        Some(self.to_csv())
117    }
118    fn ui(&mut self, ui: &mut Ui) {
119        let th = theme(ui);
120        let ramp = Ramp::sequential(&th);
121        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
122        let painter = ui.painter_at(rect);
123        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
124        let weights: Vec<f64> = self.tiles.iter().map(|t| t.value).collect();
125        let area = rect.shrink(6.0);
126        let rects = squarify(area, &weights);
127        if rects.is_empty() {
128            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
129            return;
130        }
131        let (vmin, vmax) = self.value_range();
132        let hover = resp.hover_pos();
133        let mut tip: Option<(Pos2, String)> = None;
134        let base = ui.id().with("treemap");
135
136        for (idx, r) in &rects {
137            let t = &self.tiles[*idx];
138            let color = ramp.at(normalize(t.value, vmin, vmax));
139            painter.rect_filled(r.shrink(1.0), egui::CornerRadius::same(2), color);
140            painter.rect_stroke(r.shrink(1.0), egui::CornerRadius::same(2), Stroke::new(1.0, th.bg), egui::StrokeKind::Inside);
141            // AccessKit node over the tile (FC-4): role=Image, label = the tile's
142            // label, numeric_value = its weight/area value. Keyed on the original
143            // index so identity is stable even if two tiles share a label (FC-5);
144            // the label is disambiguated with the index for query-uniqueness.
145            a11y_node(
146                ui,
147                base,
148                *idx,
149                Sense::hover(),
150                *r,
151                Semantics::image(format!("{} · tile {idx}", t.label)).value(t.value),
152            );
153            // Label only if the tile is roomy enough to read.
154            if r.width() > 42.0 && r.height() > 18.0 {
155                painter.text(
156                    r.left_top() + vec2(4.0, 3.0),
157                    Align2::LEFT_TOP,
158                    &t.label,
159                    FontId::proportional(11.0),
160                    contrast_text(color),
161                );
162            }
163            if let Some(hp) = hover {
164                if r.contains(hp) {
165                    tip = Some((hp, format!("{}\n{:.3}", t.label, t.value)));
166                }
167            }
168        }
169        if let Some((at, text)) = tip {
170            paint_tooltip(&painter, &th, at, &text);
171        }
172    }
173    fn state_json(&self) -> serde_json::Value {
174        let total: f64 = self.tiles.iter().map(|t| t.value).sum();
175        serde_json::json!({
176            "kind": "treemap",
177            "tiles": self.tiles.len(),
178            "total": total,
179            "labels": self.tiles.iter().map(|t| &t.label).collect::<Vec<_>>(),
180        })
181    }
182}
183
184/// Pick black or white text for legibility over `bg` (luminance threshold).
185fn contrast_text(bg: egui::Color32) -> egui::Color32 {
186    let lum = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32;
187    if lum > 140.0 { egui::Color32::from_gray(20) } else { egui::Color32::from_gray(235) }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    fn area() -> Rect {
195        Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(100.0, 100.0))
196    }
197
198    #[test]
199    fn rects_cover_area_and_match_count() {
200        let rects = squarify(area(), &[1.0, 1.0, 1.0, 1.0]);
201        assert_eq!(rects.len(), 4);
202        // total tiled area equals the input area (slice-and-dice is a partition)
203        let sum: f32 = rects.iter().map(|(_, r)| r.area()).sum();
204        assert!((sum - area().area()).abs() < 1.0, "tiles must partition the area");
205    }
206
207    #[test]
208    fn area_is_proportional_to_weight() {
209        let rects = squarify(area(), &[3.0, 1.0]);
210        let a0 = rects.iter().find(|(i, _)| *i == 0).unwrap().1.area();
211        let a1 = rects.iter().find(|(i, _)| *i == 1).unwrap().1.area();
212        // 3:1 weight → ~3:1 area
213        assert!((a0 / a1 - 3.0).abs() < 0.2, "ratio {} should be ~3", a0 / a1);
214    }
215
216    #[test]
217    fn zero_and_negative_weights_skipped() {
218        let rects = squarify(area(), &[2.0, 0.0, -5.0, 2.0]);
219        let idxs: Vec<usize> = rects.iter().map(|(i, _)| *i).collect();
220        assert_eq!(idxs, vec![0, 3]);
221    }
222
223    #[test]
224    fn empty_and_all_zero_produce_nothing() {
225        assert!(squarify(area(), &[]).is_empty());
226        assert!(squarify(area(), &[0.0, 0.0]).is_empty());
227    }
228
229    #[test]
230    fn single_tile_fills_whole_area() {
231        let rects = squarify(area(), &[5.0]);
232        assert_eq!(rects.len(), 1);
233        assert_eq!(rects[0].1, area());
234    }
235
236    #[test]
237    fn rects_are_disjoint() {
238        let rects = squarify(area(), &[1.0, 2.0, 3.0, 4.0, 5.0]);
239        for i in 0..rects.len() {
240            for j in (i + 1)..rects.len() {
241                let inter = rects[i].1.intersect(rects[j].1);
242                assert!(inter.width() < 0.5 || inter.height() < 0.5, "tiles {i},{j} overlap");
243            }
244        }
245    }
246}