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