use chartml_core::data::DataTable;
use chartml_core::element::{ChartElement, ElementData, TextAnchor, Transform, ViewBox};
use chartml_core::error::ChartError;
use chartml_core::layout::margins::{calculate_margins, MarginConfig};
use chartml_core::plugin::ChartConfig;
use chartml_core::scales::{ScaleBand, ScaleLinear};
use chartml_core::layout::adaptive_tick_count;
use chartml_core::spec::{ChartMode, Orientation};
use chartml_core::layout::labels::{LabelStrategy, LabelStrategyConfig};
use chartml_core::layout::legend::{calculate_legend_layout, LegendConfig};
use crate::helpers::{GridConfig, format_value, generate_annotations, generate_x_axis, generate_x_axis_numeric, generate_x_axis_with_display, generate_y_axis_with_display, generate_y_axis_numeric, generate_y_axis_numeric_right, generate_legend, get_color_field, get_data_labels_config, get_field_name, get_x_format, get_y_axis_bounds, get_y_format, nice_domain, offset_element};
struct SingleSeriesBarParams<'a> {
category_field: &'a str,
value_field: &'a str,
categories: &'a [String],
inner_width: f64,
inner_height: f64,
is_horizontal: bool,
y_fmt_ref: Option<&'a str>,
domain_min: f64,
domain_max: f64,
}
struct MultiSeriesBarParams<'a> {
category_field: &'a str,
value_field: &'a str,
color_field: &'a str,
categories: &'a [String],
inner_width: f64,
inner_height: f64,
is_stacked: bool,
is_normalized: bool,
is_horizontal: bool,
y_fmt_ref: Option<&'a str>,
domain_min: f64,
domain_max: f64,
}
pub fn render_bar(data: &DataTable, config: &ChartConfig) -> Result<ChartElement, ChartError> {
use chartml_core::spec::{FieldRef, FieldRefItem, FieldSpec};
let multi_fields: Vec<FieldSpec> = match &config.visualize.rows {
Some(FieldRef::Multiple(items)) => items.iter().map(|item| match item {
FieldRefItem::Detailed(spec) => spec.as_ref().clone(),
FieldRefItem::Simple(name) => FieldSpec {
field: name.clone(), mark: None, axis: None, label: None,
color: None, format: None, data_labels: None,
line_style: None, upper: None, lower: None, opacity: None,
},
}).collect(),
_ => vec![],
};
if !multi_fields.is_empty() {
return render_combo(data, config, &multi_fields);
}
let category_field = get_field_name(&config.visualize.columns)?;
let value_field = get_field_name(&config.visualize.rows)?;
let color_field = get_color_field(config);
let (categories, display_labels): (Vec<String>, Option<Vec<String>>) = if color_field.is_none() {
let all_vals = data.all_values(&category_field);
if all_vals.is_empty() {
return Err(ChartError::DataError("No category values found".into()));
}
let has_duplicates = {
let mut seen = std::collections::HashSet::new();
all_vals.iter().any(|v| !seen.insert(v.as_str()))
};
if has_duplicates {
let band_keys: Vec<String> = all_vals.iter().enumerate()
.map(|(i, v)| format!("{}\x00{}", v, i))
.collect();
(band_keys, Some(all_vals))
} else {
(all_vals, None)
}
} else {
let unique = data.unique_values(&category_field);
if unique.is_empty() {
return Err(ChartError::DataError("No category values found".into()));
}
(unique, None)
};
let is_horizontal = matches!(config.visualize.orientation, Some(Orientation::Horizontal));
let is_normalized = matches!(config.visualize.mode, Some(ChartMode::Normalized));
let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked)) || is_normalized;
let _is_grouped = matches!(config.visualize.mode, Some(ChartMode::Grouped));
let x_format = get_x_format(config);
let y_fmt = get_y_format(config);
let y_fmt_ref = y_fmt.as_deref();
let (axis_min, axis_max) = get_y_axis_bounds(config);
let labels_for_strategy = display_labels.as_deref().unwrap_or(&categories);
let x_extra_margin = if !is_horizontal {
let estimated_width = config.width - 80.0;
let x_strategy = LabelStrategy::determine(labels_for_strategy, estimated_width, &LabelStrategyConfig::default());
match &x_strategy {
LabelStrategy::Rotated { margin, .. } => *margin,
_ => 0.0,
}
} else {
0.0
};
let (prelim_data_min, prelim_data_max): (f64, f64) = if let Some(ref color_f) = color_field {
if is_stacked {
let groups = data.group_by(color_f);
let series_names = data.unique_values(color_f);
let stacked_vals: Vec<f64> = categories.iter().map(|cat| {
series_names.iter().map(|s| {
groups.get(s).and_then(|series_data| {
(0..series_data.num_rows()).find_map(|i| {
if series_data.get_string(i, &category_field).as_deref() == Some(cat.as_str()) {
series_data.get_f64(i, &value_field)
} else {
None
}
})
}).unwrap_or(0.0)
}).sum::<f64>()
}).collect();
let mn = stacked_vals.iter().cloned().fold(f64::INFINITY, f64::min);
let mx = stacked_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
(mn, mx)
} else {
let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
(mn, mx)
}
} else {
let vals: Vec<f64> = (0..data.num_rows()).filter_map(|i| data.get_f64(i, &value_field)).collect();
let mn = vals.iter().cloned().fold(f64::INFINITY, f64::min);
let mx = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
(mn, mx)
};
let prelim_data_max = if prelim_data_max <= 0.0 { 1.0 } else { prelim_data_max };
let prelim_data_min = if prelim_data_min >= 0.0 { 0.0 } else { prelim_data_min };
let prelim_domain_max = if is_normalized {
1.0
} else {
let raw_max = axis_max.unwrap_or(prelim_data_max);
if axis_max.is_none() { nice_domain(axis_min.unwrap_or(prelim_data_min), raw_max, 5).1 } else { raw_max }
};
let prelim_domain_min = if is_normalized { 0.0 } else { axis_min.unwrap_or(prelim_data_min) };
let y_tick_labels_for_margin: Vec<String> = if !is_horizontal {
let prelim_fmt = if is_normalized { Some(".0%") } else { y_fmt_ref };
vec![
format_value(prelim_domain_max, prelim_fmt),
format_value(prelim_domain_min, prelim_fmt),
]
} else {
let display = display_labels.as_deref().unwrap_or(&categories);
display.to_vec()
};
let has_x_axis_label = config.visualize.axes.as_ref()
.and_then(|a| a.x.as_ref())
.and_then(|a| a.label.as_ref())
.is_some();
let margin_config = MarginConfig {
has_title: config.title.is_some(),
has_legend: color_field.is_some(),
has_x_axis_label,
x_label_strategy_margin: x_extra_margin,
y_tick_labels: y_tick_labels_for_margin,
..Default::default()
};
let margins = calculate_margins(&margin_config);
let inner_width = margins.inner_width(config.width);
let inner_height = margins.inner_height(config.height);
let mut children = Vec::new();
let grid = GridConfig::from_config(config);
let _tick_count = adaptive_tick_count(inner_height);
let raw_data_max = prelim_data_max;
let (domain_min, domain_max) = if is_normalized {
(0.0, 1.0)
} else {
let raw_domain_min = axis_min.unwrap_or(prelim_data_min);
let raw_domain_max = axis_max.unwrap_or(raw_data_max);
if axis_min.is_none() && axis_max.is_none() {
nice_domain(raw_domain_min, raw_domain_max, 5)
} else {
(raw_domain_min, raw_domain_max)
}
};
let effective_y_fmt: Option<String> = if is_normalized {
Some(".0%".to_string())
} else {
y_fmt.clone()
};
let effective_y_fmt_ref = effective_y_fmt.as_deref();
let (_, bar_elements) = if let Some(ref color_f) = color_field {
render_multi_series_bars(
data,
config,
&MultiSeriesBarParams {
category_field: &category_field,
value_field: &value_field,
color_field: color_f,
categories: &categories,
inner_width,
inner_height,
is_stacked,
is_normalized,
is_horizontal,
y_fmt_ref,
domain_min,
domain_max,
},
)?
} else {
render_single_series_bars(
data,
config,
&SingleSeriesBarParams {
category_field: &category_field,
value_field: &value_field,
categories: &categories,
inner_width,
inner_height,
is_horizontal,
y_fmt_ref,
domain_min,
domain_max,
},
)?
};
let axis_elements = if is_horizontal {
let x_axis = generate_y_axis_with_display(&categories, display_labels.as_deref(), (0.0, inner_height), 0.0, None);
let y_axis = generate_x_axis_numeric((domain_min, domain_max), (0.0, inner_width), margins.top + inner_height, effective_y_fmt_ref, 5, Some(inner_height), &grid);
let mut axes = Vec::new();
axes.extend(x_axis.into_iter().map(|e| offset_element(e, margins.left, margins.top)));
axes.extend(y_axis.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
axes
} else {
let bottom_axis_label = config.visualize.axes.as_ref()
.and_then(|a| a.x.as_ref())
.and_then(|a| a.label.as_deref());
let x_axis_result = generate_x_axis_with_display(&crate::helpers::XAxisParams {
labels: &categories,
display_label_overrides: display_labels.as_deref(),
range: (0.0, inner_width),
y_position: margins.top + inner_height,
available_width: inner_width,
x_format: x_format.as_deref(),
chart_height: Some(inner_height),
grid: &grid,
axis_label: bottom_axis_label,
});
let left_axis_label = config.visualize.axes.as_ref()
.and_then(|a| a.left.as_ref())
.and_then(|a| a.label.as_deref());
let y_axis = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
domain: (domain_min, domain_max),
range: (inner_height, 0.0),
x_position: margins.left,
fmt: effective_y_fmt_ref,
tick_count: adaptive_tick_count(inner_height),
chart_width: Some(inner_width),
grid: &grid,
axis_label: left_axis_label,
});
let mut axes = Vec::new();
axes.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
axes.extend(y_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
axes
};
children.push(ChartElement::Group {
class: "axes".to_string(),
transform: None,
children: axis_elements,
});
children.push(ChartElement::Group {
class: "bars".to_string(),
transform: Some(Transform::Translate(margins.left, margins.top)),
children: bar_elements,
});
if !is_horizontal {
if let Some(annotations) = config.visualize.annotations.as_deref() {
if !annotations.is_empty() {
use chartml_core::scales::ScaleLinear;
let ann_scale = ScaleLinear::new((domain_min, domain_max), (inner_height, 0.0));
let ann_cats = display_labels.as_deref().unwrap_or(&categories);
let ann_elements = generate_annotations(
annotations,
&ann_scale,
0.0,
inner_width,
inner_height,
Some(ann_cats),
);
if !ann_elements.is_empty() {
children.push(ChartElement::Group {
class: "annotations".to_string(),
transform: Some(Transform::Translate(margins.left, margins.top)),
children: ann_elements,
});
}
}
}
}
if let Some(ref color_f) = color_field {
let series_names = data.unique_values(color_f);
let legend_config = LegendConfig::default();
let legend_layout = calculate_legend_layout(&series_names, &config.colors, config.width, &legend_config);
let legend_y = config.height - legend_layout.total_height - 8.0;
let legend_elements = generate_legend(&series_names, &config.colors, config.width, legend_y);
children.push(ChartElement::Group {
class: "legend".to_string(),
transform: None,
children: legend_elements,
});
}
Ok(ChartElement::Svg {
viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
width: Some(config.width),
height: Some(config.height),
class: "chartml-bar".to_string(),
children,
})
}
fn render_single_series_bars(
data: &DataTable,
config: &ChartConfig,
params: &SingleSeriesBarParams,
) -> Result<(f64, Vec<ChartElement>), ChartError> {
let category_field = params.category_field;
let value_field = params.value_field;
let categories = params.categories;
let inner_width = params.inner_width;
let inner_height = params.inner_height;
let is_horizontal = params.is_horizontal;
let y_fmt_ref = params.y_fmt_ref;
let domain_min = params.domain_min;
let domain_max = params.domain_max;
let values: Vec<f64> = (0..data.num_rows())
.filter_map(|i| data.get_f64(i, value_field))
.collect();
let value_max = values.iter().cloned().fold(0.0_f64, f64::max);
let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
let effective_max = domain_max;
let mut elements = Vec::new();
let fill_color = config.colors.first()
.cloned()
.unwrap_or_else(|| "#2E7D9A".to_string());
if is_horizontal {
let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
.padding(crate::helpers::adaptive_bar_padding(categories.len()));
let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
let bar_render_height = band.bandwidth().min(40.0);
let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
for i in 0..data.num_rows() {
let cat = match data.get_string(i, category_field) {
Some(c) => c,
None => continue,
};
let val = data.get_f64(i, value_field).unwrap_or(0.0);
let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
let y = match band.map(band_key) {
Some(y) => y,
None => continue,
};
let bar_width = linear.map(val);
elements.push(ChartElement::Rect {
x: 0.0,
y: y + y_inset,
width: bar_width,
height: bar_render_height,
fill: fill_color.clone(),
stroke: None,
class: "bar".to_string(),
data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
});
}
} else {
let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
.padding(crate::helpers::adaptive_bar_padding(categories.len()));
let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
let max_bar_width = inner_width * 0.2;
let bar_render_width = band.bandwidth().min(max_bar_width);
let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
for i in 0..data.num_rows() {
let cat = match data.get_string(i, category_field) {
Some(c) => c,
None => continue,
};
let val = data.get_f64(i, value_field).unwrap_or(0.0);
let band_key = categories.get(i).map(|k| k.as_str()).unwrap_or(&cat);
let x = match band.map(band_key) {
Some(x) => x,
None => continue,
};
let bar_val_y = linear.map(val);
let bar_zero_y = linear.map(0.0);
let bar_height = (bar_zero_y - bar_val_y).abs();
let rect_y = bar_val_y.min(bar_zero_y);
elements.push(ChartElement::Rect {
x: x + x_inset,
y: rect_y,
width: bar_render_width,
height: bar_height,
fill: fill_color.clone(),
stroke: None,
class: "bar".to_string(),
data: Some(ElementData::new(&cat, format_value(val, y_fmt_ref))),
});
if let Some(dl) = get_data_labels_config(config) {
if dl.show == Some(true) {
let label_fmt = dl.format.as_deref().or(y_fmt_ref);
let label_y = match dl.position.as_deref() {
Some("center") => rect_y + bar_height / 2.0,
Some("bottom") => rect_y + bar_height - 5.0,
_ => if val >= 0.0 { rect_y - 5.0 } else { rect_y + bar_height + 12.0 }, };
elements.push(ChartElement::Text {
x: x + band.bandwidth() / 2.0,
y: label_y,
content: format_value(val, label_fmt),
anchor: TextAnchor::Middle,
dominant_baseline: None,
transform: None,
font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "11px".to_string())),
font_weight: None,
fill: Some(dl.color.clone().unwrap_or_else(|| "#333".to_string())),
class: "data-label".to_string(),
data: None,
});
}
}
}
}
Ok((value_max, elements))
}
fn render_multi_series_bars(
data: &DataTable,
config: &ChartConfig,
params: &MultiSeriesBarParams,
) -> Result<(f64, Vec<ChartElement>), ChartError> {
let category_field = params.category_field;
let value_field = params.value_field;
let color_field = params.color_field;
let categories = params.categories;
let inner_width = params.inner_width;
let inner_height = params.inner_height;
let is_stacked = params.is_stacked;
let is_normalized = params.is_normalized;
let is_horizontal = params.is_horizontal;
let y_fmt_ref = params.y_fmt_ref;
let domain_min = params.domain_min;
let domain_max = params.domain_max;
use chartml_core::layout::stack::{StackLayout, StackOffset};
let series_names = data.unique_values(color_field);
let groups = data.group_by(color_field);
let mut elements = Vec::new();
if is_stacked {
let mut values_matrix: Vec<Vec<f64>> = Vec::new();
for series in &series_names {
let mut series_vals = Vec::new();
let series_data = groups.get(series);
for cat in categories {
let val = series_data
.map(|sd| {
(0..sd.num_rows())
.find_map(|i| {
if sd.get_string(i, category_field).as_deref() == Some(cat.as_str()) {
sd.get_f64(i, value_field)
} else {
None
}
})
.unwrap_or(0.0)
})
.unwrap_or(0.0);
series_vals.push(val);
}
values_matrix.push(series_vals);
}
let stack = if is_normalized {
StackLayout::new().offset(StackOffset::Normalize)
} else {
StackLayout::new()
};
let stacked_points = stack.layout(categories, &series_names, &values_matrix);
let (effective_min, effective_max) = if is_normalized {
(0.0, 1.0)
} else {
let value_max = stacked_points
.iter()
.map(|p| p.y1)
.fold(0.0_f64, f64::max);
let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
(domain_min, if domain_max < f64::MAX { domain_max } else { value_max })
};
if is_horizontal {
let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
.padding(crate::helpers::adaptive_bar_padding(categories.len()));
let linear = ScaleLinear::new((effective_min, effective_max), (0.0, inner_width));
let bar_render_height = band.bandwidth().min(40.0);
let y_inset = (band.bandwidth() - bar_render_height) / 2.0;
for point in &stacked_points {
let y = match band.map(&point.key) {
Some(y) => y,
None => continue,
};
let x_left = linear.map(point.y0);
let x_right = linear.map(point.y1);
let bar_width = (x_right - x_left).abs();
let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
let fill = config
.colors
.get(series_idx)
.cloned()
.unwrap_or_else(|| "#2E7D9A".to_string());
elements.push(ChartElement::Rect {
x: x_left.min(x_right),
y: y + y_inset,
width: bar_width,
height: bar_render_height,
fill,
stroke: None,
class: "bar".to_string(),
data: Some(
ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
.with_series(&point.series),
),
});
}
} else {
let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
.padding(crate::helpers::adaptive_bar_padding(categories.len()));
let linear = ScaleLinear::new((effective_min, effective_max), (inner_height, 0.0));
let max_bar_width = inner_width * 0.2;
let bar_render_width = band.bandwidth().min(max_bar_width);
let x_inset = (band.bandwidth() - bar_render_width) / 2.0;
for point in &stacked_points {
let x = match band.map(&point.key) {
Some(x) => x,
None => continue,
};
let y_top = linear.map(point.y1);
let y_bottom = linear.map(point.y0);
let bar_height = (y_bottom - y_top).abs();
let series_idx = series_names.iter().position(|s| s == &point.series).unwrap_or(0);
let fill = config
.colors
.get(series_idx)
.cloned()
.unwrap_or_else(|| "#2E7D9A".to_string());
elements.push(ChartElement::Rect {
x: x + x_inset,
y: y_top,
width: bar_render_width,
height: bar_height,
fill,
stroke: None,
class: "bar".to_string(),
data: Some(
ElementData::new(&point.key, format_value(point.value, y_fmt_ref))
.with_series(&point.series),
),
});
}
}
Ok((effective_max, elements))
} else {
let value_max = (0..data.num_rows())
.filter_map(|i| data.get_f64(i, value_field))
.fold(0.0_f64, f64::max);
let value_max = if value_max <= 0.0 { 1.0 } else { value_max };
let effective_max = if domain_max < f64::MAX { domain_max } else { value_max };
let num_series = series_names.len().max(1);
if is_horizontal {
let band = ScaleBand::new(categories.to_vec(), (0.0, inner_height))
.padding(0.05);
let linear = ScaleLinear::new((domain_min, effective_max), (0.0, inner_width));
let sub_band_height = band.bandwidth() / num_series as f64;
for i in 0..data.num_rows() {
let cat = match data.get_string(i, category_field) {
Some(c) => c,
None => continue,
};
let series = match data.get_string(i, color_field) {
Some(s) => s,
None => continue,
};
let val = data.get_f64(i, value_field).unwrap_or(0.0);
let y_base = match band.map(&cat) {
Some(y) => y,
None => continue,
};
let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
let y = y_base + series_idx as f64 * sub_band_height;
let bar_left = linear.map(0.0);
let bar_right = linear.map(val);
let bar_width = (bar_right - bar_left).abs();
let fill = config
.colors
.get(series_idx)
.cloned()
.unwrap_or_else(|| "#2E7D9A".to_string());
elements.push(ChartElement::Rect {
x: bar_left.min(bar_right),
y,
width: bar_width,
height: sub_band_height,
fill,
stroke: None,
class: "bar".to_string(),
data: Some(
ElementData::new(&cat, format_value(val, y_fmt_ref)).with_series(&series),
),
});
}
} else {
let band = ScaleBand::new(categories.to_vec(), (0.0, inner_width))
.padding(0.05);
let linear = ScaleLinear::new((domain_min, effective_max), (inner_height, 0.0));
let sub_band_width = band.bandwidth() / num_series as f64;
for i in 0..data.num_rows() {
let cat = match data.get_string(i, category_field) {
Some(c) => c,
None => continue,
};
let series = match data.get_string(i, color_field) {
Some(s) => s,
None => continue,
};
let val = data.get_f64(i, value_field).unwrap_or(0.0);
let x_base = match band.map(&cat) {
Some(x) => x,
None => continue,
};
let series_idx = series_names.iter().position(|s| s == &series).unwrap_or(0);
let x = x_base + series_idx as f64 * sub_band_width;
let bar_top = linear.map(val);
let bar_bottom = linear.map(0.0);
let bar_height = (bar_bottom - bar_top).abs();
let fill = config
.colors
.get(series_idx)
.cloned()
.unwrap_or_else(|| "#2E7D9A".to_string());
elements.push(ChartElement::Rect {
x,
y: bar_top,
width: sub_band_width,
height: bar_height,
fill,
stroke: None,
class: "bar".to_string(),
data: Some(
ElementData::new(&cat, format_value(val, y_fmt_ref)).with_series(&series),
),
});
}
}
Ok((value_max, elements))
}
}
fn render_combo(
data: &DataTable,
config: &ChartConfig,
fields: &[chartml_core::spec::FieldSpec],
) -> Result<ChartElement, ChartError> {
use chartml_core::shapes::LineGenerator;
use chartml_core::layout::stack::StackLayout;
let category_field = get_field_name(&config.visualize.columns)?;
let categories = data.unique_values(&category_field);
if categories.is_empty() {
return Err(ChartError::DataError("No category values found".into()));
}
let y_fmt = get_y_format(config);
let y_fmt_ref = y_fmt.as_deref();
let grid = GridConfig::from_config(config);
let x_format = get_x_format(config);
let color_field = get_color_field(config);
let is_stacked = matches!(config.visualize.mode, Some(ChartMode::Stacked));
let has_right = fields.iter().any(|f| f.axis.as_deref() == Some("right"));
let right_fmt = config.visualize.axes.as_ref()
.and_then(|a| a.right.as_ref())
.and_then(|a| a.format.as_deref());
let right_tick_labels: Vec<String> = if has_right {
let right_max = fields.iter()
.filter(|f| f.axis.as_deref() == Some("right"))
.flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
.fold(0.0_f64, f64::max);
let right_domain_max = config.visualize.axes.as_ref()
.and_then(|a| a.right.as_ref())
.and_then(|a| a.max)
.unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
let tmp_scale = ScaleLinear::new((0.0, right_domain_max), (0.0, 100.0));
tmp_scale.ticks(5).iter().map(|v| format_value(*v, right_fmt)).collect()
} else {
vec![]
};
let has_x_axis_label = config.visualize.axes.as_ref()
.and_then(|a| a.x.as_ref())
.and_then(|a| a.label.as_ref())
.is_some();
let margin_config = MarginConfig {
has_title: config.title.is_some(),
has_legend: fields.len() > 1 || color_field.is_some(),
has_y_axis_label: false,
has_x_axis_label,
has_right_axis: has_right,
right_tick_labels,
..Default::default()
};
let margins = calculate_margins(&margin_config);
let inner_width = margins.inner_width(config.width);
let inner_height = margins.inner_height(config.height);
let band = ScaleBand::new(categories.clone(), (0.0, inner_width))
.padding(crate::helpers::adaptive_bar_padding(categories.len()));
let bandwidth = band.bandwidth();
let left_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
.filter(|f| f.axis.as_deref() != Some("right"))
.collect();
let right_fields: Vec<&chartml_core::spec::FieldSpec> = fields.iter()
.filter(|f| f.axis.as_deref() == Some("right"))
.collect();
let left_max = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
let color_series = data.unique_values(color_f);
let mut max_stack = 0.0_f64;
for f in &left_fields {
for cat in &categories {
let mut stack_total = 0.0_f64;
for series in &color_series {
let val = (0..data.num_rows())
.find(|&i| {
data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
&& data.get_string(i, color_f).as_deref() == Some(series.as_str())
})
.and_then(|i| data.get_f64(i, &f.field))
.unwrap_or(0.0);
stack_total += val;
}
max_stack = max_stack.max(stack_total);
}
}
max_stack
} else {
left_fields.iter()
.flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
.fold(0.0_f64, f64::max)
};
let left_data_min = left_fields.iter()
.flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
.fold(0.0_f64, f64::min);
let left_data_min = if left_data_min >= 0.0 { 0.0 } else { left_data_min };
let axes_left = config.visualize.axes.as_ref().and_then(|a| a.left.as_ref());
let left_explicit_min = axes_left.and_then(|a| a.min);
let left_explicit_max = axes_left.and_then(|a| a.max);
let raw_left_domain_min = left_explicit_min.unwrap_or(left_data_min);
let raw_left_domain_max = left_explicit_max.unwrap_or(if left_max <= 0.0 { 1.0 } else { left_max });
let (left_domain_min, left_domain_max) = if left_explicit_min.is_none() && left_explicit_max.is_none() {
nice_domain(raw_left_domain_min, raw_left_domain_max, 5)
} else {
(raw_left_domain_min, raw_left_domain_max)
};
let left_scale = ScaleLinear::new((left_domain_min, left_domain_max), (inner_height, 0.0));
let right_scale = if !right_fields.is_empty() {
let right_max = right_fields.iter()
.flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
.fold(0.0_f64, f64::max);
let right_data_min = right_fields.iter()
.flat_map(|f| (0..data.num_rows()).filter_map(|i| data.get_f64(i, &f.field)))
.fold(0.0_f64, f64::min);
let axes_right = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref());
let right_explicit_min = axes_right.and_then(|a| a.min);
let right_explicit_max = axes_right.and_then(|a| a.max);
let raw_right_domain_min = right_explicit_min.unwrap_or(if right_data_min < 0.0 { right_data_min } else { 0.0 });
let raw_right_domain_max = right_explicit_max.unwrap_or(if right_max <= 0.0 { 1.0 } else { right_max });
let (right_domain_min, right_domain_max) = if right_explicit_min.is_none() && right_explicit_max.is_none() {
nice_domain(raw_right_domain_min, raw_right_domain_max, 5)
} else {
(raw_right_domain_min, raw_right_domain_max)
};
Some(ScaleLinear::new((right_domain_min, right_domain_max), (inner_height, 0.0)))
} else {
None
};
let mut children = Vec::new();
let bottom_axis_label = config.visualize.axes.as_ref()
.and_then(|a| a.x.as_ref())
.and_then(|a| a.label.as_deref());
let x_axis_result = generate_x_axis(&crate::helpers::XAxisParams {
labels: &categories,
display_label_overrides: None,
range: (0.0, inner_width),
y_position: margins.top + inner_height,
available_width: inner_width,
x_format: x_format.as_deref(),
chart_height: Some(inner_height),
grid: &grid,
axis_label: bottom_axis_label,
});
let left_axis_label = axes_left.and_then(|a| a.label.as_deref());
let y_axis_left = generate_y_axis_numeric(&crate::helpers::YAxisNumericParams {
domain: (left_domain_min, left_domain_max),
range: (inner_height, 0.0),
x_position: margins.left,
fmt: y_fmt_ref,
tick_count: adaptive_tick_count(inner_height),
chart_width: Some(inner_width),
grid: &grid,
axis_label: left_axis_label,
});
let mut axis_elements = Vec::new();
axis_elements.extend(x_axis_result.elements.into_iter().map(|e| offset_element(e, margins.left, 0.0)));
axis_elements.extend(y_axis_left.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
if let Some(ref rs) = right_scale {
let right_fmt = config.visualize.axes.as_ref()
.and_then(|a| a.right.as_ref())
.and_then(|a| a.format.as_deref());
let right_axis = generate_y_axis_numeric_right(
rs.domain(), (inner_height, 0.0), margins.left + inner_width,
right_fmt, adaptive_tick_count(inner_height),
None,
);
axis_elements.extend(right_axis.into_iter().map(|e| offset_element(e, 0.0, margins.top)));
}
if let Some(label) = config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.label.clone()) {
let rx = config.width - 12.0;
axis_elements.push(ChartElement::Text {
x: rx,
y: margins.top + inner_height / 2.0,
content: label,
anchor: TextAnchor::Middle,
dominant_baseline: None,
transform: Some(Transform::Rotate(90.0, rx, margins.top + inner_height / 2.0)),
font_size: Some("12px".to_string()),
font_weight: None,
fill: Some("#666".to_string()),
class: "axis-label".to_string(),
data: None,
});
}
children.push(ChartElement::Group {
class: "axes".to_string(), transform: None, children: axis_elements,
});
let mut mark_elements = Vec::new();
let line_gen = LineGenerator::new().curve(chartml_core::shapes::CurveType::MonotoneX);
let num_bar_fields = fields.iter()
.filter(|f| f.mark.as_deref().unwrap_or("bar") == "bar")
.count()
.max(1);
let max_bar_width = inner_width * 0.2;
let effective_bandwidth = bandwidth.min(max_bar_width);
let combo_x_inset = (bandwidth - effective_bandwidth) / 2.0;
let sub_bar_padding = effective_bandwidth * 0.05;
let sub_bar_width = (effective_bandwidth - sub_bar_padding * (num_bar_fields as f64 - 1.0).max(0.0)) / num_bar_fields as f64;
let mut bar_field_idx = 0_usize;
let mut series_names = Vec::new();
let mut series_colors = Vec::new();
let mut series_marks = Vec::new();
let stacked_bar_rendered = if let (true, Some(color_f)) = (is_stacked, color_field.as_ref()) {
let color_series = data.unique_values(color_f);
for field_spec in fields.iter() {
let mark = field_spec.mark.as_deref().unwrap_or("bar");
if mark != "bar" { continue; }
let field_name = &field_spec.field;
let is_right = field_spec.axis.as_deref() == Some("right");
let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
let fmt_ref = if is_right {
config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
} else {
y_fmt_ref
};
let mut values_matrix: Vec<Vec<f64>> = Vec::new();
for series in &color_series {
let mut series_vals = Vec::new();
for cat in &categories {
let val = (0..data.num_rows())
.find(|&i| {
data.get_string(i, &category_field).as_deref() == Some(cat.as_str())
&& data.get_string(i, color_f).as_deref() == Some(series.as_str())
})
.and_then(|i| data.get_f64(i, field_name))
.unwrap_or(0.0);
series_vals.push(val);
}
values_matrix.push(series_vals);
}
let stack = StackLayout::new();
let stacked_points = stack.layout(&categories, &color_series, &values_matrix);
let bar_render_width = bandwidth.min(max_bar_width);
let x_inset = (bandwidth - bar_render_width) / 2.0;
for point in &stacked_points {
let x = match band.map(&point.key) { Some(x) => x, None => continue };
let y_top = scale.map(point.y1);
let y_bottom = scale.map(point.y0);
let bar_height = (y_bottom - y_top).abs();
let series_idx = color_series.iter().position(|s| s == &point.series).unwrap_or(0);
let fill = config.colors.get(series_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
mark_elements.push(ChartElement::Rect {
x: x + x_inset + margins.left, y: y_top + margins.top,
width: bar_render_width, height: bar_height,
fill, stroke: None,
class: "bar".to_string(),
data: Some(ElementData::new(&point.key, format_value(point.value, fmt_ref)).with_series(&point.series)),
});
}
}
for (si, series_name) in color_series.iter().enumerate() {
let color = config.colors.get(si).cloned().unwrap_or_else(|| "#2E7D9A".to_string());
series_names.push(series_name.clone());
series_colors.push(color);
series_marks.push("bar".to_string());
}
true
} else {
false
};
for (field_idx, field_spec) in fields.iter().enumerate() {
let field_name = &field_spec.field;
let is_right = field_spec.axis.as_deref() == Some("right");
let scale = if is_right { right_scale.as_ref().unwrap_or(&left_scale) } else { &left_scale };
let mark = field_spec.mark.as_deref().unwrap_or("bar");
let color = field_spec.color.clone()
.unwrap_or_else(|| config.colors.get(field_idx).cloned().unwrap_or_else(|| "#2E7D9A".to_string()));
let label = field_spec.label.clone().unwrap_or_else(|| field_name.clone());
let fmt_ref = if is_right {
config.visualize.axes.as_ref().and_then(|a| a.right.as_ref()).and_then(|a| a.format.as_deref())
} else {
y_fmt_ref
};
match mark {
"bar" if stacked_bar_rendered => {
}
"bar" => {
let this_bar_idx = bar_field_idx;
bar_field_idx += 1;
for row_i in 0..data.num_rows() {
let cat = match data.get_string(row_i, &category_field) { Some(c) => c, None => continue };
let val = data.get_f64(row_i, field_name).unwrap_or(0.0);
let x = match band.map(&cat) { Some(x) => x, None => continue };
let bar_x = x + combo_x_inset + this_bar_idx as f64 * (sub_bar_width + sub_bar_padding);
let bar_val_y = scale.map(val);
let bar_zero_y = scale.map(0.0);
let bar_height = (bar_zero_y - bar_val_y).abs();
let rect_y = bar_val_y.min(bar_zero_y);
mark_elements.push(ChartElement::Rect {
x: bar_x + margins.left, y: rect_y + margins.top,
width: sub_bar_width, height: bar_height,
fill: color.clone(), stroke: None,
class: "bar".to_string(),
data: Some(ElementData::new(&cat, format_value(val, fmt_ref)).with_series(&label)),
});
if let Some(ref dl) = field_spec.data_labels {
if dl.show == Some(true) {
let dl_fmt = dl.format.as_deref().or(fmt_ref);
mark_elements.push(ChartElement::Text {
x: bar_x + sub_bar_width / 2.0 + margins.left,
y: rect_y + margins.top - 5.0,
content: format_value(val, dl_fmt),
anchor: TextAnchor::Middle, dominant_baseline: None,
transform: None,
font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "11px".to_string())),
font_weight: None,
fill: Some(dl.color.clone().unwrap_or_else(|| "#333".to_string())),
class: "data-label".to_string(), data: None,
});
}
}
}
}
_ => {
let mut points = Vec::new();
let mut point_data = Vec::new();
for cat in &categories {
let row_i = match (0..data.num_rows()).find(|&i| data.get_string(i, &category_field).as_deref() == Some(cat.as_str())) {
Some(i) => i, None => continue,
};
let val = match data.get_f64(row_i, field_name) { Some(v) => v, None => continue };
let x = match band.map(cat) { Some(x) => x + bandwidth / 2.0, None => continue };
let y = scale.map(val);
points.push((x + margins.left, y + margins.top));
point_data.push((cat.clone(), val));
}
if !points.is_empty() {
let path_d = line_gen.generate(&points);
mark_elements.push(ChartElement::Path {
d: path_d, fill: None, stroke: Some(color.clone()),
stroke_width: Some(2.0), stroke_dasharray: None,
opacity: None,
class: "line".to_string(),
data: Some(ElementData::new(&label, "").with_series(&label)),
});
for (i, &(px, py)) in points.iter().enumerate() {
let (ref cat, val) = point_data[i];
mark_elements.push(ChartElement::Circle {
cx: px, cy: py, r: 5.0,
fill: color.clone(), stroke: Some("#fff".to_string()),
class: "chartml-line-dot".to_string(),
data: Some(ElementData::new(cat, format_value(val, fmt_ref)).with_series(&label)),
});
}
if let Some(ref dl) = field_spec.data_labels {
if dl.show == Some(true) {
let dl_fmt = dl.format.as_deref().or(fmt_ref);
for (i, &(px, py)) in points.iter().enumerate() {
let (_, val) = &point_data[i];
mark_elements.push(ChartElement::Text {
x: px, y: py - 10.0,
content: format_value(*val, dl_fmt),
anchor: TextAnchor::Middle, dominant_baseline: None,
transform: None,
font_size: Some(dl.font_size.map(|s| format!("{}px", s)).unwrap_or_else(|| "11px".to_string())),
font_weight: None,
fill: Some(dl.color.clone().unwrap_or_else(|| color.clone())),
class: "data-label".to_string(), data: None,
});
}
}
}
}
}
}
if !(stacked_bar_rendered && mark == "bar") {
series_names.push(label);
series_colors.push(color);
series_marks.push(mark.to_string());
}
}
children.push(ChartElement::Group {
class: "marks".to_string(), transform: None, children: mark_elements,
});
if let Some(annotations) = config.visualize.annotations.as_deref() {
if !annotations.is_empty() {
let ann_elements = generate_annotations(
annotations,
&left_scale,
0.0,
inner_width,
inner_height,
Some(&categories),
);
if !ann_elements.is_empty() {
children.push(ChartElement::Group {
class: "annotations".to_string(),
transform: Some(Transform::Translate(margins.left, margins.top)),
children: ann_elements,
});
}
}
}
if series_names.len() > 1 {
let mut legend_elements = Vec::new();
let total_w: f64 = series_names.iter().map(|name| {
let tw = chartml_core::layout::labels::approximate_text_width(name);
12.0 + 6.0 + tw + 16.0
}).sum();
let mut x_offset = (config.width - total_w).max(0.0) / 2.0;
for (i, name) in series_names.iter().enumerate() {
let color = &series_colors[i];
let mark = series_marks[i].as_str();
let y = config.height - 10.0;
match mark {
"line" => {
legend_elements.push(ChartElement::Line {
x1: x_offset, y1: y + 6.0, x2: x_offset + 12.0, y2: y + 6.0,
stroke: color.clone(), stroke_width: Some(2.5),
stroke_dasharray: None, class: "legend-symbol legend-line".to_string(),
});
}
_ => {
legend_elements.push(ChartElement::Rect {
x: x_offset, y, width: 12.0, height: 12.0,
fill: color.clone(), stroke: None,
class: "legend-symbol".to_string(), data: None,
});
}
}
legend_elements.push(ChartElement::Text {
x: x_offset + 18.0, y: y + 10.0, content: name.clone(),
anchor: TextAnchor::Start, dominant_baseline: None,
transform: None, font_size: Some("11px".to_string()),
font_weight: None,
fill: Some("#333".to_string()), class: "legend-label".to_string(), data: None,
});
let tw = chartml_core::layout::labels::approximate_text_width(name);
x_offset += 12.0 + 6.0 + tw + 16.0;
}
children.push(ChartElement::Group {
class: "legend".to_string(), transform: None, children: legend_elements,
});
}
Ok(ChartElement::Svg {
viewbox: ViewBox::new(0.0, 0.0, config.width, config.height),
width: Some(config.width),
height: Some(config.height),
class: "chartml-bar chartml-combo".to_string(),
children,
})
}