use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::gradient::{self, Colors, Gradient};
use skia_safe::{Canvas, Color, Color4f, PaintStyle, PathBuilder, Point, Rect};
use rustmotion_core::css::CssStyle;
use rustmotion_core::engine::animator::AnimatedProperties;
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color};
use rustmotion_core::schema::TimelineStep;
use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
fn default_color() -> String {
"#22C55E".to_string()
}
fn default_stroke_width() -> f32 {
2.0
}
fn default_fill_opacity() -> f32 {
0.2
}
fn default_animated() -> bool {
true
}
fn default_animation_duration() -> f64 {
1.0
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct Sparkline {
pub data: Vec<f64>,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub fill: bool,
#[serde(default = "default_fill_opacity")]
pub fill_opacity: f32,
#[serde(default = "default_stroke_width")]
pub stroke_width: f32,
#[serde(default = "default_animated")]
pub animated: bool,
#[serde(default = "default_animation_duration")]
pub animation_duration: f64,
#[serde(flatten)]
pub timing: TimingConfig,
#[serde(default)]
pub style: CssStyle,
#[serde(default)]
pub timeline: Vec<TimelineStep>,
#[serde(default)]
pub stagger: Option<f32>,
}
rustmotion_core::impl_traits!(Sparkline {
Animatable => animation,
Timed => timing,
Styled => style,
});
fn series_scale(values: impl Iterator<Item = f64> + Clone) -> (f64, f64, impl Fn(f64) -> f32) {
let min_val = values.clone().fold(f64::INFINITY, f64::min);
let max_val = values.fold(f64::NEG_INFINITY, f64::max);
let (min_val, max_val) = if min_val.is_finite() && max_val.is_finite() {
(min_val, max_val)
} else {
(0.0, 0.0)
};
let span = max_val - min_val;
let flat = span.abs() < f64::EPSILON;
let range = if flat { 1.0 } else { span };
(min_val, max_val, move |v: f64| {
if flat {
0.5
} else {
((v - min_val) / range) as f32
}
})
}
impl Sparkline {
fn progress_at(&self, time: f64) -> f32 {
if !self.animated {
return 1.0;
}
let start = self.timing.start_at.unwrap_or(0.0);
let elapsed = (time - start).max(0.0);
let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
1.0 - (1.0 - p).powi(3)
}
fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
let w = layout_w;
let h = layout_h;
let n = self.data.len();
if n < 2 {
return;
}
let progress = self.progress_at(time);
let (_, _, norm) = series_scale(self.data.iter().copied());
let pad = self.stroke_width;
let mut line_path = PathBuilder::new();
let mut fill_path = PathBuilder::new();
for (i, &val) in self.data.iter().enumerate() {
let x = pad + (i as f32 / (n - 1) as f32) * (w - pad * 2.0);
let y = pad + (h - pad * 2.0) - norm(val) * (h - pad * 2.0);
if i == 0 {
line_path.move_to((x, y));
fill_path.move_to((x, h - pad));
fill_path.line_to((x, y));
} else {
line_path.line_to((x, y));
fill_path.line_to((x, y));
}
}
let last_x = pad + (w - pad * 2.0);
fill_path.line_to((last_x, h - pad));
fill_path.close();
let clip_w = w * progress;
canvas.save();
canvas.clip_rect(
Rect::from_xywh(0.0, 0.0, clip_w, h),
skia_safe::ClipOp::Intersect,
false,
);
if self.fill {
let (r, g, b, _) = parse_hex_color(&self.color);
let top_color = Color::from_argb((self.fill_opacity * 255.0) as u8, r, g, b);
let bottom_color = Color::from_argb(0, r, g, b);
let colors4f = [Color4f::from(top_color), Color4f::from(bottom_color)];
let stops = Colors::new(&colors4f, None, skia_safe::TileMode::Clamp, None);
let grad = Gradient::new(stops, gradient::Interpolation::default());
let shader = gradient::shaders::linear_gradient(
(Point::new(0.0, 0.0), Point::new(0.0, h)),
&grad,
None,
);
if let Some(shader) = shader {
let mut fill_paint = skia_safe::Paint::default();
fill_paint.set_style(PaintStyle::Fill);
fill_paint.set_anti_alias(true);
fill_paint.set_shader(shader);
canvas.draw_path(&fill_path.detach(), &fill_paint);
}
}
let mut line_paint = paint_from_hex(&self.color);
line_paint.set_style(PaintStyle::Stroke);
line_paint.set_stroke_width(self.stroke_width);
line_paint.set_anti_alias(true);
line_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
line_paint.set_stroke_join(skia_safe::paint::Join::Round);
canvas.draw_path(&line_path.detach(), &line_paint);
canvas.restore();
}
}
impl Painter for Sparkline {
fn paint_content(
&self,
canvas: &Canvas,
layout: &BoxLayout,
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
self.paint(canvas, layout.width, layout.height, ctx.time);
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustmotion_core::traits::TimingConfig;
fn base_sparkline(data: Vec<f64>) -> Sparkline {
Sparkline {
data,
color: default_color(),
fill: false,
fill_opacity: default_fill_opacity(),
stroke_width: default_stroke_width(),
animated: true,
animation_duration: 1.0,
timing: TimingConfig::default(),
style: CssStyle::default(),
timeline: Vec::new(),
stagger: None,
}
}
fn ink_bounds(
surface: &mut skia_safe::Surface,
w: i32,
h: i32,
) -> Option<(i32, i32, i32, i32)> {
let snapshot = surface.image_snapshot();
let info = skia_safe::ImageInfo::new(
(w, h),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Premul,
None,
);
let mut buf = vec![0u8; (w * h * 4) as usize];
snapshot.read_pixels(
&info,
&mut buf,
(w * 4) as usize,
skia_safe::IPoint::new(0, 0),
skia_safe::image::CachingHint::Disallow,
);
let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
for y in 0..h {
for x in 0..w {
if buf[((y * w + x) * 4 + 3) as usize] > 0 {
minx = minx.min(x);
maxx = maxx.max(x);
miny = miny.min(y);
maxy = maxy.max(y);
}
}
}
(minx <= maxx).then_some((minx, maxx, miny, maxy))
}
#[test]
fn a_flat_series_is_centered_not_pinned_to_the_bottom_edge() {
const H: i32 = 40;
let flat = base_sparkline(vec![7.0, 7.0, 7.0, 7.0, 7.0]);
let mut surface = skia_safe::surfaces::raster_n32_premul((120, H)).expect("raster surface");
{
let canvas = surface.canvas();
flat.paint(canvas, 120.0, H as f32, 10.0);
}
let (_minx, _maxx, miny, maxy) =
ink_bounds(&mut surface, 120, H).expect("flat sparkline must still paint a line");
let mid = (miny + maxy) as f32 / 2.0;
let bottom_edge = H as f32 - flat.stroke_width;
assert!(
(mid - H as f32 / 2.0).abs() < 6.0,
"flat series line should sit near vertical center (y~{}), got y=[{miny}..{maxy}]",
H / 2
);
assert!(
(bottom_edge - maxy as f32).abs() > 6.0,
"flat series line must not be pinned to the bottom edge: y=[{miny}..{maxy}], bottom={bottom_edge}"
);
}
#[test]
fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
let mut sparkline = base_sparkline(vec![1.0, 2.0, 3.0]);
sparkline.animation_duration = 1.5;
sparkline.timing = TimingConfig {
start_at: Some(2.0),
end_at: None,
};
assert_eq!(sparkline.progress_at(2.0), 0.0);
assert!(sparkline.progress_at(2.75) < 1.0);
assert_eq!(sparkline.progress_at(3.5), 1.0);
}
}