Skip to main content

chartml_chart_cartesian/
lib.rs

1use chartml_core::data::DataTable;
2use chartml_core::element::{ChartElement, Dimensions};
3use chartml_core::error::ChartError;
4use chartml_core::plugin::{ChartConfig, ChartRenderer};
5use chartml_core::spec::VisualizeSpec;
6
7mod bar;
8mod line;
9mod area;
10pub(crate) mod helpers;
11
12pub use bar::render_bar;
13pub use line::render_line;
14pub use area::render_area;
15
16pub struct CartesianRenderer;
17
18impl CartesianRenderer {
19    pub fn new() -> Self {
20        Self
21    }
22}
23
24impl Default for CartesianRenderer {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl ChartRenderer for CartesianRenderer {
31    fn render(&self, data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
32        match config.visualize.chart_type.as_str() {
33            "bar" => bar::render_bar(data, config),
34            "line" => line::render_line(data, config),
35            "area" => area::render_area(data, config),
36            other => Err(ChartError::UnknownChartType(other.to_string())),
37        }
38    }
39
40    fn default_dimensions(&self, _spec: &VisualizeSpec) -> Option<Dimensions> {
41        Some(Dimensions::new(400.0))
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use chartml_core::element::count_elements;
49    use chartml_core::data::{Row, DataTable};
50    use serde_json::json;
51
52    fn make_bar_rows() -> Vec<Row> {
53        vec![
54            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100))].into_iter().collect(),
55            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200))].into_iter().collect(),
56            [("month".to_string(), json!("Mar")), ("revenue".to_string(), json!(150))].into_iter().collect(),
57        ]
58    }
59
60    fn make_bar_data() -> DataTable {
61        DataTable::from_rows(&make_bar_rows()).unwrap()
62    }
63
64    fn make_bar_config() -> ChartConfig {
65        let viz: VisualizeSpec = serde_yaml::from_str(r#"
66            type: bar
67            columns: month
68            rows: revenue
69        "#).unwrap();
70        ChartConfig {
71            visualize: viz,
72            title: Some("Test Bar".to_string()),
73            width: 800.0,
74            height: 400.0,
75            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
76        }
77    }
78
79    fn make_line_config() -> ChartConfig {
80        let viz: VisualizeSpec = serde_yaml::from_str(r#"
81            type: line
82            columns: month
83            rows: revenue
84        "#).unwrap();
85        ChartConfig {
86            visualize: viz,
87            title: Some("Test Line".to_string()),
88            width: 800.0,
89            height: 400.0,
90            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
91        }
92    }
93
94    fn make_area_config() -> ChartConfig {
95        let viz: VisualizeSpec = serde_yaml::from_str(r#"
96            type: area
97            columns: month
98            rows: revenue
99        "#).unwrap();
100        ChartConfig {
101            visualize: viz,
102            title: Some("Test Area".to_string()),
103            width: 800.0,
104            height: 400.0,
105            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
106        }
107    }
108
109    #[test]
110    fn bar_chart_renders() {
111        let renderer = CartesianRenderer::new();
112        let data = make_bar_data();
113        let config = make_bar_config();
114        let result = renderer.render(&data, &config);
115        assert!(result.is_ok(), "Bar render failed: {:?}", result.err());
116        let element = result.unwrap();
117        let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { .. }));
118        assert_eq!(rect_count, 3, "Should have 3 bars for 3 data points, got {}", rect_count);
119    }
120
121    #[test]
122    fn bar_chart_has_svg_root() {
123        let renderer = CartesianRenderer::new();
124        let data = make_bar_data();
125        let config = make_bar_config();
126        let element = renderer.render(&data, &config).unwrap();
127        assert!(matches!(element, ChartElement::Svg { .. }), "Root should be Svg");
128    }
129
130    #[test]
131    fn bar_chart_has_no_title_in_svg() {
132        // Title is rendered as HTML outside the SVG (matching JS chartml).
133        // The SVG element tree must NOT contain a chart-title text element.
134        let renderer = CartesianRenderer::new();
135        let data = make_bar_data();
136        let config = make_bar_config();
137        let element = renderer.render(&data, &config).unwrap();
138        let title_count = count_elements(&element, &|e| {
139            matches!(e, ChartElement::Text { class, .. } if class == "chart-title")
140        });
141        assert_eq!(title_count, 0, "Title must not be in the SVG element tree");
142    }
143
144    #[test]
145    fn bar_chart_has_axes() {
146        let renderer = CartesianRenderer::new();
147        let data = make_bar_data();
148        let config = make_bar_config();
149        let element = renderer.render(&data, &config).unwrap();
150        let axis_line_count = count_elements(&element, &|e| {
151            matches!(e, ChartElement::Line { class, .. } if class == "axis-line")
152        });
153        assert!(axis_line_count >= 1, "Should have axis lines, got {}", axis_line_count);
154    }
155
156    #[test]
157    fn line_chart_renders() {
158        let renderer = CartesianRenderer::new();
159        let data = make_bar_data();
160        let config = make_line_config();
161        let result = renderer.render(&data, &config);
162        assert!(result.is_ok(), "Line render failed: {:?}", result.err());
163        let element = result.unwrap();
164        let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { .. }));
165        assert!(path_count >= 1, "Should have at least 1 path for the line, got {}", path_count);
166    }
167
168    #[test]
169    fn line_chart_path_has_stroke() {
170        let renderer = CartesianRenderer::new();
171        let data = make_bar_data();
172        let config = make_line_config();
173        let element = renderer.render(&data, &config).unwrap();
174        // Find the path and check it has a stroke
175        fn find_path(el: &ChartElement) -> Option<&ChartElement> {
176            match el {
177                ChartElement::Path { .. } => Some(el),
178                ChartElement::Svg { children, .. }
179                | ChartElement::Group { children, .. } => {
180                    children.iter().find_map(find_path)
181                }
182                _ => None,
183            }
184        }
185        let path = find_path(&element).expect("Should find a path element");
186        match path {
187            ChartElement::Path { stroke, .. } => {
188                assert!(stroke.is_some(), "Line path should have a stroke");
189            }
190            _ => unreachable!(),
191        }
192    }
193
194    #[test]
195    fn area_chart_renders() {
196        let renderer = CartesianRenderer::new();
197        let data = make_bar_data();
198        let config = make_area_config();
199        let result = renderer.render(&data, &config);
200        assert!(result.is_ok(), "Area render failed: {:?}", result.err());
201        let element = result.unwrap();
202        let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { .. }));
203        assert!(path_count >= 1, "Should have at least 1 path for the area, got {}", path_count);
204    }
205
206    #[test]
207    fn area_chart_path_has_fill() {
208        let renderer = CartesianRenderer::new();
209        let data = make_bar_data();
210        let config = make_area_config();
211        let element = renderer.render(&data, &config).unwrap();
212        fn find_path(el: &ChartElement) -> Option<&ChartElement> {
213            match el {
214                ChartElement::Path { .. } => Some(el),
215                ChartElement::Svg { children, .. }
216                | ChartElement::Group { children, .. } => {
217                    children.iter().find_map(find_path)
218                }
219                _ => None,
220            }
221        }
222        let path = find_path(&element).expect("Should find a path element");
223        match path {
224            ChartElement::Path { fill, .. } => {
225                assert!(fill.is_some(), "Area path should have a fill");
226            }
227            _ => unreachable!(),
228        }
229    }
230
231    #[test]
232    fn unknown_type_errors() {
233        let renderer = CartesianRenderer::new();
234        let data = make_bar_data();
235        let mut config = make_bar_config();
236        config.visualize.chart_type = "unknown".to_string();
237        let result = renderer.render(&data, &config);
238        assert!(result.is_err(), "Unknown chart type should produce error");
239        match result.unwrap_err() {
240            ChartError::UnknownChartType(t) => assert_eq!(t, "unknown"),
241            other => panic!("Expected UnknownChartType, got {:?}", other),
242        }
243    }
244
245    #[test]
246    fn bar_chart_no_title() {
247        let renderer = CartesianRenderer::new();
248        let data = make_bar_data();
249        let mut config = make_bar_config();
250        config.title = None;
251        let element = renderer.render(&data, &config).unwrap();
252        let title_count = count_elements(&element, &|e| {
253            matches!(e, ChartElement::Text { class, .. } if class == "chart-title")
254        });
255        assert_eq!(title_count, 0, "Should have no title element when title is None");
256    }
257
258    #[test]
259    fn default_dimensions_returns_some() {
260        let renderer = CartesianRenderer::new();
261        let viz: VisualizeSpec = serde_yaml::from_str(r#"
262            type: bar
263            columns: x
264            rows: y
265        "#).unwrap();
266        let dims = renderer.default_dimensions(&viz);
267        assert!(dims.is_some());
268        assert_eq!(dims.unwrap().height, 400.0);
269    }
270
271    #[test]
272    fn bar_chart_adaptive_padding_2_bars() {
273        // With n=2 bars and adaptive padding=0.2, each bar should be ~36.4% of inner_width.
274        // inner_width = 800 - left_margin - right_margin ≈ 800 - 60 - 20 = 720
275        // bandwidth = 0.8/2.2 * inner_width ≈ 0.3636 * inner_width ≈ 261px
276        // Bar should NOT be close to 50% (which would indicate no padding).
277        let rows: Vec<Row> = vec![
278            [("region".to_string(), json!("US")), ("revenue".to_string(), json!(55000))].into_iter().collect(),
279            [("region".to_string(), json!("EU")), ("revenue".to_string(), json!(40000))].into_iter().collect(),
280        ];
281        let data = DataTable::from_rows(&rows).unwrap();
282        let viz: VisualizeSpec = serde_yaml::from_str(r#"
283            type: bar
284            columns: region
285            rows: revenue
286        "#).unwrap();
287        let config = ChartConfig {
288            visualize: viz,
289            title: Some("Regional Revenue".to_string()),
290            width: 800.0,
291            height: 400.0,
292            colors: vec!["#2E7D9A".to_string()],
293        };
294        let renderer = CartesianRenderer::new();
295        let element = renderer.render(&data, &config).unwrap();
296
297        // Find all Rect elements (bars)
298        let mut bar_widths = Vec::new();
299        fn collect_bar_widths(el: &ChartElement, widths: &mut Vec<f64>) {
300            match el {
301                ChartElement::Rect { width, class, .. } if class == "bar" => {
302                    widths.push(*width);
303                }
304                ChartElement::Svg { children, .. }
305                | ChartElement::Group { children, .. } => {
306                    for child in children { collect_bar_widths(child, widths); }
307                }
308                _ => {}
309            }
310        }
311        collect_bar_widths(&element, &mut bar_widths);
312
313        assert_eq!(bar_widths.len(), 2, "Should have 2 bars");
314        let bar_width = bar_widths[0];
315        println!("Bar width: {:.2}px", bar_width);
316
317        // JS applies maxBarWidth = inner_width * 0.2 clamp.
318        // With y_tick_labels pre-computation: for revenue values 100/200, the
319        // tick label "200" ≈ 21px + 15px buffer = 36px left margin.
320        // inner_width = 800 - 36 - 30 = 734px → maxBarWidth = 146.8px.
321        // bandwidth for 2 bars, padding=0.2 = ~234px → clamped to ~146.8px.
322        assert!(
323            bar_width <= 150.0,
324            "Bar width {:.1}px exceeds maxBarWidth clamp",
325            bar_width
326        );
327        assert!(
328            bar_width > 50.0,
329            "Bar width {:.1}px is unreasonably narrow",
330            bar_width
331        );
332    }
333
334    #[test]
335    fn stacked_bar_chart_renders() {
336        let rows: Vec<Row> = vec![
337            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
338            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
339            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
340            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
341        ];
342        let data = DataTable::from_rows(&rows).unwrap();
343        let viz: VisualizeSpec = serde_yaml::from_str(r#"
344            type: bar
345            mode: stacked
346            columns: month
347            rows: revenue
348            marks:
349              color: product
350        "#).unwrap();
351        let config = ChartConfig {
352            visualize: viz,
353            title: Some("Stacked Bar".to_string()),
354            width: 800.0,
355            height: 400.0,
356            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
357        };
358        let renderer = CartesianRenderer::new();
359        let result = renderer.render(&data, &config);
360        assert!(result.is_ok(), "Stacked bar render failed: {:?}", result.err());
361        let element = result.unwrap();
362        let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { class, .. } if class == "bar"));
363        assert_eq!(rect_count, 4, "Should have 4 bars (2 categories x 2 series), got {}", rect_count);
364    }
365
366    #[test]
367    fn grouped_bar_chart_renders() {
368        let rows: Vec<Row> = vec![
369            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
370            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
371            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
372            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
373        ];
374        let data = DataTable::from_rows(&rows).unwrap();
375        let viz: VisualizeSpec = serde_yaml::from_str(r#"
376            type: bar
377            mode: grouped
378            columns: month
379            rows: revenue
380            marks:
381              color: product
382        "#).unwrap();
383        let config = ChartConfig {
384            visualize: viz,
385            title: None,
386            width: 800.0,
387            height: 400.0,
388            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
389        };
390        let renderer = CartesianRenderer::new();
391        let result = renderer.render(&data, &config);
392        assert!(result.is_ok(), "Grouped bar render failed: {:?}", result.err());
393        let element = result.unwrap();
394        let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { class, .. } if class == "bar"));
395        assert_eq!(rect_count, 4, "Should have 4 bars (2 categories x 2 series), got {}", rect_count);
396    }
397
398    #[test]
399    fn multi_series_line_chart_renders() {
400        let rows: Vec<Row> = vec![
401            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
402            [("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
403            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
404            [("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
405        ];
406        let data = DataTable::from_rows(&rows).unwrap();
407        let viz: VisualizeSpec = serde_yaml::from_str(r#"
408            type: line
409            columns: month
410            rows: revenue
411            marks:
412              color: product
413        "#).unwrap();
414        let config = ChartConfig {
415            visualize: viz,
416            title: Some("Multi Line".to_string()),
417            width: 800.0,
418            height: 400.0,
419            colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
420        };
421        let renderer = CartesianRenderer::new();
422        let result = renderer.render(&data, &config);
423        assert!(result.is_ok(), "Multi-series line render failed: {:?}", result.err());
424        let element = result.unwrap();
425        let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { class, .. } if class == "line"));
426        assert_eq!(path_count, 2, "Should have 2 line paths for 2 series, got {}", path_count);
427    }
428
429    #[test]
430    fn empty_data_returns_error() {
431        let renderer = CartesianRenderer::new();
432        let data = DataTable::from_rows(&Vec::<Row>::new()).unwrap();
433        let config = make_bar_config();
434        let result = renderer.render(&data, &config);
435        assert!(result.is_err(), "Empty data should produce an error");
436    }
437
438    #[test]
439    fn x_axis_horizontal_few_labels() {
440        use crate::helpers::{generate_x_axis, GridConfig};
441        let labels = vec!["A".into(), "B".into(), "C".into()];
442        let result = generate_x_axis(&crate::helpers::XAxisParams {
443            labels: &labels, display_label_overrides: None,
444            range: (0.0, 800.0), y_position: 350.0, available_width: 800.0,
445            x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
446        });
447        // Should be horizontal — no transforms on text elements
448        let text_with_transform = result.elements.iter().filter(|e| {
449            matches!(e, ChartElement::Text { transform: Some(_), .. })
450        }).count();
451        assert_eq!(text_with_transform, 0, "Horizontal strategy should have no transforms");
452    }
453
454    #[test]
455    fn x_axis_rotated_many_labels() {
456        use crate::helpers::{generate_x_axis, GridConfig};
457        let labels: Vec<String> = (0..20).map(|i| format!("Category Number {}", i)).collect();
458        let result = generate_x_axis(&crate::helpers::XAxisParams {
459            labels: &labels, display_label_overrides: None,
460            range: (0.0, 300.0), y_position: 350.0, available_width: 300.0,
461            x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
462        });
463        // Should be rotated — text elements have transforms
464        let text_with_transform = result.elements.iter().filter(|e| {
465            matches!(e, ChartElement::Text { transform: Some(_), .. })
466        }).count();
467        assert!(text_with_transform > 0, "Rotated strategy should have transforms");
468    }
469
470    #[test]
471    fn x_axis_sampled_100_labels() {
472        use crate::helpers::{generate_x_axis, GridConfig};
473        let labels: Vec<String> = (0..100).map(|i| format!("Long Category Name {}", i)).collect();
474        let result = generate_x_axis(&crate::helpers::XAxisParams {
475            labels: &labels, display_label_overrides: None,
476            range: (0.0, 400.0), y_position: 350.0, available_width: 400.0,
477            x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
478        });
479        // Should be sampled — fewer label texts than total categories
480        let label_count = result.elements.iter().filter(|e| {
481            matches!(e, ChartElement::Text { class, .. } if class == "tick-label")
482        }).count();
483        assert!(label_count < 100, "Sampled should show fewer labels: got {}", label_count);
484        assert!(label_count >= 3, "Should show at least a few labels");
485    }
486
487    #[test]
488    fn line_chart_grid_dash_array() {
489        let data = make_bar_data();
490        // Use unquoted dashArray value (matching the examples_source.md spec)
491        let viz: VisualizeSpec = serde_yaml::from_str(r#"
492type: line
493columns: month
494rows: revenue
495style:
496  grid:
497    x: true
498    y: true
499    color: '#e0e0e0'
500    opacity: 0.5
501    dashArray: 4,4
502  showDots: true
503"#).unwrap();
504
505        // Verify the grid spec parsed correctly
506        let grid_spec = viz.style.as_ref().unwrap().grid.as_ref().unwrap();
507        assert_eq!(grid_spec.dash_array, Some("4,4".to_string()), "GridSpec.dash_array should parse from YAML");
508        assert_eq!(grid_spec.x, Some(true), "grid.x should be true");
509        assert_eq!(grid_spec.y, Some(true), "grid.y should be true");
510
511        let config = ChartConfig {
512            visualize: viz,
513            title: Some("Dashed Grid Test".to_string()),
514            width: 800.0,
515            height: 400.0,
516            colors: vec!["#2E7D9A".to_string()],
517        };
518
519        // Verify GridConfig resolves correctly
520        let grid_config = crate::helpers::GridConfig::from_config(&config);
521        assert_eq!(grid_config.dash_array, Some("4,4".to_string()), "GridConfig.dash_array should be set");
522        assert!(grid_config.show_x, "grid.show_x should be true");
523        assert!(grid_config.show_y, "grid.show_y should be true");
524
525        let renderer = CartesianRenderer::new();
526        let element = renderer.render(&data, &config).unwrap();
527
528        // Count grid lines and verify ALL have stroke_dasharray set
529        let mut dashed_grid_count = 0;
530        let mut total_grid_count = 0;
531        fn check_grid(el: &ChartElement, dashed: &mut usize, total: &mut usize) {
532            match el {
533                ChartElement::Line { class, stroke_dasharray, .. } if class.contains("grid-line") => {
534                    *total += 1;
535                    if let Some(da) = stroke_dasharray {
536                        if !da.is_empty() {
537                            *dashed += 1;
538                        }
539                    }
540                }
541                ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
542                    for child in children {
543                        check_grid(child, dashed, total);
544                    }
545                }
546                _ => {}
547            }
548        }
549        check_grid(&element, &mut dashed_grid_count, &mut total_grid_count);
550
551        assert!(total_grid_count > 0, "Should have grid lines, got {}", total_grid_count);
552        assert_eq!(dashed_grid_count, total_grid_count,
553            "All {} grid lines should have stroke_dasharray='4,4', but only {} do",
554            total_grid_count, dashed_grid_count);
555    }
556
557    #[test]
558    fn x_axis_date_labels_reformatted() {
559        use crate::helpers::{generate_x_axis, GridConfig};
560        let labels: Vec<String> = vec![
561            "2024-01-01".into(), "2024-01-02".into(), "2024-01-03".into()
562        ];
563        let result = generate_x_axis(&crate::helpers::XAxisParams {
564            labels: &labels, display_label_overrides: None,
565            range: (0.0, 800.0), y_position: 350.0, available_width: 800.0,
566            x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
567        });
568        // Labels should be reformatted as "Jan 01", "Jan 02", etc.
569        let has_reformatted = result.elements.iter().any(|e| {
570            matches!(e, ChartElement::Text { content, .. } if content.starts_with("Jan"))
571        });
572        assert!(has_reformatted, "Date labels should be reformatted");
573    }
574}