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::clip::{ClipKind, ClipPayload, CopySource};
9use facett_core::{Elm, FacetCaps, Semantics, a11y_node, theme};
10use serde::{Deserialize, Serialize};
11
12use crate::boxplot::paint_tooltip;
13use crate::ramp::{Ramp, normalize};
14
15/// A labelled, weighted item.
16#[derive(Clone, Debug, PartialEq)]
17pub struct Tile {
18    pub label: String,
19    pub value: f64,
20}
21
22impl Tile {
23    pub fn new(label: impl Into<String>, value: f64) -> Self {
24        Self { label: label.into(), value: value.max(0.0) }
25    }
26}
27
28/// Lay `weights` out into `area`, area ∝ weight, via squarified slice-and-dice.
29/// Returns `(original index, rect)` for each positive-weight item, in input
30/// order. Zero/negative weights are skipped. Pure geometry → directly testable.
31pub fn squarify(area: Rect, weights: &[f64]) -> Vec<(usize, Rect)> {
32    // Keep only positive weights, remembering their original index.
33    let items: Vec<(usize, f64)> = weights.iter().copied().enumerate().filter(|&(_, w)| w > 0.0).collect();
34    let total: f64 = items.iter().map(|(_, w)| w).sum();
35    let mut out = Vec::with_capacity(items.len());
36    if total <= 0.0 || items.is_empty() {
37        return out;
38    }
39    layout_slice(area, &items, total, &mut out);
40    out
41}
42
43/// Recursive squarify: at each step split the longest side and pack a prefix of
44/// items whose summed weight ≈ proportional to that strip, recursing on the rest.
45fn layout_slice(area: Rect, items: &[(usize, f64)], total: f64, out: &mut Vec<(usize, Rect)>) {
46    if items.is_empty() {
47        return;
48    }
49    if items.len() == 1 {
50        out.push((items[0].0, area));
51        return;
52    }
53    // Take a prefix summing to ~half the weight → balanced binary split.
54    let half = total / 2.0;
55    let mut acc = 0.0;
56    let mut cut = 0;
57    for (i, &(_, w)) in items.iter().enumerate() {
58        if acc + w > half && i > 0 {
59            break;
60        }
61        acc += w;
62        cut = i + 1;
63    }
64    cut = cut.clamp(1, items.len() - 1);
65    let (left, right) = items.split_at(cut);
66    let left_w: f64 = left.iter().map(|(_, w)| w).sum();
67    let frac = (left_w / total) as f32;
68
69    let (a, b) = if area.width() >= area.height() {
70        let xm = area.left() + area.width() * frac;
71        (Rect::from_min_max(area.min, Pos2::new(xm, area.bottom())), Rect::from_min_max(Pos2::new(xm, area.top()), area.max))
72    } else {
73        let ym = area.top() + area.height() * frac;
74        (Rect::from_min_max(area.min, Pos2::new(area.right(), ym)), Rect::from_min_max(Pos2::new(area.left(), ym), area.max))
75    };
76    layout_slice(a, left, left_w, out);
77    layout_slice(b, right, total - left_w, out);
78}
79
80/// Persistent, observable interaction state (FC-3): the original tile index the
81/// pointer last hovered. Serialized so it round-trips headlessly.
82#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
83pub struct TreemapState {
84    /// The hovered tile's *original* index (into `tiles`), or `None`.
85    pub hover: Option<usize>,
86}
87
88/// FC-2 input surface. One input: the hovered tile changing (hit-tested purely in
89/// [`TreemapView::render`], applied in [`TreemapView::update`]).
90#[derive(Clone, Debug, PartialEq)]
91pub enum TreemapMsg {
92    /// Set (or clear, with `None`) the hovered tile index.
93    Hover(Option<usize>),
94}
95
96/// FC-8: the treemap does no I/O.
97#[derive(Clone, Debug)]
98pub enum TreemapEffect {}
99
100/// A treemap over a flat list of weighted tiles.
101pub struct TreemapView {
102    pub title: String,
103    pub tiles: Vec<Tile>,
104    /// Observable interaction state (FC-3).
105    pub state: TreemapState,
106}
107
108impl TreemapView {
109    pub fn new(title: impl Into<String>, tiles: Vec<Tile>) -> Self {
110        Self { title: title.into(), tiles, state: TreemapState::default() }
111    }
112
113    /// FC-3: read the complete observable interaction state.
114    pub fn state(&self) -> &TreemapState {
115        &self.state
116    }
117
118    /// FC-2/FC-8: the single mutation path. Apply one [`TreemapMsg`]; no effects.
119    pub fn update(&mut self, msg: TreemapMsg) -> Vec<TreemapEffect> {
120        match msg {
121            TreemapMsg::Hover(h) => self.state.hover = h,
122        }
123        Vec::new()
124    }
125
126    fn value_range(&self) -> (f64, f64) {
127        let mut lo = f64::INFINITY;
128        let mut hi = f64::NEG_INFINITY;
129        for t in &self.tiles {
130            lo = lo.min(t.value);
131            hi = hi.max(t.value);
132        }
133        if lo.is_finite() { (lo, hi) } else { (0.0, 0.0) }
134    }
135
136    fn to_csv(&self) -> String {
137        let mut s = String::from("label,value\n");
138        for t in &self.tiles {
139            s.push_str(&format!("{},{}\n", t.label, t.value));
140        }
141        s
142    }
143}
144
145// ── typed copy (§16) — the chart dataset as CSV Text ──────────────────
146impl CopySource for TreemapView {
147    fn copy_kinds(&self) -> &[ClipKind] {
148        &[ClipKind::Text]
149    }
150    fn copy_payload(&self) -> Option<ClipPayload> {
151        let csv = self.to_csv();
152        (!csv.is_empty()).then_some(ClipPayload::Text(csv))
153    }
154}
155
156impl TreemapView {
157    /// FC-9 pure view: paints the tiles + hover tooltip (from the *stored* hover
158    /// tile) and **returns** the hovered-tile change as a `Msg` — never mutates.
159    fn render(&self, ui: &mut Ui) -> Vec<TreemapMsg> {
160        let th = theme(ui);
161        let ramp = Ramp::sequential(&th);
162        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
163        let painter = ui.painter_at(rect);
164        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
165        let weights: Vec<f64> = self.tiles.iter().map(|t| t.value).collect();
166        let area = rect.shrink(6.0);
167        let rects = squarify(area, &weights);
168        if rects.is_empty() {
169            // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
170            #[cfg(feature = "testmatrix")]
171            facett_core::testmatrix::emit(
172                "facett-plotters::TreemapView::ui",
173                "ui_render",
174                rects.is_empty(),
175                &format!("drew=empty_hint tiles={} rects={}", self.tiles.len(), rects.len()),
176            );
177            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
178            return hover_msg(self.state.hover, None);
179        }
180        let (vmin, vmax) = self.value_range();
181        let hover = resp.hover_pos();
182        let mut new_hover: Option<usize> = None;
183        let base = ui.id().with("treemap");
184
185        for (idx, r) in &rects {
186            let t = &self.tiles[*idx];
187            let color = ramp.at(normalize(t.value, vmin, vmax));
188            painter.rect_filled(r.shrink(1.0), egui::CornerRadius::same(2), color);
189            painter.rect_stroke(r.shrink(1.0), egui::CornerRadius::same(2), Stroke::new(1.0_f32, th.bg), egui::StrokeKind::Inside);
190            // AccessKit node over the tile (FC-4): role=Image, label = the tile's
191            // label, numeric_value = its weight/area value. Keyed on the original
192            // index so identity is stable even if two tiles share a label (FC-5);
193            // the label is disambiguated with the index for query-uniqueness.
194            a11y_node(
195                ui,
196                base,
197                *idx,
198                Sense::hover(),
199                *r,
200                Semantics::image(format!("{} · tile {idx}", t.label)).value(t.value),
201            );
202            // Label only if the tile is roomy enough to read.
203            if r.width() > 42.0 && r.height() > 18.0 {
204                painter.text(
205                    r.left_top() + vec2(4.0, 3.0),
206                    Align2::LEFT_TOP,
207                    &t.label,
208                    FontId::proportional(11.0),
209                    contrast_text(color),
210                );
211            }
212            if let Some(hp) = hover {
213                if r.contains(hp) {
214                    new_hover = Some(*idx);
215                }
216            }
217        }
218        // Tooltip painted last (on top) from the *persisted* hover tile (set by
219        // `update` last frame), keeping `render` a pure function of `&self`.
220        if let Some(hp) = hover {
221            if let Some(t) = self.state.hover.and_then(|i| self.tiles.get(i)) {
222                paint_tooltip(&painter, &th, hp, &format!("{}\n{:.3}", t.label, t.value));
223            }
224        }
225        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
226        #[cfg(feature = "testmatrix")]
227        facett_core::testmatrix::emit(
228            "facett-plotters::TreemapView::ui",
229            "ui_render",
230            !rects.is_empty() && rects.len() <= self.tiles.len(),
231            &format!("tiles={} rects={}", self.tiles.len(), rects.len()),
232        );
233        hover_msg(self.state.hover, new_hover)
234    }
235}
236
237/// Emit a `Hover` message only when the hovered tile actually changed.
238fn hover_msg(current: Option<usize>, next: Option<usize>) -> Vec<TreemapMsg> {
239    if current != next { vec![TreemapMsg::Hover(next)] } else { Vec::new() }
240}
241
242impl Elm for TreemapView {
243    type Model = TreemapState;
244    type Msg = TreemapMsg;
245    type Effect = TreemapEffect;
246
247    fn title(&self) -> &str {
248        &self.title
249    }
250    fn state(&self) -> &TreemapState {
251        &self.state
252    }
253    fn update(&mut self, msg: TreemapMsg) -> Vec<TreemapEffect> {
254        TreemapView::update(self, msg)
255    }
256    fn view(&self, ui: &mut Ui) -> Vec<TreemapMsg> {
257        self.render(ui)
258    }
259}
260
261// Form 3: richer derived `state_json` (tile count + total + labels) preserved
262// exactly, so the macro's default `serde(state())` is suppressed.
263facett_core::impl_facet_via_elm!(TreemapView, custom_state_json, {
264    fn caps(&self) -> FacetCaps {
265        FacetCaps::NONE.themeable().copyable().resizable()
266    }
267    fn copy(&mut self) -> Option<String> {
268        Some(self.to_csv())
269    }
270    fn state_json(&self) -> serde_json::Value {
271        let total: f64 = self.tiles.iter().map(|t| t.value).sum();
272        serde_json::json!({
273            "kind": "treemap",
274            "tiles": self.tiles.len(),
275            "total": total,
276            "labels": self.tiles.iter().map(|t| &t.label).collect::<Vec<_>>(),
277        })
278    }
279});
280
281/// Pick black or white text for legibility over `bg` (luminance threshold).
282fn contrast_text(bg: egui::Color32) -> egui::Color32 {
283    let lum = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32;
284    if lum > 140.0 { egui::Color32::from_gray(20) } else { egui::Color32::from_gray(235) }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn typed_copy_is_the_dataset_csv() {
293        use facett_core::clip::{ClipKind, CopySource};
294        let v = TreemapView::new("t", vec![Tile::new("a", 3.0), Tile::new("b", 7.0)]);
295        let p = v.copy_payload().expect("tiles copy");
296        assert_eq!(p.kind(), ClipKind::Text);
297        assert_eq!(p.as_text(), v.to_csv());
298    }
299
300    fn area() -> Rect {
301        Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(100.0, 100.0))
302    }
303
304    #[test]
305    fn rects_cover_area_and_match_count() {
306        let rects = squarify(area(), &[1.0, 1.0, 1.0, 1.0]);
307        assert_eq!(rects.len(), 4);
308        // total tiled area equals the input area (slice-and-dice is a partition)
309        let sum: f32 = rects.iter().map(|(_, r)| r.area()).sum();
310        assert!((sum - area().area()).abs() < 1.0, "tiles must partition the area");
311    }
312
313    #[test]
314    fn area_is_proportional_to_weight() {
315        let rects = squarify(area(), &[3.0, 1.0]);
316        let a0 = rects.iter().find(|(i, _)| *i == 0).unwrap().1.area();
317        let a1 = rects.iter().find(|(i, _)| *i == 1).unwrap().1.area();
318        // 3:1 weight → ~3:1 area
319        assert!((a0 / a1 - 3.0).abs() < 0.2, "ratio {} should be ~3", a0 / a1);
320    }
321
322    #[test]
323    fn zero_and_negative_weights_skipped() {
324        let rects = squarify(area(), &[2.0, 0.0, -5.0, 2.0]);
325        let idxs: Vec<usize> = rects.iter().map(|(i, _)| *i).collect();
326        assert_eq!(idxs, vec![0, 3]);
327    }
328
329    #[test]
330    fn empty_and_all_zero_produce_nothing() {
331        assert!(squarify(area(), &[]).is_empty());
332        assert!(squarify(area(), &[0.0, 0.0]).is_empty());
333    }
334
335    #[test]
336    fn single_tile_fills_whole_area() {
337        let rects = squarify(area(), &[5.0]);
338        assert_eq!(rects.len(), 1);
339        assert_eq!(rects[0].1, area());
340    }
341
342    #[test]
343    fn rects_are_disjoint() {
344        let rects = squarify(area(), &[1.0, 2.0, 3.0, 4.0, 5.0]);
345        for i in 0..rects.len() {
346            for j in (i + 1)..rects.len() {
347                let inter = rects[i].1.intersect(rects[j].1);
348                assert!(inter.width() < 0.5 || inter.height() < 0.5, "tiles {i},{j} overlap");
349            }
350        }
351    }
352
353    // ── Elm (FC-2/FC-3) harness properties ────────────────────────────────
354    #[test]
355    fn hover_transitions_are_observable() {
356        let mut v = TreemapView::new("t", vec![Tile::new("a", 3.0), Tile::new("b", 7.0)]);
357        assert_eq!(v.state().hover, None);
358        let snap = facett_core::harness::snapshot(&mut v, [TreemapMsg::Hover(Some(1))]);
359        assert_eq!(snap.hover, Some(1));
360        let snap = facett_core::harness::snapshot(&mut v, [TreemapMsg::Hover(None)]);
361        assert_eq!(snap.hover, None);
362    }
363
364    #[test]
365    fn drive_produces_no_effects() {
366        let mut v = TreemapView::new("t", vec![Tile::new("a", 3.0)]);
367        let effects = facett_core::harness::drive(&mut v, [TreemapMsg::Hover(Some(0)), TreemapMsg::Hover(None)]);
368        assert!(effects.is_empty());
369    }
370
371    #[test]
372    fn state_serde_round_trips() {
373        let st = TreemapState { hover: Some(4) };
374        let back: TreemapState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
375        assert_eq!(st, back);
376    }
377}