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