Skip to main content

damascene_core/plot/
decimate.rs

1//! Down-sampling an over-dense series to the pixel budget — the
2//! library-side half of the data-density story (the plan's decision 5).
3//!
4//! A **virtual** app resamples its own source and never needs this; a
5//! **dump-everything** app hands the whole series and opts into a
6//! [`Decimation`] so the plot stays fast. [`minmax`] keeps the visual
7//! envelope (spikes survive) by emitting the min and max sample of each
8//! pixel-column bucket — the right default for monitoring / TSDB data, where
9//! a dropped spike is a dropped incident.
10
11#![warn(missing_docs)]
12
13use crate::plot::series::Sample;
14
15/// How the plot reduces an over-dense series to the pixel budget.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum Decimation {
18    /// Min/max-per-column envelope: two samples per bucket (the lowest and
19    /// highest `y`), so peaks and troughs are never smoothed away.
20    MinMax,
21}
22
23/// Reduce `samples` (assumed ascending in `x`, as a time series is) to at
24/// most `2 * buckets` points across the visible `x_window`, keeping the
25/// min and max `y` of each column. Samples outside the window are dropped.
26/// Returns the input unchanged when it is already within budget or the
27/// window is degenerate.
28pub fn minmax(samples: &[Sample], x_window: (f64, f64), buckets: usize) -> Vec<Sample> {
29    let lo = x_window.0.min(x_window.1);
30    let hi = x_window.0.max(x_window.1);
31    let span = hi - lo;
32    if buckets == 0 || !span.is_finite() || span <= 0.0 || samples.len() <= buckets * 2 {
33        return samples.to_vec();
34    }
35
36    // (min-y, max-y) sample per column, columns already in x order.
37    let mut cols: Vec<Option<(Sample, Sample)>> = vec![None; buckets];
38    for &s in samples {
39        if !s.x.is_finite() || !s.y.is_finite() || s.x < lo || s.x > hi {
40            continue;
41        }
42        let frac = (s.x - lo) / span;
43        let b = ((frac * buckets as f64) as usize).min(buckets - 1);
44        match &mut cols[b] {
45            None => cols[b] = Some((s, s)),
46            Some((mn, mx)) => {
47                if s.y < mn.y {
48                    *mn = s;
49                }
50                if s.y > mx.y {
51                    *mx = s;
52                }
53            }
54        }
55    }
56
57    let mut out = Vec::with_capacity(buckets * 2);
58    for (mn, mx) in cols.into_iter().flatten() {
59        // Emit the two envelope points in x order so the polyline stays
60        // monotonic in x; collapse to one when they coincide.
61        let (first, second) = if mn.x <= mx.x { (mn, mx) } else { (mx, mn) };
62        out.push(first);
63        if second.x != first.x || second.y != first.y {
64            out.push(second);
65        }
66    }
67    out
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    fn series(n: usize) -> Vec<Sample> {
75        (0..n)
76            .map(|i| Sample::new(i as f64, (i as f64 * 0.1).sin()))
77            .collect()
78    }
79
80    #[test]
81    fn within_budget_is_unchanged() {
82        let s = series(10);
83        assert_eq!(minmax(&s, (0.0, 9.0), 8), s);
84    }
85
86    #[test]
87    fn reduces_to_envelope_within_budget() {
88        let s = series(10_000);
89        let out = minmax(&s, (0.0, 9_999.0), 100);
90        assert!(out.len() <= 200, "≤ 2 per bucket, got {}", out.len());
91        assert!(
92            out.len() > 100,
93            "keeps a useful envelope, got {}",
94            out.len()
95        );
96        // x stays ascending.
97        assert!(out.windows(2).all(|w| w[0].x <= w[1].x));
98    }
99
100    #[test]
101    fn preserves_spikes() {
102        // A flat line with one tall spike: decimation must keep the spike.
103        let mut s: Vec<Sample> = (0..1000).map(|i| Sample::new(i as f64, 0.0)).collect();
104        s[500].y = 99.0;
105        let out = minmax(&s, (0.0, 999.0), 50);
106        assert!(
107            out.iter().any(|p| p.y == 99.0),
108            "the spike must survive decimation"
109        );
110    }
111
112    #[test]
113    fn drops_out_of_window_samples() {
114        let s = series(1000);
115        let out = minmax(&s, (400.0, 600.0), 50);
116        assert!(out.iter().all(|p| p.x >= 400.0 && p.x <= 600.0));
117    }
118
119    #[test]
120    fn degenerate_window_returns_input() {
121        let s = series(1000);
122        assert_eq!(minmax(&s, (5.0, 5.0), 50), s);
123    }
124}