use crate::core::{RenderScale, SpacingConfig, TypographyConfig};
#[derive(Debug, Clone, PartialEq)]
pub struct TextPosition {
pub x: f32,
pub y: f32,
pub size: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ComputedMarginsPixels {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutRect {
pub left: f32,
pub top: f32,
pub right: f32,
pub bottom: f32,
}
impl LayoutRect {
pub fn width(&self) -> f32 {
self.right - self.left
}
pub fn height(&self) -> f32 {
self.bottom - self.top
}
pub fn center_x(&self) -> f32 {
(self.left + self.right) / 2.0
}
pub fn center_y(&self) -> f32 {
(self.top + self.bottom) / 2.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlotLayout {
pub plot_area: LayoutRect,
pub title_pos: Option<TextPosition>,
pub xlabel_pos: Option<TextPosition>,
pub ylabel_pos: Option<TextPosition>,
pub xtick_baseline_y: f32,
pub ytick_right_x: f32,
pub margins: ComputedMarginsPixels,
}
#[derive(Debug, Clone)]
pub struct PlotContent {
pub title: Option<String>,
pub xlabel: Option<String>,
pub ylabel: Option<String>,
pub show_tick_labels: bool,
pub max_ytick_chars: usize,
pub max_xtick_chars: usize,
}
impl Default for PlotContent {
fn default() -> Self {
Self {
title: None,
xlabel: None,
ylabel: None,
show_tick_labels: true,
max_ytick_chars: 0,
max_xtick_chars: 0,
}
}
}
impl PlotContent {
pub fn new() -> Self {
Self::default()
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_xlabel(mut self, label: impl Into<String>) -> Self {
self.xlabel = Some(label.into());
self
}
pub fn with_ylabel(mut self, label: impl Into<String>) -> Self {
self.ylabel = Some(label.into());
self
}
#[deprecated(
since = "0.3.6",
note = "x-tick character counts are currently ignored; use with_ytick_chars() instead"
)]
pub fn with_tick_chars(self, max_ytick: usize, _max_xtick: usize) -> Self {
self.with_ytick_chars(max_ytick)
}
pub fn with_ytick_chars(mut self, max_ytick: usize) -> Self {
self.max_ytick_chars = max_ytick;
self
}
pub fn with_tick_labels(mut self, show_tick_labels: bool) -> Self {
self.show_tick_labels = show_tick_labels;
self
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MeasuredDimensions {
pub title: Option<(f32, f32)>,
pub xlabel: Option<(f32, f32)>,
pub ylabel: Option<(f32, f32)>,
pub xtick: Option<(f32, f32)>,
pub ytick: Option<(f32, f32)>,
pub right_margin: Option<f32>,
}
pub fn estimate_text_width(text: &str, font_size_px: f32) -> f32 {
let char_width = font_size_px * 0.6;
text.chars().count() as f32 * char_width
}
pub fn estimate_text_height(font_size_px: f32) -> f32 {
font_size_px * 1.2
}
pub fn estimate_tick_label_width(max_chars: usize, font_size_px: f32) -> f32 {
let chars = max_chars.max(3); estimate_text_width(&"X".repeat(chars), font_size_px)
}
#[derive(Debug, Clone)]
pub struct LayoutConfig {
pub edge_buffer_pt: f32,
pub center_plot: bool,
pub max_margin_fraction: f32,
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
edge_buffer_pt: 5.0, center_plot: true,
max_margin_fraction: 0.4, }
}
}
#[derive(Default)]
pub struct LayoutCalculator {
pub config: LayoutConfig,
}
impl LayoutCalculator {
pub fn new(config: LayoutConfig) -> Self {
Self { config }
}
pub fn compute(
&self,
canvas_size: (u32, u32),
content: &PlotContent,
typography: &TypographyConfig,
spacing: &SpacingConfig,
dpi: f32,
measurements: Option<&MeasuredDimensions>,
) -> PlotLayout {
let (canvas_width, canvas_height) = (canvas_size.0 as f32, canvas_size.1 as f32);
let render_scale = RenderScale::from_canvas_size(canvas_size.0, canvas_size.1, dpi);
let edge_buffer = render_scale.points_to_pixels(self.config.edge_buffer_pt);
let title_pad = render_scale.points_to_pixels(spacing.title_pad);
let label_pad = render_scale.points_to_pixels(spacing.label_pad);
let tick_pad = render_scale.points_to_pixels(spacing.tick_pad);
let title_size_px = render_scale.points_to_pixels(typography.title_size());
let label_size_px = render_scale.points_to_pixels(typography.label_size());
let tick_size_px = render_scale.points_to_pixels(typography.tick_size());
let measured_title = measurements.and_then(|m| m.title);
let measured_xlabel = measurements.and_then(|m| m.xlabel);
let measured_ylabel = measurements.and_then(|m| m.ylabel);
let measured_xtick = measurements.and_then(|m| m.xtick);
let measured_ytick = measurements.and_then(|m| m.ytick);
let measured_right_margin = measurements.and_then(|m| m.right_margin);
let title_height = if content.title.is_some() {
measured_title
.map(|(_, h)| h)
.unwrap_or_else(|| estimate_text_height(title_size_px))
} else {
0.0
};
let xlabel_height = if content.xlabel.is_some() {
measured_xlabel
.map(|(_, h)| h)
.unwrap_or_else(|| estimate_text_height(label_size_px))
} else {
0.0
};
let ylabel_width = if content.ylabel.is_some() {
measured_ylabel
.map(|(_, h)| h)
.unwrap_or_else(|| estimate_text_height(label_size_px))
} else {
0.0
};
let (xtick_height, ytick_width, tick_pad) = if content.show_tick_labels {
(
measured_xtick
.map(|(_, h)| h)
.unwrap_or_else(|| estimate_text_height(tick_size_px)),
measured_ytick.map(|(w, _)| w).unwrap_or_else(|| {
estimate_tick_label_width(
content.max_ytick_chars.max(5), tick_size_px,
)
}),
tick_pad,
)
} else {
(0.0, 0.0, 0.0)
};
let mut min_top = edge_buffer;
if content.title.is_some() {
min_top += title_height + title_pad;
}
let mut min_bottom = edge_buffer + xtick_height + tick_pad;
if content.xlabel.is_some() {
min_bottom += xlabel_height + label_pad;
}
let mut min_left = edge_buffer + ytick_width + tick_pad;
if content.ylabel.is_some() {
min_left += ylabel_width + label_pad;
}
let min_right = measured_right_margin
.unwrap_or(edge_buffer)
.max(edge_buffer);
let max_h_margin = canvas_width * self.config.max_margin_fraction;
let max_v_margin = canvas_height * self.config.max_margin_fraction;
let final_top = min_top.min(max_v_margin);
let final_bottom = min_bottom.min(max_v_margin);
let final_left = min_left.min(max_h_margin);
let mut final_right = min_right.min(max_h_margin);
if self.config.center_plot {
let extra_right = (final_left - final_right).max(0.0);
final_right += extra_right;
}
let plot_area = LayoutRect {
left: final_left,
top: final_top,
right: canvas_width - final_right,
bottom: canvas_height - final_bottom,
};
let title_pos = content.title.as_ref().map(|_| TextPosition {
x: plot_area.center_x(), y: edge_buffer, size: title_size_px,
});
let xlabel_pos = content.xlabel.as_ref().map(|_| TextPosition {
x: plot_area.center_x(), y: canvas_height - edge_buffer - xlabel_height, size: label_size_px,
});
let ylabel_pos = content.ylabel.as_ref().map(|_| TextPosition {
x: edge_buffer + ylabel_width / 2.0, y: plot_area.center_y(), size: label_size_px,
});
let xtick_baseline_y = plot_area.bottom + tick_pad;
let ytick_right_x = plot_area.left - tick_pad;
PlotLayout {
plot_area,
title_pos,
xlabel_pos,
ylabel_pos,
xtick_baseline_y,
ytick_right_x,
margins: ComputedMarginsPixels {
left: final_left,
right: final_right,
top: final_top,
bottom: final_bottom,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_typography() -> TypographyConfig {
TypographyConfig::default()
}
fn default_spacing() -> SpacingConfig {
SpacingConfig::default()
}
#[test]
fn test_with_ytick_chars_sets_only_ytick_estimate() {
let content = PlotContent::new().with_ytick_chars(7);
assert_eq!(content.max_ytick_chars, 7);
assert_eq!(content.max_xtick_chars, 0);
}
#[test]
fn test_with_tick_chars_compatibility_matches_ytick_only_layout() {
let calculator = LayoutCalculator::default();
#[allow(deprecated)]
let compatibility_content = PlotContent::new().with_tick_chars(6, 42);
let ytick_only_content = PlotContent::new().with_ytick_chars(6);
assert_eq!(compatibility_content.max_ytick_chars, 6);
assert_eq!(compatibility_content.max_xtick_chars, 0);
let compatibility_layout = calculator.compute(
(640, 480),
&compatibility_content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
let ytick_only_layout = calculator.compute(
(640, 480),
&ytick_only_content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
assert_eq!(compatibility_layout, ytick_only_layout);
}
#[test]
fn test_estimate_text_width() {
let width = estimate_text_width("Hello", 12.0);
assert!((width - 36.0).abs() < 0.1);
}
#[test]
fn test_estimate_text_height() {
let height = estimate_text_height(14.0);
assert!((height - 16.8).abs() < 0.1);
}
#[test]
fn test_layout_all_elements() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new()
.with_title("Test Title")
.with_xlabel("X Values")
.with_ylabel("Y Values")
.with_ytick_chars(5);
let layout = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
assert!(layout.plot_area.width() > 0.0);
assert!(layout.plot_area.height() > 0.0);
assert!(layout.title_pos.is_some());
let title = layout.title_pos.unwrap();
assert!(title.y < 50.0);
assert!(layout.xlabel_pos.is_some());
assert!(layout.ylabel_pos.is_some());
}
#[test]
fn test_layout_horizontal_text_positions_use_top_origin() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new()
.with_title("Baseline Title")
.with_xlabel("Baseline X");
let typography = default_typography();
let spacing = default_spacing();
let layout = calculator.compute((640, 480), &content, &typography, &spacing, 100.0, None);
let pt_to_px = |pt: f32| pt * 100.0 / 72.0;
let edge_buffer = pt_to_px(LayoutConfig::default().edge_buffer_pt);
let expected_title_top = edge_buffer;
let expected_xlabel_top =
480.0 - edge_buffer - estimate_text_height(pt_to_px(typography.label_size()));
let title = layout.title_pos.expect("title should be present");
let xlabel = layout.xlabel_pos.expect("xlabel should be present");
assert!((title.y - expected_title_top).abs() < 1.0);
assert!((xlabel.y - expected_xlabel_top).abs() < 1.0);
}
#[test]
fn test_layout_uses_measured_dimensions_when_provided() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new()
.with_title("Measured Title")
.with_xlabel("Measured X")
.with_ylabel("Measured Y");
let estimated = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
let measured_dims = MeasuredDimensions {
title: Some((180.0, 42.0)),
xlabel: Some((120.0, 34.0)),
ylabel: Some((140.0, 50.0)),
xtick: None,
ytick: None,
right_margin: None,
};
let measured = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
Some(&measured_dims),
);
assert!(measured.margins.top > estimated.margins.top);
assert!(measured.margins.bottom > estimated.margins.bottom);
assert!(measured.margins.left > estimated.margins.left);
}
#[test]
fn test_layout_empty_measurements_match_estimates() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new()
.with_title("Title")
.with_xlabel("X")
.with_ylabel("Y");
let estimated = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
let empty = MeasuredDimensions::default();
let with_empty = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
Some(&empty),
);
assert_eq!(estimated, with_empty);
}
#[test]
fn test_layout_no_title() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new().with_xlabel("X").with_ylabel("Y");
let layout = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
assert!(layout.title_pos.is_none());
assert!(layout.margins.top < 50.0);
}
#[test]
fn test_layout_no_labels() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new().with_ytick_chars(5);
let layout = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
assert!(layout.title_pos.is_none());
assert!(layout.xlabel_pos.is_none());
assert!(layout.ylabel_pos.is_none());
assert!(layout.plot_area.width() > 500.0);
assert!(layout.plot_area.height() > 400.0);
}
#[test]
fn test_layout_without_tick_labels_reclaims_tick_margins() {
let calculator = LayoutCalculator::default();
let with_ticks = PlotContent::new()
.with_xlabel("X Axis")
.with_ylabel("Y Axis")
.with_ytick_chars(6);
let without_ticks = with_ticks.clone().with_tick_labels(false);
let layout_with_ticks = calculator.compute(
(640, 480),
&with_ticks,
&default_typography(),
&default_spacing(),
100.0,
None,
);
let layout_without_ticks = calculator.compute(
(640, 480),
&without_ticks,
&default_typography(),
&default_spacing(),
100.0,
None,
);
assert!(layout_without_ticks.margins.bottom < layout_with_ticks.margins.bottom);
assert!(layout_without_ticks.margins.left < layout_with_ticks.margins.left);
assert!(layout_without_ticks.plot_area.width() > layout_with_ticks.plot_area.width());
assert!(layout_without_ticks.plot_area.height() > layout_with_ticks.plot_area.height());
}
#[test]
fn test_layout_respects_edge_buffer() {
let calculator = LayoutCalculator::new(LayoutConfig {
edge_buffer_pt: 10.0,
..Default::default()
});
let content = PlotContent::new();
let layout = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
let expected_buffer = 10.0 * 100.0 / 72.0;
assert!(layout.margins.right >= expected_buffer - 1.0);
}
#[test]
fn test_layout_measured_right_margin_keeps_edge_buffer_floor() {
let calculator = LayoutCalculator::new(LayoutConfig {
edge_buffer_pt: 10.0,
center_plot: false,
..Default::default()
});
let content = PlotContent::new();
let measured = MeasuredDimensions {
right_margin: Some(2.0),
..Default::default()
};
let layout = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
Some(&measured),
);
let expected_buffer = 10.0 * 100.0 / 72.0;
assert!(
layout.margins.right >= expected_buffer - 1.0,
"measured right margins should not shrink below the configured edge buffer"
);
}
#[test]
fn test_layout_centering() {
let calculator = LayoutCalculator::new(LayoutConfig {
center_plot: true,
..Default::default()
});
let content = PlotContent::new()
.with_ylabel("Y Label")
.with_ytick_chars(5);
let layout = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
let margin_diff = (layout.margins.right - layout.margins.left).abs();
assert!(
margin_diff < 1.0,
"Margins should be equal: left={}, right={}",
layout.margins.left,
layout.margins.right
);
let plot_center = layout.plot_area.center_x();
let canvas_center = 640.0 / 2.0;
let center_diff = (plot_center - canvas_center).abs();
assert!(
center_diff < 1.0,
"Plot should be centered: plot_center={}, canvas_center={}",
plot_center,
canvas_center
);
}
#[test]
fn test_layout_keeps_asymmetric_margins_when_centering_disabled() {
let calculator = LayoutCalculator::new(LayoutConfig {
center_plot: false,
..Default::default()
});
let content = PlotContent::new()
.with_ylabel("Y Label")
.with_ytick_chars(5);
let layout = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
assert!(
layout.margins.left > layout.margins.right,
"left margin should grow for ylabel/ticks without mirroring to the right"
);
let plot_center = layout.plot_area.center_x();
let canvas_center = 640.0 / 2.0;
let center_diff = plot_center - canvas_center;
assert!(
center_diff > 0.0,
"plot area center should move right when the left margin grows without right mirroring: plot_center={}, canvas_center={}",
plot_center,
canvas_center
);
}
#[test]
fn test_layout_uses_measured_tick_dimensions_when_provided() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new().with_ytick_chars(5);
let estimated = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
let measured_dims = MeasuredDimensions {
xtick: Some((40.0, 28.0)),
ytick: Some((72.0, 20.0)),
..MeasuredDimensions::default()
};
let measured = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
Some(&measured_dims),
);
assert!(measured.margins.bottom > estimated.margins.bottom);
assert!(measured.margins.left > estimated.margins.left);
}
#[test]
fn test_layout_margin_clamping() {
let calculator = LayoutCalculator::new(LayoutConfig {
max_margin_fraction: 0.3,
..Default::default()
});
let content = PlotContent::new().with_ylabel("Very Long Y-Axis Label That Would Be Huge");
let layout = calculator.compute(
(200, 150), &content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
assert!(layout.margins.left <= 200.0 * 0.3 + 1.0);
}
#[test]
fn test_layout_very_long_title() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new()
.with_title("This is an Extremely Long Title That Should Still Render Properly Without Breaking the Layout")
.with_xlabel("X Axis")
.with_ylabel("Y Axis");
let layout = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
assert!(layout.plot_area.width() > 0.0);
assert!(layout.plot_area.height() > 0.0);
assert!(layout.title_pos.is_some());
assert!(layout.plot_area.width() > 300.0);
}
#[test]
fn test_layout_unicode_labels() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new()
.with_title("数据分析 - データ分析")
.with_xlabel("時間 (μs)")
.with_ylabel("Amplitude (±σ)")
.with_ytick_chars(6);
let layout = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
assert!(layout.plot_area.width() > 0.0);
assert!(layout.plot_area.height() > 0.0);
assert!(layout.title_pos.is_some());
assert!(layout.xlabel_pos.is_some());
assert!(layout.ylabel_pos.is_some());
}
#[test]
fn test_layout_very_small_canvas() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new()
.with_title("Title")
.with_xlabel("X")
.with_ylabel("Y");
let layout = calculator.compute(
(100, 80), &content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
assert!(layout.plot_area.width() > 0.0);
assert!(layout.plot_area.height() > 0.0);
assert!(layout.margins.left < 50.0);
assert!(layout.margins.top < 40.0);
}
#[test]
fn test_layout_high_dpi() {
let calculator = LayoutCalculator::default();
let content = PlotContent::new()
.with_title("High DPI Title")
.with_xlabel("X Axis")
.with_ylabel("Y Axis");
let layout_100dpi = calculator.compute(
(640, 480),
&content,
&default_typography(),
&default_spacing(),
100.0,
None,
);
let layout_200dpi = calculator.compute(
(1280, 960), &content,
&default_typography(),
&default_spacing(),
200.0,
None,
);
let ratio_100 = layout_100dpi.plot_area.width() / 640.0;
let ratio_200 = layout_200dpi.plot_area.width() / 1280.0;
let diff = (ratio_100 - ratio_200).abs() / ratio_100;
assert!(diff < 0.2, "DPI scaling ratio diff: {}", diff);
}
}