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 pub data: Vec<TreemapItem>,
52 #[serde(default = "default_gap")]
54 pub gap: f32,
55 #[serde(default = "default_border_radius")]
57 pub border_radius: f32,
58 #[serde(default = "default_show_labels")]
60 pub show_labels: bool,
61 #[serde(default)]
63 pub show_values: bool,
64 #[serde(default = "default_animated")]
66 pub animated: bool,
67 #[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 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 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 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 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 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 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 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 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 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 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}