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