1use serde::{Deserialize, Serialize};
19
20#[derive(Clone, Copy, Debug, PartialEq)]
23pub enum TimeEffect {
24 WindowChanged { start: f64, end: f64 },
26}
27
28#[derive(Clone, Copy, Debug, PartialEq)]
31pub enum TimeMsg {
32 Pan(f64),
34 Zoom { factor: f64, pivot: f64 },
37 Fit { start: f64, end: f64 },
39 SetBounds { min: f64, max: f64 },
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
47pub struct TimeAxis {
48 pub start: f64,
50 pub end: f64,
52 pub min: f64,
54 pub max: f64,
55 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 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 pub fn span(&self) -> f64 {
74 self.end - self.start
75 }
76
77 pub fn max_span(&self) -> f64 {
79 self.max - self.min
80 }
81
82 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 pub fn from_frac(&self, f: f64) -> f64 {
92 self.start + f * self.span()
93 }
94
95 fn clamp_window(&mut self) {
98 let extent = self.max_span();
99 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 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; }
115 self.start = start;
116 self.end = start + span;
117 }
118
119 pub fn pan(&mut self, delta: f64) {
122 self.start += delta;
123 self.end += delta;
124 self.clamp_window();
125 }
126
127 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 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 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 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 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 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 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); 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); assert!(approx(ax.span(), 25.0));
235 for _ in 0..10 {
236 ax.zoom(0.5, 50.0); }
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); ax.pan(1000.0); 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); 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 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 let fx = ax.update(TimeMsg::Pan(-10.0));
279 assert!(fx.is_empty(), "clamped-to-edge pan is a no-op");
280 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 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}