use chartml_core::data::DataTable;
use chartml_core::element::{ChartElement, Dimensions};
use chartml_core::error::ChartError;
use chartml_core::plugin::{ChartConfig, ChartRenderer};
use chartml_core::spec::VisualizeSpec;
mod bar;
mod line;
mod area;
pub(crate) mod helpers;
pub use bar::{bar_animation_origin, render_bar};
pub use line::render_line;
pub use area::render_area;
pub struct CartesianRenderer;
impl CartesianRenderer {
pub fn new() -> Self {
Self
}
}
impl Default for CartesianRenderer {
fn default() -> Self {
Self::new()
}
}
impl ChartRenderer for CartesianRenderer {
fn render(&self, data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
match config.visualize.chart_type.as_str() {
"bar" => bar::render_bar(data, config),
"line" => line::render_line(data, config),
"area" => area::render_area(data, config),
other => Err(ChartError::UnknownChartType(other.to_string())),
}
}
fn default_dimensions(&self, _spec: &VisualizeSpec) -> Option<Dimensions> {
Some(Dimensions::new(400.0))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use chartml_core::element::count_elements;
use chartml_core::data::{Row, DataTable};
use serde_json::json;
fn make_bar_rows() -> Vec<Row> {
vec![
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200))].into_iter().collect(),
[("month".to_string(), json!("Mar")), ("revenue".to_string(), json!(150))].into_iter().collect(),
]
}
fn make_bar_data() -> DataTable {
DataTable::from_rows(&make_bar_rows()).unwrap()
}
fn make_bar_config() -> ChartConfig {
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
columns: month
rows: revenue
"#).unwrap();
ChartConfig {
visualize: viz,
title: Some("Test Bar".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
theme: chartml_core::theme::Theme::default(),
}
}
fn make_line_config() -> ChartConfig {
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: line
columns: month
rows: revenue
"#).unwrap();
ChartConfig {
visualize: viz,
title: Some("Test Line".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
theme: chartml_core::theme::Theme::default(),
}
}
fn make_area_config() -> ChartConfig {
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: area
columns: month
rows: revenue
"#).unwrap();
ChartConfig {
visualize: viz,
title: Some("Test Area".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string(), "#4A7C59".to_string()],
theme: chartml_core::theme::Theme::default(),
}
}
#[test]
fn phase4_theme_typography_flows_to_axis_label_text() {
use chartml_core::theme::{TextTransform, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config();
let mut t = Theme::default();
t.label_font_family = "serif".into();
t.label_letter_spacing = 1.5;
t.label_text_transform = TextTransform::Uppercase;
t.label_font_weight = 600;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
fn walk<'a>(el: &'a ChartElement, out: &mut Vec<&'a ChartElement>) {
match el {
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
walk(c, out);
}
}
_ => out.push(el),
}
}
let mut leaves = Vec::new();
walk(&element, &mut leaves);
let mut axis_label_count = 0usize;
for leaf in &leaves {
if let ChartElement::Text {
class,
font_family,
letter_spacing,
text_transform,
font_weight,
..
} = leaf
{
let is_axis_label = class
.split_whitespace()
.any(|c| c == "axis-label");
if !is_axis_label {
continue;
}
axis_label_count += 1;
assert_eq!(
font_family.as_deref(),
Some("serif"),
"axis-label text must carry theme.label_font_family"
);
assert_eq!(
letter_spacing.as_deref(),
Some("1.5"),
"axis-label text must carry theme.label_letter_spacing"
);
assert_eq!(
text_transform.as_deref(),
Some("uppercase"),
"axis-label text must carry theme.label_text_transform"
);
assert_eq!(
font_weight.as_deref(),
Some("600"),
"axis-label text must carry theme.label_font_weight"
);
}
}
assert!(
axis_label_count > 0,
"bar chart should have at least one axis-label text"
);
}
#[test]
fn phase4_theme_typography_flows_to_tick_value_text() {
use chartml_core::theme::{TextTransform, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config();
let mut t = Theme::default();
t.numeric_font_family = "monospace".into();
t.label_letter_spacing = 0.75;
t.label_text_transform = TextTransform::Lowercase;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let mut found = false;
fn visit<F: FnMut(&ChartElement)>(el: &ChartElement, f: &mut F) {
f(el);
match el {
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
visit(c, f);
}
}
_ => {}
}
}
visit(&element, &mut |el| {
if let ChartElement::Text {
class,
font_family,
letter_spacing,
text_transform,
..
} = el
{
if class
.split_whitespace()
.any(|c| c == "tick-value")
{
found = true;
assert_eq!(
font_family.as_deref(),
Some("monospace"),
"tick-value text must carry theme.numeric_font_family"
);
assert_eq!(
letter_spacing.as_deref(),
Some("0.75"),
"tick-value text must inherit theme.label_letter_spacing"
);
assert_eq!(
text_transform.as_deref(),
Some("lowercase"),
"tick-value text must inherit theme.label_text_transform"
);
}
}
});
assert!(found, "bar chart should emit at least one tick-value text");
}
#[test]
fn bar_chart_renders() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_bar_config();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Bar render failed: {:?}", result.err());
let element = result.unwrap();
let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { .. }));
assert_eq!(rect_count, 3, "Should have 3 bars for 3 data points, got {}", rect_count);
}
#[test]
fn bar_chart_has_svg_root() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_bar_config();
let element = renderer.render(&data, &config).unwrap();
assert!(matches!(element, ChartElement::Svg { .. }), "Root should be Svg");
}
#[test]
fn bar_chart_has_no_title_in_svg() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_bar_config();
let element = renderer.render(&data, &config).unwrap();
let title_count = count_elements(&element, &|e| {
matches!(e, ChartElement::Text { class, .. } if class == "chart-title")
});
assert_eq!(title_count, 0, "Title must not be in the SVG element tree");
}
#[test]
fn bar_chart_has_axes() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_bar_config();
let element = renderer.render(&data, &config).unwrap();
let axis_line_count = count_elements(&element, &|e| {
matches!(e, ChartElement::Line { class, .. } if class == "axis-line")
});
assert!(axis_line_count >= 1, "Should have axis lines, got {}", axis_line_count);
}
#[test]
fn line_chart_renders() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_line_config();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Line render failed: {:?}", result.err());
let element = result.unwrap();
let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { .. }));
assert!(path_count >= 1, "Should have at least 1 path for the line, got {}", path_count);
}
#[test]
fn line_chart_path_has_stroke() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_line_config();
let element = renderer.render(&data, &config).unwrap();
fn find_path(el: &ChartElement) -> Option<&ChartElement> {
match el {
ChartElement::Path { .. } => Some(el),
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
children.iter().find_map(find_path)
}
_ => None,
}
}
let path = find_path(&element).expect("Should find a path element");
match path {
ChartElement::Path { stroke, .. } => {
assert!(stroke.is_some(), "Line path should have a stroke");
}
_ => unreachable!(),
}
}
#[test]
fn area_chart_renders() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_area_config();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Area render failed: {:?}", result.err());
let element = result.unwrap();
let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { .. }));
assert!(path_count >= 1, "Should have at least 1 path for the area, got {}", path_count);
}
#[test]
fn area_chart_path_has_fill() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let config = make_area_config();
let element = renderer.render(&data, &config).unwrap();
fn find_path(el: &ChartElement) -> Option<&ChartElement> {
match el {
ChartElement::Path { .. } => Some(el),
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
children.iter().find_map(find_path)
}
_ => None,
}
}
let path = find_path(&element).expect("Should find a path element");
match path {
ChartElement::Path { fill, .. } => {
assert!(fill.is_some(), "Area path should have a fill");
}
_ => unreachable!(),
}
}
#[test]
fn unknown_type_errors() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config();
config.visualize.chart_type = "unknown".to_string();
let result = renderer.render(&data, &config);
assert!(result.is_err(), "Unknown chart type should produce error");
match result.unwrap_err() {
ChartError::UnknownChartType(t) => assert_eq!(t, "unknown"),
other => panic!("Expected UnknownChartType, got {:?}", other),
}
}
#[test]
fn bar_chart_no_title() {
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config();
config.title = None;
let element = renderer.render(&data, &config).unwrap();
let title_count = count_elements(&element, &|e| {
matches!(e, ChartElement::Text { class, .. } if class == "chart-title")
});
assert_eq!(title_count, 0, "Should have no title element when title is None");
}
#[test]
fn default_dimensions_returns_some() {
let renderer = CartesianRenderer::new();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
columns: x
rows: y
"#).unwrap();
let dims = renderer.default_dimensions(&viz);
assert!(dims.is_some());
assert_eq!(dims.unwrap().height, 400.0);
}
#[test]
fn bar_chart_adaptive_padding_2_bars() {
let rows: Vec<Row> = vec![
[("region".to_string(), json!("US")), ("revenue".to_string(), json!(55000))].into_iter().collect(),
[("region".to_string(), json!("EU")), ("revenue".to_string(), json!(40000))].into_iter().collect(),
];
let data = DataTable::from_rows(&rows).unwrap();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
columns: region
rows: revenue
"#).unwrap();
let config = ChartConfig {
visualize: viz,
title: Some("Regional Revenue".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string()],
theme: chartml_core::theme::Theme::default(),
};
let renderer = CartesianRenderer::new();
let element = renderer.render(&data, &config).unwrap();
let mut bar_widths = Vec::new();
fn collect_bar_widths(el: &ChartElement, widths: &mut Vec<f64>) {
match el {
ChartElement::Rect { width, class, .. } if class.split_whitespace().any(|c| c == "bar") => {
widths.push(*width);
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for child in children { collect_bar_widths(child, widths); }
}
_ => {}
}
}
collect_bar_widths(&element, &mut bar_widths);
assert_eq!(bar_widths.len(), 2, "Should have 2 bars");
let bar_width = bar_widths[0];
println!("Bar width: {:.2}px", bar_width);
assert!(
bar_width <= 150.0,
"Bar width {:.1}px exceeds maxBarWidth clamp",
bar_width
);
assert!(
bar_width > 50.0,
"Bar width {:.1}px is unreasonably narrow",
bar_width
);
}
#[test]
fn stacked_bar_chart_renders() {
let rows: Vec<Row> = vec![
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
];
let data = DataTable::from_rows(&rows).unwrap();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
mode: stacked
columns: month
rows: revenue
marks:
color: product
"#).unwrap();
let config = ChartConfig {
visualize: viz,
title: Some("Stacked Bar".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
theme: chartml_core::theme::Theme::default(),
};
let renderer = CartesianRenderer::new();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Stacked bar render failed: {:?}", result.err());
let element = result.unwrap();
let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { class, .. } if class.split_whitespace().any(|c| c == "bar")));
assert_eq!(rect_count, 4, "Should have 4 bars (2 categories x 2 series), got {}", rect_count);
}
#[test]
fn grouped_bar_chart_renders() {
let rows: Vec<Row> = vec![
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
];
let data = DataTable::from_rows(&rows).unwrap();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
mode: grouped
columns: month
rows: revenue
marks:
color: product
"#).unwrap();
let config = ChartConfig {
visualize: viz,
title: None,
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
theme: chartml_core::theme::Theme::default(),
};
let renderer = CartesianRenderer::new();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Grouped bar render failed: {:?}", result.err());
let element = result.unwrap();
let rect_count = count_elements(&element, &|e| matches!(e, ChartElement::Rect { class, .. } if class.split_whitespace().any(|c| c == "bar")));
assert_eq!(rect_count, 4, "Should have 4 bars (2 categories x 2 series), got {}", rect_count);
}
#[test]
fn multi_series_line_chart_renders() {
let rows: Vec<Row> = vec![
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(100)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(50)), ("product".to_string(), json!("B"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(200)), ("product".to_string(), json!("A"))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(80)), ("product".to_string(), json!("B"))].into_iter().collect(),
];
let data = DataTable::from_rows(&rows).unwrap();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: line
columns: month
rows: revenue
marks:
color: product
"#).unwrap();
let config = ChartConfig {
visualize: viz,
title: Some("Multi Line".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string(), "#D4A445".to_string()],
theme: chartml_core::theme::Theme::default(),
};
let renderer = CartesianRenderer::new();
let result = renderer.render(&data, &config);
assert!(result.is_ok(), "Multi-series line render failed: {:?}", result.err());
let element = result.unwrap();
let path_count = count_elements(&element, &|e| matches!(e, ChartElement::Path { class, .. } if class.split_whitespace().any(|c| c == "chartml-line-path")));
assert_eq!(path_count, 2, "Should have 2 line paths for 2 series, got {}", path_count);
}
#[test]
fn empty_data_returns_error() {
let renderer = CartesianRenderer::new();
let data = DataTable::from_rows(&Vec::<Row>::new()).unwrap();
let config = make_bar_config();
let result = renderer.render(&data, &config);
assert!(result.is_err(), "Empty data should produce an error");
}
#[test]
fn x_axis_horizontal_few_labels() {
use crate::helpers::{generate_x_axis, GridConfig};
let labels = vec!["A".into(), "B".into(), "C".into()];
let result = generate_x_axis(&crate::helpers::XAxisParams {
labels: &labels, display_label_overrides: None,
range: (0.0, 800.0), y_position: 350.0, available_width: 800.0,
x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
theme: &chartml_core::theme::Theme::default(),
});
let text_with_transform = result.elements.iter().filter(|e| {
matches!(e, ChartElement::Text { transform: Some(_), .. })
}).count();
assert_eq!(text_with_transform, 0, "Horizontal strategy should have no transforms");
}
#[test]
fn x_axis_rotated_many_labels() {
use crate::helpers::{generate_x_axis, GridConfig};
let labels: Vec<String> = (0..20).map(|i| format!("Category Number {}", i)).collect();
let result = generate_x_axis(&crate::helpers::XAxisParams {
labels: &labels, display_label_overrides: None,
range: (0.0, 300.0), y_position: 350.0, available_width: 300.0,
x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
theme: &chartml_core::theme::Theme::default(),
});
let text_with_transform = result.elements.iter().filter(|e| {
matches!(e, ChartElement::Text { transform: Some(_), .. })
}).count();
assert!(text_with_transform > 0, "Rotated strategy should have transforms");
}
#[test]
fn x_axis_rotated_labels_preserve_full_text() {
use crate::helpers::{generate_x_axis, GridConfig};
let labels: Vec<String> = vec![
"Monday, January 6th, 2025".into(),
"Monday, January 13th, 2025".into(),
"Monday, January 20th, 2025".into(),
"Monday, January 27th, 2025".into(),
"Monday, February 3rd, 2025".into(),
"Monday, February 10th, 2025".into(),
"Monday, February 17th, 2025".into(),
"Monday, February 24th, 2025".into(),
"Monday, March 3rd, 2025".into(),
"Monday, March 10th, 2025".into(),
"Monday, March 17th, 2025".into(),
"Monday, March 24th, 2025".into(),
];
let result = generate_x_axis(&crate::helpers::XAxisParams {
labels: &labels, display_label_overrides: None,
range: (0.0, 600.0), y_position: 350.0, available_width: 600.0,
x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
theme: &chartml_core::theme::Theme::default(),
});
let tick_texts: Vec<&str> = result.elements.iter().filter_map(|e| {
if let ChartElement::Text { content, class, .. } = e {
if class.split_whitespace().any(|c| c == "tick-label") {
return Some(content.as_str());
}
}
None
}).collect();
for text in &tick_texts {
assert!(!text.contains('\u{2026}'),
"Rotated label should NOT be truncated but got: {text:?}");
}
assert!(tick_texts.iter().any(|t| *t == "Monday, January 6th, 2025"),
"Expected full label text in output, got: {:?}", tick_texts);
}
#[test]
fn x_axis_sampled_100_labels() {
use crate::helpers::{generate_x_axis, GridConfig};
let labels: Vec<String> = (0..100).map(|i| format!("Long Category Name {}", i)).collect();
let result = generate_x_axis(&crate::helpers::XAxisParams {
labels: &labels, display_label_overrides: None,
range: (0.0, 400.0), y_position: 350.0, available_width: 400.0,
x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
theme: &chartml_core::theme::Theme::default(),
});
let label_count = result.elements.iter().filter(|e| {
matches!(e, ChartElement::Text { class, .. } if class.split_whitespace().any(|c| c == "tick-label"))
}).count();
assert!(label_count < 100, "Sampled should show fewer labels: got {}", label_count);
assert!(label_count >= 3, "Should show at least a few labels");
}
#[test]
fn line_chart_grid_dash_array() {
let data = make_bar_data();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: line
columns: month
rows: revenue
style:
grid:
x: true
y: true
color: '#e0e0e0'
opacity: 0.5
dashArray: 4,4
showDots: true
"#).unwrap();
let grid_spec = viz.style.as_ref().unwrap().grid.as_ref().unwrap();
assert_eq!(grid_spec.dash_array, Some("4,4".to_string()), "GridSpec.dash_array should parse from YAML");
assert_eq!(grid_spec.x, Some(true), "grid.x should be true");
assert_eq!(grid_spec.y, Some(true), "grid.y should be true");
let config = ChartConfig {
visualize: viz,
title: Some("Dashed Grid Test".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string()],
theme: chartml_core::theme::Theme::default(),
};
let grid_config = crate::helpers::GridConfig::from_config(&config);
assert_eq!(grid_config.dash_array, Some("4,4".to_string()), "GridConfig.dash_array should be set");
assert!(grid_config.show_x, "grid.show_x should be true");
assert!(grid_config.show_y, "grid.show_y should be true");
let renderer = CartesianRenderer::new();
let element = renderer.render(&data, &config).unwrap();
let mut dashed_grid_count = 0;
let mut total_grid_count = 0;
fn check_grid(el: &ChartElement, dashed: &mut usize, total: &mut usize) {
match el {
ChartElement::Line { class, stroke_dasharray, .. } if class.contains("grid-line") => {
*total += 1;
if let Some(da) = stroke_dasharray {
if !da.is_empty() {
*dashed += 1;
}
}
}
ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
for child in children {
check_grid(child, dashed, total);
}
}
_ => {}
}
}
check_grid(&element, &mut dashed_grid_count, &mut total_grid_count);
assert!(total_grid_count > 0, "Should have grid lines, got {}", total_grid_count);
assert_eq!(dashed_grid_count, total_grid_count,
"All {} grid lines should have stroke_dasharray='4,4', but only {} do",
total_grid_count, dashed_grid_count);
}
fn collect_series_stroke_widths(el: &ChartElement, out: &mut Vec<f64>) {
match el {
ChartElement::Path { stroke_width: Some(w), class, .. }
if class.split_whitespace().any(|c| c == "series-line") =>
{
out.push(*w);
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
collect_series_stroke_widths(c, out);
}
}
_ => {}
}
}
fn collect_line_stroke_widths_by_class(
el: &ChartElement,
out: &mut std::collections::HashMap<String, Vec<f64>>,
) {
match el {
ChartElement::Line { stroke_width: Some(w), class, .. } => {
for token in class.split_whitespace() {
if matches!(token, "axis-line" | "grid-line" | "tick") {
out.entry(token.to_string()).or_default().push(*w);
}
}
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
collect_line_stroke_widths_by_class(c, out);
}
}
_ => {}
}
}
fn collect_bar_corner_radii(
el: &ChartElement,
out: &mut Vec<(Option<f64>, Option<f64>)>,
) {
match el {
ChartElement::Rect { rx, ry, class, .. }
if class.split_whitespace().any(|c| c == "bar") =>
{
out.push((*rx, *ry));
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
collect_bar_corner_radii(c, out);
}
}
_ => {}
}
}
fn collect_dot_radii(el: &ChartElement, out: &mut Vec<f64>) {
match el {
ChartElement::Circle { r, class, .. }
if class.split_whitespace().any(|c| c == "dot-marker") =>
{
out.push(*r);
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
collect_dot_radii(c, out);
}
}
_ => {}
}
}
#[test]
fn phase5_bar_corner_radius_omitted_by_default() {
let renderer = CartesianRenderer::new();
let element = renderer
.render(&make_bar_data(), &make_bar_config())
.expect("render");
let mut radii = Vec::new();
collect_bar_corner_radii(&element, &mut radii);
assert!(!radii.is_empty(), "expected bar rects in default bar chart");
for (rx, ry) in &radii {
assert!(rx.is_none(), "default theme must leave Rect.rx == None");
assert!(ry.is_none(), "default theme must leave Rect.ry == None");
}
}
#[test]
fn phase5_custom_bar_corner_radius_emits_rx_ry() {
use chartml_core::theme::{BarCornerRadius, Theme};
let renderer = CartesianRenderer::new();
let mut config = make_bar_config();
let mut t = Theme::default();
t.bar_corner_radius = BarCornerRadius::Uniform(8.0);
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut radii = Vec::new();
collect_bar_corner_radii(&element, &mut radii);
assert!(!radii.is_empty());
for (rx, ry) in &radii {
assert_eq!(*rx, Some(8.0), "rx must match theme.bar_corner_radius");
assert_eq!(*ry, Some(8.0), "ry must match theme.bar_corner_radius");
}
}
fn collect_bar_elements<'a>(el: &'a ChartElement, out: &mut Vec<&'a ChartElement>) {
match el {
ChartElement::Rect { class, .. } | ChartElement::Path { class, .. }
if class.split_whitespace().any(|c| c == "bar-rect") =>
{
out.push(el);
}
ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
for c in children {
collect_bar_elements(c, out);
}
}
_ => {}
}
}
#[test]
fn phase_followup_bar_top_rounding_zero_is_plain_rect() {
use chartml_core::theme::{BarCornerRadius, Theme};
let renderer = CartesianRenderer::new();
let mut config = make_bar_config();
let mut t = Theme::default();
t.bar_corner_radius = BarCornerRadius::Top(0.0);
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut bars = Vec::new();
collect_bar_elements(&element, &mut bars);
assert!(!bars.is_empty());
for b in &bars {
match b {
ChartElement::Rect { rx, ry, .. } => {
assert!(rx.is_none(), "Top(0.0) must emit Rect with rx=None");
assert!(ry.is_none(), "Top(0.0) must emit Rect with ry=None");
}
other => panic!("Top(0.0) must emit Rect, got {:?}", other),
}
}
}
#[test]
fn phase_followup_bar_top_rounding_vertical() {
use chartml_core::theme::{BarCornerRadius, Theme};
let renderer = CartesianRenderer::new();
let mut config = make_bar_config();
let mut t = Theme::default();
t.bar_corner_radius = BarCornerRadius::Top(8.0);
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut bars = Vec::new();
collect_bar_elements(&element, &mut bars);
assert!(!bars.is_empty(), "expected bar elements");
for b in &bars {
match b {
ChartElement::Path { d, .. } => {
assert_eq!(
d.matches("A 8,8").count(),
2,
"vertical Top(8) must produce 2 arcs, got d={d}"
);
}
other => panic!("vertical Top(8) must emit Path, got {:?}", other),
}
}
}
#[test]
fn phase_followup_bar_top_rounding_horizontal() {
use chartml_core::theme::{BarCornerRadius, Theme};
let renderer = CartesianRenderer::new();
let mut config = make_bar_config();
config.visualize.orientation = Some(chartml_core::spec::Orientation::Horizontal);
let mut t = Theme::default();
t.bar_corner_radius = BarCornerRadius::Top(8.0);
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut bars = Vec::new();
collect_bar_elements(&element, &mut bars);
assert!(!bars.is_empty(), "expected bar elements (horizontal)");
for b in &bars {
match b {
ChartElement::Path { d, .. } => {
assert_eq!(
d.matches("A 8,8").count(),
2,
"horizontal Top(8) must produce 2 arcs, got d={d}"
);
}
other => panic!("horizontal Top(8) must emit Path, got {:?}", other),
}
}
}
#[test]
fn phase_followup_bar_top_rounding_negative_vertical() {
use chartml_core::theme::{BarCornerRadius, Theme};
use crate::bar::{build_bar_element, BarRectSpec};
let mut theme = Theme::default();
theme.bar_corner_radius = BarCornerRadius::Top(8.0);
let pos = build_bar_element(
BarRectSpec {
x: 100.0, y: 50.0, width: 40.0, height: 200.0,
is_horizontal: false, is_negative: false,
fill: "#000".into(),
class: "bar bar-rect".into(),
data: None,
},
&theme,
);
let neg = build_bar_element(
BarRectSpec {
x: 100.0, y: 50.0, width: 40.0, height: 200.0,
is_horizontal: false, is_negative: true,
fill: "#000".into(),
class: "bar bar-rect".into(),
data: None,
},
&theme,
);
let pos_d = match &pos {
ChartElement::Path { d, .. } => d.clone(),
_ => panic!("pos must be Path"),
};
let neg_d = match &neg {
ChartElement::Path { d, .. } => d.clone(),
_ => panic!("neg must be Path"),
};
assert_eq!(pos_d.matches("A 8,8").count(), 2);
assert_eq!(neg_d.matches("A 8,8").count(), 2);
assert!(
pos_d.starts_with("M 100,58"),
"pos vertical Top path should start at y+r=58, got {pos_d}"
);
assert!(
neg_d.starts_with("M 100,50"),
"neg vertical Top path should start at (x, y)=(100, 50), got {neg_d}"
);
assert!(
neg_d.contains(",242"),
"neg vertical Top path should contain y1-r=242, got {neg_d}"
);
}
#[test]
fn phase5_custom_series_line_weight_flows_to_line_path() {
use chartml_core::theme::Theme;
let renderer = CartesianRenderer::new();
let mut config = make_line_config();
let mut t = Theme::default();
t.series_line_weight = 4.0;
config.theme = t;
let element = renderer
.render(&make_bar_data(), &config)
.expect("render");
let mut widths = Vec::new();
collect_series_stroke_widths(&element, &mut widths);
assert!(!widths.is_empty(), "expected at least one series-line path");
for w in &widths {
assert_eq!(*w, 4.0, "series-line stroke_width must read from theme");
}
}
#[test]
fn phase5_custom_series_line_weight_flows_to_area_outline() {
use chartml_core::theme::Theme;
let renderer = CartesianRenderer::new();
let mut config = make_area_config();
let mut t = Theme::default();
t.series_line_weight = 3.5;
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut widths = Vec::new();
collect_series_stroke_widths(&element, &mut widths);
assert!(!widths.is_empty(), "expected area outline series-line path");
for w in &widths {
assert_eq!(*w, 3.5);
}
}
#[test]
fn phase5_custom_dot_radius_flows_to_line_markers() {
use chartml_core::theme::Theme;
let renderer = CartesianRenderer::new();
let mut config = make_line_config();
let mut t = Theme::default();
t.dot_radius = 10.0;
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut radii = Vec::new();
collect_dot_radii(&element, &mut radii);
assert!(!radii.is_empty(), "expected dot-marker circles on line chart");
for r in &radii {
assert_eq!(*r, 10.0);
}
}
#[test]
fn phase5_custom_axis_and_grid_line_weights_flow_to_line_strokes() {
use chartml_core::theme::Theme;
let renderer = CartesianRenderer::new();
let mut config = make_bar_config();
let mut t = Theme::default();
t.axis_line_weight = 2.5;
t.grid_line_weight = 0.5;
config.theme = t;
let element = renderer.render(&make_bar_data(), &config).expect("render");
let mut by_class: std::collections::HashMap<String, Vec<f64>> =
std::collections::HashMap::new();
collect_line_stroke_widths_by_class(&element, &mut by_class);
let axis = by_class.get("axis-line").cloned().unwrap_or_default();
let ticks = by_class.get("tick").cloned().unwrap_or_default();
let grid = by_class.get("grid-line").cloned().unwrap_or_default();
assert!(!axis.is_empty(), "expected axis-line elements");
assert!(!ticks.is_empty(), "expected tick elements");
assert!(!grid.is_empty(), "expected grid-line elements");
for w in &axis {
assert_eq!(*w, 2.5, "axis-line stroke_width must read from theme.axis_line_weight");
}
for w in &ticks {
assert_eq!(*w, 2.5, "tick stroke_width must read from theme.axis_line_weight");
}
for w in &grid {
assert_eq!(*w, 0.5, "grid-line stroke_width must read from theme.grid_line_weight");
}
}
#[test]
fn x_axis_date_labels_reformatted() {
use crate::helpers::{generate_x_axis, GridConfig};
let labels: Vec<String> = vec![
"2024-01-01".into(), "2024-01-02".into(), "2024-01-03".into()
];
let result = generate_x_axis(&crate::helpers::XAxisParams {
labels: &labels, display_label_overrides: None,
range: (0.0, 800.0), y_position: 350.0, available_width: 800.0,
x_format: None, chart_height: None, grid: &GridConfig::default(), axis_label: None,
theme: &chartml_core::theme::Theme::default(),
});
let has_reformatted = result.elements.iter().any(|e| {
matches!(e, ChartElement::Text { content, .. } if content.starts_with("Jan"))
});
assert!(has_reformatted, "Date labels should be reformatted");
}
fn count_grid_lines(el: &ChartElement) -> (usize, usize) {
let (mut vx, mut hy) = (0usize, 0usize);
fn visit(el: &ChartElement, vx: &mut usize, hy: &mut usize) {
match el {
ChartElement::Line { class, .. } => {
let has_x = class.split_whitespace().any(|c| c == "grid-line-x");
let has_y = class.split_whitespace().any(|c| c == "grid-line-y");
if has_x {
*vx += 1;
}
if has_y {
*hy += 1;
}
}
ChartElement::Svg { children, .. }
| ChartElement::Group { children, .. } => {
for c in children {
visit(c, vx, hy);
}
}
_ => {}
}
}
visit(el, &mut vx, &mut hy);
(vx, hy)
}
fn make_bar_config_both_grids() -> ChartConfig {
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
columns: month
rows: revenue
style:
grid:
x: true
y: true
"#).unwrap();
ChartConfig {
visualize: viz,
title: Some("Test Bar GridStyle".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string()],
theme: chartml_core::theme::Theme::default(),
}
}
#[test]
fn phase6_grid_style_both_default_emits_both_orientations() {
use chartml_core::theme::{GridStyle, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config_both_grids();
let mut t = Theme::default();
t.grid_style = GridStyle::Both;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let (vx, hy) = count_grid_lines(&element);
assert!(vx > 0, "Both: expected vertical gridlines (grid-line-x)");
assert!(hy > 0, "Both: expected horizontal gridlines (grid-line-y)");
}
#[test]
fn phase6_grid_style_horizontal_only_skips_vertical() {
use chartml_core::theme::{GridStyle, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config_both_grids();
let mut t = Theme::default();
t.grid_style = GridStyle::HorizontalOnly;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let (vx, hy) = count_grid_lines(&element);
assert_eq!(vx, 0, "HorizontalOnly: no grid-line-x expected, got {}", vx);
assert!(hy > 0, "HorizontalOnly: expected grid-line-y lines");
}
#[test]
fn phase6_grid_style_vertical_only_skips_horizontal() {
use chartml_core::theme::{GridStyle, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config_both_grids();
let mut t = Theme::default();
t.grid_style = GridStyle::VerticalOnly;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let (vx, hy) = count_grid_lines(&element);
assert!(vx > 0, "VerticalOnly: expected grid-line-x lines");
assert_eq!(hy, 0, "VerticalOnly: no grid-line-y expected, got {}", hy);
}
#[test]
fn phase6_grid_style_none_skips_all_gridlines() {
use chartml_core::theme::{GridStyle, Theme};
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_bar_config_both_grids();
let mut t = Theme::default();
t.grid_style = GridStyle::None;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let (vx, hy) = count_grid_lines(&element);
assert_eq!(vx, 0, "None: no grid-line-x expected, got {}", vx);
assert_eq!(hy, 0, "None: no grid-line-y expected, got {}", hy);
}
fn make_bar_data_crossing_zero() -> DataTable {
let rows = vec![
[("month".to_string(), json!("Jan")), ("revenue".to_string(), json!(-5))].into_iter().collect(),
[("month".to_string(), json!("Feb")), ("revenue".to_string(), json!(0))].into_iter().collect(),
[("month".to_string(), json!("Mar")), ("revenue".to_string(), json!(10))].into_iter().collect(),
];
DataTable::from_rows(&rows).unwrap()
}
fn count_zero_lines(el: &ChartElement) -> usize {
count_elements(el, &|e| {
matches!(e, ChartElement::Line { class, .. } if class.split_whitespace().any(|c| c == "zero-line"))
})
}
#[test]
fn phase7_default_theme_emits_no_zero_line() {
let renderer = CartesianRenderer::new();
let data = make_bar_data_crossing_zero();
let config = make_bar_config();
let element = renderer.render(&data, &config).unwrap();
assert_eq!(count_zero_lines(&element), 0, "default theme must not emit zero-line");
}
#[test]
fn phase7_bar_crossing_zero_emits_one_zero_line() {
use chartml_core::theme::{Theme, ZeroLineSpec};
let renderer = CartesianRenderer::new();
let data = make_bar_data_crossing_zero();
let mut config = make_bar_config();
let mut t = Theme::default();
t.zero_line = Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 });
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
assert_eq!(count_zero_lines(&element), 1, "expected exactly one zero-line");
fn find_zero_line(el: &ChartElement) -> Option<(String, Option<f64>)> {
match el {
ChartElement::Line { class, stroke, stroke_width, .. }
if class.split_whitespace().any(|c| c == "zero-line") =>
{
Some((stroke.clone(), *stroke_width))
}
ChartElement::Group { children, .. } | ChartElement::Svg { children, .. } => {
children.iter().find_map(find_zero_line)
}
_ => None,
}
}
let (stroke, width) = find_zero_line(&element).expect("zero-line present");
assert_eq!(stroke, "#ff0000");
assert_eq!(width, Some(1.5));
}
#[test]
fn phase7_horizontal_bar_crossing_zero_emits_one_zero_line() {
use chartml_core::theme::{Theme, ZeroLineSpec};
let renderer = CartesianRenderer::new();
let data = make_bar_data_crossing_zero();
let viz: VisualizeSpec = serde_yaml::from_str(r#"
type: bar
orientation: horizontal
columns: month
rows: revenue
"#).unwrap();
let mut theme = Theme::default();
theme.zero_line = Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 });
let config = ChartConfig {
visualize: viz,
title: Some("Test Horizontal Bar".to_string()),
width: 800.0,
height: 400.0,
colors: vec!["#2E7D9A".to_string()],
theme,
};
let element = renderer.render(&data, &config).unwrap();
assert_eq!(count_zero_lines(&element), 1, "expected exactly one zero-line");
struct ZeroLineGeom {
x1: f64,
y1: f64,
x2: f64,
y2: f64,
stroke: String,
stroke_width: Option<f64>,
}
fn find_zero_line_geom(el: &ChartElement) -> Option<ZeroLineGeom> {
match el {
ChartElement::Line { class, x1, y1, x2, y2, stroke, stroke_width, .. }
if class.split_whitespace().any(|c| c == "zero-line") =>
{
Some(ZeroLineGeom {
x1: *x1,
y1: *y1,
x2: *x2,
y2: *y2,
stroke: stroke.clone(),
stroke_width: *stroke_width,
})
}
ChartElement::Group { children, .. } | ChartElement::Svg { children, .. } => {
children.iter().find_map(find_zero_line_geom)
}
_ => None,
}
}
let ZeroLineGeom { x1, y1, x2, y2, stroke, stroke_width: width } =
find_zero_line_geom(&element).expect("zero-line present");
assert!(
(x1 - x2).abs() < f64::EPSILON,
"horizontal-bar zero-line must be vertical: x1={x1} x2={x2}",
);
assert!(
(y1 - y2).abs() > f64::EPSILON,
"horizontal-bar zero-line must have non-zero height: y1={y1} y2={y2}",
);
assert_eq!(stroke, "#ff0000");
assert_eq!(width, Some(1.5));
}
#[test]
fn phase7_bar_all_positive_emits_no_zero_line() {
use chartml_core::theme::{Theme, ZeroLineSpec};
let renderer = CartesianRenderer::new();
let data = make_bar_data(); let mut config = make_bar_config();
let mut t = Theme::default();
t.zero_line = Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 });
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
assert_eq!(
count_zero_lines(&element),
0,
"all-positive data must not emit a zero-line",
);
}
#[test]
fn phase7_line_crossing_zero_emits_one_zero_line() {
use chartml_core::theme::{Theme, ZeroLineSpec};
let renderer = CartesianRenderer::new();
let data = make_bar_data_crossing_zero();
let mut config = make_line_config();
let mut t = Theme::default();
t.zero_line = Some(ZeroLineSpec { color: "#00ff00".into(), width: 2.0 });
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
assert_eq!(count_zero_lines(&element), 1);
}
fn count_halos(el: &ChartElement) -> usize {
count_elements(el, &|e| matches!(e, ChartElement::Path { class, .. } if class == "dot-halo"))
}
fn count_dot_markers(el: &ChartElement) -> usize {
count_elements(el, &|e| matches!(e, ChartElement::Circle { class, .. } if class.contains("dot-marker")))
}
#[test]
fn phase8_line_default_theme_emits_no_halo() {
let renderer = CartesianRenderer::new();
let element = renderer.render(&make_bar_data(), &make_line_config()).unwrap();
assert_eq!(count_halos(&element), 0, "default theme line chart must emit zero halos");
}
#[test]
fn phase8_line_halo_matches_dot_count_and_ordering() {
use chartml_core::theme::Theme;
let renderer = CartesianRenderer::new();
let data = make_bar_data();
let mut config = make_line_config();
let mut t = Theme::default();
t.dot_halo_color = Some("#ffffff".to_string());
t.dot_halo_width = 1.5;
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
let dot_n = count_dot_markers(&element);
let halo_n = count_halos(&element);
assert!(dot_n > 0, "line chart should produce at least one dot-marker");
assert_eq!(halo_n, dot_n, "one halo per dot-marker required");
fn walk_lines_group(el: &ChartElement) -> Option<&Vec<ChartElement>> {
match el {
ChartElement::Group { class, children, .. } if class == "lines" => Some(children),
ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
children.iter().find_map(walk_lines_group)
}
_ => None,
}
}
let lines = walk_lines_group(&element).expect("lines group");
let mut pair = 0;
let mut iter = lines.iter().peekable();
while let Some(el) = iter.next() {
if let ChartElement::Path { class, .. } = el {
if class == "dot-halo" {
match iter.peek() {
Some(ChartElement::Circle { class: cc, .. }) => {
assert!(cc.contains("dot-marker"));
pair += 1;
}
other => panic!("halo not followed by dot: {:?}", other.map(|_| "other")),
}
}
}
}
assert_eq!(pair, dot_n);
fn first_halo(el: &ChartElement) -> Option<(String, f64)> {
match el {
ChartElement::Path { class, stroke, stroke_width, .. } if class == "dot-halo" => {
Some((stroke.clone().unwrap_or_default(), stroke_width.unwrap_or(-1.0)))
}
ChartElement::Svg { children, .. } | ChartElement::Group { children, .. } => {
children.iter().find_map(first_halo)
}
_ => None,
}
}
let (stroke, width) = first_halo(&element).unwrap();
assert_eq!(stroke, "#ffffff");
assert!((width - 1.5).abs() < 1e-9);
}
#[test]
fn phase7_area_crossing_zero_emits_one_zero_line() {
use chartml_core::theme::{Theme, ZeroLineSpec};
let renderer = CartesianRenderer::new();
let data = make_bar_data_crossing_zero();
let mut config = make_area_config();
let mut t = Theme::default();
t.zero_line = Some(ZeroLineSpec { color: "#0000ff".into(), width: 1.0 });
config.theme = t;
let element = renderer.render(&data, &config).unwrap();
assert_eq!(count_zero_lines(&element), 1);
}
}