use super::linear::format_value;
use super::{Scale, Tick, TickPriority};
const LOG_SCALE_MIN_POSITIVE: f64 = 1e-9;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LogScale {
pub min: f64,
pub max: f64,
}
impl LogScale {
pub fn new(min: f64, max: f64) -> Self {
Self { min, max }
}
fn safe_min(&self) -> f64 {
self.min.max(LOG_SCALE_MIN_POSITIVE)
}
fn safe_max(&self) -> f64 {
self.max.max(self.safe_min() + LOG_SCALE_MIN_POSITIVE)
}
fn log_min(&self) -> f64 {
self.safe_min().log10()
}
fn log_max(&self) -> f64 {
self.safe_max().log10()
}
}
impl Scale for LogScale {
fn domain(&self) -> (f64, f64) {
(self.min, self.max)
}
fn map(&self, v: f64) -> f64 {
let log_range = self.log_max() - self.log_min();
if log_range <= 0.0 {
return 0.5;
}
let safe_v = v.max(LOG_SCALE_MIN_POSITIVE);
(safe_v.log10() - self.log_min()) / log_range
}
fn invert(&self, t: f64) -> f64 {
let log_range = self.log_max() - self.log_min();
10.0_f64.powf(self.log_min() + t * log_range)
}
fn ticks(&self, target_count: usize) -> Vec<Tick> {
let log_min = self.log_min();
let log_max = self.log_max();
if !(log_max > log_min) {
let v = self.safe_min();
return vec![Tick { value: v, label: format_value(v, v) }];
}
let decade_start = log_min.floor() as i64;
let decade_end = log_max.ceil() as i64;
let num_decades = (decade_end - decade_start).max(1) as usize;
let subdivisions: &[f64] =
if num_decades >= target_count.max(1) { &[1.0] } else { &[1.0, 2.0, 5.0] };
let filter_min = self.safe_min();
let filter_max = self.safe_max();
let mut out = Vec::new();
for decade in decade_start..=decade_end {
let base = 10.0_f64.powi(decade as i32);
for &mult in subdivisions {
let value = base * mult;
if value + 1e-12 < filter_min || value - 1e-12 > filter_max {
continue;
}
out.push(Tick { value, label: format_value(value, base) });
}
}
out
}
fn tick_priority(&self, v: f64) -> TickPriority {
if v <= 0.0 || !v.is_finite() {
return TickPriority::Minor;
}
let log10 = v.log10();
if (log10 - log10.round()).abs() < 1e-6 {
TickPriority::Major
} else {
TickPriority::Minor
}
}
fn windowed(&self, min: f64, max: f64) -> Option<Box<dyn Scale>> {
Some(Box::new(LogScale::new(min, max)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_and_invert_round_trip() {
let scale = LogScale::new(1.0, 1000.0);
for v in [1.0, 10.0, 100.0, 1000.0] {
let t = scale.map(v);
let back = scale.invert(t);
assert!((back - v).abs() / v < 1e-9);
}
}
#[test]
fn non_positive_domain_does_not_panic_or_nan() {
let scale = LogScale::new(-10.0, 0.0);
let t = scale.map(-5.0);
assert!(t.is_finite());
let ticks = scale.ticks(5);
assert!(!ticks.is_empty());
for tick in &ticks {
assert!(tick.value.is_finite());
}
}
#[test]
fn ticks_land_on_decade_boundaries_for_wide_domain() {
let scale = LogScale::new(1.0, 100_000.0);
let ticks = scale.ticks(4);
for tick in &ticks {
let log10 = tick.value.log10();
assert!((log10 - log10.round()).abs() < 1e-9);
}
}
#[test]
fn ticks_subdivide_for_narrow_domain() {
let scale = LogScale::new(1.0, 10.0);
let ticks = scale.ticks(6);
assert!(ticks.iter().any(|t| (t.value - 2.0).abs() < 1e-9));
assert!(ticks.iter().any(|t| (t.value - 5.0).abs() < 1e-9));
}
#[test]
fn decade_boundaries_report_major_priority() {
let scale = LogScale::new(1.0, 100_000.0);
for v in [1.0, 10.0, 100.0, 1_000.0, 10_000.0, 100_000.0] {
assert_eq!(scale.tick_priority(v), TickPriority::Major, "decade boundary {v} must report Major priority");
}
}
#[test]
fn subdivision_ticks_report_minor_priority() {
let scale = LogScale::new(1.0, 10.0);
for v in [2.0, 5.0] {
assert_eq!(scale.tick_priority(v), TickPriority::Minor, "a 2x/5x subdivision tick {v} must report Minor priority");
}
}
#[test]
fn non_positive_value_reports_minor_priority_never_panics() {
let scale = LogScale::new(1.0, 100.0);
assert_eq!(scale.tick_priority(-5.0), TickPriority::Minor);
assert_eq!(scale.tick_priority(0.0), TickPriority::Minor);
}
#[test]
fn a_dense_log_axis_generated_tick_set_contains_both_major_and_minor_priorities() {
let scale = LogScale::new(1.0, 10.0);
let ticks = scale.ticks(6);
let priorities: Vec<TickPriority> = ticks.iter().map(|t| scale.tick_priority(t.value)).collect();
assert!(priorities.contains(&TickPriority::Major), "expected at least one Major decade tick, got {priorities:?}");
assert!(priorities.contains(&TickPriority::Minor), "expected at least one Minor subdivision tick, got {priorities:?}");
}
#[test]
fn windowed_rebuilds_a_log_scale_over_the_given_bounds() {
let scale = LogScale::new(1.0, 100_000.0);
let windowed = scale.windowed(10.0, 1000.0).expect("LogScale supports windowing");
assert_eq!(windowed.domain(), (10.0, 1000.0));
}
}