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 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 #[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 #[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 (Relative(-self.edge_space), Relative(1.0 + self.edge_space))
224 } else {
225 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 (unmoved_left, unmoved_right)
234 } else {
235 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 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 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 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 let center = Relative(0.5);
548 vp.set_viewport_to_clipped(center, center, &n);
549 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 vp.set_viewport_to_clipped(Relative(0.1), Relative(0.3), &n);
579 let mut t = 0.0;
580 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 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 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); let clipped = vp.clip_to(&old_num_timestamps, &new_num_timestamps);
627
628 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 let old_width = 0.1; let expected_relative_width = old_width * 1000.0 / 2000.0; 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 let mut vp = Viewport::default();
653 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); let clipped = vp.clip_to(&old_num_timestamps, &new_num_timestamps);
663
664 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 assert!(
674 clipped.curr_left.0 >= -vp.edge_space,
675 "Left edge out of bounds: {}",
676 clipped.curr_left.0
677 );
678
679 let expected_relative_width = 50.0 / 500.0; 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}