#[derive(Debug, Clone, Copy)]
pub struct Margins {
pub top: f64,
pub right: f64,
pub bottom: f64,
pub left: f64,
}
impl Margins {
pub fn new(top: f64, right: f64, bottom: f64, left: f64) -> Self {
Self { top, right, bottom, left }
}
pub fn inner_width(&self, total_width: f64) -> f64 {
(total_width - self.left - self.right).max(0.0)
}
pub fn inner_height(&self, total_height: f64) -> f64 {
(total_height - self.top - self.bottom).max(0.0)
}
}
impl Default for Margins {
fn default() -> Self {
Self { top: 20.0, right: 30.0, bottom: 40.0, left: 70.0 }
}
}
pub struct MarginConfig {
pub has_title: bool,
pub has_x_axis_label: bool,
pub has_y_axis_label: bool,
pub has_right_axis: bool,
pub legend_height: f64,
pub y_tick_labels: Vec<String>,
pub right_tick_labels: Vec<String>,
pub x_label_strategy_margin: f64,
pub max_left_margin: f64,
pub max_right_margin: f64,
pub chart_height: f64,
pub tick_value_metrics: super::labels::TextMetrics,
pub axis_label_metrics: super::labels::TextMetrics,
}
impl Default for MarginConfig {
fn default() -> Self {
Self {
has_title: false,
has_x_axis_label: false,
has_y_axis_label: false,
has_right_axis: false,
legend_height: 0.0,
y_tick_labels: Vec::new(),
right_tick_labels: Vec::new(),
x_label_strategy_margin: 0.0,
max_left_margin: 250.0,
max_right_margin: 250.0,
chart_height: 400.0,
tick_value_metrics: super::labels::TextMetrics::default(),
axis_label_metrics: super::labels::TextMetrics::default(),
}
}
}
pub fn calculate_margins(config: &MarginConfig) -> Margins {
use super::labels::measure_text;
let top = 20.0;
let max_y_label_width = config.y_tick_labels.iter()
.map(|l| measure_text(l, &config.tick_value_metrics))
.fold(0.0_f64, f64::max);
let tick_padding = 15.0;
let left_base = if max_y_label_width > 0.0 {
max_y_label_width + tick_padding
} else {
70.0 };
let left = if config.has_y_axis_label {
let axis_label_width = if config.axis_label_metrics.is_legacy_default() {
14.0_f64
} else {
(config.axis_label_metrics.font_size_px + 2.0).max(14.0)
};
let gap = 4.0_f64;
let min_with_label = max_y_label_width + tick_padding + gap + axis_label_width;
left_base.max(min_with_label)
} else {
left_base
}.min(config.max_left_margin);
let right = if config.has_right_axis {
let max_right_width = config.right_tick_labels.iter()
.map(|l| measure_text(l, &config.tick_value_metrics))
.fold(0.0_f64, f64::max);
(max_right_width + 24.0 + 20.0)
.min(config.max_right_margin)
} else {
30.0 };
let base_bottom = if config.chart_height < 300.0 {
(config.chart_height * 0.16).clamp(20.0, 40.0)
} else {
40.0
};
let bottom = base_bottom
+ config.x_label_strategy_margin
+ if config.has_x_axis_label { 20.0 } else { 0.0 }
+ if config.legend_height > 0.0 { config.legend_height + 8.0 } else { 0.0 };
Margins { top, right, bottom, left }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_margins() {
let m = Margins::default();
assert_eq!(m.top, 20.0);
assert_eq!(m.right, 30.0);
assert_eq!(m.bottom, 40.0);
assert_eq!(m.left, 70.0);
}
#[test]
fn margins_inner_dimensions() {
let m = Margins::new(10.0, 20.0, 30.0, 40.0);
assert!((m.inner_width(800.0) - 740.0).abs() < f64::EPSILON);
assert!((m.inner_height(600.0) - 560.0).abs() < f64::EPSILON);
}
#[test]
fn margins_inner_dimensions_clamp_to_zero() {
let m = Margins::new(300.0, 300.0, 300.0, 300.0);
assert_eq!(m.inner_width(100.0), 0.0);
assert_eq!(m.inner_height(100.0), 0.0);
}
#[test]
fn margins_with_title() {
let config = MarginConfig {
has_title: true,
..Default::default()
};
let m = calculate_margins(&config);
assert_eq!(m.top, 20.0); }
#[test]
fn margins_without_title() {
let config = MarginConfig::default();
let m = calculate_margins(&config);
assert_eq!(m.top, 20.0);
}
#[test]
fn margins_with_single_row_legend() {
let config = MarginConfig {
legend_height: 20.0, ..Default::default()
};
let m = calculate_margins(&config);
assert_eq!(m.bottom, 68.0); }
#[test]
fn margins_with_multi_row_legend() {
let config = MarginConfig {
legend_height: 60.0, ..Default::default()
};
let m = calculate_margins(&config);
assert_eq!(m.bottom, 108.0); }
#[test]
fn margins_with_y_labels() {
use crate::layout::labels::approximate_text_width;
let config = MarginConfig {
y_tick_labels: vec!["100,000".into(), "1,000,000".into()],
..Default::default()
};
let m = calculate_margins(&config);
let expected_max_width = approximate_text_width("1,000,000");
let expected_left = expected_max_width + 15.0;
assert!((m.left - expected_left).abs() < f64::EPSILON,
"Expected left margin ~{}, got {}", expected_left, m.left);
}
#[test]
fn margins_capped() {
let config = MarginConfig {
y_tick_labels: vec!["A".repeat(100)], max_left_margin: 250.0,
..Default::default()
};
let m = calculate_margins(&config);
assert!(m.left <= 250.0, "Left margin {} exceeds cap of 250", m.left);
}
}