use crate::core::units::{REFERENCE_DPI, RenderScale, in_to_px, pt_to_px, px_to_in};
use crate::core::{PlottingError, Result};
use crate::render::{FontFamily, FontWeight};
pub const DEFAULT_AUTOSCALE_MARGIN: f64 = 0.05;
#[derive(Debug, Clone, PartialEq)]
pub struct FigureConfig {
pub width: f32,
pub height: f32,
pub dpi: f32,
}
impl FigureConfig {
pub fn new(width: f32, height: f32, dpi: f32) -> Self {
Self { width, height, dpi }
}
pub fn canvas_size(&self) -> (u32, u32) {
(
in_to_px(self.width, self.dpi) as u32,
in_to_px(self.height, self.dpi) as u32,
)
}
pub fn aspect_ratio(&self) -> f32 {
self.width / self.height
}
pub fn render_scale(&self) -> RenderScale {
RenderScale::new(self.dpi).with_figure(self.width, self.height)
}
}
impl Default for FigureConfig {
fn default() -> Self {
Self {
width: 6.4, height: 4.8, dpi: 100.0, }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TypographyConfig {
pub base_size: f32,
pub title_scale: f32,
pub label_scale: f32,
pub tick_scale: f32,
pub legend_scale: f32,
pub family: FontFamily,
pub title_weight: FontWeight,
}
impl TypographyConfig {
pub fn new(base_size: f32) -> Self {
Self {
base_size,
..Default::default()
}
}
pub fn title_size(&self) -> f32 {
self.base_size * self.title_scale
}
pub fn label_size(&self) -> f32 {
self.base_size * self.label_scale
}
pub fn tick_size(&self) -> f32 {
self.base_size * self.tick_scale
}
pub fn legend_size(&self) -> f32 {
self.base_size * self.legend_scale
}
pub fn title_size_px(&self, dpi: f32) -> f32 {
pt_to_px(self.title_size(), dpi)
}
pub fn label_size_px(&self, dpi: f32) -> f32 {
pt_to_px(self.label_size(), dpi)
}
pub fn tick_size_px(&self, dpi: f32) -> f32 {
pt_to_px(self.tick_size(), dpi)
}
pub fn legend_size_px(&self, dpi: f32) -> f32 {
pt_to_px(self.legend_size(), dpi)
}
pub fn scale(mut self, factor: f32) -> Self {
self.base_size *= factor;
self
}
pub fn base_size(mut self, size: f32) -> Self {
self.base_size = size;
self
}
pub fn title_scale(mut self, scale: f32) -> Self {
self.title_scale = scale;
self
}
pub fn family(mut self, family: FontFamily) -> Self {
self.family = family;
self
}
pub fn title_weight(mut self, weight: FontWeight) -> Self {
self.title_weight = weight;
self
}
}
impl Default for TypographyConfig {
fn default() -> Self {
Self {
base_size: 10.0, title_scale: 1.4, label_scale: 1.0, tick_scale: 0.9, legend_scale: 0.9, family: FontFamily::SansSerif,
title_weight: FontWeight::Normal,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LineConfig {
pub data_width: f32,
pub axis_width: f32,
pub grid_width: f32,
pub tick_width: f32,
pub tick_length: f32,
}
impl LineConfig {
pub fn data_width_px(&self, dpi: f32) -> f32 {
pt_to_px(self.data_width, dpi)
}
pub fn axis_width_px(&self, dpi: f32) -> f32 {
pt_to_px(self.axis_width, dpi)
}
pub fn grid_width_px(&self, dpi: f32) -> f32 {
pt_to_px(self.grid_width, dpi)
}
pub fn tick_width_px(&self, dpi: f32) -> f32 {
pt_to_px(self.tick_width, dpi)
}
pub fn tick_length_px(&self, dpi: f32) -> f32 {
pt_to_px(self.tick_length, dpi)
}
pub fn data_width(mut self, width: f32) -> Self {
self.data_width = width;
self
}
pub fn axis_width(mut self, width: f32) -> Self {
self.axis_width = width;
self
}
pub fn grid_width(mut self, width: f32) -> Self {
self.grid_width = width;
self
}
}
impl Default for LineConfig {
fn default() -> Self {
Self {
data_width: 1.5, axis_width: 0.8,
grid_width: 0.8, tick_width: 0.6,
tick_length: 4.0,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SpacingConfig {
pub title_pad: f32,
pub label_pad: f32,
pub tick_pad: f32,
pub legend_pad: f32,
}
impl SpacingConfig {
pub fn title_pad_px(&self, dpi: f32) -> f32 {
pt_to_px(self.title_pad, dpi)
}
pub fn label_pad_px(&self, dpi: f32) -> f32 {
pt_to_px(self.label_pad, dpi)
}
pub fn tick_pad_px(&self, dpi: f32) -> f32 {
pt_to_px(self.tick_pad, dpi)
}
pub fn legend_pad_px(&self, dpi: f32) -> f32 {
pt_to_px(self.legend_pad, dpi)
}
}
impl Default for SpacingConfig {
fn default() -> Self {
Self {
title_pad: 6.0, label_pad: 4.0, tick_pad: 3.5, legend_pad: 8.0,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum MarginConfig {
Proportional {
left: f32,
right: f32,
top: f32,
bottom: f32,
},
Auto {
min: f32,
max: f32,
},
Fixed {
left: f32,
right: f32,
top: f32,
bottom: f32,
},
ContentDriven {
edge_buffer: f32,
center_plot: bool,
},
}
impl MarginConfig {
pub fn proportional() -> Self {
MarginConfig::Proportional {
left: 0.125,
right: 0.1,
top: 0.12,
bottom: 0.11,
}
}
pub fn matplotlib() -> Self {
Self::proportional()
}
pub fn proportional_custom(left: f32, right: f32, top: f32, bottom: f32) -> Self {
MarginConfig::Proportional {
left,
right,
top,
bottom,
}
}
pub fn auto() -> Self {
MarginConfig::Auto { min: 0.3, max: 1.0 }
}
pub fn auto_with_bounds(min: f32, max: f32) -> Self {
MarginConfig::Auto { min, max }
}
pub fn fixed_uniform(margin: f32) -> Self {
MarginConfig::Fixed {
left: margin,
right: margin,
top: margin,
bottom: margin,
}
}
pub fn fixed(left: f32, right: f32, top: f32, bottom: f32) -> Self {
MarginConfig::Fixed {
left,
right,
top,
bottom,
}
}
pub fn content_driven() -> Self {
MarginConfig::ContentDriven {
edge_buffer: 5.0,
center_plot: true,
}
}
pub fn content_driven_custom(edge_buffer: f32, center_plot: bool) -> Self {
MarginConfig::ContentDriven {
edge_buffer,
center_plot,
}
}
pub(crate) fn validate_for_figure(&self, figure: &FigureConfig) -> Result<()> {
fn invalid(message: impl Into<String>) -> PlottingError {
PlottingError::InvalidInput(message.into())
}
fn validate_non_negative(values: &[(&str, f32)], mode: &str) -> Result<()> {
for (name, value) in values {
if !value.is_finite() || *value < 0.0 {
return Err(invalid(format!(
"{mode} margin {name} must be a finite, non-negative value ({name}={value})"
)));
}
}
Ok(())
}
match self {
MarginConfig::Proportional {
left,
right,
top,
bottom,
} => {
validate_non_negative(
&[
("left", *left),
("right", *right),
("top", *top),
("bottom", *bottom),
],
"Proportional",
)?;
if left + right >= 1.0 || top + bottom >= 1.0 {
return Err(invalid(format!(
"Opposing proportional margins must sum to less than 1 (left+right={}, top+bottom={})",
left + right,
top + bottom
)));
}
}
MarginConfig::Auto { min, max } => {
validate_non_negative(&[("min", *min), ("max", *max)], "Auto")?;
if min > max {
return Err(invalid(format!(
"Auto margin minimum must not exceed maximum (min={min}, max={max})"
)));
}
if 2.0 * min >= figure.width || 2.0 * min >= figure.height {
return Err(invalid(format!(
"Auto margin minimum must leave positive figure space (min={min}, width={}, height={})",
figure.width, figure.height
)));
}
}
MarginConfig::Fixed {
left,
right,
top,
bottom,
} => {
validate_non_negative(
&[
("left", *left),
("right", *right),
("top", *top),
("bottom", *bottom),
],
"Fixed",
)?;
if left + right >= figure.width || top + bottom >= figure.height {
return Err(invalid(format!(
"Opposing fixed margins must leave positive figure space (left+right={}, top+bottom={}, width={}, height={})",
left + right,
top + bottom,
figure.width,
figure.height
)));
}
}
MarginConfig::ContentDriven { edge_buffer, .. } => {
validate_non_negative(&[("edge_buffer", *edge_buffer)], "Content-driven")?;
let edge_buffer_px = pt_to_px(*edge_buffer, figure.dpi);
if !edge_buffer_px.is_finite() {
return Err(invalid(format!(
"Content-driven margin edge_buffer is out of pixel range (edge_buffer={edge_buffer}, dpi={})",
figure.dpi
)));
}
let edge_buffer_in = *edge_buffer / crate::core::POINTS_PER_INCH;
if edge_buffer_in >= figure.width / 2.0 || edge_buffer_in >= figure.height / 2.0 {
return Err(invalid(format!(
"Content-driven margin edge_buffer must leave positive figure space (edge_buffer={edge_buffer}, width={}, height={})",
figure.width, figure.height
)));
}
}
}
Ok(())
}
}
impl Default for MarginConfig {
fn default() -> Self {
MarginConfig::ContentDriven {
edge_buffer: 5.0, center_plot: true, }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ComputedMargins {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
}
impl ComputedMargins {
pub fn left_px(&self, dpi: f32) -> f32 {
in_to_px(self.left, dpi)
}
pub fn right_px(&self, dpi: f32) -> f32 {
in_to_px(self.right, dpi)
}
pub fn top_px(&self, dpi: f32) -> f32 {
in_to_px(self.top, dpi)
}
pub fn bottom_px(&self, dpi: f32) -> f32 {
in_to_px(self.bottom, dpi)
}
}
impl Default for ComputedMargins {
fn default() -> Self {
Self {
left: 0.8,
right: 0.3,
top: 0.5,
bottom: 0.6,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpineConfig {
pub left: bool,
pub right: bool,
pub top: bool,
pub bottom: bool,
pub offset: f32,
}
impl SpineConfig {
pub fn new() -> Self {
Self::default()
}
pub fn despine() -> Self {
Self {
left: true,
right: false,
top: false,
bottom: true,
offset: 0.0,
}
}
pub fn minimal() -> Self {
Self::despine()
}
pub fn none() -> Self {
Self {
left: false,
right: false,
top: false,
bottom: false,
offset: 0.0,
}
}
pub fn all() -> Self {
Self::default()
}
pub fn with_offset(mut self, offset: f32) -> Self {
self.offset = offset;
self
}
pub fn hide_left(mut self) -> Self {
self.left = false;
self
}
pub fn hide_right(mut self) -> Self {
self.right = false;
self
}
pub fn hide_top(mut self) -> Self {
self.top = false;
self
}
pub fn hide_bottom(mut self) -> Self {
self.bottom = false;
self
}
pub fn show_left(mut self) -> Self {
self.left = true;
self
}
pub fn show_right(mut self) -> Self {
self.right = true;
self
}
pub fn show_top(mut self) -> Self {
self.top = true;
self
}
pub fn show_bottom(mut self) -> Self {
self.bottom = true;
self
}
pub fn any_visible(&self) -> bool {
self.left || self.right || self.top || self.bottom
}
pub fn offset_px(&self, dpi: f32) -> f32 {
pt_to_px(self.offset, dpi)
}
}
impl Default for SpineConfig {
fn default() -> Self {
Self {
left: true,
right: true,
top: true,
bottom: true,
offset: 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct PlotConfig {
pub figure: FigureConfig,
pub typography: TypographyConfig,
pub lines: LineConfig,
pub spacing: SpacingConfig,
pub margins: MarginConfig,
pub spines: SpineConfig,
pub x_margin: f64,
pub y_margin: f64,
}
impl Default for PlotConfig {
fn default() -> Self {
Self {
figure: FigureConfig::default(),
typography: TypographyConfig::default(),
lines: LineConfig::default(),
spacing: SpacingConfig::default(),
margins: MarginConfig::default(),
spines: SpineConfig::default(),
x_margin: DEFAULT_AUTOSCALE_MARGIN,
y_margin: DEFAULT_AUTOSCALE_MARGIN,
}
}
}
impl PlotConfig {
pub fn builder() -> PlotConfigBuilder {
PlotConfigBuilder::default()
}
pub fn canvas_size(&self) -> (u32, u32) {
self.figure.canvas_size()
}
pub fn dpi(&self) -> f32 {
self.figure.dpi
}
pub fn compute_margins(
&self,
has_title: bool,
has_xlabel: bool,
has_ylabel: bool,
) -> ComputedMargins {
match &self.margins {
MarginConfig::Proportional {
left,
right,
top,
bottom,
} => {
ComputedMargins {
left: self.figure.width * left,
right: self.figure.width * right,
top: self.figure.height * top,
bottom: self.figure.height * bottom,
}
}
MarginConfig::Auto { min, max } => {
let (min, max) =
if min.is_finite() && max.is_finite() && *min >= 0.0 && *min <= *max {
(*min, *max)
} else {
(0.3, 1.0)
};
let top = if has_title {
(crate::core::pt_to_in(self.typography.title_size())
+ crate::core::pt_to_in(self.spacing.title_pad)
+ 0.15)
.clamp(min, max)
} else {
min
};
let bottom = if has_xlabel {
(crate::core::pt_to_in(self.typography.label_size())
+ crate::core::pt_to_in(self.typography.tick_size())
+ crate::core::pt_to_in(self.spacing.label_pad)
+ crate::core::pt_to_in(self.spacing.tick_pad)
+ 0.1)
.clamp(min, max)
} else {
(crate::core::pt_to_in(self.typography.tick_size())
+ crate::core::pt_to_in(self.spacing.tick_pad)
+ 0.1)
.clamp(min, max)
};
let left = if has_ylabel {
(crate::core::pt_to_in(self.typography.label_size())
+ crate::core::pt_to_in(self.typography.tick_size()) * 4.0
+ crate::core::pt_to_in(self.spacing.label_pad)
+ 0.2)
.clamp(min, max)
} else {
(crate::core::pt_to_in(self.typography.tick_size()) * 4.0 + 0.15)
.clamp(min, max)
};
let right = min.max(0.2);
ComputedMargins {
left,
right,
top,
bottom,
}
}
MarginConfig::Fixed {
left,
right,
top,
bottom,
} => ComputedMargins {
left: *left,
right: *right,
top: *top,
bottom: *bottom,
},
MarginConfig::ContentDriven { .. } => {
ComputedMargins {
left: 0.8,
right: 0.3,
top: 0.5,
bottom: 0.6,
}
}
}
}
pub fn from_pixels(width_px: u32, height_px: u32) -> Self {
Self {
figure: FigureConfig {
width: px_to_in(width_px as f32, REFERENCE_DPI),
height: px_to_in(height_px as f32, REFERENCE_DPI),
dpi: REFERENCE_DPI,
},
..Default::default()
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PlotConfigBuilder {
config: PlotConfig,
}
impl PlotConfigBuilder {
pub fn figure(mut self, width: f32, height: f32) -> Self {
self.config.figure.width = width;
self.config.figure.height = height;
self
}
pub fn figure_px(mut self, width_px: u32, height_px: u32) -> Self {
self.config.figure.width = px_to_in(width_px as f32, REFERENCE_DPI);
self.config.figure.height = px_to_in(height_px as f32, REFERENCE_DPI);
self
}
pub fn dpi(mut self, dpi: f32) -> Self {
self.config.figure.dpi = dpi;
self
}
pub fn typography<F>(mut self, f: F) -> Self
where
F: FnOnce(TypographyConfig) -> TypographyConfig,
{
self.config.typography = f(self.config.typography);
self
}
pub fn font_size(mut self, size: f32) -> Self {
self.config.typography.base_size = size;
self
}
pub fn font_family<F>(mut self, family: F) -> Self
where
F: Into<FontFamily>,
{
self.config.typography.family = family.into();
self
}
pub fn lines<F>(mut self, f: F) -> Self
where
F: FnOnce(LineConfig) -> LineConfig,
{
self.config.lines = f(self.config.lines);
self
}
pub fn line_width(mut self, width: f32) -> Self {
self.config.lines.data_width = width;
self
}
pub fn spacing<F>(mut self, f: F) -> Self
where
F: FnOnce(SpacingConfig) -> SpacingConfig,
{
self.config.spacing = f(self.config.spacing);
self
}
pub fn margins(mut self, margins: MarginConfig) -> Self {
self.config.margins = margins;
self
}
pub fn spines(mut self, spines: SpineConfig) -> Self {
self.config.spines = spines;
self
}
pub fn despine(mut self) -> Self {
self.config.spines = SpineConfig::despine();
self
}
pub fn x_margin(mut self, margin: f64) -> Self {
self.config.x_margin = margin;
self
}
pub fn y_margin(mut self, margin: f64) -> Self {
self.config.y_margin = margin;
self
}
pub fn data_margins(mut self, x_margin: f64, y_margin: f64) -> Self {
self.config.x_margin = x_margin;
self.config.y_margin = y_margin;
self
}
pub fn build(self) -> PlotConfig {
self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_figure_config_default() {
let config = FigureConfig::default();
assert!((config.width - 6.4).abs() < 0.001);
assert!((config.height - 4.8).abs() < 0.001);
assert!((config.dpi - 100.0).abs() < 0.001);
}
#[test]
fn test_figure_config_canvas_size() {
let config = FigureConfig::default();
let (w, h) = config.canvas_size();
assert_eq!(w, 640);
assert_eq!(h, 480);
let config = FigureConfig::new(6.4, 4.8, 300.0);
let (w, h) = config.canvas_size();
assert_eq!(w, 1920);
assert_eq!(h, 1440);
}
#[test]
fn test_typography_config_default() {
let config = TypographyConfig::default();
assert!((config.base_size - 10.0).abs() < 0.001);
assert!((config.title_scale - 1.4).abs() < 0.001);
}
#[test]
fn test_typography_computed_sizes() {
let config = TypographyConfig::default();
assert!((config.title_size() - 14.0).abs() < 0.001);
assert!((config.label_size() - 10.0).abs() < 0.001);
assert!((config.tick_size() - 9.0).abs() < 0.001);
}
#[test]
fn test_typography_scale() {
let config = TypographyConfig::default().scale(1.5);
assert!((config.base_size - 15.0).abs() < 0.001);
assert!((config.title_size() - 21.0).abs() < 0.001); }
#[test]
fn test_line_config_default() {
let config = LineConfig::default();
assert!((config.data_width - 1.5).abs() < 0.001);
assert!((config.axis_width - 0.8).abs() < 0.001);
}
#[test]
fn test_margin_config_auto() {
let config = MarginConfig::auto();
match config {
MarginConfig::Auto { min, max } => {
assert!((min - 0.3).abs() < 0.001);
assert!((max - 1.0).abs() < 0.001);
}
_ => panic!("Expected Auto"),
}
}
#[test]
fn test_plot_config_builder() {
let config = PlotConfig::builder()
.figure(8.0, 6.0)
.dpi(150.0)
.font_family("New Computer Modern Sans")
.font_size(12.0)
.line_width(2.0)
.build();
assert!((config.figure.width - 8.0).abs() < 0.001);
assert!((config.figure.height - 6.0).abs() < 0.001);
assert!((config.figure.dpi - 150.0).abs() < 0.001);
assert_eq!(
config.typography.family,
FontFamily::Name("New Computer Modern Sans".to_string())
);
assert!((config.typography.base_size - 12.0).abs() < 0.001);
assert!((config.lines.data_width - 2.0).abs() < 0.001);
}
#[test]
fn test_plot_config_canvas_size() {
let config = PlotConfig::default();
let (w, h) = config.canvas_size();
assert_eq!(w, 640);
assert_eq!(h, 480);
}
#[test]
fn test_plot_config_default_autoscale_margins_match_matplotlib() {
let config = PlotConfig::default();
assert!((config.x_margin - 0.05).abs() < f64::EPSILON);
assert!((config.y_margin - 0.05).abs() < f64::EPSILON);
assert!((DEFAULT_AUTOSCALE_MARGIN - 0.05).abs() < f64::EPSILON);
}
#[test]
fn test_plot_config_builder_autoscale_margins() {
let config = PlotConfig::builder().x_margin(0.0).y_margin(0.2).build();
assert!((config.x_margin - 0.0).abs() < f64::EPSILON);
assert!((config.y_margin - 0.2).abs() < f64::EPSILON);
let both = PlotConfig::builder().data_margins(0.1, 0.0).build();
assert!((both.x_margin - 0.1).abs() < f64::EPSILON);
assert!((both.y_margin - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_computed_margins() {
let config = PlotConfig::default();
let margins = config.compute_margins(true, true, true);
assert!(margins.left > 0.0);
assert!(margins.right > 0.0);
assert!(margins.top > 0.0);
assert!(margins.bottom > 0.0);
assert!(margins.left > margins.right);
}
#[test]
fn test_compute_margins_invalid_auto_bounds_use_fallback() {
for margins in [
MarginConfig::Auto { min: 1.0, max: 0.5 },
MarginConfig::Auto {
min: f32::NAN,
max: 1.0,
},
] {
let config = PlotConfig {
margins,
..PlotConfig::default()
};
let computed = config.compute_margins(true, true, true);
assert!(computed.left.is_finite());
assert!(computed.right.is_finite());
assert!(computed.top.is_finite());
assert!(computed.bottom.is_finite());
}
}
#[test]
fn test_dpi_independence() {
let config = PlotConfig::default();
let font_px_100 = config.typography.title_size_px(100.0);
let canvas_100 = config.figure.canvas_size();
let ratio_100 = font_px_100 / canvas_100.0 as f32;
let font_px_300 = config.typography.title_size_px(300.0);
let canvas_300 = (
in_to_px(config.figure.width, 300.0) as u32,
in_to_px(config.figure.height, 300.0) as u32,
);
let ratio_300 = font_px_300 / canvas_300.0 as f32;
assert!((ratio_100 - ratio_300).abs() < 0.0001);
}
#[test]
fn test_spine_config_default() {
let spines = SpineConfig::default();
assert!(spines.left);
assert!(spines.right);
assert!(spines.top);
assert!(spines.bottom);
assert!((spines.offset - 0.0).abs() < 0.001);
}
#[test]
fn test_spine_config_despine() {
let spines = SpineConfig::despine();
assert!(spines.left);
assert!(!spines.right);
assert!(!spines.top);
assert!(spines.bottom);
}
#[test]
fn test_spine_config_none() {
let spines = SpineConfig::none();
assert!(!spines.left);
assert!(!spines.right);
assert!(!spines.top);
assert!(!spines.bottom);
assert!(!spines.any_visible());
}
#[test]
fn test_spine_config_builder_chain() {
let spines = SpineConfig::default()
.hide_top()
.hide_right()
.with_offset(5.0);
assert!(spines.left);
assert!(!spines.right);
assert!(!spines.top);
assert!(spines.bottom);
assert!((spines.offset - 5.0).abs() < 0.001);
}
#[test]
fn test_spine_config_offset_px() {
let spines = SpineConfig::default().with_offset(10.0);
assert!((spines.offset_px(72.0) - 10.0).abs() < 0.001);
assert!((spines.offset_px(144.0) - 20.0).abs() < 0.001);
}
#[test]
fn test_plot_config_builder_despine() {
let config = PlotConfig::builder().despine().build();
assert!(config.spines.left);
assert!(!config.spines.right);
assert!(!config.spines.top);
assert!(config.spines.bottom);
}
}