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)]
43pub struct Heatmap {
44    /// 2D array of values (rows x columns).
45    pub data: Vec<Vec<f64>>,
46    /// Gradient colors for the heatmap scale.
47    #[serde(default = "default_color_scale")]
48    pub color_scale: Vec<String>,
49    /// Size of each cell in pixels.
50    #[serde(default = "default_cell_size")]
51    pub cell_size: f32,
52    /// Gap between cells in pixels.
53    #[serde(default = "default_cell_gap")]
54    pub cell_gap: f32,
55    /// Corner radius of each cell.
56    #[serde(default = "default_cell_radius")]
57    pub cell_radius: f32,
58    /// Whether the heatmap animates in.
59    #[serde(default = "default_animated")]
60    pub animated: bool,
61    /// Duration of the reveal animation in seconds.
62    #[serde(default = "default_animation_duration")]
63    pub animation_duration: f64,
64    #[serde(flatten)]
65    pub timing: TimingConfig,
66    #[serde(default)]
67    pub style: CssStyle,
68    #[serde(default)]
69    pub timeline: Vec<TimelineStep>,
70    #[serde(default)]
71    pub stagger: Option<f32>,
72}
73
74rustmotion_core::impl_traits!(Heatmap {
75    Animatable => animation,
76    Timed => timing,
77    Styled => style,
78});
79
80fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
81    (a as f32 + (b as f32 - a as f32) * t)
82        .round()
83        .clamp(0.0, 255.0) as u8
84}
85
86fn interpolate_color(scale: &[String], t: f32) -> (u8, u8, u8) {
87    let t = t.clamp(0.0, 1.0);
88    if scale.len() < 2 {
89        let (r, g, b, _) = parse_hex_color(&scale[0]);
90        return (r, g, b);
91    }
92    let n = scale.len() - 1;
93    let scaled = t * n as f32;
94    // Clamp the segment index (t=1.0 lands exactly on `n`, one past the
95    // last valid segment), but re-derive `local_t` from the *clamped*
96    // segment rather than reusing the unclamped one — otherwise t=1.0
97    // computed local_t=0.0 against the clamped (second-to-last) segment and
98    // resolved to the second-to-last color instead of the last one.
99    let segment = (scaled.floor() as usize).min(n - 1);
100    let local_t = (scaled - segment as f32).clamp(0.0, 1.0);
101    let (r1, g1, b1, _) = parse_hex_color(&scale[segment]);
102    let (r2, g2, b2, _) = parse_hex_color(&scale[segment + 1]);
103    (
104        lerp_u8(r1, r2, local_t),
105        lerp_u8(g1, g2, local_t),
106        lerp_u8(b1, b2, local_t),
107    )
108}
109
110impl Heatmap {
111    fn progress_at(&self, time: f64) -> f32 {
112        if !self.animated {
113            return 1.0;
114        }
115        // Ramp measured from `start_at`, not from scene time zero — matches
116        // `Counter::ramp_progress`. A heatmap delayed with `start_at` used
117        // to read raw scene time, so it was already fully revealed on the
118        // very first frame it became visible.
119        let start = self.timing.start_at.unwrap_or(0.0);
120        let elapsed = (time - start).max(0.0);
121        let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
122        1.0 - (1.0 - p).powi(3)
123    }
124
125    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
126        let w = layout_w;
127        let h = layout_h;
128
129        if self.data.is_empty() || self.color_scale.is_empty() {
130            return;
131        }
132
133        let progress = self.progress_at(time);
134
135        // Animation: clip rect expanding from left to right
136        let clip_w = w * progress;
137        canvas.save();
138        canvas.clip_rect(
139            Rect::from_xywh(0.0, 0.0, clip_w, h),
140            skia_safe::ClipOp::Intersect,
141            false,
142        );
143
144        let step = self.cell_size + self.cell_gap;
145
146        for (row_idx, row) in self.data.iter().enumerate() {
147            for (col_idx, &val) in row.iter().enumerate() {
148                // `color_scale` documents an absolute 0.0-1.0 semantic
149                // (SKILL.md: "2D array of f64, values 0.0-1.0"), not a
150                // per-render min-max scale. Renormalizing meant a grid of
151                // constant values (or any subrange, e.g. [0.8, 0.9, 1.0])
152                // painted identically to a grid of zeros — a flat or
153                // uniformly-high grid is not the same fact as "nothing
154                // happened". Clamp into the documented range instead of
155                // rescaling to whatever the data happens to span.
156                let normalized = (val as f32).clamp(0.0, 1.0);
157                let (r, g, b) = interpolate_color(&self.color_scale, normalized);
158
159                let x = col_idx as f32 * step;
160                let y = row_idx as f32 * step;
161
162                let rect = Rect::from_xywh(x, y, self.cell_size, self.cell_size);
163                let rrect = RRect::new_rect_xy(rect, self.cell_radius, self.cell_radius);
164
165                let color = skia_safe::Color::from_rgb(r, g, b);
166                let mut paint = skia_safe::Paint::default();
167                paint.set_color(color);
168                paint.set_style(PaintStyle::Fill);
169                paint.set_anti_alias(true);
170
171                canvas.draw_rrect(rrect, &paint);
172            }
173        }
174
175        canvas.restore();
176    }
177}
178
179impl Painter for Heatmap {
180    fn paint_content(
181        &self,
182        canvas: &Canvas,
183        layout: &BoxLayout,
184        _props: &AnimatedProperties,
185        ctx: &PaintCtx,
186    ) {
187        self.paint(canvas, layout.width, layout.height, ctx.time);
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use rustmotion_core::traits::TimingConfig;
195
196    fn base_heatmap(data: Vec<Vec<f64>>) -> Heatmap {
197        Heatmap {
198            data,
199            color_scale: default_color_scale(),
200            cell_size: default_cell_size(),
201            cell_gap: default_cell_gap(),
202            cell_radius: default_cell_radius(),
203            animated: true,
204            animation_duration: 1.5,
205            timing: TimingConfig::default(),
206            style: CssStyle::default(),
207            timeline: Vec::new(),
208            stagger: None,
209        }
210    }
211
212    fn cell_color(heatmap: &Heatmap, w: i32, h: i32, time: f64) -> (u8, u8, u8) {
213        let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
214        {
215            let canvas = surface.canvas();
216            heatmap.paint(canvas, w as f32, h as f32, time);
217        }
218        let snapshot = surface.image_snapshot();
219        let info = skia_safe::ImageInfo::new(
220            (1, 1),
221            skia_safe::ColorType::RGBA8888,
222            skia_safe::AlphaType::Premul,
223            None,
224        );
225        let mut buf = [0u8; 4];
226        // Sample the middle of the top-left cell.
227        let x = (heatmap.cell_size / 2.0) as i32;
228        let y = (heatmap.cell_size / 2.0) as i32;
229        snapshot.read_pixels(
230            &info,
231            &mut buf,
232            4,
233            skia_safe::IPoint::new(x, y),
234            skia_safe::image::CachingHint::Disallow,
235        );
236        (buf[0], buf[1], buf[2])
237    }
238
239    #[test]
240    fn a_uniformly_low_grid_is_not_identical_to_an_all_zero_grid() {
241        // #6's exact repro: `color_scale` is documented (SKILL.md) as an
242        // *absolute* 0.0-1.0 scale, but the painter renormalized min→max —
243        // so a grid of constant 5.0s (or any other constant) rendered
244        // pixel-for-pixel identical to a grid of constant 0.0s, both
245        // collapsing to the scale's first (lowest) color.
246        let uniform = base_heatmap(vec![vec![5.0, 5.0, 5.0], vec![5.0, 5.0, 5.0]]);
247        let zero = base_heatmap(vec![vec![0.0, 0.0, 0.0], vec![0.0, 0.0, 0.0]]);
248        let uniform_color = cell_color(&uniform, 200, 100, 10.0);
249        let zero_color = cell_color(&zero, 200, 100, 10.0);
250        assert_ne!(
251            uniform_color, zero_color,
252            "a grid of 5.0s must not render identically to a grid of 0.0s"
253        );
254    }
255
256    #[test]
257    fn absolute_values_are_not_renormalized_to_the_data_subrange() {
258        // A grid whose values happen to span [0.8, 1.0] must not stretch
259        // that subrange to fill the whole color scale — 0.8 reads as
260        // "mostly full", not as "the bottom of whatever this grid contains".
261        let high = base_heatmap(vec![vec![0.8, 0.9, 1.0]]);
262        let low = base_heatmap(vec![vec![0.0, 0.1, 0.2]]);
263        let high_first_cell = cell_color(&high, 200, 100, 10.0);
264        let low_first_cell = cell_color(&low, 200, 100, 10.0);
265        assert_ne!(
266            high_first_cell, low_first_cell,
267            "0.8 and 0.0 must not render as the same color"
268        );
269    }
270
271    #[test]
272    fn interpolate_color_at_the_top_of_the_scale_returns_the_last_color() {
273        // Surfaced while chasing #6: the clamped segment index was reused
274        // for `local_t` too, so t=1.0 exactly computed `local_t = 0.0` for
275        // the *clamped* (second-to-last) segment instead of `local_t = 1.0`
276        // — landing on the second-to-last color rather than the last
277        // (brightest) one.
278        let scale = default_color_scale();
279        let (r, g, b) = interpolate_color(&scale, 1.0);
280        let (er, eg, eb, _) = parse_hex_color(scale.last().unwrap());
281        assert_eq!(
282            (r, g, b),
283            (er, eg, eb),
284            "t=1.0 must resolve to the last color in the scale"
285        );
286    }
287
288    #[test]
289    fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
290        let mut heatmap = base_heatmap(vec![vec![1.0]]);
291        heatmap.animation_duration = 1.5;
292        heatmap.timing = TimingConfig {
293            start_at: Some(2.0),
294            end_at: None,
295        };
296        assert_eq!(heatmap.progress_at(2.0), 0.0);
297        assert!(heatmap.progress_at(2.75) < 1.0);
298        assert_eq!(heatmap.progress_at(3.5), 1.0);
299    }
300}