Skip to main content

forge_charts/
zoom.rs

1//! Drag-to-zoom range selection.
2//!
3//! Pure data + math; the chart wires it to mouse events. The chart
4//! component owns three signals — the committed [`ZoomRange`], the
5//! drag-start pixel X (None when no drag is in progress), and the
6//! drag-end pixel X — and uses the helpers here to commit a zoom on
7//! mouseup, clamp inverted drags, and reject accidentally-tiny ones.
8
9/// A committed zoom range in **data index space**, inclusive at both
10/// ends. `from_index` and `to_index` index into the chart's original
11/// unsliced data array; the chart slices the rendered view by this
12/// range and offsets the hovered index back to the original space
13/// when handing it to the tooltip slot.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct ZoomRange {
16    pub from_index: usize,
17    pub to_index: usize,
18}
19
20impl ZoomRange {
21    /// Number of points in this range, inclusive.
22    #[must_use]
23    pub const fn len(self) -> usize {
24        // `to_index >= from_index` after `clamp`; the +1 stays in bounds
25        // for usize since we never construct out-of-range values.
26        self.to_index - self.from_index + 1
27    }
28
29    #[must_use]
30    pub const fn is_empty(self) -> bool {
31        self.from_index > self.to_index
32    }
33
34    /// Slice indices to display from the unsliced source. Use to
35    /// translate "display index i" → "original index `from + i`".
36    #[must_use]
37    pub const fn offset_to_original(self, display_index: usize) -> usize {
38        self.from_index + display_index
39    }
40}
41
42/// Minimum span (in data points) that a drag must cover before we
43/// commit it as a zoom. Drags shorter than this are treated as a
44/// click and ignored. Keeps accidental tiny drags from snapping the
45/// chart to a single point.
46pub const MIN_ZOOM_SPAN: usize = 2;
47
48/// Build a [`ZoomRange`] from two indices that came out of a drag.
49/// Auto-orients (handles right-to-left drags) and clamps to the
50/// available range. Returns `None` when the drag is too short to
51/// be useful (see [`MIN_ZOOM_SPAN`]).
52#[must_use]
53pub fn commit_drag(a: usize, b: usize, n_points: usize) -> Option<ZoomRange> {
54    if n_points == 0 {
55        return None;
56    }
57    let last = n_points - 1;
58    let lo = a.min(b).min(last);
59    let hi = a.max(b).min(last);
60    if hi - lo + 1 < MIN_ZOOM_SPAN {
61        return None;
62    }
63    Some(ZoomRange {
64        from_index: lo,
65        to_index: hi,
66    })
67}
68
69#[cfg(test)]
70mod tests {
71    #![allow(
72        clippy::expect_used,
73        reason = "panicking helpers are fine in unit tests; failure is what we want"
74    )]
75    use super::*;
76
77    #[test]
78    fn commit_drag_oriented_lr() {
79        let z = commit_drag(2, 5, 10).expect("ok");
80        assert_eq!(z.from_index, 2);
81        assert_eq!(z.to_index, 5);
82        assert_eq!(z.len(), 4);
83    }
84
85    #[test]
86    fn commit_drag_oriented_rl_normalizes() {
87        let z = commit_drag(5, 2, 10).expect("ok");
88        assert_eq!(z.from_index, 2);
89        assert_eq!(z.to_index, 5);
90    }
91
92    #[test]
93    fn commit_drag_clamps_to_last_index() {
94        let z = commit_drag(0, 999, 10).expect("ok");
95        assert_eq!(z.from_index, 0);
96        assert_eq!(z.to_index, 9);
97    }
98
99    #[test]
100    fn commit_drag_rejects_tiny_spans() {
101        // Single-point drag (effectively a click) → no zoom.
102        assert!(commit_drag(4, 4, 10).is_none());
103    }
104
105    #[test]
106    fn commit_drag_accepts_min_span() {
107        // 2-point span is the smallest allowed.
108        let z = commit_drag(3, 4, 10).expect("min span ok");
109        assert_eq!(z.len(), 2);
110    }
111
112    #[test]
113    fn commit_drag_handles_empty_input() {
114        assert!(commit_drag(0, 0, 0).is_none());
115    }
116
117    #[test]
118    fn offset_to_original_shifts_by_from_index() {
119        let z = ZoomRange {
120            from_index: 7,
121            to_index: 12,
122        };
123        assert_eq!(z.offset_to_original(0), 7);
124        assert_eq!(z.offset_to_original(5), 12);
125    }
126}