Skip to main content

wowlab_types/stats/
timeline_layout.rs

1// #t(file: rust_inline_test_module_size) timeline contract tests share private geometry and numeric helpers with this implementation
2
3//! Timeline layout helpers shared by time-vs-event UI (tick placement and lane packing).
4
5use core::cmp::Ordering;
6
7use serde::{Deserialize, Serialize};
8
9/// Target pixel gap between adjacent ticks when no caller preference is set.
10pub 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/// Pick "nice" tick positions (ms) inside `[0, duration_ms]` for a timeline axis.
42#[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/// Pack `(start, end)` intervals into non-overlapping lanes via greedy scheduling, returning lanes in input order.
76#[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        // BOUNDS: original_index came from `0..n` and `lanes` has length `n`.
106
107        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/// Layout + viewport inputs for a timeline (pixels and milliseconds; `zoom`/`pan_ms` are clamped by the math).
149#[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/// Derived pixel/time geometry for one render of a timeline.
165#[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/// A timeline viewport: the committed pan offset and zoom factor.
182#[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/// Derive all pixel/time geometry (including axis ticks) for one timeline render; the source of truth for px<->ms.
196#[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/// Compute the new viewport after a cursor-anchored zoom step (`local_x` pixels from the track left, cursor time fixed).
228#[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/// Compute the new viewport after dragging the track by `delta_px` pixels (zoom unchanged).
267#[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}