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#[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#[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#[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#[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 #[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}