use super::{Scale, Tick, TickPriority};
const MINUTE: i64 = 60;
const HOUR: i64 = 3_600;
const DAY: i64 = 86_400;
const DAYS_IN_MONTH: [i32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
fn days_in_month(year: i32, month: i32) -> i32 {
if month == 2 && is_leap_year(year) {
29
} else {
DAYS_IN_MONTH[(month - 1) as usize]
}
}
pub fn timestamp_to_date(ts: i64) -> (i32, i32, i32, i32, i32, i32) {
let time_of_day = ts.rem_euclid(DAY);
let hour = (time_of_day / HOUR) as i32;
let minute = ((time_of_day % HOUR) / MINUTE) as i32;
let second = (time_of_day % MINUTE) as i32;
let mut days = ts.div_euclid(DAY);
let mut year = 1970_i32;
if days >= 0 {
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if days < days_in_year as i64 {
break;
}
days -= days_in_year as i64;
year += 1;
}
} else {
loop {
year -= 1;
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
days += days_in_year as i64;
if days >= 0 {
break;
}
}
}
let mut month = 1_i32;
loop {
let dim = days_in_month(year, month) as i64;
if days < dim {
break;
}
days -= dim;
month += 1;
}
let day = days as i32 + 1;
(year, month, day, hour, minute, second)
}
fn date_to_timestamp(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: i32) -> i64 {
let mut days: i64 = 0;
if year >= 1970 {
for y in 1970..year {
days += if is_leap_year(y) { 366 } else { 365 };
}
} else {
for y in year..1970 {
days -= if is_leap_year(y) { 366 } else { 365 };
}
}
for m in 1..month {
days += days_in_month(year, m) as i64;
}
days += (day - 1) as i64;
days * DAY + hour as i64 * HOUR + minute as i64 * MINUTE + second as i64
}
fn month_index(year: i32, month: i32) -> i64 {
year as i64 * 12 + (month as i64 - 1)
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
#[repr(u8)]
pub enum TickMarkWeight {
LessThanSecond = 1,
#[default]
Second = 5,
Minute1 = 10,
Minute5 = 15,
Minute30 = 20,
Hour = 30,
Hour3 = 31,
Hour4 = 35,
Hour6 = 36,
Hour12 = 37,
Day = 50,
Month = 60,
Year = 70,
}
impl TickMarkWeight {
pub fn is_major(&self) -> bool {
matches!(self, TickMarkWeight::Year | TickMarkWeight::Month)
}
pub fn is_medium(&self) -> bool {
matches!(self, TickMarkWeight::Day)
}
}
pub fn boundary_weight(ts_secs: i64) -> TickMarkWeight {
let (_, month, day, hour, minute, second) = timestamp_to_date(ts_secs);
let midnight = hour == 0 && minute == 0 && second == 0;
if month == 1 && day == 1 && midnight {
return TickMarkWeight::Year;
}
if day == 1 && midnight {
return TickMarkWeight::Month;
}
if midnight {
return TickMarkWeight::Day;
}
if ts_secs.rem_euclid(12 * HOUR) == 0 {
return TickMarkWeight::Hour12;
}
if ts_secs.rem_euclid(6 * HOUR) == 0 {
return TickMarkWeight::Hour6;
}
if ts_secs.rem_euclid(4 * HOUR) == 0 {
return TickMarkWeight::Hour4;
}
if ts_secs.rem_euclid(3 * HOUR) == 0 {
return TickMarkWeight::Hour3;
}
if ts_secs.rem_euclid(HOUR) == 0 {
return TickMarkWeight::Hour;
}
if ts_secs.rem_euclid(30 * MINUTE) == 0 {
return TickMarkWeight::Minute30;
}
if ts_secs.rem_euclid(5 * MINUTE) == 0 {
return TickMarkWeight::Minute5;
}
if ts_secs.rem_euclid(MINUTE) == 0 {
return TickMarkWeight::Minute1;
}
TickMarkWeight::Second
}
const MONTH_NAMES: [&str; 12] =
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
pub fn format_by_weight(ts_secs: i64, weight: TickMarkWeight) -> String {
let (year, month, day, hour, minute, second) = timestamp_to_date(ts_secs);
match weight {
TickMarkWeight::Year => format!("{year}"),
TickMarkWeight::Month => MONTH_NAMES[(month - 1) as usize].to_owned(),
TickMarkWeight::Day => format!("{day} {}", MONTH_NAMES[(month - 1) as usize]),
TickMarkWeight::Hour12
| TickMarkWeight::Hour6
| TickMarkWeight::Hour4
| TickMarkWeight::Hour3
| TickMarkWeight::Hour
| TickMarkWeight::Minute30
| TickMarkWeight::Minute5
| TickMarkWeight::Minute1 => format!("{hour:02}:{minute:02}"),
TickMarkWeight::Second | TickMarkWeight::LessThanSecond => {
format!("{hour:02}:{minute:02}:{second:02}")
}
}
}
const SUB_MONTH_STEPS_SECS: &[i64] = &[
1,
2,
5,
10,
15,
30,
MINUTE,
5 * MINUTE,
15 * MINUTE,
30 * MINUTE,
HOUR,
3 * HOUR,
6 * HOUR,
12 * HOUR,
DAY,
2 * DAY,
3 * DAY,
5 * DAY,
7 * DAY,
10 * DAY,
14 * DAY,
21 * DAY,
];
const MONTH_STEPS: &[i64] = &[1, 2, 3, 6, 12];
fn fits_budget(count: i64, target: i64) -> bool {
count <= target * 2
}
fn walk_seconds_ticks(min_ts: f64, max_ts: f64, min_secs: i64, max_secs: i64, step: i64) -> Vec<Tick> {
let mut t = min_secs.div_euclid(step) * step;
if t < min_secs {
t += step;
}
let mut out = Vec::new();
while t <= max_secs {
let value = t as f64;
if value >= min_ts && value <= max_ts {
let weight = boundary_weight(t);
out.push(Tick { value, label: format_by_weight(t, weight) });
}
t += step;
}
out
}
fn walk_months_ticks(min_ts: f64, max_ts: f64, min_secs: i64, max_secs: i64, step_months: i64) -> Vec<Tick> {
let (y0, m0, ..) = timestamp_to_date(min_secs);
let (y1, m1, ..) = timestamp_to_date(max_secs);
let idx0 = month_index(y0, m0);
let idx1 = month_index(y1, m1);
let mut idx = idx0.div_euclid(step_months) * step_months;
if idx < idx0 {
idx += step_months;
}
let mut out = Vec::new();
while idx <= idx1 {
let year = idx.div_euclid(12) as i32;
let month = (idx.rem_euclid(12) + 1) as i32;
let ts = date_to_timestamp(year, month, 1, 0, 0, 0);
let value = ts as f64;
if value >= min_ts && value <= max_ts {
let weight = boundary_weight(ts);
out.push(Tick { value, label: format_by_weight(ts, weight) });
}
idx += step_months;
}
out
}
fn walk_years_ticks(min_ts: f64, max_ts: f64, min_secs: i64, max_secs: i64, step_years: i64) -> Vec<Tick> {
let (y0, ..) = timestamp_to_date(min_secs);
let (y1, ..) = timestamp_to_date(max_secs);
let step_years = step_years.max(1);
let mut year = (y0 as i64).div_euclid(step_years) * step_years;
if year < y0 as i64 {
year += step_years;
}
let mut out = Vec::new();
while year <= y1 as i64 {
let ts = date_to_timestamp(year as i32, 1, 1, 0, 0, 0);
let value = ts as f64;
if value >= min_ts && value <= max_ts {
let weight = boundary_weight(ts);
out.push(Tick { value, label: format_by_weight(ts, weight) });
}
year += step_years;
}
out
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TimeScale {
pub min_ts: f64,
pub max_ts: f64,
}
impl TimeScale {
pub fn new(min_ts: f64, max_ts: f64) -> Self {
Self { min_ts, max_ts }
}
fn range(&self) -> f64 {
self.max_ts - self.min_ts
}
}
impl Scale for TimeScale {
fn domain(&self) -> (f64, f64) {
(self.min_ts, self.max_ts)
}
fn map(&self, v: f64) -> f64 {
let range = self.range();
if range.abs() < f64::EPSILON {
return 0.5;
}
(v - self.min_ts) / range
}
fn invert(&self, t: f64) -> f64 {
self.min_ts + t * self.range()
}
fn ticks(&self, target_count: usize) -> Vec<Tick> {
let (min_ts, max_ts) = (self.min_ts, self.max_ts);
if !min_ts.is_finite() || !max_ts.is_finite() || max_ts <= min_ts {
let anchor = if min_ts.is_finite() { min_ts } else { 0.0 };
let secs = anchor.floor() as i64;
let weight = boundary_weight(secs);
return vec![Tick { value: anchor, label: format_by_weight(secs, weight) }];
}
let target = target_count.max(1) as i64;
let min_secs = min_ts.floor() as i64;
let max_secs = max_ts.ceil() as i64;
let span_secs = (max_secs - min_secs).max(1);
for &step in SUB_MONTH_STEPS_SECS {
let count = span_secs / step + 1;
if fits_budget(count, target) {
return walk_seconds_ticks(min_ts, max_ts, min_secs, max_secs, step);
}
}
let (y0, m0, ..) = timestamp_to_date(min_secs);
let (y1, m1, ..) = timestamp_to_date(max_secs);
let span_months = month_index(y1, m1) - month_index(y0, m0);
for &step in MONTH_STEPS {
let count = span_months / step + 1;
if fits_budget(count, target) {
return walk_months_ticks(min_ts, max_ts, min_secs, max_secs, step);
}
}
let span_years = ((y1 - y0) as i64).max(1) as f64;
let step_years = super::linear::nice_step(span_years, target as f64).round().max(1.0) as i64;
walk_years_ticks(min_ts, max_ts, min_secs, max_secs, step_years)
}
fn format_value(&self, v: f64) -> String {
let secs = v.floor() as i64;
let (_, month, day, hour, minute, _second) = timestamp_to_date(secs);
format!("{day} {} {hour:02}:{minute:02}", MONTH_NAMES[(month - 1) as usize])
}
fn tick_weight(&self, v: f64) -> Option<TickMarkWeight> {
Some(boundary_weight(v.floor() as i64))
}
fn tick_priority(&self, v: f64) -> TickPriority {
if boundary_weight(v.floor() as i64).is_major() {
TickPriority::Major
} else {
TickPriority::Minor
}
}
fn windowed(&self, min: f64, max: f64) -> Option<Box<dyn Scale>> {
Some(Box::new(TimeScale::new(min, max)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: i32) -> f64 {
date_to_timestamp(year, month, day, hour, minute, second) as f64
}
#[test]
fn tick_weight_ordering_matches_source_hierarchy() {
assert!(TickMarkWeight::Year > TickMarkWeight::Month);
assert!(TickMarkWeight::Month > TickMarkWeight::Day);
assert!(TickMarkWeight::Day > TickMarkWeight::Hour12);
assert!(TickMarkWeight::Hour12 > TickMarkWeight::Hour);
assert!(TickMarkWeight::Hour > TickMarkWeight::Minute1);
assert!(TickMarkWeight::Minute1 > TickMarkWeight::Second);
}
#[test]
fn weight_classification_matches_source() {
assert!(TickMarkWeight::Year.is_major());
assert!(TickMarkWeight::Month.is_major());
assert!(!TickMarkWeight::Day.is_major());
assert!(TickMarkWeight::Day.is_medium());
assert!(!TickMarkWeight::Hour.is_medium());
}
#[test]
fn calendar_round_trip_accuracy() {
let feb_28_2023 = date_to_timestamp(2023, 2, 28, 12, 0, 0);
let mar_1_2023 = date_to_timestamp(2023, 3, 1, 12, 0, 0);
assert_eq!(timestamp_to_date(feb_28_2023), (2023, 2, 28, 12, 0, 0));
assert_eq!(timestamp_to_date(mar_1_2023), (2023, 3, 1, 12, 0, 0));
let feb_29_2024 = date_to_timestamp(2024, 2, 29, 0, 0, 0);
assert_eq!(timestamp_to_date(feb_29_2024), (2024, 2, 29, 0, 0, 0));
}
#[test]
fn boundary_weight_classifies_calendar_tiers() {
let jan1 = date_to_timestamp(2024, 1, 1, 0, 0, 0);
let jul1 = date_to_timestamp(2024, 7, 1, 0, 0, 0);
let day15 = date_to_timestamp(2024, 3, 15, 0, 0, 0);
let noon = date_to_timestamp(2024, 3, 15, 12, 0, 0);
let half_hour = date_to_timestamp(2024, 3, 15, 12, 30, 0);
assert_eq!(boundary_weight(jan1), TickMarkWeight::Year);
assert_eq!(boundary_weight(jul1), TickMarkWeight::Month);
assert_eq!(boundary_weight(day15), TickMarkWeight::Day);
assert_eq!(boundary_weight(noon), TickMarkWeight::Hour12);
assert_eq!(boundary_weight(half_hour), TickMarkWeight::Minute30);
}
#[test]
fn format_table_matches_weight_tier() {
let t = date_to_timestamp(2023, 1, 2, 3, 4, 5);
assert_eq!(format_by_weight(t, TickMarkWeight::Year), "2023");
assert_eq!(format_by_weight(t, TickMarkWeight::Month), "Jan");
assert_eq!(format_by_weight(t, TickMarkWeight::Day), "2 Jan");
assert_eq!(format_by_weight(t, TickMarkWeight::Hour), "03:04");
assert_eq!(format_by_weight(t, TickMarkWeight::Second), "03:04:05");
}
#[test]
fn ticks_over_two_hours_pick_intraday_cadence() {
let start = ts(2024, 6, 1, 8, 0, 0);
let end = ts(2024, 6, 1, 10, 0, 0);
let scale = TimeScale::new(start, end);
let ticks = scale.ticks(6);
assert!(!ticks.is_empty());
assert!(ticks.len() <= 6 * 3, "tick count should stay near the target, got {}", ticks.len());
for w in ticks.windows(2) {
assert!(w[1].value > w[0].value, "ticks must be strictly increasing");
}
for t in &ticks {
assert!(t.value >= start && t.value <= end, "tick must fall inside the domain");
let weight = boundary_weight(t.value as i64);
assert!(
weight <= TickMarkWeight::Hour12,
"a 2-hour span must not produce coarser-than-half-day ticks, got {weight:?}"
);
}
}
#[test]
fn ticks_over_three_days_pick_hour_scale_cadence() {
let start = ts(2024, 6, 10, 0, 0, 0);
let end = ts(2024, 6, 13, 0, 0, 0);
let scale = TimeScale::new(start, end);
let ticks = scale.ticks(6);
assert!(!ticks.is_empty());
for w in ticks.windows(2) {
assert!(w[1].value > w[0].value);
}
for t in &ticks {
assert!(t.value >= start && t.value <= end);
}
assert!(ticks.iter().any(|t| boundary_weight(t.value as i64) <= TickMarkWeight::Day));
assert!(ticks.iter().all(|t| boundary_weight(t.value as i64) <= TickMarkWeight::Day));
}
#[test]
fn ticks_over_two_months_pick_day_scale_cadence() {
let start = ts(2024, 1, 1, 0, 0, 0);
let end = ts(2024, 3, 2, 0, 0, 0);
let scale = TimeScale::new(start, end);
let ticks = scale.ticks(6);
assert!(!ticks.is_empty());
for w in ticks.windows(2) {
assert!(w[1].value > w[0].value);
}
for t in &ticks {
assert!(t.value >= start && t.value <= end);
}
assert!(ticks.iter().all(|t| boundary_weight(t.value as i64) <= TickMarkWeight::Month));
}
#[test]
fn ticks_over_three_years_include_year_boundaries() {
let start = ts(2022, 1, 1, 0, 0, 0);
let end = ts(2025, 1, 1, 0, 0, 0);
let scale = TimeScale::new(start, end);
let ticks = scale.ticks(6);
assert!(!ticks.is_empty());
for w in ticks.windows(2) {
assert!(w[1].value > w[0].value);
}
for t in &ticks {
assert!(t.value >= start && t.value <= end);
}
assert!(
ticks.iter().any(|t| boundary_weight(t.value as i64) == TickMarkWeight::Year),
"a 3-year span must surface at least one Year-weight tick, got {ticks:?}"
);
}
#[test]
fn year_boundary_outweighs_month_boundary_in_the_same_window() {
let start = ts(2022, 1, 1, 0, 0, 0);
let end = ts(2025, 1, 1, 0, 0, 0);
let scale = TimeScale::new(start, end);
let ticks = scale.ticks(6);
let max_weight = ticks.iter().map(|t| boundary_weight(t.value as i64)).max();
let month_tick_exists = ticks.iter().any(|t| boundary_weight(t.value as i64) == TickMarkWeight::Month);
assert_eq!(max_weight, Some(TickMarkWeight::Year));
assert!(month_tick_exists, "expected a mixed-weight window, got {ticks:?}");
assert!(TickMarkWeight::Year > TickMarkWeight::Month);
}
#[test]
fn map_and_invert_round_trip() {
let start = ts(2020, 1, 1, 0, 0, 0);
let end = ts(2024, 1, 1, 0, 0, 0);
let scale = TimeScale::new(start, end);
for v in [start, start + 1.0, (start + end) / 2.0, end - 1.0, end] {
let t = scale.map(v);
let back = scale.invert(t);
assert!((back - v).abs() < 1e-6, "round trip drifted: {v} -> {back}");
}
}
#[test]
fn zero_span_domain_does_not_panic() {
let point = ts(2024, 5, 17, 12, 0, 0);
let scale = TimeScale::new(point, point);
assert_eq!(scale.map(point), 0.5);
let ticks = scale.ticks(5);
assert_eq!(ticks.len(), 1);
assert_eq!(ticks[0].value, point);
}
#[test]
fn non_finite_domain_does_not_panic() {
let scale = TimeScale::new(f64::NAN, f64::NAN);
let ticks = scale.ticks(5);
assert_eq!(ticks.len(), 1);
assert!(ticks[0].value.is_finite());
}
#[test]
fn format_value_gives_a_full_calendar_label_not_a_raw_number() {
let t = ts(2024, 3, 15, 9, 5, 0);
let scale = TimeScale::new(t - 3600.0, t + 3600.0);
assert_eq!(scale.format_value(t), "15 Mar 09:05");
}
#[test]
fn scale_trait_tick_weight_matches_boundary_weight_directly() {
let jan1 = ts(2024, 1, 1, 0, 0, 0);
let scale: &dyn Scale = &TimeScale::new(jan1 - 3600.0, jan1 + 3600.0);
assert_eq!(scale.tick_weight(jan1), Some(boundary_weight(jan1 as i64)));
assert_eq!(scale.tick_weight(jan1), Some(TickMarkWeight::Year));
}
#[test]
fn tick_priority_marks_year_and_month_boundaries_major_everything_else_minor() {
let scale = TimeScale::new(1_704_067_200.0, 1_704_067_200.0 + 62.0 * 86_400.0);
let jan1_2024 = ts(2024, 1, 1, 0, 0, 0);
let feb1_2024 = ts(2024, 2, 1, 0, 0, 0);
let jan2_2024 = ts(2024, 1, 2, 0, 0, 0);
assert_eq!(scale.tick_priority(jan1_2024), TickPriority::Major, "a Year boundary must report Major priority");
assert_eq!(scale.tick_priority(feb1_2024), TickPriority::Major, "a Month boundary must report Major priority");
assert_eq!(scale.tick_priority(jan2_2024), TickPriority::Minor, "an ordinary Day boundary must report Minor priority");
}
#[test]
fn windowed_rebuilds_a_time_scale_over_the_given_unix_second_bounds() {
let jan1_2024 = ts(2024, 1, 1, 0, 0, 0);
let scale = TimeScale::new(jan1_2024, jan1_2024 + 90.0 * 86_400.0);
let window_start = jan1_2024 + 10.0 * 86_400.0;
let window_end = jan1_2024 + 20.0 * 86_400.0;
let windowed = scale.windowed(window_start, window_end).expect("TimeScale supports windowing");
assert_eq!(windowed.domain(), (window_start, window_end));
}
}