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 pub data: Vec<Vec<f64>>,
53 #[serde(default = "default_color_scale")]
55 pub color_scale: Vec<String>,
56 #[serde(default = "default_cell_size")]
58 pub cell_size: f32,
59 #[serde(default = "default_cell_gap")]
61 pub cell_gap: f32,
62 #[serde(default = "default_cell_radius")]
64 pub cell_radius: f32,
65 #[serde(default = "default_animated")]
67 pub animated: bool,
68 #[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}