Skip to main content

rustmotion_components/
sparkline.rs

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
64/// `(min, max, normalize)` for a min-max scaled series, matching
65/// `chart::line::series_scale`'s flat-series handling: a constant series is
66/// centred (`0.5`) instead of collapsing to the bottom edge. Dividing by a
67/// `max(0.001)` floor mapped a constant series to 0, so a flat series read
68/// as "all zero" instead of "constant at some value". Duplicated locally —
69/// `chart::line::series_scale` is `pub(super)` to the `chart` module, not
70/// reachable from here.
71fn 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        // Ramp measured from `start_at`, not from scene time zero — matches
97        // `Counter::ramp_progress`. A sparkline delayed with `start_at` used
98        // to read raw scene time, so it was already fully revealed on the
99        // very first frame it became visible.
100        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        // Clip for animation
142        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        // Gradient fill
151        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        // Line stroke
175        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        // #7's exact repro: with every value equal, `(val - min_val)` is
257        // 0 for every point, so the line was drawn on the bottom edge
258        // (`h - pad`) — reading as "a series of zeroes" instead of "a
259        // constant series at some value". `chart::line::series_scale`
260        // was fixed to center a flat series (0.5) for exactly this reason.
261        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}