1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::gradient::{self, Colors, Gradient};
4use skia_safe::{Canvas, Color, Color4f, PaintStyle, PathBuilder, Point, Rect};
5
6use rustmotion_core::css::CssStyle;
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color};
10use rustmotion_core::schema::TimelineStep;
11use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
12
13fn default_color() -> String {
14 "#22C55E".to_string()
15}
16
17fn default_stroke_width() -> f32 {
18 2.0
19}
20
21fn default_fill_opacity() -> f32 {
22 0.2
23}
24
25fn default_animated() -> bool {
26 true
27}
28
29fn default_animation_duration() -> f64 {
30 1.0
31}
32
33#[derive(Debug, Serialize, Deserialize, JsonSchema)]
34pub struct Sparkline {
35 pub data: Vec<f64>,
36 #[serde(default = "default_color")]
37 pub color: String,
38 #[serde(default)]
39 pub fill: bool,
40 #[serde(default = "default_fill_opacity")]
41 pub fill_opacity: f32,
42 #[serde(default = "default_stroke_width")]
43 pub stroke_width: f32,
44 #[serde(default = "default_animated")]
45 pub animated: bool,
46 #[serde(default = "default_animation_duration")]
47 pub animation_duration: f64,
48 #[serde(flatten)]
49 pub timing: TimingConfig,
50 #[serde(default)]
51 pub style: CssStyle,
52 #[serde(default)]
53 pub timeline: Vec<TimelineStep>,
54 #[serde(default)]
55 pub stagger: Option<f32>,
56}
57
58rustmotion_core::impl_traits!(Sparkline {
59 Animatable => animation,
60 Timed => timing,
61 Styled => style,
62});
63
64fn series_scale(values: impl Iterator<Item = f64> + Clone) -> (f64, f64, impl Fn(f64) -> f32) {
72 let min_val = values.clone().fold(f64::INFINITY, f64::min);
73 let max_val = values.fold(f64::NEG_INFINITY, f64::max);
74 let (min_val, max_val) = if min_val.is_finite() && max_val.is_finite() {
75 (min_val, max_val)
76 } else {
77 (0.0, 0.0)
78 };
79 let span = max_val - min_val;
80 let flat = span.abs() < f64::EPSILON;
81 let range = if flat { 1.0 } else { span };
82 (min_val, max_val, move |v: f64| {
83 if flat {
84 0.5
85 } else {
86 ((v - min_val) / range) as f32
87 }
88 })
89}
90
91impl Sparkline {
92 fn progress_at(&self, time: f64) -> f32 {
93 if !self.animated {
94 return 1.0;
95 }
96 let start = self.timing.start_at.unwrap_or(0.0);
101 let elapsed = (time - start).max(0.0);
102 let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
103 1.0 - (1.0 - p).powi(3)
104 }
105
106 fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
107 let w = layout_w;
108 let h = layout_h;
109 let n = self.data.len();
110 if n < 2 {
111 return;
112 }
113
114 let progress = self.progress_at(time);
115
116 let (_, _, norm) = series_scale(self.data.iter().copied());
117
118 let pad = self.stroke_width;
119
120 let mut line_path = PathBuilder::new();
121 let mut fill_path = PathBuilder::new();
122
123 for (i, &val) in self.data.iter().enumerate() {
124 let x = pad + (i as f32 / (n - 1) as f32) * (w - pad * 2.0);
125 let y = pad + (h - pad * 2.0) - norm(val) * (h - pad * 2.0);
126
127 if i == 0 {
128 line_path.move_to((x, y));
129 fill_path.move_to((x, h - pad));
130 fill_path.line_to((x, y));
131 } else {
132 line_path.line_to((x, y));
133 fill_path.line_to((x, y));
134 }
135 }
136
137 let last_x = pad + (w - pad * 2.0);
138 fill_path.line_to((last_x, h - pad));
139 fill_path.close();
140
141 let clip_w = w * progress;
143 canvas.save();
144 canvas.clip_rect(
145 Rect::from_xywh(0.0, 0.0, clip_w, h),
146 skia_safe::ClipOp::Intersect,
147 false,
148 );
149
150 if self.fill {
152 let (r, g, b, _) = parse_hex_color(&self.color);
153 let top_color = Color::from_argb((self.fill_opacity * 255.0) as u8, r, g, b);
154 let bottom_color = Color::from_argb(0, r, g, b);
155
156 let colors4f = [Color4f::from(top_color), Color4f::from(bottom_color)];
157 let stops = Colors::new(&colors4f, None, skia_safe::TileMode::Clamp, None);
158 let grad = Gradient::new(stops, gradient::Interpolation::default());
159 let shader = gradient::shaders::linear_gradient(
160 (Point::new(0.0, 0.0), Point::new(0.0, h)),
161 &grad,
162 None,
163 );
164
165 if let Some(shader) = shader {
166 let mut fill_paint = skia_safe::Paint::default();
167 fill_paint.set_style(PaintStyle::Fill);
168 fill_paint.set_anti_alias(true);
169 fill_paint.set_shader(shader);
170 canvas.draw_path(&fill_path.detach(), &fill_paint);
171 }
172 }
173
174 let mut line_paint = paint_from_hex(&self.color);
176 line_paint.set_style(PaintStyle::Stroke);
177 line_paint.set_stroke_width(self.stroke_width);
178 line_paint.set_anti_alias(true);
179 line_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
180 line_paint.set_stroke_join(skia_safe::paint::Join::Round);
181 canvas.draw_path(&line_path.detach(), &line_paint);
182
183 canvas.restore();
184 }
185}
186
187impl Painter for Sparkline {
188 fn paint_content(
189 &self,
190 canvas: &Canvas,
191 layout: &BoxLayout,
192 _props: &AnimatedProperties,
193 ctx: &PaintCtx,
194 ) {
195 self.paint(canvas, layout.width, layout.height, ctx.time);
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use rustmotion_core::traits::TimingConfig;
203
204 fn base_sparkline(data: Vec<f64>) -> Sparkline {
205 Sparkline {
206 data,
207 color: default_color(),
208 fill: false,
209 fill_opacity: default_fill_opacity(),
210 stroke_width: default_stroke_width(),
211 animated: true,
212 animation_duration: 1.0,
213 timing: TimingConfig::default(),
214 style: CssStyle::default(),
215 timeline: Vec::new(),
216 stagger: None,
217 }
218 }
219
220 fn ink_bounds(
221 surface: &mut skia_safe::Surface,
222 w: i32,
223 h: i32,
224 ) -> Option<(i32, i32, i32, i32)> {
225 let snapshot = surface.image_snapshot();
226 let info = skia_safe::ImageInfo::new(
227 (w, h),
228 skia_safe::ColorType::RGBA8888,
229 skia_safe::AlphaType::Premul,
230 None,
231 );
232 let mut buf = vec![0u8; (w * h * 4) as usize];
233 snapshot.read_pixels(
234 &info,
235 &mut buf,
236 (w * 4) as usize,
237 skia_safe::IPoint::new(0, 0),
238 skia_safe::image::CachingHint::Disallow,
239 );
240 let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
241 for y in 0..h {
242 for x in 0..w {
243 if buf[((y * w + x) * 4 + 3) as usize] > 0 {
244 minx = minx.min(x);
245 maxx = maxx.max(x);
246 miny = miny.min(y);
247 maxy = maxy.max(y);
248 }
249 }
250 }
251 (minx <= maxx).then_some((minx, maxx, miny, maxy))
252 }
253
254 #[test]
255 fn a_flat_series_is_centered_not_pinned_to_the_bottom_edge() {
256 const H: i32 = 40;
262 let flat = base_sparkline(vec![7.0, 7.0, 7.0, 7.0, 7.0]);
263 let mut surface = skia_safe::surfaces::raster_n32_premul((120, H)).expect("raster surface");
264 {
265 let canvas = surface.canvas();
266 flat.paint(canvas, 120.0, H as f32, 10.0);
267 }
268 let (_minx, _maxx, miny, maxy) =
269 ink_bounds(&mut surface, 120, H).expect("flat sparkline must still paint a line");
270 let mid = (miny + maxy) as f32 / 2.0;
271 let bottom_edge = H as f32 - flat.stroke_width;
272 assert!(
273 (mid - H as f32 / 2.0).abs() < 6.0,
274 "flat series line should sit near vertical center (y~{}), got y=[{miny}..{maxy}]",
275 H / 2
276 );
277 assert!(
278 (bottom_edge - maxy as f32).abs() > 6.0,
279 "flat series line must not be pinned to the bottom edge: y=[{miny}..{maxy}], bottom={bottom_edge}"
280 );
281 }
282
283 #[test]
284 fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
285 let mut sparkline = base_sparkline(vec![1.0, 2.0, 3.0]);
286 sparkline.animation_duration = 1.5;
287 sparkline.timing = TimingConfig {
288 start_at: Some(2.0),
289 end_at: None,
290 };
291 assert_eq!(sparkline.progress_at(2.0), 0.0);
292 assert!(sparkline.progress_at(2.75) < 1.0);
293 assert_eq!(sparkline.progress_at(3.5), 1.0);
294 }
295}