1use core::cmp::Ordering;
6
7use serde::{Deserialize, Serialize};
8
9pub const DEFAULT_TARGET_PIXELS_PER_TICK: u32 = 100;
11
12const FALLBACK_STEP_MS: f64 = 1_000.0;
13
14#[derive(Clone, Copy)]
15struct IndexedInterval {
16 original_index: usize,
17 start: f64,
18 end: f64,
19}
20
21#[rustfmt::skip]
22const NICE_STEPS_MS: &[f64] = &[
23 50.0,
24 100.0,
25 250.0,
26 500.0,
27 1_000.0,
28 2_000.0,
29 5_000.0,
30 10_000.0,
31 15_000.0,
32 30_000.0,
33 60_000.0,
34 120_000.0,
35 300_000.0,
36 600_000.0,
37 1_800_000.0,
38 3_600_000.0,
39];
40
41#[must_use]
43pub fn compute_timeline_ticks(
44 duration_ms: f64,
45 pixels_per_ms: f64,
46 target_pixels_per_tick: u32,
47) -> Vec<f64> {
48 if !duration_ms.is_finite() || duration_ms <= 0.0 {
49 return Vec::new();
50 }
51
52 if !pixels_per_ms.is_finite() || pixels_per_ms <= 0.0 {
53 return Vec::new();
54 }
55
56 let target_ms = f64::from(target_pixels_per_tick) / pixels_per_ms;
57 let largest_step = NICE_STEPS_MS.last().copied().unwrap_or(FALLBACK_STEP_MS);
58 let step = NICE_STEPS_MS
59 .iter()
60 .copied()
61 .find(|s| *s >= target_ms)
62 .unwrap_or(largest_step);
63
64 let mut out = Vec::new();
65 let mut t = 0.0;
66
67 while t <= duration_ms + f64::EPSILON {
68 out.push(t);
69 t += step;
70 }
71
72 out
73}
74
75#[must_use]
77pub fn assign_timeline_lanes(starts: &[f64], ends: &[f64]) -> Vec<u32> {
78 let n = starts.len().min(ends.len());
79
80 if n == 0 {
81 return Vec::new();
82 }
83
84 let mut indexed: Vec<IndexedInterval> = (0..n)
85 .filter_map(|index| normalized_interval(index, starts, ends))
86 .collect();
87
88 indexed.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(Ordering::Equal));
89
90 let mut lane_ends: Vec<f64> = Vec::new();
91 let mut lanes: Vec<u32> = vec![0; n];
92
93 for IndexedInterval {
94 original_index,
95 start,
96 end,
97 } in indexed
98 {
99 if !start.is_finite() {
100 continue;
101 }
102
103 let lane_idx = reserve_lane(&mut lane_ends, start, end);
104 let lane_u32 = u32::try_from(lane_idx).unwrap_or(u32::MAX);
105 if let Some(slot) = lanes.get_mut(original_index) {
108 *slot = lane_u32;
109 }
110 }
111
112 lanes
113}
114
115fn normalized_interval(index: usize, starts: &[f64], ends: &[f64]) -> Option<IndexedInterval> {
116 let start = *starts.get(index)?;
117 let end = *ends.get(index)?;
118
119 if !start.is_finite() || !end.is_finite() || end < start {
120 return Some(IndexedInterval {
121 original_index: index,
122 start: f64::NEG_INFINITY,
123 end: f64::NEG_INFINITY,
124 });
125 }
126
127 Some(IndexedInterval {
128 original_index: index,
129 start,
130 end,
131 })
132}
133
134fn reserve_lane(lane_ends: &mut Vec<f64>, start: f64, end: f64) -> usize {
135 let Some(index) = lane_ends.iter().position(|lane_end| *lane_end <= start) else {
136 lane_ends.push(end);
137
138 return lane_ends.len() - 1;
139 };
140
141 if let Some(lane_end) = lane_ends.get_mut(index) {
142 *lane_end = end;
143 }
144
145 index
146}
147
148#[derive(Clone, Debug, Deserialize, Serialize)]
150#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
151#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
152#[serde(rename_all = "camelCase")]
153pub struct TimelineGeometry {
154 pub container_width: f64,
155 pub gutter_width: f64,
156 pub right_gutter: f64,
157 pub duration_ms: f64,
158 pub pan_ms: f64,
159 pub zoom: f64,
160 pub min_zoom: f64,
161 pub max_zoom: f64,
162}
163
164#[derive(Clone, Debug, Serialize)]
166#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
167#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
168#[serde(rename_all = "camelCase")]
169pub struct TimelineMetrics {
170 pub track_width: f64,
171 pub px_per_ms: f64,
172 pub visible_ms: f64,
173 pub safe_zoom: f64,
174 pub clamped_pan_ms: f64,
175 pub max_pan_ms: f64,
176 pub visible_start_ms: f64,
177 pub visible_end_ms: f64,
178 pub ticks: Vec<f64>,
179}
180
181#[derive(Clone, Debug, Serialize)]
183#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
184#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
185#[serde(rename_all = "camelCase")]
186pub struct TimelineViewport {
187 pub pan_ms: f64,
188 pub zoom: f64,
189}
190
191fn clamp(value: f64, lo: f64, hi: f64) -> f64 {
192 value.max(lo).min(hi)
193}
194
195#[must_use]
197pub fn compute_timeline_metrics(geo: &TimelineGeometry) -> TimelineMetrics {
198 let track_width = (geo.container_width - geo.gutter_width - geo.right_gutter).max(0.0);
199 let safe_zoom = clamp(geo.zoom, geo.min_zoom, geo.max_zoom);
200 let visible_ms = if geo.duration_ms > 0.0 {
201 geo.duration_ms / safe_zoom
202 } else {
203 0.0
204 };
205 let px_per_ms = if track_width > 0.0 && visible_ms > 0.0 {
206 track_width / visible_ms
207 } else {
208 0.0
209 };
210 let max_pan_ms = (geo.duration_ms - visible_ms).max(0.0);
211 let clamped_pan_ms = clamp(geo.pan_ms, 0.0, max_pan_ms);
212 let ticks = compute_timeline_ticks(geo.duration_ms, px_per_ms, DEFAULT_TARGET_PIXELS_PER_TICK);
213
214 TimelineMetrics {
215 track_width,
216 px_per_ms,
217 visible_ms,
218 safe_zoom,
219 clamped_pan_ms,
220 max_pan_ms,
221 visible_start_ms: clamped_pan_ms,
222 visible_end_ms: clamped_pan_ms + visible_ms,
223 ticks,
224 }
225}
226
227#[must_use]
229pub fn zoom_timeline_at(
230 geo: &TimelineGeometry,
231 local_x: f64,
232 zoom_in: bool,
233 zoom_factor: f64,
234) -> TimelineViewport {
235 let m = compute_timeline_metrics(geo);
236
237 if m.track_width <= 0.0 || geo.duration_ms <= 0.0 || local_x < 0.0 || local_x > m.track_width {
238 return TimelineViewport {
239 pan_ms: m.clamped_pan_ms,
240 zoom: m.safe_zoom,
241 };
242 }
243
244 let cursor_fraction = local_x / m.track_width;
245 let cursor_ms = m.clamped_pan_ms + cursor_fraction * m.visible_ms;
246 let factor = if zoom_in {
247 zoom_factor
248 } else {
249 1.0 / zoom_factor
250 };
251 let next_zoom = clamp(m.safe_zoom * factor, geo.min_zoom, geo.max_zoom);
252 let next_visible_ms = geo.duration_ms / next_zoom;
253 let next_max_pan = (geo.duration_ms - next_visible_ms).max(0.0);
254 let next_pan = clamp(
255 cursor_ms - cursor_fraction * next_visible_ms,
256 0.0,
257 next_max_pan,
258 );
259
260 TimelineViewport {
261 pan_ms: next_pan,
262 zoom: next_zoom,
263 }
264}
265
266#[must_use]
268pub fn pan_timeline_by(
269 geo: &TimelineGeometry,
270 start_pan_ms: f64,
271 delta_px: f64,
272) -> TimelineViewport {
273 let m = compute_timeline_metrics(geo);
274
275 if m.track_width <= 0.0 {
276 return TimelineViewport {
277 pan_ms: m.clamped_pan_ms,
278 zoom: m.safe_zoom,
279 };
280 }
281
282 let delta_ms = -(delta_px / m.track_width) * m.visible_ms;
283 let next_pan = clamp(start_pan_ms + delta_ms, 0.0, m.max_pan_ms);
284
285 TimelineViewport {
286 pan_ms: next_pan,
287 zoom: m.safe_zoom,
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use googletest::prelude::*;
294
295 use super::*;
296
297 const TOL: f64 = 1e-9;
298
299 #[gtest]
300 fn ticks_empty_for_zero_duration() -> Result<()> {
301 verify_that!(compute_timeline_ticks(0.0, 1.0, 100), is_empty())
302 }
303
304 #[gtest]
305 fn ticks_empty_for_zero_zoom() -> Result<()> {
306 verify_that!(compute_timeline_ticks(10_000.0, 0.0, 100), is_empty())
307 }
308
309 #[gtest]
310 fn ticks_pick_one_second_step_at_typical_zoom() -> Result<()> {
311 let ticks = compute_timeline_ticks(10_000.0, 0.1, 100);
312
313 verify_that!(
314 ticks,
315 container_eq([
316 0.0, 1_000.0, 2_000.0, 3_000.0, 4_000.0, 5_000.0, 6_000.0, 7_000.0, 8_000.0,
317 9_000.0, 10_000.0,
318 ])
319 )
320 }
321
322 #[gtest]
323 fn ticks_pick_ten_second_step_when_zoomed_out() -> Result<()> {
324 let ticks = compute_timeline_ticks(60_000.0, 0.01, 100);
325
326 verify_that!(
327 ticks,
328 container_eq([
329 0.0, 10_000.0, 20_000.0, 30_000.0, 40_000.0, 50_000.0, 60_000.0
330 ])
331 )
332 }
333
334 #[gtest]
335 fn ticks_pick_fine_step_when_zoomed_in() -> Result<()> {
336 let ticks = compute_timeline_ticks(200.0, 2.0, 100);
337
338 verify_that!(ticks, container_eq([0.0, 50.0, 100.0, 150.0, 200.0]))
339 }
340
341 #[gtest]
342 fn lanes_single_lane_for_disjoint_events() -> Result<()> {
343 let starts = vec![0.0, 1_000.0, 2_000.0];
344 let ends = vec![500.0, 1_500.0, 2_500.0];
345 let lanes = assign_timeline_lanes(&starts, &ends);
346
347 verify_that!(lanes, container_eq([0, 0, 0]))
348 }
349
350 #[gtest]
351 fn lanes_stack_overlapping_events() -> Result<()> {
352 let starts = vec![0.0, 500.0, 2_000.0];
353 let ends = vec![1_000.0, 1_500.0, 3_000.0];
354 let lanes = assign_timeline_lanes(&starts, &ends);
355
356 verify_that!(lanes, elements_are![eq(&0u32), eq(&1u32), eq(&0u32)])
357 }
358
359 #[gtest]
360 fn lanes_handles_three_concurrent_events() -> Result<()> {
361 let starts = vec![0.0, 100.0, 200.0];
362 let ends = vec![1_000.0, 1_000.0, 1_000.0];
363 let lanes = assign_timeline_lanes(&starts, &ends);
364 let mut sorted = lanes.clone();
365
366 sorted.sort_unstable();
367
368 verify_that!(sorted, container_eq([0, 1, 2]))
369 }
370
371 #[gtest]
372 fn lanes_empty_input() -> Result<()> {
373 verify_that!(assign_timeline_lanes(&[], &[]), is_empty())
374 }
375
376 #[gtest]
377 fn lanes_preserves_input_order() -> Result<()> {
378 let starts = vec![2_000.0, 0.0, 500.0];
379 let ends = vec![3_000.0, 1_000.0, 1_500.0];
380 let lanes = assign_timeline_lanes(&starts, &ends);
381
382 verify_that!(lanes, elements_are![eq(&0u32), eq(&0u32), eq(&1u32)])
383 }
384
385 #[gtest]
386 fn lanes_ignore_invalid_intervals_without_consuming_lanes() -> Result<()> {
387 let starts = vec![f64::NAN, 0.0, 500.0, 2_000.0];
388 let ends = vec![1_000.0, 1_000.0, 100.0, 3_000.0];
389
390 verify_that!(
391 assign_timeline_lanes(&starts, &ends),
392 container_eq([0, 0, 0, 0])
393 )
394 }
395
396 #[gtest]
397 fn lanes_use_only_the_shared_input_prefix() -> Result<()> {
398 verify_that!(
399 assign_timeline_lanes(&[0.0, 100.0], &[50.0]),
400 container_eq([0])
401 )
402 }
403
404 fn geo(zoom: f64, pan_ms: f64) -> TimelineGeometry {
405 TimelineGeometry {
406 container_width: 1_000.0,
407 gutter_width: 0.0,
408 right_gutter: 0.0,
409 duration_ms: 10_000.0,
410 pan_ms,
411 zoom,
412 min_zoom: 1.0,
413 max_zoom: 200.0,
414 }
415 }
416
417 #[gtest]
418 fn metrics_fit_to_track() -> Result<()> {
419 let m = compute_timeline_metrics(&geo(1.0, 0.0));
420
421 wowlab_test_support::verify_all!(
422 m.track_width => near(1_000.0, TOL),
423 m.visible_ms => near(10_000.0, TOL),
424 m.max_pan_ms => near(0.0, TOL),
425 m.clamped_pan_ms => near(0.0, TOL),
426 m.visible_end_ms => near(10_000.0, TOL),
427 m.px_per_ms => near(0.1, 1e-12),
428 m.ticks.len() => eq(11),
429 )
430 }
431
432 #[gtest]
433 fn metrics_gutters_reduce_track_width() -> Result<()> {
434 let mut g = geo(1.0, 0.0);
435
436 g.gutter_width = 80.0;
437 g.right_gutter = 8.0;
438 let m = compute_timeline_metrics(&g);
439
440 verify_that!(m.track_width, near(912.0, TOL))
441 }
442
443 #[gtest]
444 fn metrics_clamp_pan_to_window() -> Result<()> {
445 let m = compute_timeline_metrics(&geo(2.0, 999_999.0));
446
447 wowlab_test_support::verify_all!(
448 m.visible_ms => near(5_000.0, TOL),
449 m.max_pan_ms => near(5_000.0, TOL),
450 m.clamped_pan_ms => near(5_000.0, TOL),
451 m.visible_end_ms => near(10_000.0, TOL),
452 )
453 }
454
455 #[gtest]
456 fn metrics_zero_duration_is_empty() -> Result<()> {
457 let mut g = geo(1.0, 0.0);
458
459 g.duration_ms = 0.0;
460 let m = compute_timeline_metrics(&g);
461
462 wowlab_test_support::verify_all!(
463 m.px_per_ms => near(0.0, TOL),
464 m.visible_ms => near(0.0, TOL),
465 m.ticks => is_empty(),
466 )
467 }
468
469 #[gtest]
470 fn zoom_in_keeps_cursor_time_fixed() -> Result<()> {
471 let v = zoom_timeline_at(&geo(1.0, 0.0), 500.0, true, 2.0);
472
473 verify_that!(v.zoom, near(2.0, TOL))?;
474 verify_that!(v.pan_ms, near(2_500.0, TOL))?;
475 let after = compute_timeline_metrics(&geo(v.zoom, v.pan_ms));
476 let cursor = after.clamped_pan_ms + 0.5 * after.visible_ms;
477
478 verify_that!(cursor, near(5_000.0, TOL))
479 }
480
481 #[gtest]
482 fn zoom_out_clamps_to_min_zoom() -> Result<()> {
483 let v = zoom_timeline_at(&geo(1.0, 0.0), 500.0, false, 2.0);
484
485 wowlab_test_support::verify_all!(
486 v.zoom => near(1.0, TOL),
487 v.pan_ms => near(0.0, TOL),
488 )
489 }
490
491 #[gtest]
492 fn zoom_in_clamps_to_max_zoom() -> Result<()> {
493 let v = zoom_timeline_at(&geo(200.0, 0.0), 500.0, true, 2.0);
494
495 verify_that!(v.zoom, near(200.0, TOL))
496 }
497
498 #[gtest]
499 fn zoom_ignores_cursor_outside_track() -> Result<()> {
500 let v = zoom_timeline_at(&geo(1.0, 0.0), 2_000.0, true, 2.0);
501
502 wowlab_test_support::verify_all!(
503 v.zoom => near(1.0, TOL),
504 v.pan_ms => near(0.0, TOL),
505 )
506 }
507
508 #[gtest]
509 fn pan_drag_right_moves_window_earlier() -> Result<()> {
510 let v = pan_timeline_by(&geo(2.0, 2_000.0), 2_000.0, 100.0);
511
512 wowlab_test_support::verify_all!(
513 v.zoom => near(2.0, TOL),
514 v.pan_ms => near(1_500.0, TOL),
515 )
516 }
517
518 #[gtest]
519 fn pan_clamps_at_zero() -> Result<()> {
520 let v = pan_timeline_by(&geo(2.0, 0.0), 0.0, 100.0);
521
522 verify_that!(v.pan_ms, near(0.0, TOL))
523 }
524}