pub fn nice_ticks(min: f64, max: f64, target_count: u32) -> Vec<(f64, String)> {
if target_count == 0 {
return Vec::new();
}
let (lo, hi) = if min <= max { (min, max) } else { (max, min) };
if (hi - lo).abs() < f64::EPSILON {
let label = format_tick(lo, 0);
return vec![(lo, label)];
}
let rough_step = (hi - lo) / target_count as f64;
let magnitude = 10_f64.powf(rough_step.abs().log10().floor());
let fraction = rough_step / magnitude;
let nice_fraction: f64 = if fraction <= 1.5 {
1.0
} else if fraction <= 3.0 {
2.0
} else if fraction <= 7.0 {
5.0
} else {
10.0
};
let nice_step = nice_fraction * magnitude;
let decimals = (-nice_step.abs().log10().floor()).max(0.0) as usize;
let first = (lo / nice_step).ceil() * nice_step;
let mut ticks = Vec::new();
let mut t = first;
while t <= hi + nice_step * 1e-9 {
let label = format_tick(t, decimals);
ticks.push((t, label));
t += nice_step;
}
ticks
}
fn format_tick(value: f64, decimals: usize) -> String {
let v = if value.abs() < 1e-10 { 0.0 } else { value };
format!("{:.prec$}", v, prec = decimals)
}
#[cfg(test)]
mod tests {
use super::*;
fn values(ticks: &[(f64, String)]) -> Vec<f64> {
ticks.iter().map(|(v, _)| *v).collect()
}
fn labels(ticks: &[(f64, String)]) -> Vec<&str> {
ticks.iter().map(|(_, l)| l.as_str()).collect()
}
#[test]
fn symmetric_integer_range() {
let t = nice_ticks(-10.0, 10.0, 5);
let v = values(&t);
assert!(v.contains(&-10.0), "missing -10: {v:?}");
assert!(v.contains(&-5.0), "missing -5: {v:?}");
assert!(v.contains(&0.0), "missing 0: {v:?}");
assert!(v.contains(&5.0), "missing 5: {v:?}");
assert!(v.contains(&10.0), "missing 10: {v:?}");
for (_, lbl) in &t {
assert!(!lbl.contains('.'), "expected integer labels, got {lbl}");
}
}
#[test]
fn unit_range_five_ticks() {
let t = nice_ticks(0.0, 1.0, 5);
let v = values(&t);
assert!(
v.iter().any(|x| (x - 0.0).abs() < 1e-9),
"missing 0.0: {v:?}"
);
assert!(
v.iter().any(|x| (x - 0.2).abs() < 1e-9),
"missing 0.2: {v:?}"
);
assert!(
v.iter().any(|x| (x - 1.0).abs() < 1e-9),
"missing 1.0: {v:?}"
);
for (_, lbl) in &t {
assert!(lbl.contains('.'), "expected decimal labels, got {lbl}");
}
}
#[test]
fn asymmetric_range_picks_nice_step() {
let t = nice_ticks(-3.7, 4.2, 5);
let v = values(&t);
assert!(
v.iter().any(|x| (*x - (-2.0)).abs() < 1e-9),
"missing -2: {v:?}"
);
assert!(v.iter().any(|x| (x - 0.0).abs() < 1e-9), "missing 0: {v:?}");
assert!(v.iter().any(|x| (x - 4.0).abs() < 1e-9), "missing 4: {v:?}");
for (_, lbl) in &t {
assert!(!lbl.contains('.'), "expected integer labels, got {lbl}");
}
}
#[test]
fn zero_count_returns_empty() {
assert!(nice_ticks(0.0, 10.0, 0).is_empty());
}
#[test]
fn degenerate_range_returns_single_tick() {
let t = nice_ticks(5.0, 5.0, 5);
assert_eq!(t.len(), 1);
assert!((t[0].0 - 5.0).abs() < 1e-9);
}
#[test]
fn labels_match_values() {
for t in nice_ticks(-10.0, 10.0, 4) {
let parsed: f64 = t.1.parse().expect("label should be parseable as f64");
assert!(
(parsed - t.0).abs() < 1e-9,
"label mismatch: {} vs {}",
t.1,
t.0
);
}
}
#[test]
fn ticks_are_sorted_ascending() {
let t = nice_ticks(-100.0, 100.0, 8);
let v = values(&t);
for w in v.windows(2) {
assert!(w[0] < w[1], "ticks not sorted: {v:?}");
}
}
}