use crate::spec::TimeUnit;
const MS_PER_DAY: i64 = 86_400_000;
const MS_PER_HOUR: i64 = 3_600_000;
const MS_PER_MIN: i64 = 60_000;
const MS_PER_SEC: i64 = 1_000;
pub(crate) fn days_from_civil(y: i64, m: u64, d: u64) -> i64 {
let y = y - (m <= 2) as i64;
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64; let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146097 + doe as i64 - 719468
}
pub(crate) fn civil_from_days(z: i64) -> (i64, u64, u64) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + 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; let m = if mp < 10 { mp + 3 } else { mp - 9 }; (y + (m <= 2) as i64, m, d)
}
fn is_leap(y: i64) -> bool {
y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
}
fn days_in_month(y: i64, m: u64) -> u64 {
debug_assert!((1..=12).contains(&m), "month {m} out of range");
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(y) => 29,
2 => 28,
_ => 0,
}
}
fn digits(b: &[u8], start: usize, n: usize) -> Option<u64> {
if start + n > b.len() {
return None;
}
let mut val = 0u64;
for &c in &b[start..start + n] {
if !c.is_ascii_digit() {
return None;
}
val = val * 10 + (c - b'0') as u64;
}
Some(val)
}
fn parse_date(b: &[u8]) -> Option<(i64, usize)> {
let y = digits(b, 0, 4)? as i64;
if b.get(4) != Some(&b'-') {
return None;
}
let m = digits(b, 5, 2)?;
if b.get(7) != Some(&b'-') {
return None;
}
let d = digits(b, 8, 2)?;
if !(1..=12).contains(&m) || !(1..=days_in_month(y, m)).contains(&d) {
return None;
}
Some((days_from_civil(y, m, d), 10))
}
fn parse_time(b: &[u8], i: usize) -> Option<(i64, usize)> {
let h = digits(b, i, 2)?;
if b.get(i + 2) != Some(&b':') {
return None;
}
let min = digits(b, i + 3, 2)?;
if b.get(i + 5) != Some(&b':') {
return None;
}
let s = digits(b, i + 6, 2)?;
if h > 23 || min > 59 || s > 59 {
return None;
}
Some(((h * 3600 + min * 60 + s) as i64 * 1000, i + 8))
}
fn parse_fraction(b: &[u8], i: usize) -> Option<(i64, usize)> {
let start = i + 1; let mut j = start;
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
let n = j - start;
if n == 0 || n > 3 {
return None;
}
let mut frac = 0i64;
for &c in &b[start..j] {
frac = frac * 10 + (c - b'0') as i64;
}
let scale = match n {
1 => 100,
2 => 10,
_ => 1,
};
Some((frac * scale, j))
}
fn parse_offset(b: &[u8], i: usize) -> Option<(i64, usize)> {
match b.get(i) {
Some(b'Z') => Some((0, i + 1)),
Some(&c @ (b'+' | b'-')) => {
let sign = if c == b'+' { 1 } else { -1 };
let h = digits(b, i + 1, 2)?;
if b.get(i + 3) != Some(&b':') {
return None;
}
let m = digits(b, i + 4, 2)?;
if h > 23 || m > 59 {
return None;
}
Some((sign * (h * 60 + m) as i64 * 60_000, i + 6))
}
_ => None,
}
}
pub(crate) fn parse_temporal(s: &str) -> Option<f64> {
let b = s.as_bytes();
let (days, mut i) = if b.len() >= 10 && b[4] == b'-' {
let (days, mut i) = parse_date(b)?;
if i == b.len() {
return Some((days * MS_PER_DAY) as f64); }
match b[i] {
b'T' | b' ' => i += 1,
_ => return None,
}
(days, i)
} else {
(0, 0)
};
let (mut ms, ni) = parse_time(b, i)?;
i = ni;
if i < b.len() && b[i] == b'.' {
let (frac, ni) = parse_fraction(b, i)?;
ms += frac;
i = ni;
}
let mut offset = 0;
if i < b.len() {
let (off, ni) = parse_offset(b, i)?;
offset = off;
i = ni;
}
if i != b.len() {
return None; }
Some((days * MS_PER_DAY + ms - offset) as f64)
}
pub(crate) fn format_iso(ms: f64) -> String {
let ms = ms as i64;
let (y, m, d) = civil_from_days(ms.div_euclid(MS_PER_DAY));
let rem = ms.rem_euclid(MS_PER_DAY);
let (hh, mi, ss, milli) = (
rem / MS_PER_HOUR,
rem / MS_PER_MIN % 60,
rem / MS_PER_SEC % 60,
rem % MS_PER_SEC,
);
if rem == 0 {
format!("{y:04}-{m:02}-{d:02}")
} else if milli == 0 {
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mi:02}:{ss:02}")
} else {
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mi:02}:{ss:02}.{milli:03}")
}
}
#[derive(Clone, Copy)]
enum Unit {
Sec,
Min,
Hour,
Day,
Month,
Quarter,
Year,
}
#[derive(Clone, Copy)]
enum Rung {
Fixed(i64, Unit),
Week,
Months(i64, Unit),
Years(i64),
}
const LADDER: [Rung; 16] = [
Rung::Fixed(MS_PER_SEC, Unit::Sec),
Rung::Fixed(5 * MS_PER_SEC, Unit::Sec),
Rung::Fixed(15 * MS_PER_SEC, Unit::Sec),
Rung::Fixed(30 * MS_PER_SEC, Unit::Sec),
Rung::Fixed(MS_PER_MIN, Unit::Min),
Rung::Fixed(5 * MS_PER_MIN, Unit::Min),
Rung::Fixed(15 * MS_PER_MIN, Unit::Min),
Rung::Fixed(30 * MS_PER_MIN, Unit::Min),
Rung::Fixed(MS_PER_HOUR, Unit::Hour),
Rung::Fixed(3 * MS_PER_HOUR, Unit::Hour),
Rung::Fixed(6 * MS_PER_HOUR, Unit::Hour),
Rung::Fixed(12 * MS_PER_HOUR, Unit::Hour),
Rung::Fixed(MS_PER_DAY, Unit::Day),
Rung::Week,
Rung::Months(1, Unit::Month),
Rung::Months(3, Unit::Quarter),
];
const MIN_TICKS: usize = 3;
impl Rung {
fn unit(self) -> Unit {
match self {
Rung::Fixed(_, u) | Rung::Months(_, u) => u,
Rung::Week => Unit::Day,
Rung::Years(_) => Unit::Year,
}
}
fn floor(self, ms: i64) -> i64 {
match self {
Rung::Fixed(w, _) => ms.div_euclid(w) * w,
Rung::Week => {
let z = ms.div_euclid(MS_PER_DAY);
(z - (z + 3).rem_euclid(7)) * MS_PER_DAY
}
Rung::Months(n, _) => {
let (y, m, _) = civil_from_days(ms.div_euclid(MS_PER_DAY));
month_start((y * 12 + m as i64 - 1).div_euclid(n) * n)
}
Rung::Years(n) => {
let (y, _, _) = civil_from_days(ms.div_euclid(MS_PER_DAY));
days_from_civil(y.div_euclid(n) * n, 1, 1) * MS_PER_DAY
}
}
}
fn next(self, ms: i64) -> i64 {
match self {
Rung::Fixed(w, _) => ms + w,
Rung::Week => ms + 7 * MS_PER_DAY,
Rung::Months(n, _) => {
let (y, m, _) = civil_from_days(ms.div_euclid(MS_PER_DAY));
month_start(y * 12 + m as i64 - 1 + n)
}
Rung::Years(n) => {
let (y, _, _) = civil_from_days(ms.div_euclid(MS_PER_DAY));
days_from_civil(y + n, 1, 1) * MS_PER_DAY
}
}
}
}
fn month_start(m0: i64) -> i64 {
days_from_civil(m0.div_euclid(12), (m0.rem_euclid(12) + 1) as u64, 1) * MS_PER_DAY
}
fn rung_ticks(rung: Rung, min: i64, max: i64, cap: usize) -> Option<Vec<i64>> {
let mut t = rung.floor(min);
let mut out = vec![t];
while t < max {
t = rung.next(t);
out.push(t);
if out.len() > cap {
return None;
}
}
Some(out)
}
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
fn parts(ms: i64) -> (i64, u64, u64, i64, i64, i64) {
let (y, m, d) = civil_from_days(ms.div_euclid(MS_PER_DAY));
let s = ms.rem_euclid(MS_PER_DAY) / 1000;
(y, m, d, s / 3600, s / 60 % 60, s % 60)
}
fn tick_label(unit: Unit, ms: i64, prev: Option<i64>) -> String {
let (y, mo, d, hh, mi, ss) = parts(ms);
let month = MONTHS[mo as usize - 1];
let yy = y.rem_euclid(100);
let new_day = prev.is_none_or(|p| p.div_euclid(MS_PER_DAY) != ms.div_euclid(MS_PER_DAY));
let new_year = prev.is_none_or(|p| parts(p).0 != y);
match unit {
Unit::Sec => {
let t = format!("{hh:02}:{mi:02}:{ss:02}");
if new_day {
format!("{month} {d} {t}")
} else {
t
}
}
Unit::Min | Unit::Hour => {
let t = format!("{hh:02}:{mi:02}");
if new_day {
format!("{month} {d} {t}")
} else {
t
}
}
Unit::Day => {
if new_year {
format!("{month} {d} '{yy:02}")
} else {
format!("{month} {d}")
}
}
Unit::Month => {
if new_year {
format!("{month} '{yy:02}")
} else {
month.to_string()
}
}
Unit::Quarter => {
let q = (mo - 1) / 3 + 1;
if new_year {
format!("Q{q} '{yy:02}")
} else {
format!("Q{q}")
}
}
Unit::Year => y.to_string(),
}
}
pub(crate) struct TemporalAxis {
pub domain: [f64; 2],
pub ticks: Vec<(f64, String)>,
}
fn accept(rung: Rung, ticks: &[i64], plot_w: usize) -> Option<TemporalAxis> {
let (lo, hi) = (ticks[0], ticks[ticks.len() - 1]);
let span = (hi - lo) as f64;
let mut labeled = Vec::with_capacity(ticks.len());
let mut next_free = 0usize;
let mut prev = None;
for &t in ticks {
let label = tick_label(rung.unit(), t, prev);
prev = Some(t);
let len = label.chars().count();
if len > plot_w {
return None;
}
let col =
((((t - lo) as f64 / span) * (plot_w - 1) as f64).round() as usize).min(plot_w - 1);
let start = (1 + col).saturating_sub(len / 2).min(plot_w - len);
if start < next_free {
return None;
}
next_free = start + len + 1;
labeled.push((t as f64, label));
}
Some(TemporalAxis {
domain: [lo as f64, hi as f64],
ticks: labeled,
})
}
pub(crate) fn temporal_axis(min_ms: f64, max_ms: f64, plot_w: usize) -> TemporalAxis {
let (min, max) = (min_ms as i64, max_ms as i64);
let cap = plot_w / 2 + 1;
if cap < MIN_TICKS {
return fallback(min_ms, max_ms);
}
for rung in LADDER {
let Some(ticks) = rung_ticks(rung, min, max, cap) else {
continue;
};
if ticks.len() < MIN_TICKS {
continue;
}
if let Some(axis) = accept(rung, &ticks, plot_w) {
return axis;
}
}
let mut n = 1;
loop {
match rung_ticks(Rung::Years(n), min, max, cap) {
Some(ticks) if ticks.len() < MIN_TICKS => break,
Some(ticks) => {
if let Some(axis) = accept(Rung::Years(n), &ticks, plot_w) {
return axis;
}
}
None => {}
}
n = next_year_step(n);
}
fallback(min_ms, max_ms)
}
fn fallback(min_ms: f64, max_ms: f64) -> TemporalAxis {
let (min, max) = (min_ms as i64, max_ms as i64);
let unit = if max - min >= MS_PER_DAY {
Unit::Day
} else if max - min >= MS_PER_MIN {
Unit::Min
} else {
Unit::Sec
};
let mut ticks = vec![(min_ms, tick_label(unit, min, None))];
if max > min {
ticks.push((max_ms, tick_label(unit, max, None)));
}
TemporalAxis {
domain: [min_ms, max_ms],
ticks,
}
}
fn next_year_step(n: i64) -> i64 {
let mut pow = 1;
while n >= 10 * pow {
pow *= 10;
}
match n / pow {
1 => 2 * pow,
2 => 5 * pow,
_ => 10 * pow,
}
}
fn rung_for(u: TimeUnit) -> Rung {
match u {
TimeUnit::Year => Rung::Years(1),
TimeUnit::Quarter => Rung::Months(3, Unit::Quarter),
TimeUnit::Month => Rung::Months(1, Unit::Month),
TimeUnit::Week => Rung::Week,
TimeUnit::Day => Rung::Fixed(MS_PER_DAY, Unit::Day),
TimeUnit::Hour => Rung::Fixed(MS_PER_HOUR, Unit::Hour),
TimeUnit::Minute => Rung::Fixed(MS_PER_MIN, Unit::Min),
}
}
fn label_unit(u: TimeUnit) -> Unit {
match u {
TimeUnit::Year => Unit::Year,
TimeUnit::Quarter => Unit::Quarter,
TimeUnit::Month => Unit::Month,
TimeUnit::Week | TimeUnit::Day => Unit::Day,
TimeUnit::Hour => Unit::Hour,
TimeUnit::Minute => Unit::Min,
}
}
pub(crate) fn bucket_start(ms: f64, u: TimeUnit) -> f64 {
rung_for(u).floor(ms as i64) as f64
}
pub(crate) fn next_bucket(ms: f64, u: TimeUnit) -> f64 {
rung_for(u).next(ms as i64) as f64
}
pub(crate) fn bucket_key(ms: f64, u: TimeUnit) -> String {
let start = bucket_start(ms, u) as i64;
let (y, m, d) = civil_from_days(start.div_euclid(MS_PER_DAY));
let rem = start.rem_euclid(MS_PER_DAY);
let (hh, mi) = (rem / MS_PER_HOUR, rem / MS_PER_MIN % 60);
match u {
TimeUnit::Year => format!("{y:04}"),
TimeUnit::Quarter => format!("{y:04}-Q{}", (m - 1) / 3 + 1),
TimeUnit::Month => format!("{y:04}-{m:02}"),
TimeUnit::Week | TimeUnit::Day => format!("{y:04}-{m:02}-{d:02}"),
TimeUnit::Hour => format!("{y:04}-{m:02}-{d:02} {hh:02}h"),
TimeUnit::Minute => format!("{y:04}-{m:02}-{d:02} {hh:02}:{mi:02}"),
}
}
pub(crate) fn bucket_display(ms: f64, u: TimeUnit, prev: Option<f64>) -> String {
let start = bucket_start(ms, u) as i64;
let prev = prev.map(|p| bucket_start(p, u) as i64);
tick_label(label_unit(u), start, prev)
}
pub(crate) fn bucket_count(min_ms: f64, max_ms: f64, u: TimeUnit) -> usize {
let first = bucket_start(min_ms, u) as i64;
let last = bucket_start(max_ms, u) as i64;
let steps = match u {
TimeUnit::Year | TimeUnit::Quarter | TimeUnit::Month => {
let (y0, m0, _) = civil_from_days(first.div_euclid(MS_PER_DAY));
let (y1, m1, _) = civil_from_days(last.div_euclid(MS_PER_DAY));
let months = (y1 * 12 + m1 as i64) - (y0 * 12 + m0 as i64);
match u {
TimeUnit::Year => months / 12,
TimeUnit::Quarter => months / 3,
_ => months,
}
}
TimeUnit::Week => (last - first) / (7 * MS_PER_DAY),
TimeUnit::Day => (last - first) / MS_PER_DAY,
TimeUnit::Hour => (last - first) / MS_PER_HOUR,
TimeUnit::Minute => (last - first) / MS_PER_MIN,
};
steps as usize + 1
}
#[cfg(test)]
mod tests {
use super::*;
fn ref_days_in_month(y: i64, m: u64) -> u64 {
let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => unreachable!("month {m} out of range"),
}
}
#[test]
fn civil_epoch_round_trip_exhaustive() {
let start = days_from_civil(1600, 1, 1);
let end = days_from_civil(2400, 12, 31);
let (mut y, mut m, mut d) = (1600i64, 1u64, 1u64);
for z in start..=end {
assert_eq!(civil_from_days(z), (y, m, d), "civil_from_days({z})");
assert_eq!(days_from_civil(y, m, d), z, "round-trip at {z}");
d += 1;
if d > ref_days_in_month(y, m) {
d = 1;
m += 1;
if m > 12 {
m = 1;
y += 1;
}
}
}
}
#[test]
fn leap_and_month_length_edges() {
assert!(parse_temporal("2000-02-29").is_some()); assert!(parse_temporal("1900-02-29").is_none()); assert!(parse_temporal("2024-02-29").is_some()); assert!(parse_temporal("2026-02-29").is_none()); assert!(parse_temporal("2024-01-31").is_some()); assert!(parse_temporal("2024-04-31").is_none()); }
#[test]
fn parse_table_maps_to_millis() {
const DAY: i64 = 86_400_000;
let date = days_from_civil(2026, 7, 5) * DAY; let noon = 14 * 3_600_000 + 30 * 60_000; assert_eq!(parse_temporal("2026-07-05"), Some(date as f64));
assert_eq!(
parse_temporal("2026-07-05T14:30:00"),
Some((date + noon) as f64)
);
assert_eq!(
parse_temporal("2026-07-05 14:30:00"),
Some((date + noon) as f64)
);
assert_eq!(
parse_temporal("2026-07-05T14:30:00.123"),
Some((date + noon + 123) as f64)
);
assert_eq!(
parse_temporal("2026-07-05T14:30:00.1"),
Some((date + noon + 100) as f64)
);
assert_eq!(
parse_temporal("2026-07-05T14:30:00.12"),
Some((date + noon + 120) as f64)
);
assert_eq!(
parse_temporal("2026-07-05T14:30:00Z"),
Some((date + noon) as f64)
);
assert_eq!(
parse_temporal("2026-07-05T12:30:00-02:00"),
Some((date + noon) as f64)
);
assert_eq!(
parse_temporal("2026-07-05T16:30:00+02:00"),
Some((date + noon) as f64)
);
assert_eq!(parse_temporal("14:30:00"), Some(noon as f64));
}
fn iso(ms: f64) -> String {
let ms = ms as i64;
let (y, m, d) = civil_from_days(ms.div_euclid(MS_PER_DAY));
let s = ms.rem_euclid(MS_PER_DAY) / 1000;
if s == 0 {
format!("{y:04}-{m:02}-{d:02}")
} else {
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}",
s / 3600,
s / 60 % 60,
s % 60
)
}
}
fn axis(min: &str, max: &str, plot_w: usize) -> (Vec<(String, String)>, [String; 2]) {
let ax = temporal_axis(
parse_temporal(min).unwrap(),
parse_temporal(max).unwrap(),
plot_w,
);
(
ax.ticks.into_iter().map(|(t, l)| (iso(t), l)).collect(),
[iso(ax.domain[0]), iso(ax.domain[1])],
)
}
fn assert_axis(
got: (Vec<(String, String)>, [String; 2]),
want_ticks: &[(&str, &str)],
want_domain: [&str; 2],
) {
let ticks: Vec<(&str, &str)> = got
.0
.iter()
.map(|(p, l)| (p.as_str(), l.as_str()))
.collect();
assert_eq!(ticks, want_ticks);
assert_eq!(got.1, want_domain);
}
#[test]
fn temporal_axis_week_steps_expand_domain_to_mondays() {
assert_axis(
axis("2026-06-03", "2026-06-27", 72),
&[
("2026-06-01", "Jun 1 '26"),
("2026-06-08", "Jun 8"),
("2026-06-15", "Jun 15"),
("2026-06-22", "Jun 22"),
("2026-06-29", "Jun 29"),
],
["2026-06-01", "2026-06-29"],
);
}
#[test]
fn temporal_axis_quarter_steps_year_rollover() {
assert_axis(
axis("2025-01-01", "2026-12-01", 72),
&[
("2025-01-01", "Q1 '25"),
("2025-04-01", "Q2"),
("2025-07-01", "Q3"),
("2025-10-01", "Q4"),
("2026-01-01", "Q1 '26"),
("2026-04-01", "Q2"),
("2026-07-01", "Q3"),
("2026-10-01", "Q4"),
("2027-01-01", "Q1 '27"),
],
["2025-01-01", "2027-01-01"],
);
}
#[test]
fn temporal_axis_six_hour_steps_day_rollover() {
assert_axis(
axis("2026-06-14T06:00:00", "2026-06-15T06:00:00", 72),
&[
("2026-06-14T06:00:00", "Jun 14 06:00"),
("2026-06-14T12:00:00", "12:00"),
("2026-06-14T18:00:00", "18:00"),
("2026-06-15", "Jun 15 00:00"),
("2026-06-15T06:00:00", "06:00"),
],
["2026-06-14T06:00:00", "2026-06-15T06:00:00"],
);
}
#[test]
fn temporal_axis_thirty_second_steps() {
assert_axis(
axis("2026-06-14T14:30:00", "2026-06-14T14:31:00", 72),
&[
("2026-06-14T14:30:00", "Jun 14 14:30:00"),
("2026-06-14T14:30:30", "14:30:30"),
("2026-06-14T14:31:00", "14:31:00"),
],
["2026-06-14T14:30:00", "2026-06-14T14:31:00"],
);
}
#[test]
fn temporal_axis_thirty_minute_steps() {
assert_axis(
axis("2026-06-14T14:30:00", "2026-06-14T16:00:00", 72),
&[
("2026-06-14T14:30:00", "Jun 14 14:30"),
("2026-06-14T15:00:00", "15:00"),
("2026-06-14T15:30:00", "15:30"),
("2026-06-14T16:00:00", "16:00"),
],
["2026-06-14T14:30:00", "2026-06-14T16:00:00"],
);
}
#[test]
fn temporal_axis_century_span_coarsens_past_cap() {
assert_axis(
axis("1600-01-01", "2400-01-01", 72),
&[
("1600-01-01", "1600"),
("1700-01-01", "1700"),
("1800-01-01", "1800"),
("1900-01-01", "1900"),
("2000-01-01", "2000"),
("2100-01-01", "2100"),
("2200-01-01", "2200"),
("2300-01-01", "2300"),
("2400-01-01", "2400"),
],
["1600-01-01", "2400-01-01"],
);
}
#[test]
fn temporal_axis_degenerate_widths_fall_back() {
for w in [0, 1, 2] {
assert_axis(
axis("2026-06-03", "2026-06-27", w),
&[("2026-06-03", "Jun 3 '26"), ("2026-06-27", "Jun 27 '26")],
["2026-06-03", "2026-06-27"],
);
}
}
#[test]
fn temporal_axis_single_instant_dedupes_fallback() {
assert_axis(
axis("2026-06-14T14:30:00", "2026-06-14T14:30:00", 12),
&[("2026-06-14T14:30:00", "Jun 14 14:30:00")],
["2026-06-14T14:30:00", "2026-06-14T14:30:00"],
);
}
#[test]
fn temporal_axis_year_steps() {
assert_axis(
axis("2024-01-01", "2030-01-01", 72),
&[
("2024-01-01", "2024"),
("2025-01-01", "2025"),
("2026-01-01", "2026"),
("2027-01-01", "2027"),
("2028-01-01", "2028"),
("2029-01-01", "2029"),
("2030-01-01", "2030"),
],
["2024-01-01", "2030-01-01"],
);
}
#[test]
fn temporal_axis_three_months_daily_coarsens_to_months() {
assert_axis(
axis("2026-06-01", "2026-08-31", 72),
&[
("2026-06-01", "Jun '26"),
("2026-07-01", "Jul"),
("2026-08-01", "Aug"),
("2026-09-01", "Sep"),
],
["2026-06-01", "2026-09-01"],
);
}
#[test]
fn temporal_axis_thirty_six_hours_coarsens_to_twelve_hours() {
assert_axis(
axis("2026-06-14T06:00:00", "2026-06-15T18:00:00", 72),
&[
("2026-06-14", "Jun 14 00:00"),
("2026-06-14T12:00:00", "12:00"),
("2026-06-15", "Jun 15 00:00"),
("2026-06-15T12:00:00", "12:00"),
("2026-06-16", "Jun 16 00:00"),
],
["2026-06-14", "2026-06-16"],
);
}
#[test]
fn temporal_axis_narrow_plot_coarsens() {
assert_axis(
axis("2026-06-03", "2026-06-27", 30),
&[("2026-06-03", "Jun 3 '26"), ("2026-06-27", "Jun 27 '26")],
["2026-06-03", "2026-06-27"],
);
assert_axis(
axis("2025-01-01", "2026-12-01", 30),
&[
("2025-01-01", "2025"),
("2026-01-01", "2026"),
("2027-01-01", "2027"),
],
["2025-01-01", "2027-01-01"],
);
assert_axis(
axis("2026-06-14T06:00:00", "2026-06-15T06:00:00", 30),
&[
("2026-06-14", "Jun 14 '26"),
("2026-06-15", "Jun 15"),
("2026-06-16", "Jun 16"),
],
["2026-06-14", "2026-06-16"],
);
assert_axis(
axis("2024-01-01", "2030-01-01", 30),
&[
("2024-01-01", "2024"),
("2026-01-01", "2026"),
("2028-01-01", "2028"),
("2030-01-01", "2030"),
],
["2024-01-01", "2030-01-01"],
);
}
#[test]
fn temporal_axis_tiny_plot_first_and_last_fallback() {
assert_axis(
axis("2026-06-03", "2026-06-27", 12),
&[("2026-06-03", "Jun 3 '26"), ("2026-06-27", "Jun 27 '26")],
["2026-06-03", "2026-06-27"],
);
assert_axis(
axis("2025-01-01", "2026-12-01", 12),
&[("2025-01-01", "Jan 1 '25"), ("2026-12-01", "Dec 1 '26")],
["2025-01-01", "2026-12-01"],
);
assert_axis(
axis("2026-06-14T06:00:00", "2026-06-15T06:00:00", 12),
&[
("2026-06-14T06:00:00", "Jun 14 '26"),
("2026-06-15T06:00:00", "Jun 15 '26"),
],
["2026-06-14T06:00:00", "2026-06-15T06:00:00"],
);
assert_axis(
axis("2024-01-01", "2030-01-01", 12),
&[("2024-01-01", "Jan 1 '24"), ("2030-01-01", "Jan 1 '30")],
["2024-01-01", "2030-01-01"],
);
}
#[test]
fn temporal_axis_sub_day_fallback_minute_context() {
assert_axis(
axis("2026-06-14T14:30:00", "2026-06-14T18:45:00", 12),
&[
("2026-06-14T14:30:00", "Jun 14 14:30"),
("2026-06-14T18:45:00", "Jun 14 18:45"),
],
["2026-06-14T14:30:00", "2026-06-14T18:45:00"],
);
}
#[test]
fn temporal_axis_sub_minute_fallback_second_context() {
assert_axis(
axis("2026-06-14T14:30:05", "2026-06-14T14:30:45", 12),
&[
("2026-06-14T14:30:05", "Jun 14 14:30:05"),
("2026-06-14T14:30:45", "Jun 14 14:30:45"),
],
["2026-06-14T14:30:05", "2026-06-14T14:30:45"],
);
}
#[test]
fn format_iso_round_trips_the_shapes() {
for s in [
"2026-07-05",
"2026-07-05T14:30:00",
"2026-07-05T14:30:00.123",
"1600-01-01",
"2400-12-31T23:59:59",
] {
let ms = parse_temporal(s).expect("parses");
assert_eq!(format_iso(ms), s, "round-trip {s}");
}
}
#[test]
fn rejects_non_iso_shapes() {
for s in [
"2026/07/05", "07-05-2026", "2026-7-5", "2026-13-01", "2026-07-05T25:00:00", "2026-07-05T14:60:00", "2026-07-05T14:30:60", "2026-07-05T14:30:00.1234", "2026-07-05x", "hello", "", ] {
assert_eq!(parse_temporal(s), None, "expected {s:?} to be rejected");
}
}
fn ms(s: &str) -> f64 {
parse_temporal(s).expect("test timestamp parses")
}
#[test]
fn bucket_key_zero_padded_iso_prefix_per_unit() {
let t = ms("2026-06-14T09:12:45");
assert_eq!(bucket_key(t, TimeUnit::Year), "2026");
assert_eq!(bucket_key(t, TimeUnit::Quarter), "2026-Q2");
assert_eq!(bucket_key(t, TimeUnit::Month), "2026-06");
assert_eq!(bucket_key(t, TimeUnit::Week), "2026-06-08");
assert_eq!(bucket_key(t, TimeUnit::Day), "2026-06-14");
assert_eq!(bucket_key(t, TimeUnit::Hour), "2026-06-14 09h");
assert_eq!(bucket_key(t, TimeUnit::Minute), "2026-06-14 09:12");
}
#[test]
fn bucket_key_quarter_boundaries() {
for (mnth, q) in [
("01", 1),
("03", 1),
("04", 2),
("06", 2),
("07", 3),
("09", 3),
("10", 4),
("12", 4),
] {
let key = bucket_key(ms(&format!("2026-{mnth}-15")), TimeUnit::Quarter);
assert_eq!(key, format!("2026-Q{q}"), "month {mnth}");
}
}
#[test]
fn bucket_start_idempotent_and_key_stable() {
for u in [
TimeUnit::Year,
TimeUnit::Quarter,
TimeUnit::Month,
TimeUnit::Week,
TimeUnit::Day,
TimeUnit::Hour,
TimeUnit::Minute,
] {
let t = ms("2026-06-14T09:12:45");
let start = bucket_start(t, u);
assert_eq!(bucket_start(start, u), start, "idempotent floor");
assert_eq!(
bucket_key(start, u),
bucket_key(t, u),
"key stable in bucket"
);
}
}
#[test]
fn next_bucket_steps_the_calendar() {
let step = |s: &str, u| bucket_key(next_bucket(bucket_start(ms(s), u), u), u);
assert_eq!(
step("2026-06-14T09:12:00", TimeUnit::Minute),
"2026-06-14 09:13"
);
assert_eq!(
step("2026-06-14T09:12:00", TimeUnit::Hour),
"2026-06-14 10h"
);
assert_eq!(step("2026-06-14", TimeUnit::Day), "2026-06-15");
assert_eq!(step("2026-06-10", TimeUnit::Week), "2026-06-15");
assert_eq!(step("2026-12-20", TimeUnit::Month), "2027-01");
assert_eq!(step("2026-11-20", TimeUnit::Quarter), "2027-Q1");
assert_eq!(step("2026-03-20", TimeUnit::Year), "2027");
}
#[test]
fn bucket_display_context_and_delta() {
let t = ms("2026-06-14T09:12:00");
let prev_month = ms("2026-05-01");
let prev_hour = ms("2026-06-14T08:30:00");
let prev_day = ms("2026-06-13");
assert_eq!(bucket_display(t, TimeUnit::Month, None), "Jun '26");
assert_eq!(bucket_display(t, TimeUnit::Month, Some(prev_month)), "Jun");
assert_eq!(bucket_display(t, TimeUnit::Quarter, None), "Q2 '26");
assert_eq!(bucket_display(t, TimeUnit::Quarter, Some(prev_month)), "Q2");
assert_eq!(bucket_display(t, TimeUnit::Day, None), "Jun 14 '26");
assert_eq!(bucket_display(t, TimeUnit::Day, Some(prev_day)), "Jun 14");
assert_eq!(bucket_display(t, TimeUnit::Hour, None), "Jun 14 09:00");
assert_eq!(bucket_display(t, TimeUnit::Hour, Some(prev_hour)), "09:00");
assert_eq!(bucket_display(t, TimeUnit::Year, None), "2026");
assert_eq!(bucket_display(t, TimeUnit::Year, Some(prev_month)), "2026");
assert_eq!(
bucket_display(
ms("2026-06-15T00:00:00"),
TimeUnit::Hour,
Some(ms("2026-06-14T23:00:00"))
),
"Jun 15 00:00"
);
assert_eq!(
bucket_display(ms("2027-01-01"), TimeUnit::Month, Some(ms("2026-12-01"))),
"Jan '27"
);
assert_eq!(
bucket_display(ms("2027-01-01"), TimeUnit::Year, Some(ms("2026-01-01"))),
"2027"
);
}
#[test]
fn bucket_count_matches_the_walk() {
for (a, b, u) in [
("2026-06-14T09:05:00", "2026-06-14T14:45:00", TimeUnit::Hour),
("2026-06-14T09:05:00", "2026-06-14T09:05:00", TimeUnit::Hour),
("2026-01-05", "2026-04-15", TimeUnit::Month),
("2025-11-20", "2026-03-01", TimeUnit::Quarter),
("2024-02-29", "2026-03-01", TimeUnit::Year),
("2026-06-03", "2026-06-27", TimeUnit::Week),
("2026-06-01", "2026-06-30", TimeUnit::Day),
(
"2026-06-14T09:00:00",
"2026-06-14T09:59:30",
TimeUnit::Minute,
),
] {
let (min, max) = (ms(a), ms(b));
let mut n = 1usize;
let mut t = bucket_start(min, u);
let last = bucket_start(max, u);
while t < last {
t = next_bucket(t, u);
n += 1;
}
assert_eq!(bucket_count(min, max, u), n, "{a}..{b} {u:?}");
}
assert_eq!(
bucket_count(
ms("2026-06-01 00:00:00"),
ms("2026-06-30 23:59:00"),
TimeUnit::Minute
),
43_200
);
}
}