Skip to main content

rustmotion_components/chart/
mod.rs

1use rustmotion_core::error::Result;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use skia_safe::Canvas;
5
6use rustmotion_core::css::CssStyle;
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::typeface_with_fallback;
10use rustmotion_core::schema::TimelineStep;
11use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
12
13mod axes;
14mod bar;
15mod funnel;
16mod line;
17mod pie;
18mod radar;
19mod radial;
20mod scatter;
21mod waterfall;
22
23/// The engine's default series palette. `pub` so the studio can prefill an
24/// empty `colors` list with what the canvas actually renders.
25pub const DEFAULT_PALETTE: &[&str] = &[
26    "#3B82F6", "#EF4444", "#22C55E", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316",
27];
28
29/// Funnel flow direction. Closed set (painter matches both variants); JSON
30/// values unchanged ("vertical"/"horizontal").
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "snake_case")]
33pub enum ChartDirection {
34    Vertical,
35    Horizontal,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
39#[serde(rename_all = "snake_case")]
40pub enum ChartType {
41    Bar,
42    Line,
43    Pie,
44    Donut,
45    HorizontalBar,
46    Area,
47    StackedBar,
48    Radar,
49    Scatter,
50    RadialBar,
51    Funnel,
52    Waterfall,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct ChartDataPoint {
57    pub value: f64,
58    #[serde(default)]
59    pub label: Option<String>,
60    #[serde(default)]
61    pub color: Option<String>,
62}
63
64/// A data point for scatter charts with explicit x/y coordinates.
65#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
66pub struct ScatterPoint {
67    pub x: f64,
68    pub y: f64,
69    #[serde(default = "default_scatter_size")]
70    pub size: f32,
71    #[serde(default)]
72    pub color: Option<String>,
73}
74
75fn default_scatter_size() -> f32 {
76    8.0
77}
78
79/// A data series for stacked bar charts.
80#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
81pub struct ChartSeries {
82    pub name: String,
83    pub data: Vec<f64>,
84    #[serde(default)]
85    pub color: Option<String>,
86}
87
88/// A data series for radar charts.
89#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
90pub struct RadarData {
91    pub values: Vec<f64>,
92    #[serde(default)]
93    pub color: Option<String>,
94}
95
96#[derive(Debug, Serialize, Deserialize, JsonSchema)]
97pub struct Chart {
98    pub chart_type: ChartType,
99    #[serde(default)]
100    pub data: Vec<ChartDataPoint>,
101    #[serde(default = "default_animated")]
102    pub animated: bool,
103    #[serde(default = "default_animation_duration")]
104    pub animation_duration: f64,
105    #[serde(default)]
106    pub colors: Option<Vec<String>>,
107
108    // Donut-specific
109    #[serde(default = "default_inner_radius")]
110    pub inner_radius: f64,
111
112    // Area-specific
113    #[serde(default = "default_fill_opacity")]
114    pub fill_opacity: f32,
115    #[serde(default)]
116    pub smooth: bool,
117
118    // Stacked bar
119    #[serde(default)]
120    pub categories: Vec<String>,
121    #[serde(default)]
122    pub series: Vec<ChartSeries>,
123
124    // Radar
125    #[serde(default)]
126    pub axes: Vec<String>,
127    #[serde(default)]
128    pub radar_data: Vec<RadarData>,
129
130    // Scatter
131    #[serde(default)]
132    pub points: Vec<ScatterPoint>,
133
134    // Funnel direction
135    /// Direction for funnel chart: "vertical" (default) or "horizontal".
136    #[serde(default)]
137    pub direction: Option<ChartDirection>,
138
139    // Axes, grid, labels
140    #[serde(default)]
141    pub show_grid: bool,
142    #[serde(default)]
143    pub show_x_labels: bool,
144    #[serde(default)]
145    pub show_y_labels: bool,
146    #[serde(default = "default_grid_color")]
147    pub grid_color: String,
148    #[serde(default = "default_label_color")]
149    pub label_color: String,
150    #[serde(default = "default_label_font_size")]
151    pub label_font_size: f32,
152    #[serde(default)]
153    pub show_labels: bool,
154
155    #[serde(flatten)]
156    pub timing: TimingConfig,
157    #[serde(default)]
158    pub style: CssStyle,
159    #[serde(default)]
160    pub timeline: Vec<TimelineStep>,
161    #[serde(default)]
162    pub stagger: Option<f32>,
163}
164
165fn default_animated() -> bool {
166    true
167}
168
169fn default_animation_duration() -> f64 {
170    1.5
171}
172
173fn default_inner_radius() -> f64 {
174    0.6
175}
176
177fn default_fill_opacity() -> f32 {
178    0.3
179}
180
181fn default_grid_color() -> String {
182    "#FFFFFF15".to_string()
183}
184
185fn default_label_color() -> String {
186    "#888888".to_string()
187}
188
189fn default_label_font_size() -> f32 {
190    12.0
191}
192
193rustmotion_core::impl_traits!(Chart {
194    Animatable => animation,
195    Timed => timing,
196    Styled => style,
197});
198
199impl Chart {
200    pub(super) fn get_color(&self, index: usize) -> &str {
201        if let Some(colors) = &self.colors {
202            if !colors.is_empty() {
203                return &colors[index % colors.len()];
204            }
205        }
206        DEFAULT_PALETTE[index % DEFAULT_PALETTE.len()]
207    }
208
209    fn progress_at(&self, time: f64) -> f32 {
210        if !self.animated {
211            return 1.0;
212        }
213        // Ramp measured from `start_at`, not from scene time zero — matches
214        // `Counter::ramp_progress`. A chart delayed with `start_at` used to
215        // read raw scene time, so it was already fully drawn on the very
216        // first frame it became visible.
217        let start = self.timing.start_at.unwrap_or(0.0);
218        let elapsed = (time - start).max(0.0);
219        let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
220        // ease_out_cubic
221        1.0 - (1.0 - p).powi(3)
222    }
223
224    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) -> Result<()> {
225        let w = layout_w;
226        let h = layout_h;
227        let progress = self.progress_at(time);
228
229        match self.chart_type {
230            ChartType::Bar => {
231                if self.data.is_empty() {
232                    return Ok(());
233                }
234                self.render_bar(canvas, w, h, progress)
235            }
236            ChartType::Line => {
237                if self.data.is_empty() {
238                    return Ok(());
239                }
240                self.render_line(canvas, w, h, progress)
241            }
242            ChartType::Pie => {
243                if self.data.is_empty() {
244                    return Ok(());
245                }
246                self.render_pie(canvas, w, h, progress)
247            }
248            ChartType::Donut => {
249                if self.data.is_empty() {
250                    return Ok(());
251                }
252                self.render_donut(canvas, w, h, progress)
253            }
254            ChartType::HorizontalBar => {
255                if self.data.is_empty() {
256                    return Ok(());
257                }
258                self.render_horizontal_bar(canvas, w, h, progress)
259            }
260            ChartType::Area => {
261                if self.data.is_empty() {
262                    return Ok(());
263                }
264                self.render_area(canvas, w, h, progress)
265            }
266            ChartType::StackedBar => self.render_stacked_bar(canvas, w, h, progress),
267            ChartType::Radar => self.render_radar(canvas, w, h, progress),
268            ChartType::Scatter => self.render_scatter(canvas, w, h, progress),
269            ChartType::RadialBar => {
270                if self.data.is_empty() {
271                    return Ok(());
272                }
273                self.render_radial_bar(canvas, w, h, progress)
274            }
275            ChartType::Funnel => {
276                if self.data.is_empty() {
277                    return Ok(());
278                }
279                self.render_funnel(canvas, w, h, progress)
280            }
281            ChartType::Waterfall => {
282                if self.data.is_empty() {
283                    return Ok(());
284                }
285                self.render_waterfall(canvas, w, h, progress)
286            }
287        }
288    }
289
290    /// Compute margins for axes/labels area.
291    pub(super) fn chart_margins(&self) -> (f32, f32, f32, f32) {
292        let left = if self.show_y_labels {
293            self.label_font_size * 3.5
294        } else {
295            0.0
296        };
297        let bottom = if self.show_x_labels {
298            self.label_font_size * 2.0
299        } else {
300            0.0
301        };
302        // top, right, bottom, left
303        (8.0, 8.0, bottom + 8.0, left + 8.0)
304    }
305
306    pub(super) fn make_label_font(&self) -> Option<skia_safe::Font> {
307        let font_style = skia_safe::FontStyle::normal();
308        let typeface = typeface_with_fallback("Inter", font_style).ok()?;
309        Some(skia_safe::Font::from_typeface(
310            typeface,
311            self.label_font_size,
312        ))
313    }
314}
315
316impl Painter for Chart {
317    fn paint_content(
318        &self,
319        canvas: &Canvas,
320        layout: &BoxLayout,
321        _props: &AnimatedProperties,
322        ctx: &PaintCtx,
323    ) {
324        let _ = self.paint(canvas, layout.width, layout.height, ctx.time);
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use rustmotion_core::traits::TimingConfig;
332
333    fn base_chart() -> Chart {
334        Chart {
335            chart_type: ChartType::Bar,
336            data: Vec::new(),
337            animated: true,
338            animation_duration: 1.5,
339            colors: None,
340            inner_radius: 0.6,
341            fill_opacity: 0.3,
342            smooth: false,
343            categories: Vec::new(),
344            series: Vec::new(),
345            axes: Vec::new(),
346            radar_data: Vec::new(),
347            points: Vec::new(),
348            direction: None,
349            show_grid: false,
350            show_x_labels: false,
351            show_y_labels: false,
352            grid_color: default_grid_color(),
353            label_color: default_label_color(),
354            label_font_size: default_label_font_size(),
355            show_labels: false,
356            timing: TimingConfig::default(),
357            style: rustmotion_core::css::CssStyle::default(),
358            timeline: Vec::new(),
359            stagger: None,
360        }
361    }
362
363    #[test]
364    fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
365        // #3's exact repro: a chart delayed with `start_at: 2.0` and
366        // `animation_duration: 1.5` was already fully drawn (progress 1.0)
367        // on the very first frame it became visible, because the ramp read
368        // raw scene time instead of time-since-`start_at` — the same defect
369        // `Counter::ramp_progress` was fixed for.
370        let mut chart = base_chart();
371        chart.animation_duration = 1.5;
372        chart.timing = TimingConfig {
373            start_at: Some(2.0),
374            end_at: None,
375        };
376
377        assert_eq!(
378            chart.progress_at(2.0),
379            0.0,
380            "no time has elapsed since start_at yet"
381        );
382        assert!(
383            chart.progress_at(2.75) < 1.0,
384            "still mid-ramp half a second after start_at"
385        );
386        assert_eq!(
387            chart.progress_at(3.5),
388            1.0,
389            "animation_duration has fully elapsed since start_at"
390        );
391    }
392
393    #[test]
394    fn progress_ramp_with_no_start_at_behaves_like_before() {
395        let chart = base_chart();
396        assert_eq!(chart.progress_at(0.0), 0.0);
397        assert_eq!(chart.progress_at(1.5), 1.0);
398    }
399
400    #[test]
401    fn progress_ramp_when_not_animated_is_always_complete() {
402        let mut chart = base_chart();
403        chart.animated = false;
404        chart.timing = TimingConfig {
405            start_at: Some(2.0),
406            end_at: None,
407        };
408        assert_eq!(chart.progress_at(0.0), 1.0);
409    }
410}