use chartml_core::element::{ChartElement, ElementData, TextAnchor, TextRole, TextStyle, Transform};
use chartml_core::error::ChartError;
use chartml_core::format::NumberFormatter;
use chartml_core::format::{detect_date_format, reformat_date_label};
use chartml_core::layout::labels::{LabelStrategy, LabelStrategyConfig, TextMetrics, measure_text, truncate_label_with_metrics};
use chartml_core::plugin::ChartConfig;
use chartml_core::scales::{ScaleBand, ScaleLinear};
use chartml_core::spec::{AnnotationSpec, FieldRef, FieldRefItem, MarkEncoding};
use chartml_core::theme::{GridStyle, Theme};
#[inline]
pub fn should_draw_horizontal_grid(style: &GridStyle) -> bool {
matches!(style, GridStyle::Both | GridStyle::HorizontalOnly)
}
#[inline]
pub fn should_draw_vertical_grid(style: &GridStyle) -> bool {
matches!(style, GridStyle::Both | GridStyle::VerticalOnly)
}
pub fn emit_zero_line_if_crosses(
theme: &Theme,
domain: (f64, f64),
inner_width: f64,
inner_height: f64,
is_horizontal: bool,
) -> Option<ChartElement> {
let spec = theme.zero_line.as_ref()?;
if !(domain.0 < 0.0 && domain.1 > 0.0) {
return None;
}
if is_horizontal {
let scale = ScaleLinear::new(domain, (0.0, inner_width));
let x = scale.map(0.0);
Some(ChartElement::Line {
x1: x,
y1: 0.0,
x2: x,
y2: inner_height,
stroke: spec.color.clone(),
stroke_width: Some(spec.width as f64),
stroke_dasharray: None,
class: "zero-line".to_string(),
})
} else {
let scale = ScaleLinear::new(domain, (inner_height, 0.0));
let y = scale.map(0.0);
Some(ChartElement::Line {
x1: 0.0,
y1: y,
x2: inner_width,
y2: y,
stroke: spec.color.clone(),
stroke_width: Some(spec.width as f64),
stroke_dasharray: None,
class: "zero-line".to_string(),
})
}
}
#[derive(Debug, Clone)]
pub struct GridConfig {
pub show_x: bool,
pub show_y: bool,
pub color: String,
pub opacity: f64,
pub dash_array: Option<String>,
}
impl Default for GridConfig {
fn default() -> Self {
Self {
show_x: false,
show_y: true,
color: "#e0e0e0".into(),
opacity: 0.5,
dash_array: None,
}
}
}
impl GridConfig {
pub fn from_config(config: &ChartConfig) -> Self {
let mut grid = Self {
show_x: false,
show_y: true, color: config.theme.grid.clone(),
opacity: 0.5,
dash_array: None,
};
if let Some(ref style) = config.visualize.style {
if let Some(ref g) = style.grid {
if let Some(x) = g.x { grid.show_x = x; }
if let Some(y) = g.y { grid.show_y = y; }
if let Some(ref c) = g.color { grid.color = c.clone(); }
if let Some(o) = g.opacity { grid.opacity = o; }
if let Some(ref d) = g.dash_array { grid.dash_array = Some(d.clone()); }
}
}
grid
}
}
pub struct XAxisParams<'a> {
pub labels: &'a [String],
pub display_label_overrides: Option<&'a [String]>,
pub range: (f64, f64),
pub y_position: f64,
pub available_width: f64,
pub x_format: Option<&'a str>,
pub chart_height: Option<f64>,
pub grid: &'a GridConfig,
pub axis_label: Option<&'a str>,
pub theme: &'a Theme,
}
pub struct YAxisNumericParams<'a> {
pub domain: (f64, f64),
pub range: (f64, f64),
pub x_position: f64,
pub fmt: Option<&'a str>,
pub tick_count: usize,
pub chart_width: Option<f64>,
pub grid: &'a GridConfig,
pub axis_label: Option<&'a str>,
pub theme: &'a Theme,
}
pub fn adaptive_bar_padding(num_categories: usize) -> f64 {
if num_categories <= 6 {
0.2
} else if num_categories <= 12 {
0.15
} else if num_categories <= 20 {
0.1
} else {
0.05
}
}
pub fn get_y_axis_bounds(config: &ChartConfig) -> (Option<f64>, Option<f64>) {
let axes = match &config.visualize.axes {
Some(a) => a,
None => return (None, None),
};
let axis = match &axes.left {
Some(a) => a,
None => return (None, None),
};
(axis.min, axis.max)
}
pub fn get_data_labels_config(config: &ChartConfig) -> Option<&chartml_core::spec::DataLabelsSpec> {
match &config.visualize.rows {
Some(chartml_core::spec::FieldRef::Detailed(spec)) => spec.data_labels.as_ref(),
_ => None,
}
}
pub fn get_field_name(field_ref: &Option<FieldRef>) -> Result<String, ChartError> {
fn field_or_err(spec: &chartml_core::spec::FieldSpec) -> Result<String, ChartError> {
spec.field
.clone()
.ok_or_else(|| ChartError::MissingField("rows/columns field (range-mark spec has no `field`)".into()))
}
match field_ref {
Some(FieldRef::Simple(name)) => Ok(name.clone()),
Some(FieldRef::Detailed(spec)) => field_or_err(spec),
Some(FieldRef::Multiple(items)) => match items.first() {
Some(FieldRefItem::Simple(name)) => Ok(name.clone()),
Some(FieldRefItem::Detailed(spec)) => field_or_err(spec),
None => Err(ChartError::MissingField("rows/columns field".into())),
},
None => Err(ChartError::MissingField("rows/columns field".into())),
}
}
pub fn get_color_field(config: &ChartConfig) -> Option<String> {
config
.visualize
.marks
.as_ref()?
.color
.as_ref()
.map(|enc| match enc {
MarkEncoding::Simple(name) => name.clone(),
MarkEncoding::Detailed(spec) => spec.field.clone(),
})
}
pub fn get_y_format(config: &ChartConfig) -> Option<String> {
config.visualize.axes.as_ref().and_then(|axes| {
axes.left.as_ref().and_then(|a| a.format.clone())
})
}
pub fn get_x_format(config: &ChartConfig) -> Option<String> {
config.visualize.axes.as_ref().and_then(|axes| {
axes.x.as_ref().and_then(|a| a.format.clone())
})
}
pub fn format_value(value: f64, format_str: Option<&str>) -> String {
match format_str {
Some(fmt) => NumberFormatter::new(fmt).format(value),
None => default_format_value(value),
}
}
fn format_tick_value_si(value: f64, tick_step: f64, fmt: &str) -> String {
let (divisor, suffix) = if tick_step >= 1_000_000_000.0 {
(1_000_000_000.0, "B")
} else if tick_step >= 1_000_000.0 {
(1_000_000.0, "M")
} else if tick_step >= 1_000.0 {
(1_000.0, "K")
} else {
return format_value(value, Some(fmt));
};
let scaled = value / divisor;
let fmt_no_comma = fmt.replace(',', "");
let formatted = NumberFormatter::new(&fmt_no_comma).format(scaled);
let cleaned = strip_trailing_zero_decimals(&formatted);
format!("{}{}", cleaned, suffix)
}
fn strip_trailing_zero_decimals(s: &str) -> String {
if let Some(dot_pos) = s.rfind('.') {
let after_dot = &s[dot_pos + 1..];
if !after_dot.is_empty() && after_dot.chars().all(|c| c == '0') {
return s[..dot_pos].to_string();
}
}
s.to_string()
}
pub fn format_tick_value(value: f64, tick_step: f64) -> String {
let precision = if tick_step.abs() < 1e-15 {
0usize
} else {
let p = -(tick_step.abs().log10().floor()) as i64;
p.max(0) as usize
};
let formatted = format!("{:.prec$}", value, prec = precision);
let (int_part, dec_part) = if let Some(dot_pos) = formatted.find('.') {
(&formatted[..dot_pos], Some(&formatted[dot_pos..]))
} else {
(formatted.as_str(), None)
};
let (sign, digits) = if let Some(stripped) = int_part.strip_prefix('-') {
("-", stripped)
} else {
("", int_part)
};
let with_commas = insert_commas_str(digits);
match dec_part {
Some(dec) => format!("{}{}{}", sign, with_commas, dec),
None => format!("{}{}", sign, with_commas),
}
}
fn insert_commas_str(digits: &str) -> String {
let len = digits.len();
if len <= 3 {
return digits.to_string();
}
let mut result = String::with_capacity(len + len / 3);
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
result.push(',');
}
result.push(ch);
}
result
}
fn compute_tick_step(ticks: &[f64], domain: (f64, f64)) -> f64 {
if ticks.len() >= 2 {
(ticks[1] - ticks[0]).abs()
} else {
(domain.1 - domain.0).abs().max(1.0)
}
}
fn default_format_value(value: f64) -> String {
if value == value.floor() && value.abs() < 1e15 {
let abs = value.abs() as u64;
let formatted = insert_commas(abs);
if value < 0.0 {
format!("-{}", formatted)
} else {
formatted
}
} else {
let abs_val = value.abs();
let precision = if abs_val < 1e-15 {
1usize
} else if abs_val >= 1.0 {
1usize
} else {
let digits = -(abs_val.log10().floor()) as usize;
digits.max(1)
};
let formatted = format!("{:.prec$}", value, prec = precision);
let trimmed = formatted.trim_end_matches('0');
if trimmed.ends_with('.') {
format!("{}0", trimmed)
} else {
trimmed.to_string()
}
}
}
fn insert_commas(n: u64) -> String {
let s = n.to_string();
let len = s.len();
if len <= 3 {
return s;
}
let mut result = String::with_capacity(len + len / 3);
for (i, ch) in s.chars().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
result.push(',');
}
result.push(ch);
}
result
}
pub fn format_display_labels(
raw_labels: &[String],
x_format: Option<&str>,
) -> Vec<String> {
if let Some(fmt) = x_format {
raw_labels.iter().map(|l| reformat_date_label(l, fmt)).collect()
} else if let Some(detected_fmt) = detect_date_format(raw_labels) {
raw_labels.iter().map(|l| reformat_date_label(l, &detected_fmt)).collect()
} else {
raw_labels.to_vec()
}
}
pub struct XAxisResult {
pub elements: Vec<ChartElement>,
}
pub fn generate_x_axis(params: &XAxisParams) -> XAxisResult {
generate_x_axis_with_display(params)
}
pub fn generate_x_axis_with_display(
params: &XAxisParams,
) -> XAxisResult {
let band_keys = params.labels;
let display_label_overrides = params.display_label_overrides;
let range = params.range;
let y_position = params.y_position;
let available_width = params.available_width;
let x_format = params.x_format;
let chart_height = params.chart_height;
let grid = params.grid;
let axis_label = params.axis_label;
let theme = params.theme;
let band = ScaleBand::new(band_keys.to_vec(), range);
let bandwidth = band.bandwidth();
let raw_labels: &[String] = display_label_overrides.unwrap_or(band_keys);
let display_labels = format_display_labels(raw_labels, x_format);
let label_config = LabelStrategyConfig {
text_metrics: TextMetrics::from_theme_axis_label(theme),
..LabelStrategyConfig::default()
};
let strategy = LabelStrategy::determine(&display_labels, available_width, &label_config);
let mut elements = Vec::new();
elements.push(ChartElement::Line {
x1: range.0,
y1: y_position,
x2: range.1,
y2: y_position,
stroke: theme.axis_line.clone(),
stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None,
class: "axis-line".to_string(),
});
if grid.show_x && should_draw_vertical_grid(&theme.grid_style) {
if let Some(ch) = chart_height {
for band_key in band_keys.iter() {
let x = match band.map(band_key) {
Some(x) => x + bandwidth / 2.0,
None => continue,
};
elements.push(ChartElement::Line {
x1: x, y1: y_position, x2: x, y2: y_position - ch,
stroke: grid.color.clone(), stroke_width: Some(theme.grid_line_weight as f64),
stroke_dasharray: grid.dash_array.clone(),
class: "grid-line grid-line-x".to_string(),
});
}
}
}
match &strategy {
LabelStrategy::Horizontal => {
for (i, label) in display_labels.iter().enumerate() {
let orig_label = &band_keys[i];
let x = match band.map(orig_label) {
Some(x) => x + bandwidth / 2.0,
None => continue,
};
elements.push(ChartElement::Line {
x1: x, y1: y_position, x2: x, y2: y_position + 5.0,
stroke: theme.tick.clone(), stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None, class: "tick".to_string(),
});
let ts = TextStyle::for_role(theme, TextRole::AxisLabel);
elements.push(ChartElement::Text {
x, y: y_position + 18.0,
content: label.clone(),
anchor: TextAnchor::Middle,
dominant_baseline: None,
transform: None,
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "tick-label axis-label".to_string(),
data: None,
});
}
}
LabelStrategy::Rotated { margin: _, skip_factor } => {
let visible_count = match skip_factor {
Some(factor) if *factor > 1 => {
(0..display_labels.len()).filter(|i| i % factor == 0).count()
}
_ => display_labels.len(),
};
let cos45 = std::f64::consts::FRAC_PI_4.cos(); let available_per_visible = if visible_count > 0 {
available_width / visible_count as f64
} else {
available_width
};
let spacing = 6.0;
let overlap_width = (available_per_visible - spacing) / cos45;
let max_full_width = if overlap_width > 0.0 {
overlap_width
} else {
0.0
};
for (i, label) in display_labels.iter().enumerate() {
let orig_label = &band_keys[i];
let x = match band.map(orig_label) {
Some(x) => x + bandwidth / 2.0,
None => continue,
};
elements.push(ChartElement::Line {
x1: x, y1: y_position, x2: x, y2: y_position + 5.0,
stroke: theme.tick.clone(), stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None, class: "tick".to_string(),
});
let should_show = match skip_factor {
Some(factor) => i % factor == 0,
None => true,
};
if should_show {
let display_text = if max_full_width > 0.0 {
truncate_label_with_metrics(label, max_full_width, &TextMetrics::from_theme_axis_label(theme))
} else {
label.clone()
};
let is_truncated = display_text != *label;
let ts = TextStyle::for_role(theme, TextRole::AxisLabel);
elements.push(ChartElement::Text {
x, y: y_position + 10.0,
content: display_text,
anchor: TextAnchor::End,
dominant_baseline: None,
transform: Some(Transform::Rotate(-45.0, x, y_position + 10.0)),
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "tick-label axis-label".to_string(),
data: if is_truncated {
Some(ElementData::new(label.clone(), ""))
} else {
None
},
});
}
}
}
LabelStrategy::Truncated { max_width } => {
for (i, label) in display_labels.iter().enumerate() {
let orig_label = &band_keys[i];
let x = match band.map(orig_label) {
Some(x) => x + bandwidth / 2.0,
None => continue,
};
elements.push(ChartElement::Line {
x1: x, y1: y_position, x2: x, y2: y_position + 5.0,
stroke: theme.tick.clone(), stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None, class: "tick".to_string(),
});
let truncated = truncate_label_with_metrics(label, *max_width, &TextMetrics::from_theme_axis_label(theme));
let is_truncated = truncated != *label;
let ts = TextStyle::for_role(theme, TextRole::AxisLabel);
elements.push(ChartElement::Text {
x, y: y_position + 18.0,
content: truncated,
anchor: TextAnchor::Middle,
dominant_baseline: None,
transform: None,
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "tick-label axis-label".to_string(),
data: if is_truncated {
Some(ElementData::new(label.clone(), ""))
} else {
None
},
});
}
}
LabelStrategy::Sampled { indices } => {
let sampled_count = indices.len();
let available_per_sampled = if sampled_count > 0 {
available_width / sampled_count as f64
} else {
available_width
};
let sampled_max_width = available_per_sampled - 10.0;
for (i, label) in display_labels.iter().enumerate() {
let orig_label = &band_keys[i];
let x = match band.map(orig_label) {
Some(x) => x + bandwidth / 2.0,
None => continue,
};
elements.push(ChartElement::Line {
x1: x, y1: y_position, x2: x, y2: y_position + 5.0,
stroke: theme.tick.clone(), stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None, class: "tick".to_string(),
});
if indices.contains(&i) {
let display_text = if sampled_max_width > 0.0 {
truncate_label_with_metrics(label, sampled_max_width, &TextMetrics::from_theme_axis_label(theme))
} else {
label.clone()
};
let is_truncated = display_text != *label;
let ts = TextStyle::for_role(theme, TextRole::AxisLabel);
elements.push(ChartElement::Text {
x, y: y_position + 18.0,
content: display_text,
anchor: TextAnchor::Middle,
dominant_baseline: None,
transform: None,
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "tick-label axis-label".to_string(),
data: if is_truncated {
Some(ElementData::new(label.clone(), ""))
} else {
None
},
});
}
}
}
}
if let Some(label_text) = axis_label {
let mid_x = (range.0 + range.1) / 2.0;
let label_y = y_position + 38.0;
let ts = TextStyle::for_role(theme, TextRole::AxisLabel);
elements.push(ChartElement::Text {
x: mid_x,
y: label_y,
content: label_text.to_string(),
anchor: TextAnchor::Middle,
dominant_baseline: None,
transform: None,
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "axis-label".to_string(),
data: None,
});
}
XAxisResult { elements }
}
pub fn generate_y_axis_with_display(
band_keys: &[String],
display_label_overrides: Option<&[String]>,
range: (f64, f64),
x_position: f64,
_formatter: Option<&str>,
theme: &Theme,
) -> Vec<ChartElement> {
let band = ScaleBand::new(band_keys.to_vec(), range);
let bandwidth = band.bandwidth();
let mut elements = Vec::new();
let display_labels: &[String] = display_label_overrides.unwrap_or(band_keys);
elements.push(ChartElement::Line {
x1: x_position,
y1: range.0.min(range.1),
x2: x_position,
y2: range.0.max(range.1),
stroke: theme.axis_line.clone(),
stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None,
class: "axis-line".to_string(),
});
for (i, band_key) in band_keys.iter().enumerate() {
let y = match band.map(band_key) {
Some(y) => y + bandwidth / 2.0,
None => continue,
};
elements.push(ChartElement::Line {
x1: x_position - 5.0,
y1: y,
x2: x_position,
y2: y,
stroke: theme.tick.clone(),
stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None,
class: "tick".to_string(),
});
let ts = TextStyle::for_role(theme, TextRole::AxisLabel);
elements.push(ChartElement::Text {
x: x_position - 8.0,
y,
content: display_labels[i].clone(),
anchor: TextAnchor::End,
dominant_baseline: Some("middle".to_string()),
transform: None,
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "tick-label axis-label".to_string(),
data: None,
});
}
elements
}
pub fn generate_y_axis_numeric(
params: &YAxisNumericParams,
) -> Vec<ChartElement> {
let domain = params.domain;
let range = params.range;
let x_position = params.x_position;
let fmt = params.fmt;
let tick_count = params.tick_count;
let chart_width = params.chart_width;
let grid = params.grid;
let axis_label = params.axis_label;
let theme = params.theme;
let scale = ScaleLinear::new(domain, range);
let _ = tick_count;
let ticks = d3_ticks(domain.0, domain.1, 5);
let tick_step = compute_tick_step(&ticks, domain);
let mut elements = Vec::new();
elements.push(ChartElement::Line {
x1: x_position,
y1: range.0.min(range.1),
x2: x_position,
y2: range.0.max(range.1),
stroke: theme.axis_line.clone(),
stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None,
class: "axis-line".to_string(),
});
for val in &ticks {
let y = scale.map(*val);
let label = match fmt {
Some(f) => format_tick_value_si(*val, tick_step, f),
None => format_tick_value(*val, tick_step),
};
if grid.show_y && should_draw_horizontal_grid(&theme.grid_style) {
if let Some(cw) = chart_width {
elements.push(ChartElement::Line {
x1: x_position,
y1: y,
x2: x_position + cw,
y2: y,
stroke: grid.color.clone(),
stroke_width: Some(theme.grid_line_weight as f64),
stroke_dasharray: grid.dash_array.clone(),
class: "grid-line grid-line-y".to_string(),
});
}
}
elements.push(ChartElement::Line {
x1: x_position - 5.0,
y1: y,
x2: x_position,
y2: y,
stroke: theme.tick.clone(),
stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None,
class: "tick".to_string(),
});
let ts = TextStyle::for_role(theme, TextRole::TickValue);
elements.push(ChartElement::Text {
x: x_position - 8.0,
y,
content: label,
anchor: TextAnchor::End,
dominant_baseline: Some("middle".to_string()),
transform: None,
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "tick-label tick-value".to_string(),
data: None,
});
}
if let Some(label_text) = axis_label {
let mid_y = (range.0 + range.1) / 2.0;
let max_tick_width = ticks.iter()
.map(|val| {
let label = match fmt {
Some(f) => format_value(*val, Some(f)),
None => format_tick_value(*val, tick_step),
};
measure_text(&label, &TextMetrics::from_theme_tick_value(theme))
})
.fold(0.0_f64, f64::max);
let label_x = (x_position - 8.0 - max_tick_width - 12.0).max(10.0);
let ts = TextStyle::for_role(theme, TextRole::AxisLabel);
elements.push(ChartElement::Text {
x: label_x,
y: mid_y,
content: label_text.to_string(),
anchor: TextAnchor::Middle,
dominant_baseline: Some("middle".to_string()),
transform: Some(Transform::Rotate(-90.0, label_x, mid_y)),
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "axis-label".to_string(),
data: None,
});
}
elements
}
pub fn generate_y_axis_numeric_right(
domain: (f64, f64),
range: (f64, f64),
x_position: f64,
fmt: Option<&str>,
tick_count: usize,
axis_label: Option<&str>,
theme: &Theme,
) -> Vec<ChartElement> {
let scale = ScaleLinear::new(domain, range);
let _ = tick_count;
let ticks = d3_ticks(domain.0, domain.1, 5);
let tick_step = compute_tick_step(&ticks, domain);
let mut elements = Vec::new();
elements.push(ChartElement::Line {
x1: x_position, y1: range.0.min(range.1),
x2: x_position, y2: range.0.max(range.1),
stroke: theme.axis_line.clone(), stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None, class: "axis-line".to_string(),
});
for val in &ticks {
let y = scale.map(*val);
let label = match fmt {
Some(f) => format_tick_value_si(*val, tick_step, f),
None => format_tick_value(*val, tick_step),
};
elements.push(ChartElement::Line {
x1: x_position, y1: y,
x2: x_position + 5.0, y2: y,
stroke: theme.tick.clone(), stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None, class: "tick".to_string(),
});
let ts = TextStyle::for_role(theme, TextRole::TickValue);
elements.push(ChartElement::Text {
x: x_position + 8.0, y,
content: label,
anchor: TextAnchor::Start,
dominant_baseline: Some("middle".to_string()),
transform: None,
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "tick-label tick-value".to_string(),
data: None,
});
}
if let Some(label_text) = axis_label {
let mid_y = (range.0 + range.1) / 2.0;
let label_x = x_position + 45.0;
let ts = TextStyle::for_role(theme, TextRole::AxisLabel);
elements.push(ChartElement::Text {
x: label_x,
y: mid_y,
content: label_text.to_string(),
anchor: TextAnchor::Middle,
dominant_baseline: Some("middle".to_string()),
transform: Some(Transform::Rotate(90.0, label_x, mid_y)),
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "axis-label".to_string(),
data: None,
});
}
elements
}
pub struct XAxisNumericParams<'a> {
pub domain: (f64, f64),
pub range: (f64, f64),
pub y_position: f64,
pub fmt: Option<&'a str>,
pub tick_count: usize,
pub chart_height: Option<f64>,
pub grid: &'a GridConfig,
pub theme: &'a Theme,
}
pub fn generate_x_axis_numeric(params: &XAxisNumericParams) -> Vec<ChartElement> {
let domain = params.domain;
let range = params.range;
let y_position = params.y_position;
let fmt = params.fmt;
let tick_count = params.tick_count;
let chart_height = params.chart_height;
let grid = params.grid;
let theme = params.theme;
let scale = ScaleLinear::new(domain, range);
let ticks = scale.ticks(tick_count);
let tick_step = compute_tick_step(&ticks, domain);
let mut elements = Vec::new();
elements.push(ChartElement::Line {
x1: range.0,
y1: y_position,
x2: range.1,
y2: y_position,
stroke: theme.axis_line.clone(),
stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None,
class: "axis-line".to_string(),
});
for val in &ticks {
let x = scale.map(*val);
let label = match fmt {
Some(f) => format_tick_value_si(*val, tick_step, f),
None => format_tick_value(*val, tick_step),
};
if grid.show_x && should_draw_vertical_grid(&theme.grid_style) {
if let Some(ch) = chart_height {
elements.push(ChartElement::Line {
x1: x,
y1: y_position,
x2: x,
y2: y_position - ch,
stroke: grid.color.clone(),
stroke_width: Some(theme.grid_line_weight as f64),
stroke_dasharray: grid.dash_array.clone(),
class: "grid-line grid-line-x".to_string(),
});
}
}
elements.push(ChartElement::Line {
x1: x,
y1: y_position,
x2: x,
y2: y_position + 5.0,
stroke: theme.tick.clone(),
stroke_width: Some(theme.axis_line_weight as f64),
stroke_dasharray: None,
class: "tick".to_string(),
});
let ts = TextStyle::for_role(theme, TextRole::TickValue);
elements.push(ChartElement::Text {
x,
y: y_position + 18.0,
content: label,
anchor: TextAnchor::Middle,
dominant_baseline: None,
transform: None,
font_family: ts.font_family,
font_size: ts.font_size,
font_weight: ts.font_weight,
letter_spacing: ts.letter_spacing,
text_transform: ts.text_transform,
fill: Some(theme.text_secondary.clone()),
class: "tick-label tick-value".to_string(),
data: None,
});
}
elements
}
pub use chartml_core::layout::legend::LegendMark;
pub fn generate_legend(
series_names: &[String],
colors: &[String],
chart_width: f64,
y_position: f64,
theme: &Theme,
) -> Vec<ChartElement> {
chartml_core::layout::legend::generate_legend_elements(series_names, colors, chart_width, y_position, LegendMark::Rect, theme)
}
pub fn generate_legend_with_mark(
series_names: &[String],
colors: &[String],
chart_width: f64,
y_position: f64,
mark: LegendMark,
theme: &Theme,
) -> Vec<ChartElement> {
chartml_core::layout::legend::generate_legend_elements(series_names, colors, chart_width, y_position, mark, theme)
}
pub fn d3_ticks(start: f64, stop: f64, count: usize) -> Vec<f64> {
if count == 0 {
return vec![];
}
if start == stop {
return vec![start];
}
let reverse = stop < start;
let (s0, s1) = if reverse { (stop, start) } else { (start, stop) };
let (i1, i2, inc) = d3_tick_spec(s0, s1, count as f64);
if i2 < i1 {
return vec![];
}
let n = (i2 - i1 + 1.0).round() as usize;
let mut ticks = Vec::with_capacity(n);
if reverse {
if inc < 0.0 {
for i in 0..n {
ticks.push((i2 - i as f64) / -inc);
}
} else {
for i in 0..n {
ticks.push((i2 - i as f64) * inc);
}
}
} else if inc < 0.0 {
for i in 0..n {
ticks.push((i1 + i as f64) / -inc);
}
} else {
for i in 0..n {
ticks.push((i1 + i as f64) * inc);
}
}
ticks
}
fn d3_tick_spec(start: f64, stop: f64, count: f64) -> (f64, f64, f64) {
let e10: f64 = 50_f64.sqrt(); let e5: f64 = 10_f64.sqrt(); let e2: f64 = 2_f64.sqrt();
let step = (stop - start) / count.max(0.0);
let power = step.log10().floor();
let error = step / 10_f64.powf(power);
let factor = if error >= e10 {
10.0
} else if error >= e5 {
5.0
} else if error >= e2 {
2.0
} else {
1.0
};
if power < 0.0 {
let inc = 10_f64.powf(-power) / factor;
let mut i1 = (start * inc).round();
let mut i2 = (stop * inc).round();
if i1 / inc < start { i1 += 1.0; }
if i2 / inc > stop { i2 -= 1.0; }
(i1, i2, -inc)
} else {
let inc = 10_f64.powf(power) * factor;
let mut i1 = (start / inc).round();
let mut i2 = (stop / inc).round();
if i1 * inc < start { i1 += 1.0; }
if i2 * inc > stop { i2 -= 1.0; }
(i1, i2, inc)
}
}
fn d3_tick_increment(start: f64, stop: f64, count: f64) -> f64 {
let e10: f64 = 50_f64.sqrt(); let e5: f64 = 10_f64.sqrt(); let e2: f64 = 2_f64.sqrt();
let step = (stop - start) / count.max(0.0);
let power = step.log10().floor();
let error = step / 10_f64.powf(power);
let factor = if error >= e10 {
10.0
} else if error >= e5 {
5.0
} else if error >= e2 {
2.0
} else {
1.0
};
if power < 0.0 {
-(10_f64.powf(-power) / factor)
} else {
10_f64.powf(power) * factor
}
}
pub fn nice_domain(domain_min: f64, domain_max: f64, tick_count: usize) -> (f64, f64) {
let reversed = domain_min > domain_max;
let (mut start, mut stop) = if reversed {
(domain_max, domain_min)
} else {
(domain_min, domain_max)
};
if start == stop {
return (domain_min, domain_max);
}
let count = tick_count.max(1) as f64;
let mut prestep = f64::NAN;
let mut max_iter = 10i32;
while max_iter > 0 {
max_iter -= 1;
let step = d3_tick_increment(start, stop, count);
if step == prestep {
break;
} else if step > 0.0 {
start = (start / step).floor() * step;
stop = (stop / step).ceil() * step;
} else if step < 0.0 {
start = (start * step).ceil() / step;
stop = (stop * step).floor() / step;
} else {
break;
}
prestep = step;
}
if reversed {
(stop, start)
} else {
(start, stop)
}
}
#[cfg(test)]
mod tick_tests {
use super::d3_ticks;
#[test]
fn d3_ticks_200k_domain_count5() {
let ticks = d3_ticks(0.0, 200_000.0, 5);
let expected = [0.0, 50_000.0, 100_000.0, 150_000.0, 200_000.0];
assert_eq!(ticks.len(), expected.len(), "wrong tick count: {:?}", ticks);
for (got, exp) in ticks.iter().zip(expected.iter()) {
assert!((got - exp).abs() < 1e-6, "tick mismatch: got {}, expected {}", got, exp);
}
}
#[test]
fn d3_ticks_0_to_100_count5() {
let ticks = d3_ticks(0.0, 100.0, 5);
let expected = [0.0, 20.0, 40.0, 60.0, 80.0, 100.0];
assert_eq!(ticks.len(), expected.len(), "wrong tick count: {:?}", ticks);
for (got, exp) in ticks.iter().zip(expected.iter()) {
assert!((got - exp).abs() < 1e-6, "tick mismatch: got {}, expected {}", got, exp);
}
}
#[test]
fn d3_ticks_empty_on_zero_count() {
assert!(d3_ticks(0.0, 100.0, 0).is_empty());
}
#[test]
fn d3_ticks_single_on_equal_bounds() {
let ticks = d3_ticks(50.0, 50.0, 5);
assert_eq!(ticks, vec![50.0]);
}
}
#[cfg(test)]
mod zero_line_tests {
use super::emit_zero_line_if_crosses;
use chartml_core::element::ChartElement;
use chartml_core::theme::{Theme, ZeroLineSpec};
fn themed(spec: Option<ZeroLineSpec>) -> Theme {
let mut t = Theme::default();
t.zero_line = spec;
t
}
#[test]
fn none_when_theme_zero_line_is_none() {
let theme = themed(None);
assert!(emit_zero_line_if_crosses(&theme, (-5.0, 10.0), 400.0, 300.0, false).is_none());
assert!(emit_zero_line_if_crosses(&theme, (-5.0, 10.0), 400.0, 300.0, true).is_none());
}
#[test]
fn none_when_domain_all_positive() {
let theme = themed(Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 }));
assert!(emit_zero_line_if_crosses(&theme, (0.0, 10.0), 400.0, 300.0, false).is_none());
assert!(emit_zero_line_if_crosses(&theme, (1.0, 3.0), 400.0, 300.0, false).is_none());
}
#[test]
fn none_when_domain_all_negative() {
let theme = themed(Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 }));
assert!(emit_zero_line_if_crosses(&theme, (-10.0, -1.0), 400.0, 300.0, false).is_none());
assert!(emit_zero_line_if_crosses(&theme, (-10.0, 0.0), 400.0, 300.0, false).is_none());
}
#[test]
fn vertical_emits_horizontal_line_at_value_zero() {
let theme = themed(Some(ZeroLineSpec { color: "#ff0000".into(), width: 1.5 }));
let el = emit_zero_line_if_crosses(&theme, (-5.0, 10.0), 400.0, 300.0, false)
.expect("should emit when domain crosses zero");
match el {
ChartElement::Line {
x1, y1, x2, y2, stroke, stroke_width, stroke_dasharray, class,
} => {
assert_eq!(x1, 0.0);
assert_eq!(x2, 400.0);
assert!((y1 - y2).abs() < 1e-9);
assert!((y1 - 200.0).abs() < 1e-6, "expected y=200, got {}", y1);
assert_eq!(stroke, "#ff0000");
assert_eq!(stroke_width, Some(1.5));
assert_eq!(stroke_dasharray, None);
assert_eq!(class, "zero-line");
}
other => panic!("expected ChartElement::Line, got {:?}", other),
}
}
#[test]
fn horizontal_emits_vertical_line_at_value_zero() {
let theme = themed(Some(ZeroLineSpec { color: "#00ff00".into(), width: 2.0 }));
let el = emit_zero_line_if_crosses(&theme, (-5.0, 15.0), 400.0, 300.0, true)
.expect("should emit for horizontal orientation");
match el {
ChartElement::Line {
x1, y1, x2, y2, stroke, stroke_width, class, ..
} => {
assert_eq!(y1, 0.0);
assert_eq!(y2, 300.0);
assert!((x1 - x2).abs() < 1e-9);
assert!((x1 - 100.0).abs() < 1e-6, "expected x=100, got {}", x1);
assert_eq!(stroke, "#00ff00");
assert_eq!(stroke_width, Some(2.0));
assert_eq!(class, "zero-line");
}
other => panic!("expected ChartElement::Line, got {:?}", other),
}
}
}
pub fn offset_element(element: ChartElement, dx: f64, dy: f64) -> ChartElement {
if dx == 0.0 && dy == 0.0 {
return element;
}
ChartElement::Group {
class: String::new(),
transform: Some(Transform::Translate(dx, dy)),
children: vec![element],
}
}
fn hex_to_rgba(hex: &str, opacity: f64) -> String {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return format!("rgba(0,0,0,{})", opacity);
}
let r = u8::from_str_radix(&hex[0..2], 16);
let g = u8::from_str_radix(&hex[2..4], 16);
let b = u8::from_str_radix(&hex[4..6], 16);
match (r, g, b) {
(Ok(r), Ok(g), Ok(b)) => format!("rgba({},{},{},{})", r, g, b, opacity),
_ => format!("rgba(0,0,0,{})", opacity),
}
}
fn json_to_f64(v: &serde_json::Value) -> Option<f64> {
match v {
serde_json::Value::Number(n) => n.as_f64(),
serde_json::Value::String(s) => s.parse::<f64>().ok(),
_ => None,
}
}
pub fn generate_annotations(
annotations: &[AnnotationSpec],
scale_y: &ScaleLinear,
x_start: f64,
x_end: f64,
inner_height: f64,
x_categories: Option<&[String]>,
theme: &Theme,
) -> Vec<ChartElement> {
let mut elements = Vec::new();
let resolve_dash_array = |ann: &AnnotationSpec| -> Option<String> {
if ann.dash_array.is_some() {
return ann.dash_array.clone();
}
match ann.style.as_deref() {
Some("dashed") => Some("6,4".to_string()),
Some("dotted") => Some("2,3".to_string()),
_ => None,
}
};
for ann in annotations {
let ann_type = ann.annotation_type.as_str();
let orientation = ann.orientation.as_deref().unwrap_or("horizontal");
if ann_type == "line" && orientation == "vertical" {
let value_str = match ann.value.as_ref() {
Some(v) => v.as_str().unwrap_or("").to_string(),
None => continue,
};
let x_px = if let Some(cats) = x_categories {
if let Some(idx) = cats.iter().position(|c| c == &value_str) {
let step = (x_end - x_start) / cats.len() as f64;
x_start + step * idx as f64 + step / 2.0
} else {
continue;
}
} else {
continue;
};
let color = ann.color.as_deref().unwrap_or(&theme.text_secondary).to_string();
let stroke_width = ann.stroke_width;
let dash_array = resolve_dash_array(ann);
elements.push(ChartElement::Line {
x1: x_px, y1: 0.0,
x2: x_px, y2: inner_height,
stroke: color.clone(),
stroke_width,
stroke_dasharray: dash_array,
class: "annotation-line annotation-vertical".to_string(),
});
if let Some(ref label) = ann.label {
elements.push(ChartElement::Text {
x: x_px + 4.0,
y: 14.0,
content: label.clone(),
anchor: TextAnchor::Start,
dominant_baseline: None,
transform: None,
font_family: None,
font_size: Some("12px".to_string()),
font_weight: None,
letter_spacing: None,
text_transform: None,
fill: Some(color.clone()),
class: "annotation-label".to_string(),
data: None,
});
}
} else if ann_type == "line" && orientation == "horizontal" {
let value = match ann.value.as_ref().and_then(json_to_f64) {
Some(v) => v,
None => continue,
};
let y_px = scale_y.map(value);
let color = ann.color.as_deref().unwrap_or(&theme.text_secondary).to_string();
let stroke_width = ann.stroke_width;
let dash_array = resolve_dash_array(ann);
elements.push(ChartElement::Line {
x1: x_start,
y1: y_px,
x2: x_end,
y2: y_px,
stroke: color.clone(),
stroke_width,
stroke_dasharray: dash_array,
class: "annotation-line".to_string(),
});
if let Some(ref label) = ann.label {
let label_position = ann.label_position.as_deref().unwrap_or("end");
let (label_x, anchor) = if label_position == "end" {
(x_end, TextAnchor::End)
} else {
(x_start, TextAnchor::Start)
};
elements.push(ChartElement::Text {
x: label_x,
y: y_px - 4.0,
content: label.clone(),
anchor,
dominant_baseline: None,
transform: None,
font_family: None,
font_size: Some("12px".to_string()),
font_weight: None,
letter_spacing: None,
text_transform: None,
fill: Some(color.clone()),
class: "annotation-label".to_string(),
data: None,
});
}
} else if ann_type == "band" && orientation == "horizontal" {
let from_val = match ann.from.as_ref().and_then(json_to_f64) {
Some(v) => v,
None => continue,
};
let to_val = match ann.to.as_ref().and_then(json_to_f64) {
Some(v) => v,
None => continue,
};
let y_from = scale_y.map(from_val);
let y_to = scale_y.map(to_val);
let y_top = y_from.min(y_to);
let band_height = (y_from - y_to).abs();
let band_width = x_end - x_start;
let color = ann.color.as_deref().unwrap_or(&theme.text_secondary);
let opacity = ann.opacity.unwrap_or(0.15);
let fill_color = hex_to_rgba(color, opacity);
elements.push(ChartElement::Rect {
x: x_start,
y: y_top,
width: band_width,
height: band_height,
fill: fill_color,
stroke: ann.stroke_color.clone(),
rx: None,
ry: None,
class: "annotation-band".to_string(),
data: None,
animation_origin: None,
});
if let Some(ref label) = ann.label {
elements.push(ChartElement::Text {
x: x_start + 4.0,
y: y_top + 12.0,
content: label.clone(),
anchor: TextAnchor::Start,
dominant_baseline: None,
transform: None,
font_family: None,
font_size: Some("12px".to_string()),
font_weight: None,
letter_spacing: None,
text_transform: None,
fill: Some(ann.color.clone().unwrap_or_else(|| theme.text_secondary.clone())),
class: "annotation-label".to_string(),
data: None,
});
}
}
}
elements
}