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
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(skia_safe::Font::from_typeface(
302 typeface,
303 self.label_font_size,
304 ))
305 }
306}
307
308impl Painter for Chart {
309 fn paint_content(
310 &self,
311 canvas: &Canvas,
312 layout: &BoxLayout,
313 _props: &AnimatedProperties,
314 ctx: &PaintCtx,
315 ) {
316 let _ = self.paint(canvas, layout.width, layout.height, ctx.time);
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use rustmotion_core::traits::TimingConfig;
324
325 fn base_chart() -> Chart {
326 Chart {
327 chart_type: ChartType::Bar,
328 data: Vec::new(),
329 animated: true,
330 animation_duration: 1.5,
331 colors: None,
332 inner_radius: 0.6,
333 fill_opacity: 0.3,
334 smooth: false,
335 categories: Vec::new(),
336 series: Vec::new(),
337 axes: Vec::new(),
338 radar_data: Vec::new(),
339 points: Vec::new(),
340 direction: None,
341 show_grid: false,
342 show_x_labels: false,
343 show_y_labels: false,
344 grid_color: default_grid_color(),
345 label_color: default_label_color(),
346 label_font_size: default_label_font_size(),
347 show_labels: false,
348 timing: TimingConfig::default(),
349 style: rustmotion_core::css::CssStyle::default(),
350 timeline: Vec::new(),
351 stagger: None,
352 }
353 }
354
355 #[test]
356 fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() {
357 let mut chart = base_chart();
358 chart.animation_duration = 1.5;
359 chart.timing = TimingConfig {
360 start_at: Some(2.0),
361 end_at: None,
362 };
363
364 assert_eq!(
365 chart.progress_at(2.0),
366 0.0,
367 "no time has elapsed since start_at yet"
368 );
369 assert!(
370 chart.progress_at(2.75) < 1.0,
371 "still mid-ramp half a second after start_at"
372 );
373 assert_eq!(
374 chart.progress_at(3.5),
375 1.0,
376 "animation_duration has fully elapsed since start_at"
377 );
378 }
379
380 #[test]
381 fn progress_ramp_with_no_start_at_behaves_like_before() {
382 let chart = base_chart();
383 assert_eq!(chart.progress_at(0.0), 0.0);
384 assert_eq!(chart.progress_at(1.5), 1.0);
385 }
386
387 #[test]
388 fn progress_ramp_when_not_animated_is_always_complete() {
389 let mut chart = base_chart();
390 chart.animated = false;
391 chart.timing = TimingConfig {
392 start_at: Some(2.0),
393 end_at: None,
394 };
395 assert_eq!(chart.progress_at(0.0), 1.0);
396 }
397}