#![warn(missing_docs)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Scale {
Linear,
Time,
Log {
base: f64,
},
}
const LOG_EPSILON: f64 = 1e-300;
impl Scale {
pub fn linear() -> Self {
Scale::Linear
}
pub fn time() -> Self {
Scale::Time
}
pub fn log() -> Self {
Scale::Log { base: 10.0 }
}
pub fn forward(&self, v: f64) -> f64 {
match self {
Scale::Linear | Scale::Time => v,
Scale::Log { base } => v.max(LOG_EPSILON).log(*base),
}
}
pub fn inverse(&self, u: f64) -> f64 {
match self {
Scale::Linear | Scale::Time => u,
Scale::Log { base } => base.powf(u),
}
}
pub fn map(&self, v: f64, origin: f64) -> f32 {
(self.forward(v) - self.forward(origin)) as f32
}
pub fn invert(&self, s: f32, origin: f64) -> f64 {
self.inverse(self.forward(origin) + s as f64)
}
pub fn ticks(&self, window: (f64, f64), target: usize) -> Vec<Tick> {
let (lo, hi) = window;
if !lo.is_finite() || !hi.is_finite() || hi <= lo {
return Vec::new();
}
match self {
Scale::Linear => linear_ticks(lo, hi, target.max(1))
.into_iter()
.map(|v| Tick {
value: v,
label: format_number(v, linear_tick_step(lo, hi, target.max(1))),
})
.collect(),
Scale::Log { base } => log_ticks(lo, hi, *base)
.into_iter()
.map(|v| Tick {
value: v,
label: format_number(v, 0.0),
})
.collect(),
Scale::Time => time_ticks(lo, hi, target.max(1)),
}
}
pub fn format(&self, v: f64, context: f64) -> String {
match self {
Scale::Linear | Scale::Log { .. } => {
let step = if context > 0.0 { context / 100.0 } else { 0.0 };
format_number(v, step)
}
Scale::Time => format_time(v, context),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Tick {
pub value: f64,
pub label: String,
}
fn nice_step(rough: f64) -> f64 {
if !rough.is_finite() || rough <= 0.0 {
return 1.0;
}
let pow = 10f64.powf(rough.log10().floor());
let frac = rough / pow;
let nice = if frac < 1.5 {
1.0
} else if frac < 3.0 {
2.0
} else if frac < 7.0 {
5.0
} else {
10.0
};
nice * pow
}
fn linear_tick_step(lo: f64, hi: f64, target: usize) -> f64 {
nice_step((hi - lo) / target as f64)
}
fn linear_ticks(lo: f64, hi: f64, target: usize) -> Vec<f64> {
let step = linear_tick_step(lo, hi, target);
if step <= 0.0 {
return Vec::new();
}
let start = (lo / step).ceil() * step;
let mut out = Vec::new();
let mut t = start;
let max_ticks = target.saturating_mul(4).max(8);
while t <= hi + step * 1e-9 && out.len() < max_ticks {
let snapped = (t / step).round() * step;
out.push(snapped);
t += step;
}
out
}
fn format_number(v: f64, step: f64) -> String {
let v = if v == 0.0 { 0.0 } else { v }; let decimals = if step > 0.0 && step < 1.0 {
(-step.log10().floor()).clamp(0.0, 12.0) as usize
} else {
0
};
let mut s = format!("{v:.decimals$}");
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
s
}
fn log_ticks(lo: f64, hi: f64, base: f64) -> Vec<f64> {
let lo = lo.max(LOG_EPSILON);
if hi <= lo || base <= 1.0 {
return Vec::new();
}
let lo_exp = lo.log(base).floor() as i32;
let hi_exp = hi.log(base).ceil() as i32;
let mut out = Vec::new();
for e in lo_exp..=hi_exp {
let v = base.powi(e);
if v >= lo && v <= hi && out.len() < 64 {
out.push(v);
}
}
out
}
const MINUTE: f64 = 60.0;
const HOUR: f64 = 3600.0;
const DAY: f64 = 86_400.0;
const TIME_INTERVALS: &[f64] = &[
1.0,
2.0,
5.0,
10.0,
15.0,
30.0,
MINUTE,
2.0 * MINUTE,
5.0 * MINUTE,
10.0 * MINUTE,
15.0 * MINUTE,
30.0 * MINUTE,
HOUR,
2.0 * HOUR,
3.0 * HOUR,
6.0 * HOUR,
12.0 * HOUR,
DAY,
2.0 * DAY,
7.0 * DAY,
14.0 * DAY,
30.0 * DAY,
90.0 * DAY,
365.0 * DAY,
];
fn time_ticks(lo: f64, hi: f64, target: usize) -> Vec<Tick> {
let span = hi - lo;
let rough = span / target as f64;
let interval = TIME_INTERVALS
.iter()
.copied()
.find(|&i| i >= rough)
.unwrap_or(365.0 * DAY);
let start = (lo / interval).ceil() * interval;
let mut out = Vec::new();
let mut t = start;
let max_ticks = target.saturating_mul(4).max(8);
while t <= hi + interval * 1e-9 && out.len() < max_ticks {
out.push(Tick {
value: t,
label: format_time(t, interval),
});
t += interval;
}
out
}
fn format_time(epoch_secs: f64, interval: f64) -> String {
let secs = epoch_secs.floor() as i64;
let days = secs.div_euclid(DAY as i64);
let tod = secs.rem_euclid(DAY as i64); let (h, m, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
let (y, mo, d) = civil_from_days(days);
if interval >= DAY {
format!("{y:04}-{mo:02}-{d:02}")
} else if interval >= MINUTE {
format!("{h:02}:{m:02}")
} else {
format!("{h:02}:{m:02}:{s:02}")
}
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linear_warp_is_identity_and_invertible() {
let s = Scale::linear();
assert_eq!(s.forward(42.0), 42.0);
assert_eq!(s.inverse(42.0), 42.0);
assert_eq!(s.map(105.0, 100.0), 5.0);
assert!((s.invert(5.0, 100.0) - 105.0).abs() < 1e-9);
}
#[test]
fn log_warp_roundtrips_and_pan_is_affine_in_scale_space() {
let s = Scale::log();
assert!((s.forward(1000.0) - 3.0).abs() < 1e-12);
assert!((s.inverse(3.0) - 1000.0).abs() < 1e-9);
let d1 = s.forward(100.0) - s.forward(10.0);
let d2 = s.forward(1000.0) - s.forward(100.0);
assert!((d1 - d2).abs() < 1e-12);
}
#[test]
fn log_warp_clamps_nonpositive() {
let s = Scale::log();
assert!(s.forward(0.0).is_finite());
assert!(s.forward(-5.0).is_finite());
}
#[test]
fn linear_ticks_are_nice_and_in_range() {
let s = Scale::linear();
let ticks = s.ticks((0.0, 100.0), 5);
assert!(!ticks.is_empty());
for t in &ticks {
assert!(t.value >= 0.0 && t.value <= 100.0);
}
assert_eq!(ticks.first().unwrap().value, 0.0);
assert_eq!(ticks.last().unwrap().value, 100.0);
assert_eq!(ticks[1].value, 20.0);
}
#[test]
fn linear_ticks_fractional_labels_trim_zeros() {
let s = Scale::linear();
let ticks = s.ticks((0.0, 1.0), 5);
assert!(ticks.iter().any(|t| t.label == "0.2"));
assert!(ticks.iter().any(|t| t.label == "0"));
}
#[test]
fn nice_step_picks_1_2_5() {
assert_eq!(nice_step(1.0), 1.0);
assert_eq!(nice_step(2.3), 2.0);
assert_eq!(nice_step(3.5), 5.0);
assert_eq!(nice_step(0.04), 0.05);
assert_eq!(nice_step(8.0), 10.0);
}
#[test]
fn civil_from_days_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
assert_eq!(civil_from_days(11017), (2000, 3, 1));
let days = 20628; let (y, m, d) = civil_from_days(days);
assert_eq!((y, m, d), (2026, 6, 24));
}
#[test]
fn time_ticks_clock_format_for_minutes() {
let s = Scale::time();
let base = 20628.0 * DAY + 12.0 * HOUR;
let ticks = s.ticks((base, base + HOUR), 4);
assert!(!ticks.is_empty());
assert!(
ticks
.iter()
.all(|t| t.label.len() == 5 && t.label.contains(':'))
);
assert!(ticks.iter().any(|t| t.label == "12:15"));
}
#[test]
fn time_ticks_date_format_for_multiday() {
let s = Scale::time();
let base = 20628.0 * DAY;
let ticks = s.ticks((base, base + 10.0 * DAY), 5);
assert!(!ticks.is_empty());
assert!(ticks.iter().all(|t| t.label.starts_with("2026-")));
}
#[test]
fn empty_or_inverted_window_has_no_ticks() {
let s = Scale::linear();
assert!(s.ticks((5.0, 5.0), 5).is_empty());
assert!(s.ticks((10.0, 0.0), 5).is_empty());
}
}