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)]
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}