Skip to main content

rustmotion_components/
heatmap.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::parse_hex_color;
9use rustmotion_core::schema::TimelineStep;
10use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
11
12fn default_cell_size() -> f32 {
13    14.0
14}
15
16fn default_cell_gap() -> f32 {
17    3.0
18}
19
20fn default_cell_radius() -> f32 {
21    2.0
22}
23
24fn default_animated() -> bool {
25    true
26}
27
28fn default_animation_duration() -> f64 {
29    1.5
30}
31
32fn default_color_scale() -> Vec<String> {
33    vec![
34        "#161B22".to_string(),
35        "#0E4429".to_string(),
36        "#006D32".to_string(),
37        "#26A641".to_string(),
38        "#39D353".to_string(),
39    ]
40}
41
42#[derive(Debug, Serialize, Deserialize, JsonSchema)]
43#[deprecated(
44    since = "0.7.1",
45    note = "`heatmap` is a frozen composition. Compose a `for-each` over the cells with a \
46            computed `fill` expression per cell instead — see \
47            crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
48            scheduled for removal in a future major version via `rustmotion migrate` (#335)."
49)]
50pub struct Heatmap {
51    /// 2D array of values (rows x columns).
52    pub data: Vec<Vec<f64>>,
53    /// Gradient colors for the heatmap scale.
54    #[serde(default = "default_color_scale")]
55    pub color_scale: Vec<String>,
56    /// Size of each cell in pixels.
57    #[serde(default = "default_cell_size")]
58    pub cell_size: f32,
59    /// Gap between cells in pixels.
60    #[serde(default = "default_cell_gap")]
61    pub cell_gap: f32,
62    /// Corner radius of each cell.
63    #[serde(default = "default_cell_radius")]
64    pub cell_radius: f32,
65    /// Whether the heatmap animates in.
66    #[serde(default = "default_animated")]
67    pub animated: bool,
68    /// Duration of the reveal animation in seconds.
69    #[serde(default = "default_animation_duration")]
70    pub animation_duration: f64,
71    #[serde(flatten)]
72    pub timing: TimingConfig,
73    #[serde(default)]
74    pub style: CssStyle,
75    #[serde(default)]
76    pub timeline: Vec<TimelineStep>,
77    #[serde(default)]
78    pub stagger: Option<f32>,
79}
80
81rustmotion_core::impl_traits!(Heatmap {
82    Animatable => animation,
83    Timed => timing,
84    Styled => style,
85});
86
87fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
88    (a as f32 + (b as f32 - a as f32) * t)
89        .round()
90        .clamp(0.0, 255.0) as u8
91}
92
93fn interpolate_color(scale: &[String], t: f32) -> (u8, u8, u8) {
94    let t = t.clamp(0.0, 1.0);
95    if scale.len() < 2 {
96        let (r, g, b, _) = parse_hex_color(&scale[0]);
97        return (r, g, b);
98    }
99    let n = scale.len() - 1;
100    let scaled = t * n as f32;
101    let segment = (scaled.floor() as usize).min(n - 1);
102    let local_t = (scaled - segment as f32).clamp(0.0, 1.0);
103    let (r1, g1, b1, _) = parse_hex_color(&scale[segment]);
104    let (r2, g2, b2, _) = parse_hex_color(&scale[segment + 1]);
105    (
106        lerp_u8(r1, r2, local_t),
107        lerp_u8(g1, g2, local_t),
108        lerp_u8(b1, b2, local_t),
109    )
110}
111
112impl Heatmap {
113    fn progress_at(&self, time: f64) -> f32 {
114        if !self.animated {
115            return 1.0;
116        }
117        let start = self.timing.start_at.unwrap_or(0.0);
118        let elapsed = (time - start).max(0.0);
119        let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
120        1.0 - (1.0 - p).powi(3)
121    }
122
123    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
124        let w = layout_w;
125        let h = layout_h;
126
127        if self.data.is_empty() || self.color_scale.is_empty() {
128            return;
129        }
130
131        let progress = self.progress_at(time);
132
133        let clip_w = w * progress;
134        canvas.save();
135        canvas.clip_rect(
136            Rect::from_xywh(0.0, 0.0, clip_w, h),
137            skia_safe::ClipOp::Intersect,
138            false,
139        );
140
141        let step = self.cell_size + self.cell_gap;
142
143        for (row_idx, row) in self.data.iter().enumerate() {
144            for (col_idx, &val) in row.iter().enumerate() {
145                let normalized = (val as f32).clamp(0.0, 1.0);
146                let (r, g, b) = interpolate_color(&self.color_scale, normalized);
147
148                let x = col_idx as f32 * step;
149                let y = row_idx as f32 * step;
150
151                let rect = Rect::from_xywh(x, y, self.cell_size, self.cell_size);
152                let rrect = RRect::new_rect_xy(rect, self.cell_radius, self.cell_radius);
153
154                let color = skia_safe::Color::from_rgb(r, g, b);
155                let mut paint = skia_safe::Paint::default();
156                paint.set_color(color);
157                paint.set_style(PaintStyle::Fill);
158                paint.set_anti_alias(true);
159
160                canvas.draw_rrect(rrect, &paint);
161            }
162        }
163
164        canvas.restore();
165    }
166}
167
168impl Painter for Heatmap {
169    fn paint_content(
170        &self,
171        canvas: &Canvas,
172        layout: &BoxLayout,
173        _props: &AnimatedProperties,
174        ctx: &PaintCtx,
175    ) {
176        self.paint(canvas, layout.width, layout.height, ctx.time);
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use rustmotion_core::traits::TimingConfig;
184
185    fn base_heatmap(data: Vec<Vec<f64>>) -> Heatmap {
186        Heatmap {
187            data,
188            color_scale: default_color_scale(),
189            cell_size: default_cell_size(),
190            cell_gap: default_cell_gap(),
191            cell_radius: default_cell_radius(),
192            animated: true,
193            animation_duration: 1.5,
194            timing: TimingConfig::default(),
195            style: CssStyle::default(),
196            timeline: Vec::new(),
197            stagger: None,
198        }
199    }
200
201    fn cell_color(heatmap: &Heatmap, w: i32, h: i32, time: f64) -> (u8, u8, u8) {
202        let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
203        {
204            let canvas = surface.canvas();
205            heatmap.paint(canvas, w as f32, h as f32, time);
206        }
207        let snapshot = surface.image_snapshot();
208        let info = skia_safe::ImageInfo::new(
209            (1, 1),
210            skia_safe::ColorType::RGBA8888,
211            skia_safe::AlphaType::Premul,
212            None,
213        );
214        let mut buf = [0u8; 4];
215        let x = (heatmap.cell_size / 2.0) as i32;
216        let y = (heatmap.cell_size / 2.0) as i32;
217        snapshot.read_pixels(
218            &info,
219            &mut buf,
220            4,
221            skia_safe::IPoint::new(x, y),
222            skia_safe::image::CachingHint::Disallow,
223        );
224        (buf[0], buf[1], buf[2])
225    }
226
227    #[test]
228    fn a_uniformly_low_grid_is_not_identical_to_an_all_zero_grid() {
229        let uniform = base_heatmap(vec![vec![5.0, 5.0, 5.0], vec![5.0, 5.0, 5.0]]);
230        let zero = base_heatmap(vec![vec![0.0, 0.0, 0.0], vec![0.0, 0.0, 0.0]]);
231        let uniform_color = cell_color(&uniform, 200, 100, 10.0);
232        let zero_color = cell_color(&zero, 200, 100, 10.0);
233        assert_ne!(
234            uniform_color, zero_color,
235            "a grid of 5.0s must not render identically to a grid of 0.0s"
236        );
237    }
238
239    #[test]
240    fn absolute_values_are_not_renormalized_to_the_data_subrange() {
241        let high = base_heatmap(vec![vec![0.8, 0.9, 1.0]]);
242        let low = base_heatmap(vec![vec![0.0, 0.1, 0.2]]);
243        let high_first_cell = cell_color(&high, 200, 100, 10.0);
244        let low_first_cell = cell_color(&low, 200, 100, 10.0);
245        assert_ne!(
246            high_first_cell, low_first_cell,
247            "0.8 and 0.0 must not render as the same color"
248        );
249    }
250
251    #[test]
252    fn interpolate_color_at_the_top_of_the_scale_returns_the_last_color() {
253        let scale = default_color_scale();
254        let (r, g, b) = interpolate_color(&scale, 1.0);
255        let (er, eg, eb, _) = parse_hex_color(scale.last().unwrap());
256        assert_eq!(
257            (r, g, b),
258            (er, eg, eb),
259            "t=1.0 must resolve to the last color in the scale"
260        );
261    }
262
263    #[test]
264    fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
265        let mut heatmap = base_heatmap(vec![vec![1.0]]);
266        heatmap.animation_duration = 1.5;
267        heatmap.timing = TimingConfig {
268            start_at: Some(2.0),
269            end_at: None,
270        };
271        assert_eq!(heatmap.progress_at(2.0), 0.0);
272        assert!(heatmap.progress_at(2.75) < 1.0);
273        assert_eq!(heatmap.progress_at(3.5), 1.0);
274    }
275}