use super::{Scale, Tick, TickPriority};
pub const NICE_MULTIPLIERS: [f64; 3] = [2.0, 2.5, 2.0];
pub fn nice_number(value: f64) -> f64 {
if value <= 0.0 || !value.is_finite() {
return 1.0;
}
let exp = value.log10().floor();
let base = 10.0_f64.powf(exp);
let mut current = base;
let mut idx = 0;
while current < value {
current *= NICE_MULTIPLIERS[idx % 3];
idx += 1;
if idx > 10 {
break; }
}
if idx > 0 {
let prev_idx = idx - 1;
let mut check = base;
for i in 0..prev_idx {
check *= NICE_MULTIPLIERS[i % 3];
}
if check >= value * 0.8 {
return check;
}
}
current
}
pub fn nice_step(range: f64, target_ticks: f64) -> f64 {
let target_ticks = if target_ticks > 0.0 { target_ticks } else { 1.0 };
nice_number(range / target_ticks)
}
pub fn nice_domain(data_min: f64, data_max: f64, target_ticks: usize) -> (f64, f64) {
if !data_min.is_finite() || !data_max.is_finite() || data_min >= data_max {
let anchor = if data_min.is_finite() { data_min } else { 0.0 };
return (anchor, anchor + 1.0);
}
let step = nice_step(data_max - data_min, target_ticks.max(1) as f64);
if step <= 0.0 {
return (data_min, data_max);
}
let nice_min = (data_min / step).floor() * step;
let nice_max = (data_max / step).ceil() * step;
(nice_min, nice_max)
}
pub fn decimal_precision(step: f64) -> usize {
if step <= 0.0 || !step.is_finite() || step >= 0.01 {
return 2;
}
let exp = step.log10().floor();
((-exp) as usize).min(12)
}
fn group_thousands(formatted: &str) -> String {
let (sign, rest) = match formatted.strip_prefix('-') {
Some(r) => ("-", r),
None => ("", formatted),
};
let (int_part, frac_part) = match rest.split_once('.') {
Some((i, f)) => (i, Some(f)),
None => (rest, None),
};
let bytes = int_part.as_bytes();
let mut grouped = String::with_capacity(bytes.len() + bytes.len() / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i) % 3 == 0 {
grouped.push(',');
}
grouped.push(*b as char);
}
match frac_part {
Some(f) => format!("{sign}{grouped}.{f}"),
None => format!("{sign}{grouped}"),
}
}
pub fn format_value(value: f64, step: f64) -> String {
let precision = decimal_precision(step);
let formatted = format!("{value:.precision$}");
group_thousands(&formatted)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinearScale {
pub min: f64,
pub max: f64,
}
impl LinearScale {
pub fn new(min: f64, max: f64) -> Self {
Self { min, max }
}
pub fn nice(data_min: f64, data_max: f64, target_ticks: usize) -> Self {
let (min, max) = nice_domain(data_min, data_max, target_ticks);
Self { min, max }
}
fn range(&self) -> f64 {
self.max - self.min
}
}
impl Scale for LinearScale {
fn domain(&self) -> (f64, f64) {
(self.min, self.max)
}
fn map(&self, v: f64) -> f64 {
let range = self.range();
if range.abs() < f64::EPSILON {
return 0.5;
}
(v - self.min) / range
}
fn invert(&self, t: f64) -> f64 {
self.min + t * self.range()
}
fn ticks(&self, target_count: usize) -> Vec<Tick> {
let range = self.range();
if range.abs() < f64::EPSILON {
return vec![Tick { value: self.min, label: format_value(self.min, 1.0) }];
}
let step = nice_step(range, target_count.max(1) as f64);
if step <= 0.0 {
return Vec::new();
}
let first = (self.min / step).ceil() * step;
let count = (((self.max - first) / step).ceil() as i64 + 1).max(0);
let mut out = Vec::with_capacity(count as usize);
for i in 0..count {
let value = first + (i as f64) * step;
if value > self.max + step * 1e-9 {
break;
}
out.push(Tick { value, label: format_value(value, step) });
}
out
}
fn tick_priority(&self, v: f64) -> TickPriority {
if v.abs() < 1e-9 && self.min <= 0.0 && self.max >= 0.0 {
TickPriority::Major
} else {
TickPriority::Minor
}
}
fn windowed(&self, min: f64, max: f64) -> Option<Box<dyn Scale>> {
Some(Box::new(LinearScale::new(min, max)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nice_number_ladder_values() {
let nice = nice_number(7.0);
assert!((5.0..=10.0).contains(&nice));
let nice = nice_number(23.0);
assert!((20.0..=25.0).contains(&nice));
assert_eq!(nice_number(0.0), 1.0);
assert_eq!(nice_number(-5.0), 1.0);
}
#[test]
fn decimal_precision_matches_step_magnitude() {
assert_eq!(decimal_precision(10.0), 2);
assert_eq!(decimal_precision(1.0), 2);
assert_eq!(decimal_precision(0.5), 2);
assert_eq!(decimal_precision(0.05), 2);
assert_eq!(decimal_precision(0.005), 3);
assert_eq!(decimal_precision(0.0005), 4);
}
#[test]
fn format_value_groups_thousands() {
assert_eq!(format_value(1_234_567.891, 1.0), "1,234,567.89");
assert_eq!(format_value(-1_234.5, 1.0), "-1,234.50");
assert_eq!(format_value(42.0, 1.0), "42.00");
}
#[test]
fn linear_scale_ticks_count_and_rounding() {
let scale = LinearScale::new(0.0, 100.0);
let ticks = scale.ticks(5);
assert!(!ticks.is_empty());
for t in &ticks {
assert!(t.value >= scale.min - 1e-9);
assert!(t.value <= scale.max + 1e-9);
}
assert!(ticks.len() >= 2);
}
#[test]
fn linear_scale_map_and_invert_round_trip() {
let scale = LinearScale::new(-50.0, 150.0);
for v in [-50.0, 0.0, 42.5, 150.0] {
let t = scale.map(v);
let back = scale.invert(t);
assert!((back - v).abs() < 1e-9);
}
}
#[test]
fn nice_scale_contains_data_extent() {
let scale = LinearScale::nice(3.0, 97.0, 5);
assert!(scale.min <= 3.0);
assert!(scale.max >= 97.0);
}
#[test]
fn degenerate_domain_does_not_panic() {
let scale = LinearScale::new(5.0, 5.0);
assert_eq!(scale.map(5.0), 0.5);
let ticks = scale.ticks(5);
assert_eq!(ticks.len(), 1);
}
#[test]
fn zero_reports_major_priority_when_the_domain_spans_it() {
let scale = LinearScale::new(-100.0, 100.0);
assert_eq!(scale.tick_priority(0.0), TickPriority::Major);
}
#[test]
fn zero_reports_minor_priority_when_the_domain_does_not_span_it() {
let scale = LinearScale::new(10.0, 100.0);
assert_eq!(scale.tick_priority(0.0), TickPriority::Minor, "zero is not even in this domain — must not be elevated");
}
#[test]
fn non_zero_values_always_report_minor_priority() {
let scale = LinearScale::new(-100.0, 100.0);
assert_eq!(scale.tick_priority(50.0), TickPriority::Minor);
assert_eq!(scale.tick_priority(-50.0), TickPriority::Minor);
}
#[test]
fn windowed_rebuilds_a_linear_scale_over_the_given_bounds() {
let scale = LinearScale::new(0.0, 1000.0);
let windowed = scale.windowed(200.0, 300.0).expect("LinearScale supports windowing");
assert_eq!(windowed.domain(), (200.0, 300.0));
}
}