1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
//! **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);
}
}