Skip to main content

facett_core/
time_axis.rs

1//! **Shared pan/zoom-in-time axis** (TIME-1) — the *one* time-window model behind
2//! every time-laid-out facet: gantt bars, a CFD's day columns, a calendar's weeks,
3//! a timeline's events. It is the temporal sibling of [`nav::Navigable`](crate::nav):
4//! where `Navigable` pans/zooms 2-D *space*, [`TimeAxis`] pans/zooms a 1-D *time*
5//! window — and several facets can **link** to one axis (a shared time-scrubber
6//! scrolls them together) via [`sync_from`](TimeAxis::sync_from).
7//!
8//! Like the other primitives it is **engine-agnostic** (no egui, no rendering): a
9//! serializable window with pure pan/zoom math, so the arithmetic is a *tested*
10//! property (FC-7). Time is carried as an `f64` in whatever unit the host chooses
11//! (epoch seconds, days, ms) — the axis only ever does affine arithmetic on it, so
12//! it stays unit-agnostic. It follows the Elm idiom ([`crate::elm`]): one `Model`,
13//! a [`TimeMsg`] input surface, mutation only via [`update`](TimeAxis::update),
14//! and a window change surfaced **as data** ([`TimeEffect::WindowChanged`]) so a
15//! host can propagate it to linked axes. It is a primitive *used by* facets, so —
16//! like `Navigable` — it does not itself `impl Facet`.
17
18use serde::{Deserialize, Serialize};
19
20/// Side work as data (FC-8): the visible window moved. A host relays it to any
21/// linked axes (the "one scrubber drives several facets" case).
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub enum TimeEffect {
24    /// The window is now `[start, end]`.
25    WindowChanged { start: f64, end: f64 },
26}
27
28/// Every input the axis accepts (FC-2) — the only legal way to mutate a
29/// [`TimeAxis`].
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub enum TimeMsg {
32    /// Pan by a delta in axis units (positive = window slides later).
33    Pan(f64),
34    /// Zoom by `factor` (>1 zooms *in* / narrows the window) keeping the time at
35    /// `pivot` (in axis units) fixed on screen.
36    Zoom { factor: f64, pivot: f64 },
37    /// Snap the window to exactly `[start, end]` (fit-to-range).
38    Fit { start: f64, end: f64 },
39    /// Widen the clamp bounds the window may pan/zoom within.
40    SetBounds { min: f64, max: f64 },
41}
42
43/// The shared time window (TIME-1). `[start, end]` is what's visible; `[min, max]`
44/// is the hard clamp (the data's full extent); `min_span` caps how far you can
45/// zoom in.
46#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
47pub struct TimeAxis {
48    /// Left edge of the visible window (axis units).
49    pub start: f64,
50    /// Right edge of the visible window (axis units); always `> start`.
51    pub end: f64,
52    /// Hard clamp: the window never pans/zooms outside `[min, max]`.
53    pub min: f64,
54    pub max: f64,
55    /// Smallest allowed visible span (max zoom-in).
56    pub min_span: f64,
57}
58
59impl Default for TimeAxis {
60    fn default() -> Self {
61        Self { start: 0.0, end: 1.0, min: 0.0, max: 1.0, min_span: 1e-6 }
62    }
63}
64
65impl TimeAxis {
66    /// A window spanning `[min, max]` fully zoomed out.
67    pub fn new(min: f64, max: f64) -> Self {
68        let (min, max) = if max > min { (min, max) } else { (min, min + 1.0) };
69        Self { start: min, end: max, min, max, min_span: (max - min) * 1e-6 }
70    }
71
72    /// The visible span (`end - start`).
73    pub fn span(&self) -> f64 {
74        self.end - self.start
75    }
76
77    /// The largest span the clamp allows (the full data extent).
78    pub fn max_span(&self) -> f64 {
79        self.max - self.min
80    }
81
82    /// Map an axis-unit time to a `0.0..=1.0` fraction of the visible window
83    /// (`<0` / `>1` = off-screen left/right). The renderer multiplies by pixel
84    /// width to place a bar/event.
85    pub fn to_frac(&self, t: f64) -> f64 {
86        let span = self.span();
87        if span <= 0.0 { 0.0 } else { (t - self.start) / span }
88    }
89
90    /// Inverse of [`to_frac`](Self::to_frac): the time at window fraction `f`.
91    pub fn from_frac(&self, f: f64) -> f64 {
92        self.start + f * self.span()
93    }
94
95    /// Re-clamp the window so `[start, end]` lies inside `[min, max]` while
96    /// preserving its span where possible.
97    fn clamp_window(&mut self) {
98        let extent = self.max_span();
99        // Span can't exceed the extent, nor shrink below min_span.
100        let mut span = self.span().clamp(self.min_span.min(extent).max(0.0), extent.max(self.min_span));
101        if span <= 0.0 {
102            span = extent.max(self.min_span);
103        }
104        // Slide the window back inside the bounds.
105        let mut start = self.start;
106        if start < self.min {
107            start = self.min;
108        }
109        if start + span > self.max {
110            start = self.max - span;
111        }
112        if start < self.min {
113            start = self.min; // extent smaller than span (only when span==extent)
114        }
115        self.start = start;
116        self.end = start + span;
117    }
118
119    /// **Pan** by a delta in axis units (positive slides later), clamped so the
120    /// window stays inside `[min, max]`.
121    pub fn pan(&mut self, delta: f64) {
122        self.start += delta;
123        self.end += delta;
124        self.clamp_window();
125    }
126
127    /// **Zoom** toward a pivot time by `factor` (>1 narrows / zooms in), keeping
128    /// the `pivot` time at the same on-screen fraction — the natural map/plot feel
129    /// applied to time. Clamps the new span to `[min_span, max_span]`.
130    pub fn zoom(&mut self, factor: f64, pivot: f64) {
131        if factor <= 0.0 || !factor.is_finite() {
132            return;
133        }
134        let span = self.span();
135        if span <= 0.0 {
136            return;
137        }
138        // Where the pivot sits in the current window (kept fixed).
139        let frac = ((pivot - self.start) / span).clamp(0.0, 1.0);
140        let new_span = (span / factor).clamp(self.min_span, self.max_span());
141        self.start = pivot - frac * new_span;
142        self.end = self.start + new_span;
143        self.clamp_window();
144    }
145
146    /// Snap the window to `[start, end]` (fit-to-range), clamped to bounds.
147    pub fn fit(&mut self, start: f64, end: f64) {
148        let (a, b) = if end > start { (start, end) } else { (start, start + self.min_span) };
149        self.start = a;
150        self.end = b;
151        self.clamp_window();
152    }
153
154    /// Widen (or reset) the clamp bounds, then re-clamp the window into them.
155    pub fn set_bounds(&mut self, min: f64, max: f64) {
156        if max > min {
157            self.min = min;
158            self.max = max;
159            self.min_span = self.min_span.min(max - min);
160            self.clamp_window();
161        }
162    }
163
164    /// **Link** to another axis: copy its visible window (a shared scrubber scrolls
165    /// several facets together, TIME-1). Bounds/min_span stay this axis's own.
166    pub fn sync_from(&mut self, other: &TimeAxis) {
167        self.start = other.start;
168        self.end = other.end;
169        self.clamp_window();
170    }
171
172    /// **The single mutation path** (FC-2 / FC-8). Apply one [`TimeMsg`]; return a
173    /// [`TimeEffect::WindowChanged`] iff the visible window actually moved (empty
174    /// otherwise), so linked axes only re-sync on a real change.
175    pub fn update(&mut self, msg: TimeMsg) -> Vec<TimeEffect> {
176        let before = (self.start, self.end);
177        match msg {
178            TimeMsg::Pan(d) => self.pan(d),
179            TimeMsg::Zoom { factor, pivot } => self.zoom(factor, pivot),
180            TimeMsg::Fit { start, end } => self.fit(start, end),
181            TimeMsg::SetBounds { min, max } => self.set_bounds(min, max),
182        }
183        if (self.start, self.end) != before {
184            vec![TimeEffect::WindowChanged { start: self.start, end: self.end }]
185        } else {
186            Vec::new()
187        }
188    }
189
190    /// Observable state as JSON (the `state_json` discipline).
191    pub fn state_json(&self) -> serde_json::Value {
192        serde_json::json!({
193            "start": self.start,
194            "end": self.end,
195            "span": self.span(),
196            "min": self.min,
197            "max": self.max,
198        })
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    const EPS: f64 = 1e-9;
207
208    fn approx(a: f64, b: f64) -> bool {
209        (a - b).abs() < 1e-6
210    }
211
212    #[test]
213    fn new_spans_the_full_extent() {
214        let ax = TimeAxis::new(100.0, 200.0);
215        assert_eq!(ax.span(), 100.0);
216        assert_eq!(ax.to_frac(150.0), 0.5, "midpoint is halfway");
217        assert!(approx(ax.from_frac(0.25), 125.0));
218    }
219
220    #[test]
221    fn zoom_in_keeps_the_pivot_time_fixed() {
222        let mut ax = TimeAxis::new(0.0, 100.0);
223        let pivot = 40.0;
224        let frac_before = ax.to_frac(pivot);
225        ax.zoom(2.0, pivot); // zoom in 2× about t=40
226        assert!(approx(ax.span(), 50.0), "span halved, got {}", ax.span());
227        assert!(approx(ax.to_frac(pivot), frac_before), "pivot stays at the same fraction");
228    }
229
230    #[test]
231    fn zoom_out_clamps_to_the_full_extent() {
232        let mut ax = TimeAxis::new(0.0, 100.0);
233        ax.zoom(4.0, 50.0); // zoom in to span 25
234        assert!(approx(ax.span(), 25.0));
235        for _ in 0..10 {
236            ax.zoom(0.5, 50.0); // repeatedly zoom out
237        }
238        assert!(approx(ax.span(), 100.0), "cannot zoom past the full extent, got {}", ax.span());
239        assert!(ax.start >= ax.min - EPS && ax.end <= ax.max + EPS);
240    }
241
242    #[test]
243    fn zoom_in_clamps_to_min_span() {
244        let mut ax = TimeAxis::new(0.0, 100.0);
245        ax.min_span = 5.0;
246        for _ in 0..20 {
247            ax.zoom(2.0, 30.0);
248        }
249        assert!(ax.span() >= 5.0 - EPS, "min_span floor honored, got {}", ax.span());
250    }
251
252    #[test]
253    fn pan_clamps_within_bounds() {
254        let mut ax = TimeAxis::new(0.0, 100.0);
255        ax.zoom(4.0, 50.0); // span 25, window ~[37.5, 62.5]
256        ax.pan(1000.0); // shove far right
257        assert!(approx(ax.end, 100.0), "clamped to max edge, got end={}", ax.end);
258        assert!(approx(ax.span(), 25.0), "span preserved while panning");
259        ax.pan(-1000.0); // shove far left
260        assert!(approx(ax.start, 0.0), "clamped to min edge, got start={}", ax.start);
261        assert!(approx(ax.span(), 25.0));
262    }
263
264    #[test]
265    fn fit_snaps_to_a_range() {
266        let mut ax = TimeAxis::new(0.0, 100.0);
267        ax.fit(20.0, 40.0);
268        assert!(approx(ax.start, 20.0) && approx(ax.end, 40.0));
269        // Out-of-bounds fit is clamped.
270        ax.fit(-50.0, 500.0);
271        assert!(ax.start >= ax.min - EPS && ax.end <= ax.max + EPS);
272    }
273
274    #[test]
275    fn update_emits_window_changed_only_on_a_real_move() {
276        let mut ax = TimeAxis::new(0.0, 100.0);
277        // A no-op pan at the left edge (already clamped) emits nothing.
278        let fx = ax.update(TimeMsg::Pan(-10.0));
279        assert!(fx.is_empty(), "clamped-to-edge pan is a no-op");
280        // A real zoom emits the new window.
281        let fx = ax.update(TimeMsg::Zoom { factor: 2.0, pivot: 50.0 });
282        assert_eq!(fx.len(), 1);
283        match fx[0] {
284            TimeEffect::WindowChanged { start, end } => {
285                assert!(approx(start, ax.start) && approx(end, ax.end));
286            }
287        }
288    }
289
290    #[test]
291    fn linked_axes_sync_windows() {
292        let mut a = TimeAxis::new(0.0, 100.0);
293        let mut b = TimeAxis::new(0.0, 100.0);
294        a.zoom(4.0, 25.0);
295        // Propagate a's window into b (the shared-scrubber case).
296        for fx in a.update(TimeMsg::Pan(5.0)) {
297            let TimeEffect::WindowChanged { .. } = fx;
298        }
299        b.sync_from(&a);
300        assert!(approx(a.start, b.start) && approx(a.end, b.end), "b follows a's window");
301    }
302
303    #[test]
304    fn serde_round_trip_is_identity() {
305        let mut ax = TimeAxis::new(1000.0, 5000.0);
306        ax.zoom(3.0, 2500.0);
307        let json = serde_json::to_string(&ax).unwrap();
308        let back: TimeAxis = serde_json::from_str(&json).unwrap();
309        assert_eq!(ax, back);
310    }
311}