1use 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#[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
28pub fn squarify(area: Rect, weights: &[f64]) -> Vec<(usize, Rect)> {
32 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
43fn 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 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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
83pub struct TreemapState {
84 pub hover: Option<usize>,
86}
87
88#[derive(Clone, Debug, PartialEq)]
91pub enum TreemapMsg {
92 Hover(Option<usize>),
94}
95
96#[derive(Clone, Debug)]
98pub enum TreemapEffect {}
99
100pub struct TreemapView {
102 pub title: String,
103 pub tiles: Vec<Tile>,
104 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 pub fn state(&self) -> &TreemapState {
115 &self.state
116 }
117
118 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
145impl 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 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 #[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 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 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 && r.contains(hp) {
213 new_hover = Some(*idx);
214 }
215 }
216 if let Some(hp) = hover && let Some(t) = self.state.hover.and_then(|i| self.tiles.get(i)) {
219 paint_tooltip(&painter, &th, hp, &format!("{}\n{:.3}", t.label, t.value));
220 }
221 #[cfg(feature = "testmatrix")]
223 facett_core::testmatrix::emit(
224 "facett-plotters::TreemapView::ui",
225 "ui_render",
226 !rects.is_empty() && rects.len() <= self.tiles.len(),
227 &format!("tiles={} rects={}", self.tiles.len(), rects.len()),
228 );
229 hover_msg(self.state.hover, new_hover)
230 }
231}
232
233fn hover_msg(current: Option<usize>, next: Option<usize>) -> Vec<TreemapMsg> {
235 if current != next { vec![TreemapMsg::Hover(next)] } else { Vec::new() }
236}
237
238impl Elm for TreemapView {
239 type Model = TreemapState;
240 type Msg = TreemapMsg;
241 type Effect = TreemapEffect;
242
243 fn title(&self) -> &str {
244 &self.title
245 }
246 fn state(&self) -> &TreemapState {
247 &self.state
248 }
249 fn update(&mut self, msg: TreemapMsg) -> Vec<TreemapEffect> {
250 TreemapView::update(self, msg)
251 }
252 fn view(&self, ui: &mut Ui) -> Vec<TreemapMsg> {
253 facett_core::navgraph::declare_own_draw_order(ui); self.render(ui)
255 }
256}
257
258facett_core::impl_facet_via_elm!(TreemapView, custom_state_json, {
261 fn caps(&self) -> FacetCaps {
262 FacetCaps::NONE.themeable().copyable().resizable()
263 }
264 fn copy(&mut self) -> Option<String> {
265 Some(self.to_csv())
266 }
267 fn state_json(&self) -> serde_json::Value {
268 let total: f64 = self.tiles.iter().map(|t| t.value).sum();
269 serde_json::json!({
270 "kind": "treemap",
271 "tiles": self.tiles.len(),
272 "total": total,
273 "labels": self.tiles.iter().map(|t| &t.label).collect::<Vec<_>>(),
274 })
275 }
276});
277
278fn contrast_text(bg: egui::Color32) -> egui::Color32 {
280 let lum = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32;
281 if lum > 140.0 { egui::Color32::from_gray(20) } else { egui::Color32::from_gray(235) }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn typed_copy_is_the_dataset_csv() {
290 use facett_core::clip::{ClipKind, CopySource};
291 let v = TreemapView::new("t", vec![Tile::new("a", 3.0), Tile::new("b", 7.0)]);
292 let p = v.copy_payload().expect("tiles copy");
293 assert_eq!(p.kind(), ClipKind::Text);
294 assert_eq!(p.as_text(), v.to_csv());
295 }
296
297 fn area() -> Rect {
298 Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(100.0, 100.0))
299 }
300
301 #[test]
302 fn rects_cover_area_and_match_count() {
303 let rects = squarify(area(), &[1.0, 1.0, 1.0, 1.0]);
304 assert_eq!(rects.len(), 4);
305 let sum: f32 = rects.iter().map(|(_, r)| r.area()).sum();
307 assert!((sum - area().area()).abs() < 1.0, "tiles must partition the area");
308 }
309
310 #[test]
311 fn area_is_proportional_to_weight() {
312 let rects = squarify(area(), &[3.0, 1.0]);
313 let a0 = rects.iter().find(|(i, _)| *i == 0).unwrap().1.area();
314 let a1 = rects.iter().find(|(i, _)| *i == 1).unwrap().1.area();
315 assert!((a0 / a1 - 3.0).abs() < 0.2, "ratio {} should be ~3", a0 / a1);
317 }
318
319 #[test]
320 fn zero_and_negative_weights_skipped() {
321 let rects = squarify(area(), &[2.0, 0.0, -5.0, 2.0]);
322 let idxs: Vec<usize> = rects.iter().map(|(i, _)| *i).collect();
323 assert_eq!(idxs, vec![0, 3]);
324 }
325
326 #[test]
327 fn empty_and_all_zero_produce_nothing() {
328 assert!(squarify(area(), &[]).is_empty());
329 assert!(squarify(area(), &[0.0, 0.0]).is_empty());
330 }
331
332 #[test]
333 fn single_tile_fills_whole_area() {
334 let rects = squarify(area(), &[5.0]);
335 assert_eq!(rects.len(), 1);
336 assert_eq!(rects[0].1, area());
337 }
338
339 #[test]
340 fn rects_are_disjoint() {
341 let rects = squarify(area(), &[1.0, 2.0, 3.0, 4.0, 5.0]);
342 for i in 0..rects.len() {
343 for j in (i + 1)..rects.len() {
344 let inter = rects[i].1.intersect(rects[j].1);
345 assert!(inter.width() < 0.5 || inter.height() < 0.5, "tiles {i},{j} overlap");
346 }
347 }
348 }
349
350 #[test]
352 fn hover_transitions_are_observable() {
353 let mut v = TreemapView::new("t", vec![Tile::new("a", 3.0), Tile::new("b", 7.0)]);
354 assert_eq!(v.state().hover, None);
355 let snap = facett_core::harness::snapshot(&mut v, [TreemapMsg::Hover(Some(1))]);
356 assert_eq!(snap.hover, Some(1));
357 let snap = facett_core::harness::snapshot(&mut v, [TreemapMsg::Hover(None)]);
358 assert_eq!(snap.hover, None);
359 }
360
361 #[test]
362 fn drive_produces_no_effects() {
363 let mut v = TreemapView::new("t", vec![Tile::new("a", 3.0)]);
364 let effects = facett_core::harness::drive(&mut v, [TreemapMsg::Hover(Some(0)), TreemapMsg::Hover(None)]);
365 assert!(effects.is_empty());
366 }
367
368 #[test]
369 fn state_serde_round_trips() {
370 let st = TreemapState { hover: Some(4) };
371 let back: TreemapState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
372 assert_eq!(st, back);
373 }
374}