#[must_use]
#[allow(
dead_code,
reason = "default-ticks shorthand wrapping `nice_y_ticks_capped(_, 6)`; kept for callers that want the legacy default and for the doc cross-link from the `capped` variant"
)]
pub fn nice_y_ticks(max: f64) -> Vec<f64> {
nice_y_ticks_capped(max, 6)
}
#[must_use]
pub fn nice_y_ticks_capped(max: f64, max_ticks: usize) -> Vec<f64> {
if max <= 0.0 {
return vec![0.0, 1.0];
}
let max_ticks = max_ticks.max(2);
#[allow(
clippy::cast_precision_loss,
reason = "max_ticks is single-digit; exact as f64"
)]
let target_steps = (max_ticks - 1).max(1) as f64;
let mut nice = pick_nice_step(max, target_steps);
let mut ticks = build_ticks(max, nice);
while ticks.len() > max_ticks {
nice = next_step_on_ladder(nice);
ticks = build_ticks(max, nice);
if ticks.len() <= 2 {
break;
}
}
ticks
}
fn pick_nice_step(max: f64, target_steps: f64) -> f64 {
let raw_step = max / target_steps;
let magnitude = 10f64.powi(raw_step.log10().floor() as i32);
let normalized = raw_step / magnitude;
let nice = if normalized < 1.5 {
1.0
} else if normalized < 3.5 {
2.0
} else if normalized < 7.5 {
5.0
} else {
10.0
};
nice * magnitude
}
fn next_step_on_ladder(step: f64) -> f64 {
let magnitude = 10f64.powi(step.log10().floor() as i32);
let normalized = step / magnitude;
if normalized < 1.5 {
2.0 * magnitude
} else if normalized < 3.5 {
5.0 * magnitude
} else if normalized < 7.5 {
10.0 * magnitude
} else {
20.0 * magnitude
}
}
fn build_ticks(max: f64, step: f64) -> Vec<f64> {
let magnitude = 10f64.powi(step.log10().floor() as i32);
let mut ticks = Vec::new();
let mut v = 0.0;
loop {
ticks.push(round_to(v, magnitude / 10.0));
if v >= max {
break;
}
v += step;
}
ticks
}
fn round_to(v: f64, step: f64) -> f64 {
if step <= 0.0 {
return v;
}
(v / step).round() * step
}
pub const Y_HEADROOM: f64 = 1.10;
#[must_use]
pub fn pad_y_max(raw_max: f64) -> f64 {
if raw_max <= 0.0 {
raw_max
} else {
raw_max * Y_HEADROOM
}
}
#[must_use]
pub fn select_x_indices(n_labels: usize, max_shown: usize) -> Vec<usize> {
if n_labels == 0 {
return Vec::new();
}
if n_labels == 1 {
return vec![0];
}
if max_shown <= 1 {
return vec![0, n_labels - 1];
}
if n_labels <= max_shown {
return (0..n_labels).collect();
}
let stride = ((n_labels - 1) as f64 / (max_shown - 1) as f64).ceil() as usize;
let stride = stride.max(1);
let mut out: Vec<usize> = (0..n_labels).step_by(stride).collect();
if *out.last().unwrap_or(&0) != n_labels - 1 {
out.push(n_labels - 1);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nice_y_ticks_picks_round_numbers_for_small_max() {
let ticks = nice_y_ticks(10.0);
assert_eq!(ticks, vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]);
}
#[test]
fn nice_y_ticks_picks_round_numbers_for_medium_max() {
let ticks = nice_y_ticks(87.0);
assert_eq!(ticks, vec![0.0, 20.0, 40.0, 60.0, 80.0, 100.0]);
}
#[test]
fn nice_y_ticks_handles_zero_max() {
assert_eq!(nice_y_ticks(0.0), vec![0.0, 1.0]);
}
#[test]
fn nice_y_ticks_handles_fractional_max() {
let ticks = nice_y_ticks(0.45);
assert!(ticks.first().is_some_and(|&t| t == 0.0));
assert!(ticks.last().is_some_and(|&t| t >= 0.45));
}
#[test]
fn nice_y_ticks_capped_respects_max_ticks() {
let ticks = nice_y_ticks_capped(0.4, 3);
assert!(ticks.len() <= 3, "got {ticks:?}");
assert_eq!(ticks.first(), Some(&0.0));
assert!(ticks.last().is_some_and(|&t| t >= 0.4));
}
#[test]
fn nice_y_ticks_capped_no_regression_on_big_chart() {
let big = nice_y_ticks_capped(100.0, 6);
assert_eq!(big, vec![0.0, 20.0, 40.0, 60.0, 80.0, 100.0]);
}
#[test]
fn nice_y_ticks_capped_tiny_max_doesnt_overflow_cap() {
let ticks = nice_y_ticks_capped(0.15, 4);
assert!(
ticks.len() <= 4,
"expected at most 4 ticks, got {} ({ticks:?})",
ticks.len()
);
assert!(ticks.last().is_some_and(|&t| t >= 0.15));
}
#[test]
fn select_x_indices_keeps_endpoints() {
let out = select_x_indices(30, 6);
assert_eq!(out.first(), Some(&0));
assert_eq!(out.last(), Some(&29));
}
#[test]
fn select_x_indices_caps_count() {
let out = select_x_indices(30, 6);
assert!(out.len() <= 7, "got {} indices for max_shown=6", out.len());
}
#[test]
fn select_x_indices_returns_all_when_under_cap() {
assert_eq!(select_x_indices(5, 10), vec![0, 1, 2, 3, 4]);
}
#[test]
fn select_x_indices_handles_single_label() {
assert_eq!(select_x_indices(1, 6), vec![0]);
}
#[test]
fn select_x_indices_handles_empty() {
assert_eq!(select_x_indices(0, 6), Vec::<usize>::new());
}
#[test]
fn pad_y_max_adds_headroom_above_data() {
let padded = pad_y_max(140.0);
assert!(padded >= 140.0 * 1.10 - 1e-9);
let ticks = nice_y_ticks(padded);
assert!(
ticks.last().is_some_and(|&t| t > 140.0),
"top tick {:?} should exceed raw max of 140",
ticks.last()
);
}
#[test]
fn pad_y_max_preserves_zero() {
assert_eq!(pad_y_max(0.0), 0.0);
assert_eq!(pad_y_max(-3.0), -3.0);
}
}