use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TimeEffect {
WindowChanged { start: f64, end: f64 },
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TimeMsg {
Pan(f64),
Zoom { factor: f64, pivot: f64 },
Fit { start: f64, end: f64 },
SetBounds { min: f64, max: f64 },
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct TimeAxis {
pub start: f64,
pub end: f64,
pub min: f64,
pub max: f64,
pub min_span: f64,
}
impl Default for TimeAxis {
fn default() -> Self {
Self { start: 0.0, end: 1.0, min: 0.0, max: 1.0, min_span: 1e-6 }
}
}
impl TimeAxis {
pub fn new(min: f64, max: f64) -> Self {
let (min, max) = if max > min { (min, max) } else { (min, min + 1.0) };
Self { start: min, end: max, min, max, min_span: (max - min) * 1e-6 }
}
pub fn span(&self) -> f64 {
self.end - self.start
}
pub fn max_span(&self) -> f64 {
self.max - self.min
}
pub fn to_frac(&self, t: f64) -> f64 {
let span = self.span();
if span <= 0.0 { 0.0 } else { (t - self.start) / span }
}
pub fn from_frac(&self, f: f64) -> f64 {
self.start + f * self.span()
}
fn clamp_window(&mut self) {
let extent = self.max_span();
let mut span = self.span().clamp(self.min_span.min(extent).max(0.0), extent.max(self.min_span));
if span <= 0.0 {
span = extent.max(self.min_span);
}
let mut start = self.start;
if start < self.min {
start = self.min;
}
if start + span > self.max {
start = self.max - span;
}
if start < self.min {
start = self.min; }
self.start = start;
self.end = start + span;
}
pub fn pan(&mut self, delta: f64) {
self.start += delta;
self.end += delta;
self.clamp_window();
}
pub fn zoom(&mut self, factor: f64, pivot: f64) {
if factor <= 0.0 || !factor.is_finite() {
return;
}
let span = self.span();
if span <= 0.0 {
return;
}
let frac = ((pivot - self.start) / span).clamp(0.0, 1.0);
let new_span = (span / factor).clamp(self.min_span, self.max_span());
self.start = pivot - frac * new_span;
self.end = self.start + new_span;
self.clamp_window();
}
pub fn fit(&mut self, start: f64, end: f64) {
let (a, b) = if end > start { (start, end) } else { (start, start + self.min_span) };
self.start = a;
self.end = b;
self.clamp_window();
}
pub fn set_bounds(&mut self, min: f64, max: f64) {
if max > min {
self.min = min;
self.max = max;
self.min_span = self.min_span.min(max - min);
self.clamp_window();
}
}
pub fn sync_from(&mut self, other: &TimeAxis) {
self.start = other.start;
self.end = other.end;
self.clamp_window();
}
pub fn update(&mut self, msg: TimeMsg) -> Vec<TimeEffect> {
let before = (self.start, self.end);
match msg {
TimeMsg::Pan(d) => self.pan(d),
TimeMsg::Zoom { factor, pivot } => self.zoom(factor, pivot),
TimeMsg::Fit { start, end } => self.fit(start, end),
TimeMsg::SetBounds { min, max } => self.set_bounds(min, max),
}
if (self.start, self.end) != before {
vec![TimeEffect::WindowChanged { start: self.start, end: self.end }]
} else {
Vec::new()
}
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"start": self.start,
"end": self.end,
"span": self.span(),
"min": self.min,
"max": self.max,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn new_spans_the_full_extent() {
let ax = TimeAxis::new(100.0, 200.0);
assert_eq!(ax.span(), 100.0);
assert_eq!(ax.to_frac(150.0), 0.5, "midpoint is halfway");
assert!(approx(ax.from_frac(0.25), 125.0));
}
#[test]
fn zoom_in_keeps_the_pivot_time_fixed() {
let mut ax = TimeAxis::new(0.0, 100.0);
let pivot = 40.0;
let frac_before = ax.to_frac(pivot);
ax.zoom(2.0, pivot); assert!(approx(ax.span(), 50.0), "span halved, got {}", ax.span());
assert!(approx(ax.to_frac(pivot), frac_before), "pivot stays at the same fraction");
}
#[test]
fn zoom_out_clamps_to_the_full_extent() {
let mut ax = TimeAxis::new(0.0, 100.0);
ax.zoom(4.0, 50.0); assert!(approx(ax.span(), 25.0));
for _ in 0..10 {
ax.zoom(0.5, 50.0); }
assert!(approx(ax.span(), 100.0), "cannot zoom past the full extent, got {}", ax.span());
assert!(ax.start >= ax.min - EPS && ax.end <= ax.max + EPS);
}
#[test]
fn zoom_in_clamps_to_min_span() {
let mut ax = TimeAxis::new(0.0, 100.0);
ax.min_span = 5.0;
for _ in 0..20 {
ax.zoom(2.0, 30.0);
}
assert!(ax.span() >= 5.0 - EPS, "min_span floor honored, got {}", ax.span());
}
#[test]
fn pan_clamps_within_bounds() {
let mut ax = TimeAxis::new(0.0, 100.0);
ax.zoom(4.0, 50.0); ax.pan(1000.0); assert!(approx(ax.end, 100.0), "clamped to max edge, got end={}", ax.end);
assert!(approx(ax.span(), 25.0), "span preserved while panning");
ax.pan(-1000.0); assert!(approx(ax.start, 0.0), "clamped to min edge, got start={}", ax.start);
assert!(approx(ax.span(), 25.0));
}
#[test]
fn fit_snaps_to_a_range() {
let mut ax = TimeAxis::new(0.0, 100.0);
ax.fit(20.0, 40.0);
assert!(approx(ax.start, 20.0) && approx(ax.end, 40.0));
ax.fit(-50.0, 500.0);
assert!(ax.start >= ax.min - EPS && ax.end <= ax.max + EPS);
}
#[test]
fn update_emits_window_changed_only_on_a_real_move() {
let mut ax = TimeAxis::new(0.0, 100.0);
let fx = ax.update(TimeMsg::Pan(-10.0));
assert!(fx.is_empty(), "clamped-to-edge pan is a no-op");
let fx = ax.update(TimeMsg::Zoom { factor: 2.0, pivot: 50.0 });
assert_eq!(fx.len(), 1);
match fx[0] {
TimeEffect::WindowChanged { start, end } => {
assert!(approx(start, ax.start) && approx(end, ax.end));
}
}
}
#[test]
fn linked_axes_sync_windows() {
let mut a = TimeAxis::new(0.0, 100.0);
let mut b = TimeAxis::new(0.0, 100.0);
a.zoom(4.0, 25.0);
for fx in a.update(TimeMsg::Pan(5.0)) {
let TimeEffect::WindowChanged { .. } = fx;
}
b.sync_from(&a);
assert!(approx(a.start, b.start) && approx(a.end, b.end), "b follows a's window");
}
#[test]
fn serde_round_trip_is_identity() {
let mut ax = TimeAxis::new(1000.0, 5000.0);
ax.zoom(3.0, 2500.0);
let json = serde_json::to_string(&ax).unwrap();
let back: TimeAxis = serde_json::from_str(&json).unwrap();
assert_eq!(ax, back);
}
}