use uzor::render::{RenderContext, TextAlign, TextBaseline};
use crate::coord::PlotArea;
use crate::mark::text::draw_label_right_aligned;
use crate::scale::time::TickMarkWeight;
use crate::scale::{NumberFormat, Scale, Tick, TickPriority};
use crate::theme::FigureTheme;
const TICK_LENGTH: f64 = 4.0;
const LABEL_GAP: f64 = 4.0;
#[derive(Debug, Clone, PartialEq)]
pub struct AxisTickWeightStyle {
pub major_tick_length: f64,
pub medium_tick_length: f64,
pub minor_tick_length: f64,
pub major_stroke_width: f64,
pub medium_stroke_width: f64,
pub minor_stroke_width: f64,
pub major_label_color: Option<String>,
}
impl Default for AxisTickWeightStyle {
fn default() -> Self {
Self {
major_tick_length: TICK_LENGTH * 2.0,
medium_tick_length: TICK_LENGTH * 1.5,
minor_tick_length: TICK_LENGTH,
major_stroke_width: 1.75,
medium_stroke_width: 1.25,
minor_stroke_width: 1.0,
major_label_color: None,
}
}
}
impl AxisTickWeightStyle {
pub fn flat() -> Self {
Self {
major_tick_length: TICK_LENGTH,
medium_tick_length: TICK_LENGTH,
minor_tick_length: TICK_LENGTH,
major_stroke_width: 1.0,
medium_stroke_width: 1.0,
minor_stroke_width: 1.0,
major_label_color: None,
}
}
}
pub(crate) fn resolve_tick_style(weight: Option<TickMarkWeight>, style: &AxisTickWeightStyle) -> (f64, f64) {
match weight {
Some(w) if w.is_major() => (style.major_tick_length, style.major_stroke_width),
Some(w) if w.is_medium() => (style.medium_tick_length, style.medium_stroke_width),
_ => (style.minor_tick_length, style.minor_stroke_width),
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct LabelFootprint {
pub center: f64,
pub half_extent: f64,
pub priority: TickPriority,
}
fn footprints_collide(a: &LabelFootprint, b: &LabelFootprint, gap: f64) -> bool {
(a.center - b.center).abs() < a.half_extent + b.half_extent + gap
}
fn accept_if_clear(footprints: &[LabelFootprint], accepted: &mut Vec<LabelFootprint>, visible: &mut [bool], idx: usize, gap: f64) {
let candidate = footprints[idx];
if accepted.iter().any(|a| footprints_collide(a, &candidate, gap)) {
return;
}
visible[idx] = true;
accepted.push(candidate);
}
pub(crate) fn resolve_label_priority(footprints: &[LabelFootprint], gap: f64) -> Vec<bool> {
let n = footprints.len();
let mut visible = vec![false; n];
let mut accepted: Vec<LabelFootprint> = Vec::with_capacity(n);
for (i, f) in footprints.iter().enumerate() {
if f.priority == TickPriority::Critical {
accept_if_clear(footprints, &mut accepted, &mut visible, i, gap);
}
}
let major_indices: Vec<usize> =
footprints.iter().enumerate().filter(|(_, f)| f.priority == TickPriority::Major).map(|(i, _)| i).collect();
if let (Some(&first), Some(&last)) = (major_indices.first(), major_indices.last()) {
accept_if_clear(footprints, &mut accepted, &mut visible, first, gap);
if last != first {
accept_if_clear(footprints, &mut accepted, &mut visible, last, gap);
}
}
for &i in &major_indices {
if !visible[i] {
accept_if_clear(footprints, &mut accepted, &mut visible, i, gap);
}
}
for (i, f) in footprints.iter().enumerate() {
if f.priority == TickPriority::Minor {
accept_if_clear(footprints, &mut accepted, &mut visible, i, gap);
}
}
visible
}
pub fn measure_y_axis_gutter(ctx: &mut dyn RenderContext, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize) -> f64 {
let ticks = scale.ticks(target_ticks);
if ticks.is_empty() {
return 0.0;
}
ctx.set_font(&theme.label_font);
let widest = ticks.iter().map(|t| ctx.measure_text(&t.label)).fold(0.0_f64, f64::max);
TICK_LENGTH + LABEL_GAP + widest
}
pub fn rotated_label_extent(width: f64, height: f64, degrees: f64) -> (f64, f64) {
let (s, c) = degrees.to_radians().sin_cos();
((width * c).abs() + (height * s).abs(), (width * s).abs() + (height * c).abs())
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum LabelOverflow {
#[default]
Skip,
Rotate(f64),
Auto,
}
pub const AUTO_ROTATE_DEGREES: f64 = 45.0;
pub const AUTO_ROTATE_DROP_THRESHOLD: f64 = 0.5;
pub fn measure_rotated_x_axis_gutter(ctx: &mut dyn RenderContext, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize, degrees: f64) -> f64 {
let ticks = scale.ticks(target_ticks);
if ticks.is_empty() {
return 0.0;
}
ctx.set_font(&theme.label_font);
let widest_w = ticks.iter().map(|t| ctx.measure_text(&t.label)).fold(0.0_f64, f64::max);
let row_h = ticks.iter().map(|t| ctx.text_bounds(&t.label, &theme.label_font).h).fold(0.0_f64, f64::max).max(1.0);
let (_, rotated_h) = rotated_label_extent(widest_w, row_h, degrees);
TICK_LENGTH + LABEL_GAP + rotated_h + LABEL_GAP
}
pub fn measure_x_axis_extreme_overhang(ctx: &mut dyn RenderContext, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize) -> (f64, f64) {
let ticks = scale.ticks(target_ticks);
if ticks.is_empty() {
return (0.0, 0.0);
}
ctx.set_font(&theme.label_font);
let left_overhang = ctx.measure_text(&ticks[0].label) / 2.0;
let right_overhang = ctx.measure_text(&ticks[ticks.len() - 1].label) / 2.0;
(left_overhang, right_overhang)
}
fn greedy_skip_drop_fraction(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, ticks: &[Tick]) -> f64 {
if ticks.is_empty() {
return 0.0;
}
let mut drawn = 0usize;
let mut last_label_right = f64::MIN;
for tick in ticks {
let x = area.x(scale, tick.value);
let half_w = ctx.measure_text(&tick.label) / 2.0;
if x - half_w < last_label_right + LABEL_GAP {
continue;
}
drawn += 1;
last_label_right = x + half_w;
}
1.0 - (drawn as f64 / ticks.len() as f64)
}
pub fn draw_x_axis_overflow(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize, overflow: LabelOverflow) {
let ticks = scale.ticks(target_ticks);
if ticks.is_empty() {
return;
}
let axis_y = area.rect.bottom();
ctx.set_stroke_color(&theme.axis_color);
ctx.set_stroke_width(1.0);
ctx.set_line_dash(&[]);
ctx.begin_path();
ctx.move_to(area.rect.x, axis_y);
ctx.line_to(area.rect.right(), axis_y);
ctx.stroke();
ctx.set_font(&theme.label_font);
let rotate_degrees = match overflow {
LabelOverflow::Skip => None,
LabelOverflow::Rotate(degrees) => Some(degrees),
LabelOverflow::Auto => {
if greedy_skip_drop_fraction(ctx, area, scale, &ticks) > AUTO_ROTATE_DROP_THRESHOLD {
Some(AUTO_ROTATE_DEGREES)
} else {
None
}
}
};
match rotate_degrees {
None => {
ctx.set_text_align(TextAlign::Center);
ctx.set_text_baseline(TextBaseline::Top);
let footprints: Vec<LabelFootprint> = ticks
.iter()
.map(|t| LabelFootprint { center: area.x(scale, t.value), half_extent: ctx.measure_text(&t.label) / 2.0, priority: scale.tick_priority(t.value) })
.collect();
let visible = resolve_label_priority(&footprints, LABEL_GAP);
for (i, tick) in ticks.iter().enumerate() {
let x = area.x(scale, tick.value);
ctx.set_stroke_color(&theme.axis_color);
ctx.begin_path();
ctx.move_to(x, axis_y);
ctx.line_to(x, axis_y + TICK_LENGTH);
ctx.stroke();
if !visible[i] {
continue;
}
ctx.set_fill_color(&theme.label_color);
ctx.fill_text(&tick.label, x, axis_y + TICK_LENGTH + LABEL_GAP);
}
}
Some(degrees) => {
ctx.set_text_align(TextAlign::Right);
ctx.set_text_baseline(TextBaseline::Top);
for tick in &ticks {
let x = area.x(scale, tick.value);
ctx.set_stroke_color(&theme.axis_color);
ctx.begin_path();
ctx.move_to(x, axis_y);
ctx.line_to(x, axis_y + TICK_LENGTH);
ctx.stroke();
ctx.save();
ctx.translate(x, axis_y + TICK_LENGTH + LABEL_GAP);
ctx.rotate(-degrees.to_radians());
ctx.set_fill_color(&theme.label_color);
ctx.fill_text(&tick.label, 0.0, 0.0);
ctx.restore();
}
}
}
}
fn ticks_step(ticks: &[Tick]) -> f64 {
if ticks.len() >= 2 {
(ticks[1].value - ticks[0].value).abs().max(f64::EPSILON)
} else {
1.0
}
}
pub fn draw_x_axis(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize) {
draw_x_axis_impl(ctx, area, scale, theme, target_ticks, None, None);
}
pub fn draw_x_axis_formatted(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize, format: NumberFormat) {
draw_x_axis_impl(ctx, area, scale, theme, target_ticks, Some(format), None);
}
pub fn draw_x_axis_weighted(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize, style: &AxisTickWeightStyle) {
draw_x_axis_impl(ctx, area, scale, theme, target_ticks, None, Some(style));
}
fn draw_x_axis_impl(
ctx: &mut dyn RenderContext,
area: &PlotArea,
scale: &dyn Scale,
theme: &FigureTheme,
target_ticks: usize,
format: Option<NumberFormat>,
weight_style: Option<&AxisTickWeightStyle>,
) {
let ticks = scale.ticks(target_ticks);
if ticks.is_empty() {
return;
}
let step = ticks_step(&ticks);
let axis_y = area.rect.bottom();
ctx.set_stroke_color(&theme.axis_color);
ctx.set_stroke_width(1.0);
ctx.set_line_dash(&[]);
ctx.begin_path();
ctx.move_to(area.rect.x, axis_y);
ctx.line_to(area.rect.right(), axis_y);
ctx.stroke();
ctx.set_font(&theme.label_font);
ctx.set_text_align(TextAlign::Center);
ctx.set_text_baseline(TextBaseline::Top);
let labels: Vec<String> = ticks.iter().map(|t| match format { Some(f) => f.format(t.value, step), None => t.label.clone() }).collect();
let footprints: Vec<LabelFootprint> = ticks
.iter()
.zip(labels.iter())
.map(|(t, label)| LabelFootprint { center: area.x(scale, t.value), half_extent: ctx.measure_text(label) / 2.0, priority: scale.tick_priority(t.value) })
.collect();
let visible = resolve_label_priority(&footprints, LABEL_GAP);
for (i, tick) in ticks.iter().enumerate() {
let x = area.x(scale, tick.value);
let (tick_len, major_label_color) = match weight_style {
Some(style) => {
let weight = scale.tick_weight(tick.value);
let (len, stroke_w) = resolve_tick_style(weight, style);
ctx.set_stroke_color(&theme.axis_color);
ctx.set_stroke_width(stroke_w);
let major_color = weight.filter(|w| w.is_major()).and_then(|_| style.major_label_color.as_deref());
(len, major_color)
}
None => {
ctx.set_stroke_color(&theme.axis_color);
(TICK_LENGTH, None)
}
};
ctx.begin_path();
ctx.move_to(x, axis_y);
ctx.line_to(x, axis_y + tick_len);
ctx.stroke();
if !visible[i] {
continue; }
ctx.set_fill_color(major_label_color.unwrap_or(&theme.label_color));
ctx.fill_text(&labels[i], x, axis_y + tick_len + LABEL_GAP);
}
}
pub fn draw_y_axis(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize) {
draw_y_axis_impl(ctx, area, scale, theme, target_ticks, None, None);
}
pub fn draw_y_axis_formatted(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize, format: NumberFormat) {
draw_y_axis_impl(ctx, area, scale, theme, target_ticks, Some(format), None);
}
pub fn draw_y_axis_weighted(ctx: &mut dyn RenderContext, area: &PlotArea, scale: &dyn Scale, theme: &FigureTheme, target_ticks: usize, style: &AxisTickWeightStyle) {
draw_y_axis_impl(ctx, area, scale, theme, target_ticks, None, Some(style));
}
fn draw_y_axis_impl(
ctx: &mut dyn RenderContext,
area: &PlotArea,
scale: &dyn Scale,
theme: &FigureTheme,
target_ticks: usize,
format: Option<NumberFormat>,
weight_style: Option<&AxisTickWeightStyle>,
) {
let ticks = scale.ticks(target_ticks);
if ticks.is_empty() {
return;
}
let step = ticks_step(&ticks);
let axis_x = area.rect.x;
ctx.set_stroke_color(&theme.axis_color);
ctx.set_stroke_width(1.0);
ctx.set_line_dash(&[]);
ctx.begin_path();
ctx.move_to(axis_x, area.rect.y);
ctx.line_to(axis_x, area.rect.bottom());
ctx.stroke();
let labels: Vec<String> = ticks.iter().map(|t| match format { Some(f) => f.format(t.value, step), None => t.label.clone() }).collect();
let row_height = labels
.iter()
.map(|label| ctx.text_bounds(label, &theme.label_font).h)
.fold(0.0_f64, f64::max)
.max(1.0);
let footprints: Vec<LabelFootprint> =
ticks.iter().map(|t| LabelFootprint { center: area.y(scale, t.value), half_extent: row_height / 2.0, priority: scale.tick_priority(t.value) }).collect();
let visible = resolve_label_priority(&footprints, LABEL_GAP);
for (i, (tick, label)) in ticks.iter().zip(labels.iter()).enumerate() {
let y = area.y(scale, tick.value);
let (tick_len, major_label_color) = match weight_style {
Some(style) => {
let weight = scale.tick_weight(tick.value);
let (len, stroke_w) = resolve_tick_style(weight, style);
ctx.set_stroke_color(&theme.axis_color);
ctx.set_stroke_width(stroke_w);
let major_color = weight.filter(|w| w.is_major()).and_then(|_| style.major_label_color.as_deref());
(len, major_color)
}
None => {
ctx.set_stroke_color(&theme.axis_color);
(TICK_LENGTH, None)
}
};
ctx.begin_path();
ctx.move_to(axis_x - tick_len, y);
ctx.line_to(axis_x, y);
ctx.stroke();
if !visible[i] {
continue; }
draw_label_right_aligned(ctx, label, axis_x - tick_len - LABEL_GAP, y, major_label_color.unwrap_or(&theme.label_color), &theme.label_font);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scale::LinearScale;
use crate::theme::FigureTheme;
use uzor::types::Rect;
use uzor_export::{render_to_png, ExportSpec};
fn area() -> PlotArea {
PlotArea::new(Rect::new(40.0, 10.0, 300.0, 150.0))
}
#[test]
fn ticks_step_derives_from_the_first_two_ticks() {
let ticks = vec![
Tick { value: 0.0, label: "0".to_owned() },
Tick { value: 10.0, label: "10".to_owned() },
Tick { value: 20.0, label: "20".to_owned() },
];
assert_eq!(ticks_step(&ticks), 10.0);
}
#[test]
fn ticks_step_falls_back_to_one_for_a_single_tick() {
let ticks = vec![Tick { value: 5.0, label: "5".to_owned() }];
assert_eq!(ticks_step(&ticks), 1.0);
}
#[test]
fn draw_x_axis_formatted_renders_without_panicking_for_every_variant() {
let theme = FigureTheme::dark();
let scale = LinearScale::new(0.0, 5000.0);
let spec = ExportSpec { width_px: 400, height_px: 200, dpr: 1.0, background: None };
for format in [NumberFormat::Plain, NumberFormat::Thousands, NumberFormat::Si, NumberFormat::Percent, NumberFormat::Currency("$")] {
let result = render_to_png(&spec, |ctx| {
draw_x_axis_formatted(ctx, &area(), &scale, &theme, 5, format);
});
assert!(result.is_ok(), "draw_x_axis_formatted must render cleanly for {format:?}");
}
}
#[test]
fn draw_y_axis_formatted_renders_without_panicking_for_every_variant() {
let theme = FigureTheme::dark();
let scale = LinearScale::new(-500.0, 500.0);
let spec = ExportSpec { width_px: 400, height_px: 200, dpr: 1.0, background: None };
for format in [NumberFormat::Plain, NumberFormat::Thousands, NumberFormat::Si, NumberFormat::Percent, NumberFormat::Currency("$")] {
let result = render_to_png(&spec, |ctx| {
draw_y_axis_formatted(ctx, &area(), &scale, &theme, 5, format);
});
assert!(result.is_ok(), "draw_y_axis_formatted must render cleanly for {format:?}");
}
}
#[test]
fn formatted_with_thousands_default_is_the_same_shape_as_unformatted() {
let theme = FigureTheme::dark();
let scale = LinearScale::new(0.0, 1_000_000.0);
let spec = ExportSpec { width_px: 400, height_px: 200, dpr: 1.0, background: None };
let unformatted = render_to_png(&spec, |ctx| draw_x_axis(ctx, &area(), &scale, &theme, 5));
let formatted = render_to_png(&spec, |ctx| draw_x_axis_formatted(ctx, &area(), &scale, &theme, 5, NumberFormat::default()));
assert!(unformatted.is_ok() && formatted.is_ok());
}
#[test]
fn resolve_tick_style_distinguishes_every_weight_tier() {
let style = AxisTickWeightStyle::default();
let major = resolve_tick_style(Some(TickMarkWeight::Year), &style);
let medium = resolve_tick_style(Some(TickMarkWeight::Day), &style);
let minor = resolve_tick_style(Some(TickMarkWeight::Minute1), &style);
let none = resolve_tick_style(None, &style);
assert_ne!(major, medium, "a major tick must resolve differently from a medium one");
assert_ne!(medium, minor, "a medium tick must resolve differently from a minor one");
assert_eq!(minor, none, "no weight (every non-TimeScale scale) resolves the SAME as an explicit minor tick");
assert!(major.0 > medium.0 && medium.0 > minor.0, "tick length must strictly increase major > medium > minor");
assert!(major.1 > medium.1 && medium.1 > minor.1, "stroke width must strictly increase major > medium > minor");
}
#[test]
fn flat_style_matches_minor_for_every_weight_tier() {
let flat = AxisTickWeightStyle::flat();
let major = resolve_tick_style(Some(TickMarkWeight::Year), &flat);
let minor = resolve_tick_style(None, &flat);
assert_eq!(major, minor, "AxisTickWeightStyle::flat must draw every tick identically regardless of weight");
assert_eq!(major, (TICK_LENGTH, 1.0), "flat must match the unweighted draw's own fixed tick length/stroke width");
}
#[test]
fn weighted_draw_over_a_non_time_scale_renders_byte_identical_to_the_unweighted_draw() {
let theme = FigureTheme::dark();
let scale = LinearScale::new(0.0, 1_000.0);
let spec = ExportSpec { width_px: 400, height_px: 200, dpr: 1.0, background: None };
let unweighted = render_to_png(&spec, |ctx| draw_x_axis(ctx, &area(), &scale, &theme, 6)).expect("unweighted x-axis render");
let weighted = render_to_png(&spec, |ctx| draw_x_axis_weighted(ctx, &area(), &scale, &theme, 6, &AxisTickWeightStyle::default()))
.expect("weighted x-axis render over a non-TimeScale scale");
assert_eq!(unweighted, weighted, "a non-TimeScale x-axis must render identically through the weighted entry point");
let unweighted_y = render_to_png(&spec, |ctx| draw_y_axis(ctx, &area(), &scale, &theme, 6)).expect("unweighted y-axis render");
let weighted_y = render_to_png(&spec, |ctx| draw_y_axis_weighted(ctx, &area(), &scale, &theme, 6, &AxisTickWeightStyle::default()))
.expect("weighted y-axis render over a non-TimeScale scale");
assert_eq!(unweighted_y, weighted_y, "a non-TimeScale y-axis must render identically through the weighted entry point");
}
#[test]
fn a_month_spanning_time_axis_resolves_distinguishable_major_and_minor_tick_styles() {
use crate::scale::TimeScale;
let jan1_2024 = 1_704_067_200.0; const DAY_SECS: f64 = 86_400.0;
let scale = TimeScale::new(jan1_2024, jan1_2024 + 62.0 * DAY_SECS);
let ticks = scale.ticks(6);
assert!(ticks.len() >= 2, "fixture must produce a real multi-tick set");
let style = AxisTickWeightStyle::default();
let resolved: Vec<(f64, f64)> =
ticks.iter().map(|t| resolve_tick_style(scale.tick_weight(t.value), &style)).collect();
let mut unique = resolved.clone();
unique.sort_by(|a, b| a.partial_cmp(b).unwrap());
unique.dedup();
assert!(
unique.len() >= 2,
"a month-spanning time axis must resolve at least 2 distinct tick styles (major vs. minor), got {resolved:?}"
);
let theme = FigureTheme::dark();
let spec = ExportSpec { width_px: 600, height_px: 250, dpr: 1.0, background: None };
let result = render_to_png(&spec, |ctx| draw_x_axis_weighted(ctx, &area(), &scale, &theme, 6, &style));
assert!(result.is_ok());
}
#[test]
fn measure_y_axis_gutter_grows_for_a_deliberately_wide_label() {
let theme = FigureTheme::dark();
let narrow_scale = LinearScale::new(0.0, 9.0);
let wide_scale = LinearScale::new(0.0, 999_999_999.0);
let spec = ExportSpec { width_px: 10, height_px: 10, dpr: 1.0, background: None };
let mut narrow_gutter = 0.0_f64;
let mut wide_gutter = 0.0_f64;
render_to_png(&spec, |ctx| {
narrow_gutter = measure_y_axis_gutter(ctx, &narrow_scale, &theme, 5);
wide_gutter = measure_y_axis_gutter(ctx, &wide_scale, &theme, 5);
})
.expect("render");
assert!(wide_gutter > narrow_gutter, "a wider tick label must measure a larger gutter (narrow={narrow_gutter}, wide={wide_gutter})");
}
#[test]
fn measure_y_axis_gutter_is_zero_for_an_empty_tick_set() {
let theme = FigureTheme::dark();
let band = crate::scale::BandScale::new(Vec::new(), 0.1);
let spec = ExportSpec { width_px: 10, height_px: 10, dpr: 1.0, background: None };
let mut gutter = -1.0_f64;
render_to_png(&spec, |ctx| gutter = measure_y_axis_gutter(ctx, &band, &theme, 5)).expect("render");
assert_eq!(gutter, 0.0);
}
#[test]
fn measure_x_axis_extreme_overhang_is_zero_for_an_empty_tick_set() {
let theme = FigureTheme::dark();
let band = crate::scale::BandScale::new(Vec::new(), 0.1);
let spec = ExportSpec { width_px: 10, height_px: 10, dpr: 1.0, background: None };
let mut overhang = (-1.0_f64, -1.0_f64);
render_to_png(&spec, |ctx| overhang = measure_x_axis_extreme_overhang(ctx, &band, &theme, 5)).expect("render");
assert_eq!(overhang, (0.0, 0.0));
}
#[test]
fn measure_x_axis_extreme_overhang_grows_for_a_deliberately_wide_extreme_label() {
let theme = FigureTheme::dark();
let narrow_scale = LinearScale::new(0.0, 9.0);
let wide_scale = LinearScale::new(-999_999_999.0, 999_999_999.0);
let spec = ExportSpec { width_px: 10, height_px: 10, dpr: 1.0, background: None };
let mut narrow_overhang = (0.0_f64, 0.0_f64);
let mut wide_overhang = (0.0_f64, 0.0_f64);
render_to_png(&spec, |ctx| {
narrow_overhang = measure_x_axis_extreme_overhang(ctx, &narrow_scale, &theme, 5);
wide_overhang = measure_x_axis_extreme_overhang(ctx, &wide_scale, &theme, 5);
})
.expect("render");
assert!(wide_overhang.0 > narrow_overhang.0, "a wider leftmost tick label must measure a larger left overhang");
assert!(wide_overhang.1 > narrow_overhang.1, "a wider rightmost tick label must measure a larger right overhang");
}
#[test]
fn measure_x_axis_extreme_overhang_is_half_the_extreme_labels_own_measured_width() {
let theme = FigureTheme::dark();
let scale = crate::scale::SymlogScale::new(-1_000_000.0, 1_000_000.0);
let spec = ExportSpec { width_px: 10, height_px: 10, dpr: 1.0, background: None };
let mut overhang = (0.0_f64, 0.0_f64);
let mut first_label_w = 0.0_f64;
let mut last_label_w = 0.0_f64;
render_to_png(&spec, |ctx| {
ctx.set_font(&theme.label_font);
let ticks = scale.ticks(6);
first_label_w = ctx.measure_text(&ticks[0].label);
last_label_w = ctx.measure_text(&ticks[ticks.len() - 1].label);
overhang = measure_x_axis_extreme_overhang(ctx, &scale, &theme, 6);
})
.expect("render");
assert!((overhang.0 - first_label_w / 2.0).abs() < 1e-6);
assert!((overhang.1 - last_label_w / 2.0).abs() < 1e-6);
}
#[test]
fn rotated_label_extent_at_zero_degrees_is_the_original_box() {
let (w, h) = rotated_label_extent(40.0, 12.0, 0.0);
assert!((w - 40.0).abs() < 1e-9);
assert!((h - 12.0).abs() < 1e-9);
}
#[test]
fn rotated_label_extent_at_ninety_degrees_swaps_width_and_height() {
let (w, h) = rotated_label_extent(40.0, 12.0, 90.0);
assert!((w - 12.0).abs() < 1e-6);
assert!((h - 40.0).abs() < 1e-6);
}
#[test]
fn rotated_label_extent_at_forty_five_degrees_is_narrower_than_the_full_width() {
let (w, _h) = rotated_label_extent(40.0, 12.0, 45.0);
assert!(w < 40.0, "a 45-degree rotation must reduce the label's own horizontal footprint below its unrotated width");
}
#[test]
fn draw_x_axis_overflow_skip_renders_byte_identical_to_draw_x_axis() {
let theme = FigureTheme::dark();
let scale = LinearScale::new(0.0, 1_000.0);
let spec = ExportSpec { width_px: 400, height_px: 200, dpr: 1.0, background: None };
let plain = render_to_png(&spec, |ctx| draw_x_axis(ctx, &area(), &scale, &theme, 6)).expect("plain render");
let overflow = render_to_png(&spec, |ctx| draw_x_axis_overflow(ctx, &area(), &scale, &theme, 6, LabelOverflow::Skip)).expect("overflow render");
assert_eq!(plain, overflow, "LabelOverflow::Skip must render byte-identically to draw_x_axis");
}
#[test]
fn draw_x_axis_overflow_rotate_renders_without_panicking_and_differs_from_skip() {
let theme = FigureTheme::dark();
let categories: Vec<String> = (0..12).map(|i| format!("category-name-{i}")).collect();
let band = crate::scale::BandScale::new(categories, 0.1);
let spec = ExportSpec { width_px: 300, height_px: 200, dpr: 1.0, background: None };
let skip = render_to_png(&spec, |ctx| draw_x_axis_overflow(ctx, &area(), &band, &theme, band.len(), LabelOverflow::Skip)).expect("skip render");
let rotated =
render_to_png(&spec, |ctx| draw_x_axis_overflow(ctx, &area(), &band, &theme, band.len(), LabelOverflow::Rotate(45.0))).expect("rotate render");
assert_ne!(rotated, skip, "a rotated draw must render visibly differently from the plain skip draw");
}
#[test]
fn draw_x_axis_overflow_auto_rotates_when_labels_would_mostly_collide() {
let theme = FigureTheme::dark();
let categories: Vec<String> = (0..12).map(|i| format!("category-name-{i}")).collect();
let band = crate::scale::BandScale::new(categories, 0.1);
let spec = ExportSpec { width_px: 300, height_px: 200, dpr: 1.0, background: None };
let auto = render_to_png(&spec, |ctx| draw_x_axis_overflow(ctx, &area(), &band, &theme, band.len(), LabelOverflow::Auto)).expect("auto render");
let rotated =
render_to_png(&spec, |ctx| draw_x_axis_overflow(ctx, &area(), &band, &theme, band.len(), LabelOverflow::Rotate(AUTO_ROTATE_DEGREES))).expect("rotate render");
assert_eq!(auto, rotated, "Auto must fall back to Rotate(AUTO_ROTATE_DEGREES) when most labels would collide");
}
#[test]
fn draw_x_axis_overflow_auto_matches_skip_when_labels_comfortably_fit() {
let theme = FigureTheme::dark();
let scale = LinearScale::new(0.0, 5.0);
let spec = ExportSpec { width_px: 600, height_px: 200, dpr: 1.0, background: None };
let auto = render_to_png(&spec, |ctx| draw_x_axis_overflow(ctx, &area(), &scale, &theme, 5, LabelOverflow::Auto)).expect("auto render");
let skip = render_to_png(&spec, |ctx| draw_x_axis_overflow(ctx, &area(), &scale, &theme, 5, LabelOverflow::Skip)).expect("skip render");
assert_eq!(auto, skip, "Auto must resolve to the plain skip draw when few, short labels already fit");
}
#[test]
fn measure_rotated_x_axis_gutter_grows_with_the_rotation_angle_up_to_ninety_degrees() {
let theme = FigureTheme::dark();
let categories: Vec<String> = (0..3).map(|i| format!("category-name-{i}")).collect();
let band = crate::scale::BandScale::new(categories, 0.1);
let spec = ExportSpec { width_px: 10, height_px: 10, dpr: 1.0, background: None };
let mut flat = 0.0_f64;
let mut rotated = 0.0_f64;
render_to_png(&spec, |ctx| {
flat = measure_rotated_x_axis_gutter(ctx, &band, &theme, band.len(), 0.0);
rotated = measure_rotated_x_axis_gutter(ctx, &band, &theme, band.len(), 90.0);
})
.expect("render");
assert!(rotated > flat, "a 90-degree rotated gutter must reserve more height than an unrotated (0-degree) one");
}
#[test]
fn measure_rotated_x_axis_gutter_at_forty_five_degrees_exceeds_the_unrotated_baseline() {
let theme = FigureTheme::dark();
let categories: Vec<String> = (0..8).map(|i| format!("category-{i}")).collect();
let band = crate::scale::BandScale::new(categories, 0.1);
let spec = ExportSpec { width_px: 10, height_px: 10, dpr: 1.0, background: None };
let mut unrotated_baseline = 0.0_f64;
let mut rotated_45 = 0.0_f64;
render_to_png(&spec, |ctx| {
unrotated_baseline = TICK_LENGTH + LABEL_GAP;
rotated_45 = measure_rotated_x_axis_gutter(ctx, &band, &theme, band.len(), 45.0);
})
.expect("render");
assert!(
rotated_45 > unrotated_baseline,
"a 45-degree rotated gutter ({rotated_45}) must reserve more height than the flat unrotated baseline ({unrotated_baseline})"
);
}
#[test]
fn priority_skip_with_uniform_priority_matches_the_plain_greedy_skip() {
let gap = 4.0;
let footprints = vec![
LabelFootprint { center: 0.0, half_extent: 5.0, priority: TickPriority::Minor },
LabelFootprint { center: 8.0, half_extent: 5.0, priority: TickPriority::Minor }, LabelFootprint { center: 30.0, half_extent: 3.0, priority: TickPriority::Minor },
LabelFootprint { center: 34.0, half_extent: 3.0, priority: TickPriority::Minor }, LabelFootprint { center: 60.0, half_extent: 8.0, priority: TickPriority::Minor },
];
let visible = resolve_label_priority(&footprints, gap);
let mut expected = vec![false; footprints.len()];
let mut last_right = f64::MIN;
for (i, f) in footprints.iter().enumerate() {
let left = f.center - f.half_extent;
if left < last_right + gap {
continue;
}
expected[i] = true;
last_right = f.center + f.half_extent;
}
assert_eq!(visible, expected, "uniform-priority input must degrade to EXACTLY the original nearest-neighbor-only greedy skip");
}
#[test]
fn majors_colliding_among_themselves_degrade_by_preferring_extremes_and_critical() {
let gap = 4.0;
let footprints = vec![
LabelFootprint { center: 0.0, half_extent: 10.0, priority: TickPriority::Major }, LabelFootprint { center: 15.0, half_extent: 10.0, priority: TickPriority::Major }, LabelFootprint { center: 30.0, half_extent: 10.0, priority: TickPriority::Critical }, LabelFootprint { center: 45.0, half_extent: 10.0, priority: TickPriority::Major }, LabelFootprint { center: 60.0, half_extent: 10.0, priority: TickPriority::Major }, ];
let visible = resolve_label_priority(&footprints, gap);
assert!(visible[2], "the Critical (zero) tick must never be dropped in favor of a Major");
assert!(visible[0], "the leftmost MAJOR extreme must be preferred over an interior major");
assert!(visible[4], "the rightmost MAJOR extreme must be preferred over an interior major");
assert!(!visible[1], "an interior major must be the one dropped once extremes+zero already claim the space");
assert!(!visible[3], "an interior major must be the one dropped once extremes+zero already claim the space");
}
#[test]
fn a_dense_symlog_axis_keeps_the_zero_label_visible() {
use crate::scale::SymlogScale;
let scale = SymlogScale::new(-1_000_000.0, 1_000_000.0);
let theme = FigureTheme::dark();
let a = area();
let spec = ExportSpec { width_px: 400, height_px: 200, dpr: 1.0, background: None };
let mut zero_visible = false;
let mut had_a_collision_to_resolve = false;
render_to_png(&spec, |ctx| {
ctx.set_font(&theme.label_font);
let ticks = scale.ticks(6);
let footprints: Vec<LabelFootprint> = ticks
.iter()
.map(|t| LabelFootprint { center: a.x(&scale, t.value), half_extent: ctx.measure_text(&t.label) / 2.0, priority: scale.tick_priority(t.value) })
.collect();
let visible = resolve_label_priority(&footprints, LABEL_GAP);
had_a_collision_to_resolve = visible.iter().any(|&v| !v);
let zero_idx = ticks.iter().position(|t| t.value.abs() < 1e-9).expect("a domain spanning zero must generate a zero tick");
zero_visible = visible[zero_idx];
})
.expect("render");
assert!(had_a_collision_to_resolve, "fixture must actually be dense enough to force at least one label to drop — otherwise this test proves nothing");
assert!(zero_visible, "the zero-crossing label must survive a dense symlog axis's own label-collision skip");
}
#[test]
fn a_dense_log_axis_keeps_decade_labels_while_dropping_intermediate_subdivisions() {
use crate::scale::LogScale;
let scale = LogScale::new(1.0, 100_000.0);
let theme = FigureTheme::dark();
let a = area();
let spec = ExportSpec { width_px: 400, height_px: 200, dpr: 1.0, background: None };
let mut every_major_visible = true;
let mut any_minor_dropped = false;
render_to_png(&spec, |ctx| {
ctx.set_font(&theme.label_font);
let ticks = scale.ticks(8); let footprints: Vec<LabelFootprint> = ticks
.iter()
.map(|t| LabelFootprint { center: a.x(&scale, t.value), half_extent: ctx.measure_text(&t.label) / 2.0, priority: scale.tick_priority(t.value) })
.collect();
let visible = resolve_label_priority(&footprints, LABEL_GAP);
for (i, t) in ticks.iter().enumerate() {
match scale.tick_priority(t.value) {
TickPriority::Major if !visible[i] => every_major_visible = false,
TickPriority::Minor if !visible[i] => any_minor_dropped = true,
_ => {}
}
}
})
.expect("render");
assert!(any_minor_dropped, "fixture must actually be dense enough to force at least one Minor subdivision to drop — otherwise this test proves nothing");
assert!(every_major_visible, "every decade (Major) label must survive even when the axis is dense enough to drop minor subdivisions");
}
#[test]
fn draw_x_axis_overflow_skip_still_renders_byte_identical_to_draw_x_axis_after_the_priority_fix() {
use crate::scale::SymlogScale;
let theme = FigureTheme::dark();
let scale = SymlogScale::new(-1_000_000.0, 1_000_000.0);
let spec = ExportSpec { width_px: 400, height_px: 200, dpr: 1.0, background: None };
let plain = render_to_png(&spec, |ctx| draw_x_axis(ctx, &area(), &scale, &theme, 6)).expect("plain render");
let overflow = render_to_png(&spec, |ctx| draw_x_axis_overflow(ctx, &area(), &scale, &theme, 6, LabelOverflow::Skip)).expect("overflow render");
assert_eq!(plain, overflow, "LabelOverflow::Skip must render byte-identically to draw_x_axis even for a scale with non-uniform tick priority");
}
}