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 pub data: Vec<Vec<f64>>,
46 #[serde(default = "default_color_scale")]
48 pub color_scale: Vec<String>,
49 #[serde(default = "default_cell_size")]
51 pub cell_size: f32,
52 #[serde(default = "default_cell_gap")]
54 pub cell_gap: f32,
55 #[serde(default = "default_cell_radius")]
57 pub cell_radius: f32,
58 #[serde(default = "default_animated")]
60 pub animated: bool,
61 #[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 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 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 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 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 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 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 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 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}