const NICE_NUMBERS: [f64; 4] = [1.0, 2.0, 5.0, 10.0];
#[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) as f64;
let rough_step = range / (target_ticks - 1.0);
let step = Self::nice_number(rough_step, true);
let nice_min = (min / step).floor() * step;
let nice_max = (max / step).ceil() * step;
let mut ticks = Vec::new();
let mut tick = nice_min;
let max_iterations = 100;
let mut iterations = 0;
while tick <= nice_max + step * 0.5 && iterations < max_iterations {
let clean_tick = Self::clean_float(tick, step);
ticks.push(clean_tick);
tick += step;
iterations += 1;
}
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).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}", value);
}
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 max_precision = values
.iter()
.map(|&v| Self::required_precision(v))
.max()
.unwrap_or(0)
.min(self.max_decimals);
values
.iter()
.map(|&v| {
if max_precision == 0 || (v - v.round()).abs() < 1e-9 {
format!("{:.0}", v)
} else {
let formatted = format!("{:.prec$}", v, prec = max_precision);
Self::trim_trailing_zeros(&formatted)
}
})
.collect()
}
fn clean_float(value: f64, step: f64) -> f64 {
let decimals = if step >= 1.0 {
0
} else {
(-step.log10().floor()) as i32 + 1
};
let mult = 10.0_f64.powi(decimals);
(value * mult).round() / mult
}
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_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");
}
}