Skip to main content

libsurfer/
viewport.rs

1use std::ops::RangeInclusive;
2
3use derive_more::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign};
4use num::{BigInt, BigRational, FromPrimitive, ToPrimitive};
5use serde::{Deserialize, Serialize};
6
7#[derive(
8    Debug,
9    Clone,
10    Copy,
11    Serialize,
12    Deserialize,
13    Add,
14    Sub,
15    Mul,
16    Neg,
17    AddAssign,
18    SubAssign,
19    PartialOrd,
20    PartialEq,
21)]
22pub struct Relative(pub f64);
23
24impl Relative {
25    #[must_use]
26    pub fn absolute(&self, num_timestamps: &BigInt) -> Absolute {
27        Absolute(
28            self.0
29                * num_timestamps
30                    .to_f64()
31                    .expect("Failed to convert timestamp to f64"),
32        )
33    }
34
35    #[must_use]
36    pub fn inner(&self) -> f64 {
37        self.0
38    }
39
40    #[must_use]
41    pub fn min(&self, other: &Relative) -> Self {
42        Self(self.0.min(other.0))
43    }
44
45    #[must_use]
46    pub fn max(&self, other: &Relative) -> Self {
47        Self(self.0.max(other.0))
48    }
49}
50
51impl std::ops::Div for Relative {
52    type Output = Relative;
53
54    fn div(self, rhs: Self) -> Self::Output {
55        Self(self.0 / rhs.0)
56    }
57}
58
59#[derive(
60    Debug, Clone, Copy, Serialize, Deserialize, Add, Sub, Mul, Neg, Div, PartialOrd, PartialEq,
61)]
62pub struct Absolute(pub f64);
63
64impl Absolute {
65    #[must_use]
66    pub fn relative(&self, num_timestamps: &BigInt) -> Relative {
67        Relative(
68            self.0
69                / num_timestamps
70                    .to_f64()
71                    .expect("Failed to convert timestamp to f64"),
72        )
73    }
74
75    #[must_use]
76    pub fn inner(&self) -> f64 {
77        self.0
78    }
79}
80
81impl std::ops::Div for Absolute {
82    type Output = Absolute;
83
84    fn div(self, rhs: Self) -> Self::Output {
85        Self(self.0 / rhs.0)
86    }
87}
88
89impl From<&BigInt> for Absolute {
90    fn from(value: &BigInt) -> Self {
91        Self(value.to_f64().expect("Failed to convert timestamp to f64"))
92    }
93}
94
95fn default_edge_space() -> f64 {
96    0.2
97}
98
99fn default_min_width() -> Absolute {
100    Absolute(0.5)
101}
102
103#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
104pub struct Viewport {
105    pub curr_left: Relative,
106    pub curr_right: Relative,
107
108    target_left: Relative,
109    target_right: Relative,
110
111    move_start_left: Relative,
112    move_start_right: Relative,
113
114    // Number of seconds since the the last time a movement happened
115    move_duration: Option<f32>,
116    pub move_strategy: ViewportStrategy,
117    #[serde(skip, default = "default_edge_space")]
118    edge_space: f64,
119
120    #[serde(skip, default = "default_min_width")]
121    min_width: Absolute,
122}
123
124impl Default for Viewport {
125    fn default() -> Self {
126        Self {
127            curr_left: Relative(0.0),
128            curr_right: Relative(1.0),
129            target_left: Relative(0.0),
130            target_right: Relative(1.0),
131            move_start_left: Relative(0.0),
132            move_start_right: Relative(1.0),
133            move_duration: None,
134            move_strategy: ViewportStrategy::Instant,
135            edge_space: default_edge_space(),
136            min_width: default_min_width(),
137        }
138    }
139}
140
141impl Viewport {
142    #[must_use]
143    pub fn new() -> Self {
144        Self::default()
145    }
146    #[must_use]
147    pub fn left_edge_time(self, num_timestamps: &BigInt) -> BigInt {
148        BigInt::from(self.curr_left.absolute(num_timestamps).0 as i64)
149    }
150    #[must_use]
151    pub fn right_edge_time(self, num_timestamps: &BigInt) -> BigInt {
152        BigInt::from(self.curr_right.absolute(num_timestamps).0 as i64)
153    }
154
155    #[must_use]
156    pub fn as_absolute_time(&self, x: f64, view_width: f32, num_timestamps: &BigInt) -> Absolute {
157        let time_spacing = self.width_absolute(num_timestamps) / f64::from(view_width);
158
159        self.curr_left.absolute(num_timestamps) + time_spacing * x
160    }
161
162    #[must_use]
163    pub fn as_time_bigint(&self, x: f32, view_width: f32, num_timestamps: &BigInt) -> BigInt {
164        let Viewport {
165            curr_left: left,
166            curr_right: right,
167            ..
168        } = &self;
169
170        let big_right = BigRational::from_f64(right.absolute(num_timestamps).0)
171            .unwrap_or_else(|| BigRational::from_u8(1).unwrap());
172        let big_left = BigRational::from_f64(left.absolute(num_timestamps).0)
173            .unwrap_or_else(|| BigRational::from_u8(1).unwrap());
174        let big_width =
175            BigRational::from_f32(view_width).unwrap_or_else(|| BigRational::from_u8(1).unwrap());
176        let big_x = BigRational::from_f32(x).unwrap_or_else(|| BigRational::from_u8(1).unwrap());
177
178        let time = big_left.clone() + (big_right - big_left) / big_width * big_x;
179        time.round().to_integer()
180    }
181
182    /// Computes which x-pixel corresponds to the specified time adduming the viewport is rendered
183    /// into a viewport of `view_width`
184    #[must_use]
185    pub fn pixel_from_time(&self, time: &BigInt, view_width: f32, num_timestamps: &BigInt) -> f32 {
186        let distance_from_left =
187            Absolute(time.to_f64().unwrap()) - self.curr_left.absolute(num_timestamps);
188
189        (((distance_from_left / self.width_absolute(num_timestamps)).0) * f64::from(view_width))
190            as f32
191    }
192
193    #[must_use]
194    pub fn pixel_from_absolute_time(
195        &self,
196        time: Absolute,
197        view_width: f32,
198        num_timestamps: &BigInt,
199    ) -> f32 {
200        let distance_from_left = time - self.curr_left.absolute(num_timestamps);
201
202        (((distance_from_left / self.width_absolute(num_timestamps)).0) * f64::from(view_width))
203            as f32
204    }
205
206    /// Return new viewport for a different file length
207    ///
208    /// Tries to keep the current zoom level and position. If zoom is not possible it
209    /// will zoom in as much as needed to keep border margins. If the new waveform is
210    /// too short, the viewport will be moved to the left as much as needed for the zoom level.
211    #[must_use]
212    pub fn clip_to(&self, old_num_timestamps: &BigInt, new_num_timestamps: &BigInt) -> Viewport {
213        let left_timestamp = self.curr_left.absolute(old_num_timestamps);
214        let right_timestamp = self.curr_right.absolute(old_num_timestamps);
215        let absolute_width = right_timestamp - left_timestamp;
216
217        let new_absolute_width = new_num_timestamps
218            .to_f64()
219            .expect("Failed to convert timestamp to f64")
220            * (2.0 * self.edge_space);
221        let (left, right) = if absolute_width.0 > new_absolute_width {
222            // is the new waveform so short that we can't keep the current zoom level?
223            (Relative(-self.edge_space), Relative(1.0 + self.edge_space))
224        } else {
225            // our zoom level is achievable but we don't know the waveform is long enough
226            let new_num_ts_f64 = new_num_timestamps
227                .to_f64()
228                .expect("Failed to convert timestamp to f64");
229            let unmoved_left = Relative(left_timestamp.0 / new_num_ts_f64);
230            let unmoved_right = Relative((left_timestamp + absolute_width).0 / new_num_ts_f64);
231            if unmoved_right <= Relative(1.0 + self.edge_space) {
232                // waveform is long enough, keep current view as-is
233                (unmoved_left, unmoved_right)
234            } else {
235                // waveform is too short, clip end to the right edge (including empty space)
236                // since we checked above for zoom level, we know that there must be enough
237                // waveform to the left to keep the current zoom level
238                let relative_width = absolute_width.0 / new_num_ts_f64;
239                (
240                    Relative(1.0 + self.edge_space - relative_width),
241                    Relative(1.0 + self.edge_space),
242                )
243            }
244        };
245
246        Viewport {
247            curr_left: left,
248            curr_right: right,
249            target_left: left,
250            target_right: right,
251            move_start_left: left,
252            move_start_right: right,
253            move_duration: None,
254            move_strategy: self.move_strategy,
255            edge_space: self.edge_space,
256            min_width: self.min_width,
257        }
258    }
259
260    #[inline]
261    fn width(&self) -> Relative {
262        self.curr_right - self.curr_left
263    }
264
265    #[inline]
266    fn width_absolute(&self, num_timestamps: &BigInt) -> Absolute {
267        self.width().absolute(num_timestamps)
268    }
269
270    pub fn go_to_time(&mut self, center: &BigInt, num_timestamps: &BigInt) {
271        let center_point: Absolute = center.into();
272        let half_width = self.half_width_absolute(num_timestamps);
273
274        let target_left = (center_point - half_width).relative(num_timestamps);
275        let target_right = (center_point + half_width).relative(num_timestamps);
276        self.set_viewport_to_clipped(target_left, target_right, num_timestamps);
277    }
278
279    pub fn zoom_to_fit(&mut self) {
280        self.set_target_left(Relative(0.0));
281        self.set_target_right(Relative(1.0));
282    }
283
284    pub fn go_to_start(&mut self) {
285        let old_width = self.width();
286        self.set_target_left(Relative(0.0));
287        self.set_target_right(old_width);
288    }
289
290    pub fn go_to_end(&mut self) {
291        self.set_target_left(Relative(1.0) - self.width());
292        self.set_target_right(Relative(1.0));
293    }
294
295    pub fn handle_canvas_zoom(
296        &mut self,
297        mouse_ptr_timestamp: Option<BigInt>,
298        delta: f64,
299        num_timestamps: &BigInt,
300    ) {
301        // Zoom or scroll
302        let Viewport {
303            curr_left: left,
304            curr_right: right,
305            ..
306        } = &self;
307
308        let (target_left, target_right) = if let Some(mouse_location) =
309            mouse_ptr_timestamp.map(|t| Absolute::from(&t).relative(num_timestamps))
310        {
311            (
312                (*left - mouse_location) / Relative(delta) + mouse_location,
313                (*right - mouse_location) / Relative(delta) + mouse_location,
314            )
315        } else {
316            let mid_point = self.midpoint();
317            let offset = self.half_width() * delta;
318
319            (mid_point - offset, mid_point + offset)
320        };
321
322        self.set_viewport_to_clipped(target_left, target_right, num_timestamps);
323    }
324
325    pub fn handle_canvas_scroll(&mut self, deltay: f64) {
326        // Scroll 5% of the viewport per scroll event.
327        // One scroll event yields 50
328        let scroll_step = -self.width() / Relative(50. * 20.);
329        let scaled_deltay = scroll_step * deltay;
330        self.set_viewport_to_clipped_no_width_check(
331            self.curr_left + scaled_deltay,
332            self.curr_right + scaled_deltay,
333        );
334    }
335
336    fn set_viewport_to_clipped(
337        &mut self,
338        target_left: Relative,
339        target_right: Relative,
340        num_timestamps: &BigInt,
341    ) {
342        let rel_min_width = self.min_width.relative(num_timestamps);
343
344        if (target_right - target_left) <= rel_min_width + Relative(f64::EPSILON) {
345            let center = (target_left + target_right) * 0.5;
346            self.set_viewport_to_clipped_no_width_check(
347                center - rel_min_width,
348                center + rel_min_width,
349            );
350        } else {
351            self.set_viewport_to_clipped_no_width_check(target_left, target_right);
352        }
353    }
354
355    fn set_viewport_to_clipped_no_width_check(
356        &mut self,
357        target_left: Relative,
358        target_right: Relative,
359    ) {
360        let width = target_right - target_left;
361
362        let abs_min = Relative(-self.edge_space);
363        let abs_max = Relative(1.0 + self.edge_space);
364
365        let max_right = Relative(1.0) + width * self.edge_space;
366        let min_left = -width * self.edge_space;
367        if width > (abs_max - abs_min) {
368            self.set_target_left(abs_min);
369            self.set_target_right(abs_max);
370        } else if target_left < min_left {
371            self.set_target_left(min_left);
372            self.set_target_right(min_left + width);
373        } else if target_right > max_right {
374            self.set_target_left(max_right - width);
375            self.set_target_right(max_right);
376        } else {
377            self.set_target_left(target_left);
378            self.set_target_right(target_right);
379        }
380    }
381
382    #[inline]
383    fn midpoint(&self) -> Relative {
384        (self.curr_right + self.curr_left) * 0.5
385    }
386
387    #[inline]
388    fn half_width(&self) -> Relative {
389        self.width() * 0.5
390    }
391
392    #[inline]
393    fn half_width_absolute(&self, num_timestamps: &BigInt) -> Absolute {
394        (self.width() * 0.5).absolute(num_timestamps)
395    }
396
397    pub fn zoom_to_range(&mut self, left: &BigInt, right: &BigInt, num_timestamps: &BigInt) {
398        self.set_viewport_to_clipped(
399            Absolute::from(left).relative(num_timestamps),
400            Absolute::from(right).relative(num_timestamps),
401            num_timestamps,
402        );
403    }
404
405    pub fn go_to_cursor_if_not_in_view(
406        &mut self,
407        cursor: &BigInt,
408        num_timestamps: &BigInt,
409    ) -> bool {
410        let fcursor = cursor.into();
411        if fcursor <= self.curr_left.absolute(num_timestamps)
412            || fcursor >= self.curr_right.absolute(num_timestamps)
413        {
414            self.go_to_time_f64(fcursor, num_timestamps);
415            true
416        } else {
417            false
418        }
419    }
420
421    pub fn go_to_time_f64(&mut self, center: Absolute, num_timestamps: &BigInt) {
422        let half_width = (self.curr_right.absolute(num_timestamps)
423            - self.curr_left.absolute(num_timestamps))
424            / 2.;
425
426        self.set_viewport_to_clipped(
427            (center - half_width).relative(num_timestamps),
428            (center + half_width).relative(num_timestamps),
429            num_timestamps,
430        );
431    }
432
433    fn set_target_left(&mut self, target_left: Relative) {
434        if let ViewportStrategy::Instant = self.move_strategy {
435            self.curr_left = target_left;
436        } else {
437            self.target_left = target_left;
438            self.move_start_left = self.curr_left;
439            self.move_duration = Some(0.);
440        }
441    }
442    fn set_target_right(&mut self, target_right: Relative) {
443        if let ViewportStrategy::Instant = self.move_strategy {
444            self.curr_right = target_right;
445        } else {
446            self.target_right = target_right;
447            self.move_start_right = self.curr_right;
448            self.move_duration = Some(0.);
449        }
450    }
451
452    pub fn move_viewport(&mut self, frame_time: f32) {
453        match &self.move_strategy {
454            ViewportStrategy::Instant => {
455                self.curr_left = self.target_left;
456                self.curr_right = self.target_right;
457                self.move_duration = None;
458            }
459            ViewportStrategy::EaseInOut { duration } => {
460                if let Some(move_duration) = &mut self.move_duration {
461                    if *move_duration + frame_time >= *duration {
462                        self.move_duration = None;
463                        self.curr_left = self.target_left;
464                        self.curr_right = self.target_right;
465                    } else {
466                        *move_duration += frame_time;
467
468                        self.curr_left = Relative(ease_in_out_size(
469                            self.move_start_left.0..=self.target_left.0,
470                            f64::from(*move_duration) / f64::from(*duration),
471                        ));
472                        self.curr_right = Relative(ease_in_out_size(
473                            self.move_start_right.0..=self.target_right.0,
474                            f64::from(*move_duration) / f64::from(*duration),
475                        ));
476                    }
477                }
478            }
479        }
480    }
481
482    #[must_use]
483    pub fn is_moving(&self) -> bool {
484        self.move_duration.is_some()
485    }
486}
487
488#[must_use]
489pub fn ease_in_out_size(r: RangeInclusive<f64>, t: f64) -> f64 {
490    r.start() + ((r.end() - r.start()) * -((std::f64::consts::PI * t).cos() - 1.) / 2.)
491}
492
493#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
494pub enum ViewportStrategy {
495    Instant,
496    EaseInOut { duration: f32 },
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use num::BigInt;
503
504    fn bi(n: i64) -> BigInt {
505        BigInt::from(n)
506    }
507
508    #[test]
509    fn ease_in_out_endpoints_and_mid() {
510        let r = 0.0..=10.0;
511        let s0 = ease_in_out_size(r.clone(), 0.0);
512        let s05 = ease_in_out_size(r.clone(), 0.5);
513        let s1 = ease_in_out_size(r.clone(), 1.0);
514        assert!((s0 - 0.0).abs() < 1e-12);
515        assert!((s05 - 5.0).abs() < 1e-12);
516        assert!((s1 - 10.0).abs() < 1e-12);
517    }
518
519    #[test]
520    fn relative_absolute_roundtrip() {
521        let n = bi(1000);
522        let r = Relative(0.25);
523        let abs = r.absolute(&n);
524        // 0.25 * 1000 = 250.0
525        assert!((abs.0 - 250.0).abs() < 1e-9);
526        let back = abs.relative(&n);
527        assert!((back.0 - r.0).abs() < 1e-9);
528    }
529
530    #[test]
531    fn pixel_from_time_consistency() {
532        let vp = Viewport::default();
533        let n = bi(1000);
534        let view_w = 1000.0_f32;
535        let time_abs = Absolute(250.0);
536        let x1 = vp.pixel_from_absolute_time(time_abs, view_w, &n);
537        let x2 = vp.pixel_from_time(&bi(250), view_w, &n);
538        assert!((x1 - 250.0).abs() < 1e-6);
539        assert!((x2 - 250.0).abs() < 1e-6);
540    }
541
542    #[test]
543    fn set_viewport_min_width_enforced() {
544        let mut vp = Viewport::default();
545        let n = bi(1000);
546        // Try to set zero-width viewport at center
547        let center = Relative(0.5);
548        vp.set_viewport_to_clipped(center, center, &n);
549        // width must be at least min_width.relative(n)
550        let rel_min = vp.min_width.relative(&n).0;
551        let width = (vp.curr_right - vp.curr_left).0;
552        assert!(
553            width + f64::EPSILON >= rel_min,
554            "width {width} < min {rel_min}"
555        );
556    }
557
558    #[test]
559    fn go_to_start_and_end_preserve_width() {
560        let mut vp = Viewport::default();
561        let w0 = (vp.curr_right - vp.curr_left).0;
562        vp.go_to_end();
563        let w1 = (vp.curr_right - vp.curr_left).0;
564        assert!((w0 - w1).abs() < 1e-12);
565        assert!((vp.curr_right.0 - 1.0).abs() < 1e-12);
566        vp.go_to_start();
567        let w2 = (vp.curr_right - vp.curr_left).0;
568        assert!((w0 - w2).abs() < 1e-12);
569        assert!((vp.curr_left.0 - 0.0).abs() < 1e-12);
570    }
571
572    #[test]
573    fn move_viewport_ease_in_out_reaches_target() {
574        let mut vp = Viewport::default();
575        vp.move_strategy = ViewportStrategy::EaseInOut { duration: 0.3 };
576        let n = bi(1000);
577        // request a move
578        vp.set_viewport_to_clipped(Relative(0.1), Relative(0.3), &n);
579        let mut t = 0.0;
580        // step in a few frames
581        while vp.is_moving() && t < 1.0 {
582            vp.move_viewport(0.05);
583            t += 0.05;
584        }
585        assert!(!vp.is_moving());
586        assert!((vp.curr_left.0 - 0.1).abs() < 1e-6);
587        assert!((vp.curr_right.0 - 0.3).abs() < 1e-6);
588    }
589
590    #[test]
591    fn clip_to_does_not_invert_viewport() {
592        // Regression test: when clipping viewport to a file with different num_timestamps,
593        // the viewport should never become inverted (left > right).
594        // This reproduces the exact scenario from the bug report.
595        let mut vp = Viewport::default();
596        vp.curr_left = Relative(0.9027133537478365);
597        vp.curr_right = Relative(0.9455180041784441);
598        vp.target_left = vp.curr_left;
599        vp.target_right = vp.curr_right;
600
601        let old_num_timestamps = bi(122055);
602        let new_num_timestamps = bi(131445);
603
604        let clipped = vp.clip_to(&old_num_timestamps, &new_num_timestamps);
605
606        assert!(
607            clipped.curr_left.0 < clipped.curr_right.0,
608            "Viewport inverted after clip_to: left={} >= right={}",
609            clipped.curr_left.0,
610            clipped.curr_right.0
611        );
612    }
613
614    #[test]
615    fn clip_to_preserves_valid_viewport_on_file_growth() {
616        // When file grows, viewport should still be valid and maintain approximate position
617        let mut vp = Viewport::default();
618        vp.curr_left = Relative(0.8);
619        vp.curr_right = Relative(0.9);
620        vp.target_left = vp.curr_left;
621        vp.target_right = vp.curr_right;
622
623        let old_num_timestamps = bi(1000);
624        let new_num_timestamps = bi(2000); // file doubled in size
625
626        let clipped = vp.clip_to(&old_num_timestamps, &new_num_timestamps);
627
628        // Must not be inverted
629        assert!(
630            clipped.curr_left.0 < clipped.curr_right.0,
631            "Viewport inverted: left={} >= right={}",
632            clipped.curr_left.0,
633            clipped.curr_right.0
634        );
635
636        // Width should be preserved (in absolute terms, so halved in relative terms)
637        let old_width = 0.1; // 0.9 - 0.8
638        let expected_relative_width = old_width * 1000.0 / 2000.0; // 0.05
639        let actual_width = clipped.curr_right.0 - clipped.curr_left.0;
640        assert!(
641            (actual_width - expected_relative_width).abs() < 1e-9,
642            "Width not preserved: expected={}, actual={}",
643            expected_relative_width,
644            actual_width
645        );
646    }
647
648    #[test]
649    fn clip_to_handles_file_shrink_with_viewport_overshoot() {
650        // When file shrinks and viewport would overshoot the new file boundary,
651        // clip_to must correctly reposition the viewport to stay within bounds.
652        let mut vp = Viewport::default();
653        // Viewing timestamps 950-1000 in a 1000-timestamp file (relative 0.95-1.0)
654        vp.curr_left = Relative(0.95);
655        vp.curr_right = Relative(1.0);
656        vp.target_left = vp.curr_left;
657        vp.target_right = vp.curr_right;
658
659        let old_num_timestamps = bi(1000);
660        let new_num_timestamps = bi(500); // file shrinks
661
662        let clipped = vp.clip_to(&old_num_timestamps, &new_num_timestamps);
663
664        // Must not be inverted
665        assert!(
666            clipped.curr_left.0 < clipped.curr_right.0,
667            "Viewport inverted: left={} >= right={}",
668            clipped.curr_left.0,
669            clipped.curr_right.0
670        );
671
672        // Left edge must be valid (>= -edge_space)
673        assert!(
674            clipped.curr_left.0 >= -vp.edge_space,
675            "Left edge out of bounds: {}",
676            clipped.curr_left.0
677        );
678
679        // Width should be preserved in absolute terms (50 timestamps = 0.1 relative in new file)
680        let expected_relative_width = 50.0 / 500.0; // 0.1
681        let actual_width = clipped.curr_right.0 - clipped.curr_left.0;
682        assert!(
683            (actual_width - expected_relative_width).abs() < 1e-9,
684            "Width not preserved: expected={}, actual={}",
685            expected_relative_width,
686            actual_width
687        );
688    }
689}