use crate::axes::ticks::{MAX_TICK_STEPS, clean_float, select_nice_step};
const NICE_NUMBERS: [f64; 4] = [1.0, 2.0, 5.0, 10.0];
const SCIENTIFIC_MANTISSA_DIGITS: usize = 2;
const MAX_LABEL_DIGITS: usize = 17;
fn labels_separate_values(values: &[f64], labels: &[String]) -> bool {
for (index, (&value, label)) in values.iter().zip(labels).enumerate() {
for (&earlier_value, earlier_label) in values.iter().zip(labels).take(index) {
if earlier_label == label && earlier_value != value {
return false;
}
}
}
true
}
#[derive(Debug, Clone)]
pub struct TickFormatter {
pub min_ticks: usize,
pub max_ticks: usize,
pub max_decimals: usize,
pub use_scientific: bool,
pub scientific_threshold: f64,
}
impl Default for TickFormatter {
fn default() -> Self {
Self {
min_ticks: 4,
max_ticks: 9,
max_decimals: 6,
use_scientific: true,
scientific_threshold: 1e4,
}
}
}
impl TickFormatter {
pub fn new() -> Self {
Self::default()
}
pub fn min_ticks(mut self, n: usize) -> Self {
self.min_ticks = n.max(2);
self
}
pub fn max_ticks(mut self, n: usize) -> Self {
self.max_ticks = n.max(self.min_ticks);
self
}
pub fn max_decimals(mut self, n: usize) -> Self {
self.max_decimals = n;
self
}
pub fn use_scientific(mut self, enabled: bool) -> Self {
self.use_scientific = enabled;
self
}
pub fn nice_number(value: f64, round: bool) -> f64 {
if value == 0.0 {
return 0.0;
}
let value = value.abs();
let exponent = value.log10().floor();
let fraction = value / 10.0_f64.powf(exponent);
let nice_fraction = if round {
let frac = fraction + 1e-10;
if frac < 1.5 {
1.0
} else if frac < 3.0 {
2.0
} else if frac < 7.0 {
5.0
} else {
10.0
}
} else {
if fraction <= 1.0 {
1.0
} else if fraction <= 2.0 {
2.0
} else if fraction <= 5.0 {
5.0
} else {
10.0
}
};
nice_fraction * 10.0_f64.powf(exponent)
}
pub fn generate_ticks(&self, min: f64, max: f64) -> Vec<f64> {
if min >= max {
return vec![min];
}
if !min.is_finite() || !max.is_finite() {
return vec![0.0, 1.0];
}
let range = max - min;
if range == 0.0 {
return vec![min];
}
let target_ticks = (self.min_ticks + self.max_ticks) / 2;
let step = select_nice_step(min, max, target_ticks).unwrap_or_else(|| {
let rough_step = range / (target_ticks.max(2) - 1) as f64;
Self::nice_number(rough_step, true)
});
if !step.is_finite() || step <= 0.0 {
return vec![min, max];
}
let first_index = (min / step).floor();
let last_index = (max / step).ceil();
let steps = last_index - first_index;
if !steps.is_finite() || steps < 0.0 || steps > MAX_TICK_STEPS as f64 {
return vec![min, max];
}
let start = first_index * step;
if !start.is_finite() || start + step <= start {
return vec![min, max];
}
let steps = steps.round() as usize;
let mut ticks: Vec<f64> = (0..=steps)
.map(|i| clean_float(start + (i as f64) * step, step))
.filter(|tick| tick.is_finite())
.collect();
ticks.dedup();
if ticks.is_empty() {
return vec![min, max];
}
if ticks.len() > self.max_ticks {
let skip = (ticks.len() as f64 / self.max_ticks as f64).ceil() as usize;
ticks = ticks.into_iter().step_by(skip.max(1)).collect();
}
ticks
}
pub fn format_tick(&self, value: f64) -> String {
if !value.is_finite() {
return value.to_string();
}
let abs_value = value.abs();
if self.use_scientific
&& abs_value != 0.0
&& (abs_value >= self.scientific_threshold
|| abs_value < 1.0 / self.scientific_threshold)
{
return format!("{:.2e}", value);
}
if (value - value.round()).abs() < 1e-9 {
return format!("{:.0}", Self::normalize_zero(value.round()));
}
let formatted = format!("{:.prec$}", value, prec = self.max_decimals);
Self::trim_trailing_zeros(&formatted)
}
pub fn format_ticks(&self, values: &[f64]) -> Vec<String> {
if values.is_empty() {
return Vec::new();
}
let plain_allowed = !self.use_scientific || self.plain_precision_is_sufficient(values);
if plain_allowed {
let labels = self.plain_labels(values, self.max_decimals);
if labels_separate_values(values, &labels) {
return labels;
}
if !self.use_scientific {
return self.plain_labels(values, MAX_LABEL_DIGITS);
}
}
self.scientific_labels(values)
}
fn plain_labels(&self, values: &[f64], max_decimals: usize) -> Vec<String> {
let precision = values
.iter()
.map(|&v| Self::required_precision(v))
.max()
.unwrap_or(0)
.min(max_decimals);
values
.iter()
.map(|&v| {
let v = Self::normalize_zero(v);
if precision == 0 || (v - v.round()).abs() < 1e-9 {
format!("{:.0}", Self::normalize_zero(v.round()))
} else {
let formatted = format!("{:.prec$}", v, prec = precision);
Self::trim_trailing_zeros(&formatted)
}
})
.collect()
}
fn scientific_labels(&self, values: &[f64]) -> Vec<String> {
let render = |mantissa_digits: usize| -> Vec<String> {
values
.iter()
.map(|&v| {
if v == 0.0 {
"0".to_string()
} else {
format!("{:.prec$e}", v, prec = mantissa_digits)
}
})
.collect()
};
for mantissa_digits in SCIENTIFIC_MANTISSA_DIGITS..MAX_LABEL_DIGITS {
let labels = render(mantissa_digits);
if labels_separate_values(values, &labels) {
return labels;
}
}
render(MAX_LABEL_DIGITS)
}
fn plain_precision_is_sufficient(&self, values: &[f64]) -> bool {
const MAX_RELATIVE_LABEL_ERROR: f64 = 1e-3;
let mult = 10.0_f64.powi(self.max_decimals as i32);
if !mult.is_finite() {
return true;
}
values.iter().all(|&value| {
if !value.is_finite() {
return true;
}
let rounded = (value * mult).round() / mult;
(rounded - value).abs() <= value.abs() * MAX_RELATIVE_LABEL_ERROR
})
}
fn normalize_zero(value: f64) -> f64 {
if value == 0.0 { 0.0 } else { value }
}
fn required_precision(value: f64) -> usize {
if !value.is_finite() || (value - value.round()).abs() < 1e-9 {
return 0;
}
for precision in 1..=6 {
let mult = 10.0_f64.powi(precision as i32);
let rounded = (value * mult).round() / mult;
if (value - rounded).abs() < 1e-9 {
return precision;
}
}
6
}
fn trim_trailing_zeros(s: &str) -> String {
if !s.contains('.') {
return s.to_string();
}
let trimmed = s.trim_end_matches('0');
if let Some(stripped) = trimmed.strip_suffix('.') {
stripped.to_string()
} else {
trimmed.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nice_number_round() {
assert!((TickFormatter::nice_number(0.7, true) - 1.0).abs() < 0.001);
assert!((TickFormatter::nice_number(3.2, true) - 5.0).abs() < 0.001);
assert!((TickFormatter::nice_number(2.5, true) - 2.0).abs() < 0.001);
assert!((TickFormatter::nice_number(7.8, true) - 10.0).abs() < 0.001);
assert!((TickFormatter::nice_number(12.0, true) - 10.0).abs() < 0.001);
assert!((TickFormatter::nice_number(25.0, true) - 20.0).abs() < 0.001);
assert!((TickFormatter::nice_number(55.0, true) - 50.0).abs() < 0.001);
}
#[test]
fn test_nice_number_ceil() {
assert!((TickFormatter::nice_number(0.7, false) - 1.0).abs() < 0.001);
assert!((TickFormatter::nice_number(1.5, false) - 2.0).abs() < 0.001);
assert!((TickFormatter::nice_number(3.5, false) - 5.0).abs() < 0.001);
assert!((TickFormatter::nice_number(7.0, false) - 10.0).abs() < 0.001);
}
#[test]
fn test_generate_ticks() {
let formatter = TickFormatter::default();
let ticks = formatter.generate_ticks(0.7, 9.3);
assert!(!ticks.is_empty());
assert!(ticks[0] <= 0.7);
assert!(*ticks.last().unwrap() >= 9.3);
if ticks.len() > 1 {
let step = ticks[1] - ticks[0];
for i in 2..ticks.len() {
let diff = (ticks[i] - ticks[i - 1] - step).abs();
assert!(diff < 0.001, "Ticks not evenly spaced: {:?}", ticks);
}
let step_nice = TickFormatter::nice_number(step, true);
assert!(
(step - step_nice).abs() / step < 0.1,
"Step {} is not nice (expected ~{})",
step,
step_nice
);
}
}
#[test]
fn test_generate_ticks_nice_values() {
let formatter = TickFormatter::default();
let ticks = formatter.generate_ticks(0.7, 9.3);
let has_zero_or_two = ticks.iter().any(|&t| (t - 0.0).abs() < 0.001)
|| ticks.iter().any(|&t| (t - 2.0).abs() < 0.001);
assert!(has_zero_or_two);
}
#[test]
fn test_format_tick_integers() {
let formatter = TickFormatter::default();
assert_eq!(formatter.format_tick(5.0), "5");
assert_eq!(formatter.format_tick(10.0), "10");
assert_eq!(formatter.format_tick(-3.0), "-3");
assert_eq!(formatter.format_tick(0.0), "0");
}
#[test]
fn test_format_tick_decimals() {
let formatter = TickFormatter::default();
assert_eq!(formatter.format_tick(157.0 / 50.0), "3.14");
assert_eq!(formatter.format_tick(2.5), "2.5");
assert_eq!(formatter.format_tick(1.10), "1.1");
assert_eq!(formatter.format_tick(2.500), "2.5");
}
#[test]
fn test_format_tick_scientific() {
let formatter = TickFormatter::default();
let large = formatter.format_tick(1e6);
assert!(large.contains('e'), "Expected scientific notation for 1e6");
let small = formatter.format_tick(1e-6);
assert!(small.contains('e'), "Expected scientific notation for 1e-6");
}
#[test]
fn test_format_ticks_consistent() {
let formatter = TickFormatter::default();
let values = vec![0.0, 0.5, 1.0, 1.5, 2.0];
let labels = formatter.format_ticks(&values);
assert_eq!(labels.len(), 5);
assert_eq!(labels[0], "0");
assert_eq!(labels[2], "1");
assert_eq!(labels[4], "2");
assert_eq!(labels[1], "0.5");
assert_eq!(labels[3], "1.5");
}
#[test]
fn test_format_ticks_never_mixes_notations() {
let formatter = TickFormatter::default();
let labels = formatter.format_ticks(&[0.0, 1.5e-6, 3.0e-6, 4.5e-6]);
let scientific = labels.iter().filter(|l| l.contains('e')).count();
assert_eq!(
scientific, 3,
"expected the whole axis in scientific notation: {labels:?}"
);
assert_eq!(labels[0], "0");
let unique: std::collections::BTreeSet<_> = labels.iter().collect();
assert_eq!(unique.len(), labels.len(), "duplicate labels: {labels:?}");
for values in [
vec![0.0, 200000.0, 400000.0, 600000.0],
vec![0.0, 0.25, 0.5, 0.75, 1.0],
vec![-1e-5, 0.0, 1e-5],
] {
let labels = formatter.format_ticks(&values);
assert!(
labels.iter().all(|l| !l.contains('e')),
"unexpected scientific notation in {labels:?}"
);
}
}
#[test]
fn test_format_never_renders_negative_zero() {
let formatter = TickFormatter::default();
assert_eq!(formatter.format_tick(-0.0), "0");
assert_eq!(formatter.format_ticks(&[-0.0, 0.5])[0], "0");
assert_eq!(formatter.format_ticks(&[-0.0, 1.0])[0], "0");
}
#[test]
fn test_generate_ticks_uses_canonical_step_selection() {
let formatter = TickFormatter::default();
assert_eq!(
formatter.generate_ticks(0.7, 9.3),
vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]
);
assert_eq!(
crate::axes::generate_ticks(0.7, 9.3, 6),
vec![2.0, 4.0, 6.0, 8.0]
);
}
#[test]
fn test_generate_ticks_terminates_at_extreme_magnitudes() {
let formatter = TickFormatter::default();
for (min, max) in [
(1e16, 1e16 + 2.0),
(1e16, 1e16 + 5.0),
(0.0, f64::MAX),
(f64::MIN_POSITIVE, 1.0),
] {
let ticks = formatter.generate_ticks(min, max);
assert!(!ticks.is_empty(), "no ticks for ({min}, {max})");
assert!(
ticks.len() <= 101,
"unbounded tick count for ({min}, {max}): {}",
ticks.len()
);
}
}
#[test]
fn test_edge_cases() {
let formatter = TickFormatter::default();
let ticks = formatter.generate_ticks(5.0, 5.0);
assert_eq!(ticks.len(), 1);
let ticks = formatter.generate_ticks(-10.0, -1.0);
assert!(!ticks.is_empty());
assert!(ticks[0] <= -10.0);
assert!(*ticks.last().unwrap() >= -1.0);
}
#[test]
fn test_trim_trailing_zeros() {
assert_eq!(TickFormatter::trim_trailing_zeros("3.14000"), "3.14");
assert_eq!(TickFormatter::trim_trailing_zeros("5.0"), "5");
assert_eq!(TickFormatter::trim_trailing_zeros("5"), "5");
assert_eq!(TickFormatter::trim_trailing_zeros("0.100"), "0.1");
}
}