facett-core/src/time_axis.rs
//! **Shared pan/zoom-in-time axis** (TIME-1) — the *one* time-window model behind
//! every time-laid-out facet: gantt bars, a CFD's day columns, a calendar's weeks,
//! a timeline's events. It is the temporal sibling of [`nav::Navigable`](crate::nav):
//! where `Navigable` pans/zooms 2-D *space*, [`TimeAxis`] pans/zooms a 1-D *time*
//! window — and several facets can **link** to one axis (a shared time-scrubber
//! scrolls them together) via [`sync_from`](TimeAxis::sync_from).
//!
//! Like the other primitives it is **engine-agnostic** (no egui, no rendering): a
//! serializable window with pure pan/zoom math, so the arithmetic is a *tested*
//! property (FC-7). Time is carried as an `f64` in whatever unit the host chooses
//! (epoch seconds, days, ms) — the axis only ever does affine arithmetic on it, so
//! it stays unit-agnostic. It follows the Elm idiom ([`crate::elm`]): one `Model`,
//! a [`TimeMsg`] input surface, mutation only via [`update`](TimeAxis::update),
//! and a window change surfaced **as data** ([`TimeEffect::WindowChanged`]) so a
//! host can propagate it to linked axes. It is a primitive *used by* facets, so —
//! like `Navigable` — it does not itself `impl Facet`.
use serde::{Deserialize, Serialize};
/// Side work as data (FC-8): the visible window moved. A host relays it to any
/// linked axes (the "one scrubber drives several facets" case).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TimeEffect {
/// The window is now `[start, end]`.
WindowChanged { start: f64, end: f64 },
}
/// Every input the axis accepts (FC-2) — the only legal way to mutate a
/// [`TimeAxis`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TimeMsg {
/// Pan by a delta in axis units (positive = window slides later).
Pan(f64),
/// Zoom by `factor` (>1 zooms *in* / narrows the window) keeping the time at
/// `pivot` (in axis units) fixed on screen.
Zoom { factor: f64, pivot: f64 },
/// Snap the window to exactly `[start, end]` (fit-to-range).
Fit { start: f64, end: f64 },
/// Widen the clamp bounds the window may pan/zoom within.
SetBounds { min: f64, max: f64 },
}
/// The shared time window (TIME-1). `[start, end]` is what's visible; `[min, max]`
/// is the hard clamp (the data's full extent); `min_span` caps how far you can
/// zoom in.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct TimeAxis {
/// Left edge of the visible window (axis units).
pub start: f64,
/// Right edge of the visible window (axis units); always `> start`.
pub end: f64,
/// Hard clamp: the window never pans/zooms outside `[min, max]`.
pub min: f64,
pub max: f64,
/// Smallest allowed visible span (max zoom-in).
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 {
/// A window spanning `[min, max]` fully zoomed out.
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 }
}
/// The visible span (`end - start`).
pub fn span(&self) -> f64 {
self.end - self.start
}
/// The largest span the clamp allows (the full data extent).
pub fn max_span(&self) -> f64 {
self.max - self.min
}
/// Map an axis-unit time to a `0.0..=1.0` fraction of the visible window
/// (`<0` / `>1` = off-screen left/right). The renderer multiplies by pixel
/// width to place a bar/event.
pub fn to_frac(&self, t: f64) -> f64 {
let span = self.span();
if span <= 0.0 { 0.0 } else { (t - self.start) / span }
}
/// Inverse of [`to_frac`](Self::to_frac): the time at window fraction `f`.
pub fn from_frac(&self, f: f64) -> f64 {
self.start + f * self.span()
}
/// Re-clamp the window so `[start, end]` lies inside `[min, max]` while
/// preserving its span where possible.
fn clamp_window(&mut self) {
let extent = self.max_span();
// Span can't exceed the extent, nor shrink below min_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);
}
// Slide the window back inside the bounds.
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; // extent smaller than span (only when span==extent)
}
self.start = start;
self.end = start + span;
}
/// **Pan** by a delta in axis units (positive slides later), clamped so the
/// window stays inside `[min, max]`.
pub fn pan(&mut self, delta: f64) {
self.start += delta;
self.end += delta;
self.clamp_window();
}
/// **Zoom** toward a pivot time by `factor` (>1 narrows / zooms in), keeping
/// the `pivot` time at the same on-screen fraction — the natural map/plot feel
/// applied to time. Clamps the new span to `[min_span, max_span]`.
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;
}
// Where the pivot sits in the current window (kept fixed).
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();
}
/// Snap the window to `[start, end]` (fit-to-range), clamped to bounds.
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();
}
/// Widen (or reset) the clamp bounds, then re-clamp the window into them.
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();
}
}
/// **Link** to another axis: copy its visible window (a shared scrubber scrolls
/// several facets together, TIME-1). Bounds/min_span stay this axis's own.
pub fn sync_from(&mut self, other: &TimeAxis) {
self.start = other.start;
self.end = other.end;
self.clamp_window();
}
/// **The single mutation path** (FC-2 / FC-8). Apply one [`TimeMsg`]; return a
/// [`TimeEffect::WindowChanged`] iff the visible window actually moved (empty
/// otherwise), so linked axes only re-sync on a real change.
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()
}
}
/// Observable state as JSON (the `state_json` discipline).
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); // zoom in 2× about t=40
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); // zoom in to span 25
assert!(approx(ax.span(), 25.0));
for _ in 0..10 {
ax.zoom(0.5, 50.0); // repeatedly zoom out
}
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); // span 25, window ~[37.5, 62.5]
ax.pan(1000.0); // shove far right
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); // shove far left
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));
// Out-of-bounds fit is clamped.
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);
// A no-op pan at the left edge (already clamped) emits nothing.
let fx = ax.update(TimeMsg::Pan(-10.0));
assert!(fx.is_empty(), "clamped-to-edge pan is a no-op");
// A real zoom emits the new window.
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);
// Propagate a's window into b (the shared-scrubber case).
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);
}
}