use crate::config::{AxisOptions, ChartTitleOptions, Config};
mod fit;
mod geometry;
mod nudge;
mod rect;
pub use fit::FitStrategy;
pub use geometry::{
LABEL_GAP, LEGEND_INSET, TitleBand, TitlePlacement, axis_anchor, axis_offset,
axis_title_offset_to_screen, axis_title_placement, axis_visibility_extent,
axis_visibility_rect, chart_title_placement, colorbar_rect, colorbar_title_placement,
fit_chart_title_margin, label_origin, label_rect, legend_rect, point_on_rect_side,
rect_axis_visibility_rect, screen_offset_to_axis_title,
};
pub use nudge::{Element, NudgeReject, NudgeResult};
pub use rect::{ChartArea, DataArea, Rect, RectF};
#[derive(Debug, Clone, PartialEq)]
pub struct Margins {
pub top: f32,
pub bottom: f32,
pub left: f32,
pub right: f32,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Side {
Top,
Bottom,
Left,
Right,
}
#[derive(Debug, Clone, PartialEq)]
pub enum LayoutError {
OverflowHorizontal { required: f32, available: u32 },
OverflowVertical { required: f32, available: u32 },
EmptyDataArea,
TargetOutOfChartArea,
Infeasible,
}
pub const MIN_VISIBLE_DATA_AREA_PX: u32 = 8;
pub(super) fn tick_contribution(axis: &AxisOptions) -> f32 {
axis.major_tick_length
}
pub(super) fn chart_title_contribution(title: &ChartTitleOptions) -> f32 {
title.top_margin
}
pub(super) fn colorbar_parts(cfg: &Config, side: &Side) -> Option<(f32, f32)> {
match cfg.colorbar.as_ref() {
Some(bar) if bar.visible && bar.side == *side => Some((
bar.axis.out_margin,
bar.gap_px + bar.thickness_px + tick_contribution(&bar.axis),
)),
_ => None,
}
}
pub(super) fn colorbar_contribution(cfg: &Config, side: &Side) -> f32 {
colorbar_parts(cfg, side).map_or(0.0, |(flex, fixed)| flex + fixed)
}
fn top_total(cfg: &Config) -> f32 {
chart_title_contribution(&cfg.chart_title)
+ cfg.top_x.out_margin
+ tick_contribution(&cfg.top_x)
+ colorbar_contribution(cfg, &Side::Top)
}
fn bottom_total(cfg: &Config) -> f32 {
cfg.bottom_x.out_margin
+ tick_contribution(&cfg.bottom_x)
+ colorbar_contribution(cfg, &Side::Bottom)
}
fn left_total(cfg: &Config) -> f32 {
cfg.left_y.out_margin + tick_contribution(&cfg.left_y) + colorbar_contribution(cfg, &Side::Left)
}
fn right_total(cfg: &Config) -> f32 {
cfg.right_y.out_margin
+ tick_contribution(&cfg.right_y)
+ colorbar_contribution(cfg, &Side::Right)
}
pub(super) fn axis_ref<'a>(cfg: &'a Config, side: &Side) -> &'a AxisOptions {
match side {
Side::Top => &cfg.top_x,
Side::Bottom => &cfg.bottom_x,
Side::Left => &cfg.left_y,
Side::Right => &cfg.right_y,
}
}
pub(super) fn axis_mut<'a>(cfg: &'a mut Config, side: &Side) -> &'a mut AxisOptions {
match side {
Side::Top => &mut cfg.top_x,
Side::Bottom => &mut cfg.bottom_x,
Side::Left => &mut cfg.left_y,
Side::Right => &mut cfg.right_y,
}
}
impl Config {
pub fn margins(&self) -> Margins {
Margins {
top: top_total(self),
bottom: bottom_total(self),
left: left_total(self),
right: right_total(self),
}
}
pub fn data_area(&self) -> Result<DataArea, LayoutError> {
let ca = &self.chart_area;
let m = self.margins();
if [m.top, m.bottom, m.left, m.right]
.iter()
.any(|v| !v.is_finite())
{
return Err(LayoutError::Infeasible);
}
let h_need = m.left + m.right;
if h_need > ca.width as f32 {
return Err(LayoutError::OverflowHorizontal {
required: h_need,
available: ca.width,
});
}
let v_need = m.top + m.bottom;
if v_need > ca.height as f32 {
return Err(LayoutError::OverflowVertical {
required: v_need,
available: ca.height,
});
}
let x = ca.x as f32 + m.left;
let y = ca.y as f32 + m.top;
let w = ca.width as f32 - h_need;
let h = ca.height as f32 - v_need;
if w <= 0.0 || h <= 0.0 {
return Err(LayoutError::EmptyDataArea);
}
let max = f64::from(u32::MAX);
if x < 0.0
|| y < 0.0
|| f64::from(x) + f64::from(w) > max
|| f64::from(y) + f64::from(h) > max
{
return Err(LayoutError::TargetOutOfChartArea);
}
let rect = Rect {
x: x.floor() as u32,
y: y.floor() as u32,
width: w.floor() as u32,
height: h.floor() as u32,
};
let keeps_visible_edge = |start: u32, len: u32, ca_start: u32, ca_len: u32| {
let lo = i64::from(start.max(ca_start));
let hi =
(i64::from(start) + i64::from(len)).min(i64::from(ca_start) + i64::from(ca_len));
hi - lo >= i64::from(MIN_VISIBLE_DATA_AREA_PX.min(len).min(ca_len))
};
if !keeps_visible_edge(rect.x, rect.width, ca.x, ca.width)
|| !keeps_visible_edge(rect.y, rect.height, ca.y, ca.height)
{
return Err(LayoutError::TargetOutOfChartArea);
}
Ok(DataArea(rect))
}
pub fn colorbar_band(&self, side: &Side) -> f32 {
colorbar_contribution(self, side)
}
pub fn validate(&self) -> Result<(), LayoutError> {
let _ = self.data_area()?;
for axis in [&self.top_x, &self.bottom_x, &self.left_y, &self.right_y] {
axis_dims_feasible(axis)?;
}
if let Some(bar) = self.colorbar.as_ref() {
axis_dims_feasible(&bar.axis)?;
if bar.thickness_px < 0.0 || bar.gap_px < 0.0 || bar.border_width < 0.0 {
return Err(LayoutError::Infeasible);
}
if !(bar.length_frac > 0.0 && bar.length_frac <= 1.0) {
return Err(LayoutError::Infeasible);
}
}
Ok(())
}
}
fn axis_dims_feasible(axis: &AxisOptions) -> Result<(), LayoutError> {
if axis.max <= axis.min {
return Err(LayoutError::Infeasible);
}
if axis.out_margin < 0.0
|| axis.major_tick_length < 0.0
|| axis.minor_tick_length < 0.0
|| axis.line_width < 0.0
{
return Err(LayoutError::Infeasible);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{Side, colorbar_contribution};
use crate::config::Config;
use crate::default::{default_colorbar_options, default_config};
use crate::layout::{ChartArea, LayoutError, Rect};
fn config_with_colorbar(side: Side) -> Config {
let mut cfg = default_config();
let mut bar = default_colorbar_options();
bar.side = side;
cfg.colorbar = Some(bar);
cfg
}
fn band(cfg: &Config) -> f32 {
let bar = cfg.colorbar.as_ref().expect("colourbar");
bar.gap_px + bar.thickness_px + bar.axis.out_margin + bar.axis.major_tick_length
}
#[test]
fn data_area_contained_in_chart_area() {
let cfg = default_config();
let da = cfg.data_area().unwrap();
let ca = &cfg.chart_area;
assert!(da.x >= ca.x);
assert!(da.y >= ca.y);
assert!(da.x + da.width <= ca.x + ca.width);
assert!(da.y + da.height <= ca.y + ca.height);
}
#[test]
fn margins_non_negative() {
let cfg = default_config();
let m = cfg.margins();
assert!(m.top >= 0.0);
assert!(m.bottom >= 0.0);
assert!(m.left >= 0.0);
assert!(m.right >= 0.0);
}
#[test]
fn the_colorbar_band_is_charged_to_its_own_side_only() {
for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
let base = default_config();
let cfg = config_with_colorbar(side.clone());
let (before, after) = (base.margins(), cfg.margins());
let expected = band(&cfg);
let (grew, unchanged) = match side {
Side::Top => (
after.top - before.top,
[
after.bottom - before.bottom,
after.left - before.left,
after.right - before.right,
],
),
Side::Bottom => (
after.bottom - before.bottom,
[
after.top - before.top,
after.left - before.left,
after.right - before.right,
],
),
Side::Left => (
after.left - before.left,
[
after.top - before.top,
after.bottom - before.bottom,
after.right - before.right,
],
),
Side::Right => (
after.right - before.right,
[
after.top - before.top,
after.bottom - before.bottom,
after.left - before.left,
],
),
};
assert!(
(grew - expected).abs() < 1e-3,
"{side:?}: margin grew by {grew}, band is {expected}"
);
for delta in unchanged {
assert!(
delta.abs() < 1e-6,
"{side:?}: another side moved by {delta}"
);
}
let da = cfg.data_area().expect("data area");
let base_da = base.data_area().expect("data area");
match side {
Side::Left | Side::Right => {
assert!(da.width < base_da.width);
assert_eq!(da.height, base_da.height);
}
Side::Top | Side::Bottom => {
assert!(da.height < base_da.height);
assert_eq!(da.width, base_da.width);
}
}
}
}
#[test]
fn a_hidden_colorbar_reserves_nothing() {
let mut cfg = config_with_colorbar(Side::Right);
cfg.colorbar.as_mut().expect("colourbar").visible = false;
assert_eq!(cfg.margins(), default_config().margins());
assert_eq!(colorbar_contribution(&cfg, &Side::Right), 0.0);
}
#[test]
fn a_config_without_a_colorbar_is_unaffected() {
let cfg = default_config();
assert!(cfg.colorbar.is_none());
for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
assert_eq!(colorbar_contribution(&cfg, &side), 0.0);
}
}
#[test]
fn validate_rejects_a_degenerate_colorbar() {
let good = config_with_colorbar(Side::Right);
assert!(good.validate().is_ok());
let mut reversed = good.clone();
reversed.colorbar.as_mut().unwrap().axis.max = reversed.colorbar.as_ref().unwrap().axis.min;
assert_eq!(reversed.validate(), Err(LayoutError::Infeasible));
let mut negative_margin = good.clone();
negative_margin.colorbar.as_mut().unwrap().axis.out_margin = -1.0;
assert_eq!(negative_margin.validate(), Err(LayoutError::Infeasible));
for frac in [0.0, -0.5, 1.5, f32::NAN] {
let mut bad = good.clone();
bad.colorbar.as_mut().unwrap().length_frac = frac;
assert_eq!(
bad.validate(),
Err(LayoutError::Infeasible),
"length_frac {frac} must be rejected"
);
}
}
fn small_chart(y: u32, top_margin: f32) -> Config {
let mut cfg = config_with_colorbar(Side::Right);
cfg.chart_area = ChartArea(Rect {
x: 0,
y,
width: 320,
height: 240,
});
cfg.chart_title.top_margin = top_margin;
cfg
}
fn bottom_edge(cfg: &Config) -> f32 {
cfg.chart_area.y as f32 + cfg.chart_area.height as f32 - cfg.margins().bottom
}
#[test]
fn a_negative_title_margin_is_a_coordinate() {
for (y, margin) in [(0, -5.0), (100, -60.0), (100, -90.0)] {
let cfg = small_chart(y, margin);
assert!(cfg.validate().is_ok(), "y {y}, title margin {margin}");
let da = cfg.data_area().expect("data area").0;
assert_eq!(da.y as f32, (y as f32 + cfg.margins().top).floor());
assert_eq!((da.y + da.height) as f32, bottom_edge(&cfg).floor());
}
assert!(small_chart(100, -90.0).data_area().unwrap().0.y < 100);
}
#[test]
fn a_data_area_above_the_canvas_origin_is_refused_not_shifted() {
let cfg = small_chart(0, -60.0);
assert!(cfg.margins().top < 0.0);
assert_eq!(cfg.data_area(), Err(LayoutError::TargetOutOfChartArea));
assert!(cfg.validate().is_err());
assert_eq!(
small_chart(0, -1e38).data_area(),
Err(LayoutError::TargetOutOfChartArea)
);
}
#[test]
fn a_non_finite_margin_is_rejected() {
for margin in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let cfg = small_chart(100, margin);
assert_eq!(cfg.data_area(), Err(LayoutError::Infeasible), "{margin}");
assert!(cfg.validate().is_err());
}
}
#[test]
fn a_data_area_past_u32_max_is_refused() {
let mut cfg = small_chart(0, 32.0);
cfg.bottom_x.out_margin = -1e38;
assert_eq!(cfg.data_area(), Err(LayoutError::TargetOutOfChartArea));
}
#[test]
fn the_data_area_keeps_a_visible_strip() {
use super::MIN_VISIBLE_DATA_AREA_PX as MIN;
let base = small_chart(100, 32.0);
let ca_bottom = (base.chart_area.y + base.chart_area.height) as f32;
let top = base.margins().top;
let with_strip = |inside: f32| {
let mut cfg = base.clone();
let shift = ca_bottom - inside - (cfg.chart_area.y as f32 + top);
cfg.chart_title.top_margin += shift;
cfg.bottom_x.out_margin -= shift;
cfg
};
assert!(with_strip(MIN as f32).data_area().is_ok());
assert_eq!(
with_strip(MIN as f32 - 1.0).data_area(),
Err(LayoutError::TargetOutOfChartArea)
);
assert_eq!(
with_strip(-20.0).data_area(),
Err(LayoutError::TargetOutOfChartArea)
);
let mut tiny = base.clone();
let m = tiny.margins();
tiny.bottom_x.out_margin += tiny.chart_area.height as f32 - m.top - m.bottom - 3.0;
let da = tiny.data_area().expect("tiny data area").0;
assert!(da.height < MIN);
}
#[test]
fn interactions_from_a_valid_layout_never_lose_the_data_area() {
use crate::layout::{Element, FitStrategy};
let mut seed: u64 = 0x9E37_79B9_7F4A_7C15;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f32 / (1u64 << 53) as f32
};
let sides = [Side::Top, Side::Bottom, Side::Left, Side::Right];
let mut checked = 0usize;
for _ in 0..1500 {
let mut cfg = default_config();
cfg.chart_area = ChartArea(Rect {
x: (next() * 5000.0) as u32,
y: (next() * 5000.0) as u32,
width: 40 + (next() * 4000.0) as u32,
height: 40 + (next() * 4000.0) as u32,
});
cfg.chart_title.top_margin = next() * 120.0;
for side in &sides {
super::axis_mut(&mut cfg, side).out_margin = next() * 90.0;
}
if next() < 0.5 {
let mut bar = default_colorbar_options();
bar.side = sides[(next() * 4.0) as usize % 4].clone();
cfg.colorbar = Some(bar);
}
if cfg.validate().is_err() {
continue;
}
for _ in 0..150 {
let side = sides[(next() * 4.0) as usize % 4].clone();
let (dx, dy) = ((next() - 0.5) * 120.0, (next() - 0.5) * 120.0);
let before = cfg.clone();
match (next() * 6.0) as u32 {
0 | 1 => {
let _ = cfg.nudge(Element::DataAreaEdge(side), dx, dy);
}
2 => {
let _ = cfg.nudge(Element::DataArea, dx, dy);
}
3 => {
let _ = cfg.set_side_margin(side, next() * 300.0);
}
4 => {
let ca = cfg.chart_area.0;
let _ = cfg.resize_chart_area_scaled(ChartArea(Rect {
x: ca.x,
y: ca.y,
width: 20 + (next() * 4000.0) as u32,
height: 20 + (next() * 4000.0) as u32,
}));
}
_ => {
let m = cfg.margins();
let _ = cfg.set_margins(
super::Margins {
top: m.top + dy,
..m
},
FitStrategy::ProportionalScale,
);
}
}
assert!(
cfg.validate().is_ok(),
"an interaction left an invalid layout: {:?} -> {:?}",
before.margins(),
cfg.margins()
);
let scale = 0.5 + next() * 3.5;
let shown = cfg.scaled(scale);
match shown.data_area() {
Ok(_)
| Err(LayoutError::OverflowHorizontal { .. })
| Err(LayoutError::OverflowVertical { .. })
| Err(LayoutError::EmptyDataArea) => {}
Err(e) => panic!("display scale {scale} hit a new rejection: {e:?}"),
}
checked += 1;
}
}
assert!(checked > 100_000, "only {checked} steps exercised");
}
}