Skip to main content

rustmotion_components/
treemap.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, RRect, Rect};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::{
9    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
10    typeface_with_fallback,
11};
12use rustmotion_core::schema::TimelineStep;
13use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
14
15const DEFAULT_PALETTE: &[&str] = &[
16    "#3B82F6", "#EF4444", "#22C55E", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316",
17];
18
19fn default_gap() -> f32 {
20    3.0
21}
22
23fn default_border_radius() -> f32 {
24    6.0
25}
26
27fn default_show_labels() -> bool {
28    true
29}
30
31fn default_animated() -> bool {
32    true
33}
34
35fn default_animation_duration() -> f64 {
36    1.0
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
40pub struct TreemapItem {
41    #[serde(default)]
42    pub label: Option<String>,
43    pub value: f64,
44    #[serde(default)]
45    pub color: Option<String>,
46}
47
48#[derive(Debug, Serialize, Deserialize, JsonSchema)]
49pub struct Treemap {
50    /// Data items to display in the treemap.
51    pub data: Vec<TreemapItem>,
52    /// Gap between rectangles in pixels.
53    #[serde(default = "default_gap")]
54    pub gap: f32,
55    /// Corner radius of rectangles.
56    #[serde(default = "default_border_radius")]
57    pub border_radius: f32,
58    /// Whether to show labels inside rectangles.
59    #[serde(default = "default_show_labels")]
60    pub show_labels: bool,
61    /// Whether to show values inside rectangles.
62    #[serde(default)]
63    pub show_values: bool,
64    /// Whether the treemap animates in.
65    #[serde(default = "default_animated")]
66    pub animated: bool,
67    /// Duration of the scale animation in seconds.
68    #[serde(default = "default_animation_duration")]
69    pub animation_duration: f64,
70    #[serde(flatten)]
71    pub timing: TimingConfig,
72    #[serde(default)]
73    pub style: CssStyle,
74    #[serde(default)]
75    pub timeline: Vec<TimelineStep>,
76    #[serde(default)]
77    pub stagger: Option<f32>,
78}
79
80rustmotion_core::impl_traits!(Treemap {
81    Animatable => animation,
82    Timed => timing,
83    Styled => style,
84});
85
86fn layout_treemap(items: &[(f64, usize)], rect: Rect, vertical: bool) -> Vec<(usize, Rect)> {
87    let total: f64 = items.iter().map(|i| i.0).sum();
88    if total <= 0.0 || items.is_empty() {
89        return vec![];
90    }
91    let mut results = vec![];
92    let mut offset = if vertical { rect.top } else { rect.left };
93    for &(value, idx) in items {
94        let fraction = (value / total) as f32;
95        let r = if vertical {
96            let h = rect.height() * fraction;
97            let r = Rect::from_xywh(rect.left, offset, rect.width(), h);
98            offset += h;
99            r
100        } else {
101            let w = rect.width() * fraction;
102            let r = Rect::from_xywh(offset, rect.top, w, rect.height());
103            offset += w;
104            r
105        };
106        results.push((idx, r));
107    }
108    results
109}
110
111impl Treemap {
112    fn progress_at(&self, time: f64) -> f32 {
113        if !self.animated {
114            return 1.0;
115        }
116        // Ramp measured from `start_at`, not from scene time zero — matches
117        // `Counter::ramp_progress`. A treemap delayed with `start_at` used
118        // to read raw scene time, so it was already fully scaled in on the
119        // very first frame it became visible.
120        let start = self.timing.start_at.unwrap_or(0.0);
121        let elapsed = (time - start).max(0.0);
122        let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
123        1.0 - (1.0 - p).powi(3)
124    }
125
126    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
127        let w = layout_w;
128        let h = layout_h;
129
130        if self.data.is_empty() {
131            return;
132        }
133
134        let progress = self.progress_at(time);
135
136        // Sort data by value descending, keeping original indices
137        let mut sorted: Vec<(f64, usize)> = self
138            .data
139            .iter()
140            .enumerate()
141            .map(|(i, item)| (item.value, i))
142            .collect();
143        sorted.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
144
145        let full_rect = Rect::from_xywh(0.0, 0.0, w, h);
146        let rects = layout_treemap(&sorted, full_rect, false);
147
148        let font_style = skia_safe::FontStyle::normal();
149        let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
150            return;
151        };
152
153        for (idx, rect) in &rects {
154            let item = &self.data[*idx];
155
156            // Apply gap by insetting the rect
157            let inset = self.gap / 2.0;
158            let inset_rect = Rect::from_xywh(
159                rect.left + inset,
160                rect.top + inset,
161                (rect.width() - self.gap).max(0.0),
162                (rect.height() - self.gap).max(0.0),
163            );
164
165            if inset_rect.width() <= 0.0 || inset_rect.height() <= 0.0 {
166                continue;
167            }
168
169            // Animation: scale each rect from center
170            let cx = inset_rect.left + inset_rect.width() / 2.0;
171            let cy = inset_rect.top + inset_rect.height() / 2.0;
172            let scaled_w = inset_rect.width() * progress;
173            let scaled_h = inset_rect.height() * progress;
174            let scaled_rect =
175                Rect::from_xywh(cx - scaled_w / 2.0, cy - scaled_h / 2.0, scaled_w, scaled_h);
176
177            // Color
178            let color_str = item
179                .color
180                .as_deref()
181                .unwrap_or(DEFAULT_PALETTE[*idx % DEFAULT_PALETTE.len()]);
182            let mut paint = paint_from_hex(color_str);
183            paint.set_style(PaintStyle::Fill);
184            paint.set_anti_alias(true);
185
186            let rrect = RRect::new_rect_xy(scaled_rect, self.border_radius, self.border_radius);
187            canvas.draw_rrect(rrect, &paint);
188
189            // Labels
190            if self.show_labels || self.show_values {
191                let mut text_parts: Vec<String> = vec![];
192                if self.show_labels {
193                    if let Some(label) = &item.label {
194                        text_parts.push(label.clone());
195                    }
196                }
197                if self.show_values {
198                    text_parts.push(format!("{}", item.value));
199                }
200
201                if text_parts.is_empty() {
202                    continue;
203                }
204
205                let font_size = (scaled_rect.width() * 0.12).clamp(10.0, 24.0);
206                // `draw_text_with_fallback` builds a single-line `TextBlob`
207                // (renderer/text.rs) — joining label and value with "\n"
208                // never produced a line break, it fed the blob a literal
209                // control glyph. Each part now gets its own baseline, and
210                // the space each line needs (`20.0` per line, same floor
211                // the old single-line check used) is checked before
212                // drawing instead of after.
213                let line_height = font_size * 1.2;
214                if scaled_rect.width() < 30.0
215                    || scaled_rect.height() < 20.0 * text_parts.len() as f32
216                {
217                    continue;
218                }
219
220                let font = skia_safe::Font::from_typeface(&typeface, font_size);
221                let emoji_font =
222                    emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
223
224                let mut text_paint = paint_from_hex("#FFFFFF");
225                text_paint.set_anti_alias(true);
226
227                let (_, metrics) = font.metrics();
228                let ascent = -metrics.ascent;
229                let descent = metrics.descent;
230                let block_h = line_height * text_parts.len() as f32;
231                let block_top = scaled_rect.top + scaled_rect.height() / 2.0 - block_h / 2.0;
232
233                for (li, part) in text_parts.iter().enumerate() {
234                    let part_w = measure_text_with_fallback(part, &font, &emoji_font, 0.0);
235                    let part_x = scaled_rect.left + (scaled_rect.width() - part_w) / 2.0;
236                    let line_center_y = block_top + (li as f32 + 0.5) * line_height;
237                    let part_y = line_center_y + (ascent - descent) / 2.0;
238
239                    draw_text_with_fallback(
240                        canvas,
241                        part,
242                        &font,
243                        &emoji_font,
244                        0.0,
245                        part_x,
246                        part_y,
247                        &text_paint,
248                    );
249                }
250            }
251        }
252    }
253}
254
255impl Painter for Treemap {
256    fn paint_content(
257        &self,
258        canvas: &Canvas,
259        layout: &BoxLayout,
260        _props: &AnimatedProperties,
261        ctx: &PaintCtx,
262    ) {
263        self.paint(canvas, layout.width, layout.height, ctx.time);
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use rustmotion_core::traits::TimingConfig;
271
272    fn base_treemap(data: Vec<TreemapItem>) -> Treemap {
273        Treemap {
274            data,
275            gap: default_gap(),
276            border_radius: default_border_radius(),
277            show_labels: default_show_labels(),
278            show_values: false,
279            animated: true,
280            animation_duration: 1.0,
281            timing: TimingConfig::default(),
282            style: CssStyle::default(),
283            timeline: Vec::new(),
284            stagger: None,
285        }
286    }
287
288    fn read_rgba(surface: &mut skia_safe::Surface, w: i32, h: i32) -> Vec<u8> {
289        let snapshot = surface.image_snapshot();
290        let info = skia_safe::ImageInfo::new(
291            (w, h),
292            skia_safe::ColorType::RGBA8888,
293            skia_safe::AlphaType::Premul,
294            None,
295        );
296        let mut buf = vec![0u8; (w * h * 4) as usize];
297        snapshot.read_pixels(
298            &info,
299            &mut buf,
300            (w * 4) as usize,
301            skia_safe::IPoint::new(0, 0),
302            skia_safe::image::CachingHint::Disallow,
303        );
304        buf
305    }
306
307    /// A pixel is "text ink" if it's opaque-ish and near-white — the fixed
308    /// `#FFFFFF` label/value color, distinct from the cell's own colored
309    /// (never white) `DEFAULT_PALETTE` background fill that would otherwise
310    /// dominate a naive alpha-only scan.
311    fn is_text_ink(buf: &[u8], w: i32, x: i32, y: i32) -> bool {
312        let idx = ((y * w + x) * 4) as usize;
313        let (r, g, b, a) = (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]);
314        a > 40 && r > 200 && g > 200 && b > 200
315    }
316
317    /// Contiguous vertical bands (start_y, end_y) of text ink, merging rows
318    /// separated by a 1px anti-aliasing gap but splitting on anything
319    /// wider — used to tell "two stacked text lines" apart from "one line
320    /// of text".
321    fn row_bands(buf: &[u8], w: i32, h: i32) -> Vec<(i32, i32)> {
322        let mut bands: Vec<(i32, i32)> = vec![];
323        for y in 0..h {
324            let has_ink = (0..w).any(|x| is_text_ink(buf, w, x, y));
325            if has_ink {
326                match bands.last_mut() {
327                    Some((_, end)) if y <= *end + 1 => *end = y,
328                    _ => bands.push((y, y)),
329                }
330            }
331        }
332        bands
333    }
334
335    fn row_ink_x_range(buf: &[u8], w: i32, y0: i32, y1: i32) -> (i32, i32) {
336        let (mut minx, mut maxx) = (i32::MAX, i32::MIN);
337        for y in y0..=y1 {
338            for x in 0..w {
339                if is_text_ink(buf, w, x, y) {
340                    minx = minx.min(x);
341                    maxx = maxx.max(x);
342                }
343            }
344        }
345        (minx, maxx)
346    }
347
348    #[test]
349    fn label_and_value_render_on_two_separate_centered_lines() {
350        // #8's exact repro: `text_parts.join("\n")` fed a single-line
351        // `TextBlob` a literal "\n" glyph — the label and value landed side
352        // by side on the same baseline instead of stacked, and the whole
353        // (wrongly wide) string was centered as one block, decentering the
354        // label itself.
355        const W: i32 = 300;
356        const H: i32 = 200;
357        let mut treemap = base_treemap(vec![TreemapItem {
358            label: Some("Alpha".to_string()),
359            value: 50.0,
360            color: None,
361        }]);
362        treemap.show_labels = true;
363        treemap.show_values = true;
364        treemap.animated = false;
365
366        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
367        {
368            let canvas = surface.canvas();
369            treemap.paint(canvas, W as f32, H as f32, 0.0);
370        }
371        let buf = read_rgba(&mut surface, W, H);
372        let bands = row_bands(&buf, W, H);
373        assert_eq!(
374            bands.len(),
375            2,
376            "label and value must render as two stacked lines, got bands={bands:?}"
377        );
378        for (y0, y1) in bands {
379            let (minx, maxx) = row_ink_x_range(&buf, W, y0, y1);
380            let center = (minx + maxx) as f32 / 2.0;
381            assert!(
382                (center - W as f32 / 2.0).abs() < 12.0,
383                "line y=[{y0}..{y1}] is not centered on the box: ink x center = {center}"
384            );
385        }
386    }
387
388    #[test]
389    fn a_single_label_still_renders_as_one_centered_line() {
390        const W: i32 = 300;
391        const H: i32 = 200;
392        let mut treemap = base_treemap(vec![TreemapItem {
393            label: Some("Alpha".to_string()),
394            value: 50.0,
395            color: None,
396        }]);
397        treemap.show_labels = true;
398        treemap.show_values = false;
399        treemap.animated = false;
400
401        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
402        {
403            let canvas = surface.canvas();
404            treemap.paint(canvas, W as f32, H as f32, 0.0);
405        }
406        let buf = read_rgba(&mut surface, W, H);
407        let bands = row_bands(&buf, W, H);
408        assert_eq!(
409            bands.len(),
410            1,
411            "a single label is one line, got bands={bands:?}"
412        );
413    }
414
415    #[test]
416    fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
417        let mut treemap = base_treemap(vec![TreemapItem {
418            label: None,
419            value: 1.0,
420            color: None,
421        }]);
422        treemap.animation_duration = 1.5;
423        treemap.timing = TimingConfig {
424            start_at: Some(2.0),
425            end_at: None,
426        };
427        assert_eq!(treemap.progress_at(2.0), 0.0);
428        assert!(treemap.progress_at(2.75) < 1.0);
429        assert_eq!(treemap.progress_at(3.5), 1.0);
430    }
431}