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