Skip to main content

plotkit_core/
decimate.rs

1//! Downsampling algorithms for large datasets.
2//!
3//! When plotting millions of data points, rendering every single point is
4//! wasteful: a typical screen has at most a few thousand horizontal pixels,
5//! so most points overlap and contribute nothing to the visual output.
6//! Decimation reduces the point count to a manageable size while preserving
7//! the perceived shape of the data.
8//!
9//! Two algorithms are provided:
10//!
11//! - **LTTB** (Largest Triangle Three Buckets) — the gold standard for
12//!   perceptually faithful line downsampling. Based on the 2013 paper by
13//!   Sveinn Steinarsson.
14//! - **MinMax** — a faster alternative that keeps the min and max y-value
15//!   in each bucket, ensuring peaks and troughs are never lost.
16
17/// The decimation strategy to apply when rendering a line.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum DecimateMethod {
20    /// Largest Triangle Three Buckets algorithm (best visual fidelity).
21    Lttb,
22    /// Min-max decimation (fastest, preserves extremes).
23    MinMax,
24}
25
26/// Default point count above which auto-decimation activates.
27///
28/// Rationale: a typical figure is well under ~2000 device pixels wide, so a
29/// line or scatter with more than a few thousand points cannot reveal extra
30/// detail — adjacent points collapse onto the same pixel column. We pick
31/// `5000` as a conservative ceiling that sits comfortably above the pixel
32/// budget of even very large figures while still cutting the work for the
33/// truly large series (100k–10M points) where rendering cost dominates.
34/// Series at or below this size render every point, so small/medium plots are
35/// never altered. The value is intentionally a round, documented constant so
36/// output remains deterministic and reproducible across runs.
37pub const DEFAULT_DECIMATE_THRESHOLD: usize = 5000;
38
39/// How decimation is resolved for a series at render time.
40///
41/// This drives the index-selection logic in the draw path:
42///
43/// - [`DecimateMode::Auto`] (the default) — decimate with LTTB to
44///   [`DEFAULT_DECIMATE_THRESHOLD`] points only when the series is larger than
45///   that threshold; otherwise every point is drawn. This gives large series a
46///   fast, faithful render with zero configuration while leaving small series
47///   untouched.
48/// - [`DecimateMode::Off`] — never decimate; always draw every point. Use this
49///   when exact point-for-point rendering is required.
50/// - [`DecimateMode::Explicit`] — decimate to the given point count using the
51///   given method whenever the series exceeds that count. Set via the
52///   `.decimate(n)` / `.decimate_with(n, method)` builder methods.
53#[derive(Debug, Clone, Copy, PartialEq, Default)]
54pub enum DecimateMode {
55    /// Auto-decimate large series to [`DEFAULT_DECIMATE_THRESHOLD`] via LTTB.
56    #[default]
57    Auto,
58    /// Disable decimation entirely; draw every point.
59    Off,
60    /// Decimate to `usize` points using the chosen [`DecimateMethod`].
61    Explicit(usize, DecimateMethod),
62}
63
64impl DecimateMode {
65    /// Resolves the set of point indices to draw for a series of length `n`,
66    /// given x/y data, according to this mode.
67    ///
68    /// The returned indices always include the first and last (finite) points
69    /// when decimation is applied. When no decimation is needed, the full
70    /// `0..n` range is returned. This function is pure and deterministic: the
71    /// same inputs always yield the same indices.
72    pub fn resolve_indices(self, x: &[f64], y: &[f64]) -> Vec<usize> {
73        let n = x.len();
74        match self {
75            DecimateMode::Off => (0..n).collect(),
76            DecimateMode::Auto => {
77                if n > DEFAULT_DECIMATE_THRESHOLD {
78                    lttb(x, y, DEFAULT_DECIMATE_THRESHOLD)
79                } else {
80                    (0..n).collect()
81                }
82            }
83            DecimateMode::Explicit(threshold, method) => {
84                if n > threshold {
85                    match method {
86                        DecimateMethod::Lttb => lttb(x, y, threshold),
87                        DecimateMethod::MinMax => minmax(x, y, threshold),
88                    }
89                } else {
90                    (0..n).collect()
91                }
92            }
93        }
94    }
95}
96
97/// Downsamples a series of (x, y) points to `threshold` points using the
98/// Largest Triangle Three Buckets algorithm.
99///
100/// LTTB preserves the visual shape of the line while dramatically reducing
101/// point count. A 1M-point series decimated to 1000 points is visually
102/// indistinguishable from the original at screen resolution.
103///
104/// Returns indices into the original arrays. The first and last points
105/// are always preserved.
106///
107/// # Panics
108///
109/// Panics if `x.len() != y.len()`.
110pub fn lttb(x: &[f64], y: &[f64], threshold: usize) -> Vec<usize> {
111    assert_eq!(x.len(), y.len(), "x and y must have the same length");
112
113    let n = x.len();
114
115    // Edge cases: nothing to decimate.
116    if threshold == 0 {
117        return vec![];
118    }
119    if n == 0 {
120        return vec![];
121    }
122    if threshold == 1 {
123        // Return only the first point.
124        return vec![0];
125    }
126    if threshold >= n {
127        return (0..n).collect();
128    }
129
130    // Filter out NaN points, keeping track of original indices.
131    let valid: Vec<usize> = (0..n)
132        .filter(|&i| x[i].is_finite() && y[i].is_finite())
133        .collect();
134    let valid_n = valid.len();
135
136    if valid_n == 0 {
137        return vec![];
138    }
139    if threshold >= valid_n {
140        return valid;
141    }
142    if threshold == 1 {
143        return vec![valid[0]];
144    }
145
146    let mut selected = Vec::with_capacity(threshold);
147
148    // Always select the first valid point.
149    selected.push(valid[0]);
150
151    let bucket_count = threshold - 2;
152    // Remaining points to distribute across buckets (excluding first and last).
153    let interior = valid_n - 2;
154
155    let mut prev_selected_idx = 0usize; // index into `valid`
156
157    for bucket_i in 0..bucket_count {
158        // Current bucket range in `valid` indices (1-based, skipping first point).
159        let bucket_start = 1 + (bucket_i * interior) / bucket_count;
160        let bucket_end = 1 + ((bucket_i + 1) * interior) / bucket_count;
161
162        // Next bucket range (or last point if this is the last bucket).
163        let next_start = if bucket_i + 1 < bucket_count {
164            1 + ((bucket_i + 1) * interior) / bucket_count
165        } else {
166            valid_n - 1
167        };
168        let next_end = if bucket_i + 1 < bucket_count {
169            1 + ((bucket_i + 2) * interior) / bucket_count
170        } else {
171            valid_n
172        };
173
174        // Compute average point of the next bucket.
175        let next_count = (next_end - next_start) as f64;
176        let (avg_x, avg_y) = if next_count > 0.0 {
177            let mut sx = 0.0;
178            let mut sy = 0.0;
179            for &vi in &valid[next_start..next_end] {
180                sx += x[vi];
181                sy += y[vi];
182            }
183            (sx / next_count, sy / next_count)
184        } else {
185            // Fallback: use the last point.
186            let li = valid[valid_n - 1];
187            (x[li], y[li])
188        };
189
190        // Previously selected point coordinates.
191        let prev_orig = valid[prev_selected_idx];
192        let (px, py) = (x[prev_orig], y[prev_orig]);
193
194        // Find the point in the current bucket that maximizes triangle area.
195        let mut best_area = -1.0_f64;
196        let mut best_valid_idx = bucket_start;
197
198        for (vi, &orig) in valid.iter().enumerate().skip(bucket_start).take(bucket_end - bucket_start) {
199            let area = triangle_area(px, py, x[orig], y[orig], avg_x, avg_y);
200            if area > best_area {
201                best_area = area;
202                best_valid_idx = vi;
203            }
204        }
205
206        selected.push(valid[best_valid_idx]);
207        prev_selected_idx = best_valid_idx;
208    }
209
210    // Always select the last valid point.
211    selected.push(valid[valid_n - 1]);
212
213    selected
214}
215
216/// Min-max decimation: keeps the min and max y-value in each bucket.
217/// Faster than LTTB, good for dense time series where peaks matter.
218///
219/// Returns indices into the original arrays. The first and last points
220/// are always preserved. Within each bucket, the min and max points are
221/// returned in their original order (preserving temporal coherence).
222///
223/// # Panics
224///
225/// Panics if `x.len() != y.len()`.
226pub fn minmax(x: &[f64], y: &[f64], threshold: usize) -> Vec<usize> {
227    assert_eq!(x.len(), y.len(), "x and y must have the same length");
228
229    let n = x.len();
230
231    if threshold == 0 {
232        return vec![];
233    }
234    if n == 0 {
235        return vec![];
236    }
237    if threshold == 1 {
238        return vec![0];
239    }
240    if threshold >= n {
241        return (0..n).collect();
242    }
243
244    // Filter out NaN points.
245    let valid: Vec<usize> = (0..n)
246        .filter(|&i| x[i].is_finite() && y[i].is_finite())
247        .collect();
248    let valid_n = valid.len();
249
250    if valid_n == 0 {
251        return vec![];
252    }
253    if threshold >= valid_n {
254        return valid;
255    }
256
257    let mut selected = Vec::with_capacity(threshold);
258
259    // Always select the first valid point.
260    selected.push(valid[0]);
261
262    // Each bucket produces two points (min and max), so we need
263    // (threshold - 2) / 2 buckets. Handle odd thresholds gracefully.
264    let pairs = (threshold - 2) / 2;
265    let bucket_count = if pairs == 0 { 1 } else { pairs };
266    let interior = valid_n - 2; // points between first and last
267
268    for bucket_i in 0..bucket_count {
269        let bucket_start = 1 + (bucket_i * interior) / bucket_count;
270        let bucket_end = 1 + ((bucket_i + 1) * interior) / bucket_count;
271
272        if bucket_start >= bucket_end {
273            continue;
274        }
275
276        let mut min_idx = bucket_start;
277        let mut max_idx = bucket_start;
278        let mut min_val = y[valid[bucket_start]];
279        let mut max_val = y[valid[bucket_start]];
280
281        for vi in bucket_start..bucket_end {
282            let yv = y[valid[vi]];
283            if yv < min_val {
284                min_val = yv;
285                min_idx = vi;
286            }
287            if yv > max_val {
288                max_val = yv;
289                max_idx = vi;
290            }
291        }
292
293        // Add min and max in original order.
294        if min_idx == max_idx {
295            selected.push(valid[min_idx]);
296        } else if min_idx < max_idx {
297            selected.push(valid[min_idx]);
298            selected.push(valid[max_idx]);
299        } else {
300            selected.push(valid[max_idx]);
301            selected.push(valid[min_idx]);
302        }
303    }
304
305    // Always select the last valid point.
306    let last = valid[valid_n - 1];
307    // Avoid duplicating the last point if it was already selected.
308    if selected.last() != Some(&last) {
309        selected.push(last);
310    }
311
312    selected
313}
314
315/// Computes twice the area of the triangle formed by three points.
316///
317/// Using the shoelace formula:
318///   area = |x_a(y_b - y_c) + x_b(y_c - y_a) + x_c(y_a - y_b)| / 2
319///
320/// We return the value without the `/2` since we only need relative
321/// comparisons and avoiding the division is faster.
322#[inline]
323fn triangle_area(x_a: f64, y_a: f64, x_b: f64, y_b: f64, x_c: f64, y_c: f64) -> f64 {
324    (x_a * (y_b - y_c) + x_b * (y_c - y_a) + x_c * (y_a - y_b)).abs()
325}
326
327// ---------------------------------------------------------------------------
328// Tests
329// ---------------------------------------------------------------------------
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use std::f64::consts::PI;
335
336    // -- LTTB tests --------------------------------------------------------
337
338    #[test]
339    fn lttb_identity_when_threshold_ge_len() {
340        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
341        let y = vec![0.0, 1.0, 4.0, 9.0, 16.0];
342        let indices = lttb(&x, &y, 5);
343        assert_eq!(indices, vec![0, 1, 2, 3, 4]);
344
345        let indices = lttb(&x, &y, 100);
346        assert_eq!(indices, vec![0, 1, 2, 3, 4]);
347    }
348
349    #[test]
350    fn lttb_first_and_last_always_preserved() {
351        let x: Vec<f64> = (0..100).map(|i| i as f64).collect();
352        let y: Vec<f64> = x.iter().map(|v| v * v).collect();
353        let indices = lttb(&x, &y, 10);
354        assert_eq!(*indices.first().unwrap(), 0);
355        assert_eq!(*indices.last().unwrap(), 99);
356        assert_eq!(indices.len(), 10);
357    }
358
359    #[test]
360    fn lttb_threshold_2_returns_first_and_last() {
361        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
362        let y = vec![10.0, 20.0, 30.0, 40.0, 50.0];
363        let indices = lttb(&x, &y, 2);
364        assert_eq!(indices, vec![0, 4]);
365    }
366
367    #[test]
368    fn lttb_threshold_3_returns_first_middle_last() {
369        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
370        let y = vec![0.0, 5.0, 0.0, 5.0, 0.0];
371        let indices = lttb(&x, &y, 3);
372        assert_eq!(indices.len(), 3);
373        assert_eq!(indices[0], 0);
374        assert_eq!(*indices.last().unwrap(), 4);
375    }
376
377    #[test]
378    fn lttb_known_triangle_area() {
379        // Triangle with vertices (0,0), (1,0), (0,1) has area 0.5.
380        // Our internal function returns 2*area = 1.0.
381        let area = triangle_area(0.0, 0.0, 1.0, 0.0, 0.0, 1.0);
382        assert!((area - 1.0).abs() < 1e-12);
383
384        // Degenerate (collinear) triangle has area 0.
385        let area = triangle_area(0.0, 0.0, 1.0, 1.0, 2.0, 2.0);
386        assert!(area.abs() < 1e-12);
387    }
388
389    #[test]
390    fn lttb_linear_data_any_subset_looks_identical() {
391        // For perfectly linear data y = 2x + 1, all triangles have zero area.
392        // LTTB should still return `threshold` points, all lying on the line.
393        let n = 50;
394        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
395        let y: Vec<f64> = x.iter().map(|v| 2.0 * v + 1.0).collect();
396        let indices = lttb(&x, &y, 10);
397        assert_eq!(indices.len(), 10);
398
399        // All selected points should satisfy y = 2x + 1.
400        for &idx in &indices {
401            let expected = 2.0 * x[idx] + 1.0;
402            assert!(
403                (y[idx] - expected).abs() < 1e-12,
404                "point at index {} deviates from y = 2x + 1",
405                idx
406            );
407        }
408    }
409
410    #[test]
411    fn lttb_sinusoidal_peaks_preserved() {
412        // Generate a sine wave and verify that LTTB keeps the peaks.
413        let n = 1000;
414        let x: Vec<f64> = (0..n).map(|i| i as f64 * 2.0 * PI / 100.0).collect();
415        let y: Vec<f64> = x.iter().map(|v| v.sin()).collect();
416
417        let indices = lttb(&x, &y, 50);
418
419        // The global max (~1.0) and min (~-1.0) should be close to the selected set.
420        let selected_y: Vec<f64> = indices.iter().map(|&i| y[i]).collect();
421        let max_selected = selected_y.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
422        let min_selected = selected_y.iter().cloned().fold(f64::INFINITY, f64::min);
423
424        assert!(max_selected > 0.95, "peak not preserved: max = {}", max_selected);
425        assert!(min_selected < -0.95, "trough not preserved: min = {}", min_selected);
426    }
427
428    #[test]
429    fn lttb_single_point() {
430        let x = vec![42.0];
431        let y = vec![7.0];
432        let indices = lttb(&x, &y, 5);
433        assert_eq!(indices, vec![0]);
434    }
435
436    #[test]
437    fn lttb_two_points() {
438        let x = vec![1.0, 2.0];
439        let y = vec![3.0, 4.0];
440        let indices = lttb(&x, &y, 5);
441        assert_eq!(indices, vec![0, 1]);
442    }
443
444    #[test]
445    fn lttb_nan_handling() {
446        let x = vec![0.0, 1.0, f64::NAN, 3.0, 4.0, 5.0];
447        let y = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
448        let indices = lttb(&x, &y, 4);
449        // NaN at index 2 should be skipped entirely.
450        assert!(!indices.contains(&2), "NaN index should not be selected");
451        // First valid (0) and last valid (5) should be present.
452        assert_eq!(*indices.first().unwrap(), 0);
453        assert_eq!(*indices.last().unwrap(), 5);
454    }
455
456    #[test]
457    fn lttb_empty_input() {
458        let x: Vec<f64> = vec![];
459        let y: Vec<f64> = vec![];
460        let indices = lttb(&x, &y, 10);
461        assert!(indices.is_empty());
462    }
463
464    #[test]
465    fn lttb_threshold_zero() {
466        let x = vec![0.0, 1.0, 2.0];
467        let y = vec![0.0, 1.0, 2.0];
468        let indices = lttb(&x, &y, 0);
469        assert!(indices.is_empty());
470    }
471
472    #[test]
473    fn lttb_threshold_one() {
474        let x = vec![0.0, 1.0, 2.0];
475        let y = vec![0.0, 1.0, 2.0];
476        let indices = lttb(&x, &y, 1);
477        assert_eq!(indices, vec![0]);
478    }
479
480    // -- MinMax tests ------------------------------------------------------
481
482    #[test]
483    fn minmax_preserves_extremes() {
484        // Data with a clear spike and dip.
485        let x: Vec<f64> = (0..20).map(|i| i as f64).collect();
486        let mut y: Vec<f64> = vec![0.0; 20];
487        y[5] = 100.0;  // spike
488        y[15] = -100.0; // dip
489
490        let indices = minmax(&x, &y, 10);
491        let selected_y: Vec<f64> = indices.iter().map(|&i| y[i]).collect();
492
493        assert!(selected_y.contains(&100.0), "spike not preserved");
494        assert!(selected_y.contains(&-100.0), "dip not preserved");
495    }
496
497    #[test]
498    fn minmax_identity_when_threshold_ge_len() {
499        let x = vec![0.0, 1.0, 2.0, 3.0];
500        let y = vec![1.0, 2.0, 3.0, 4.0];
501        let indices = minmax(&x, &y, 10);
502        assert_eq!(indices, vec![0, 1, 2, 3]);
503    }
504
505    #[test]
506    fn minmax_first_and_last_preserved() {
507        let x: Vec<f64> = (0..50).map(|i| i as f64).collect();
508        let y: Vec<f64> = x.iter().map(|v| v.sin()).collect();
509        let indices = minmax(&x, &y, 10);
510        assert_eq!(*indices.first().unwrap(), 0);
511        assert_eq!(*indices.last().unwrap(), 49);
512    }
513
514    #[test]
515    fn minmax_empty_input() {
516        let indices = minmax(&[], &[], 5);
517        assert!(indices.is_empty());
518    }
519
520    #[test]
521    fn minmax_threshold_zero() {
522        let indices = minmax(&[1.0, 2.0], &[3.0, 4.0], 0);
523        assert!(indices.is_empty());
524    }
525
526    #[test]
527    fn minmax_nan_handling() {
528        let x = vec![0.0, 1.0, f64::NAN, 3.0, 4.0];
529        let y = vec![0.0, 10.0, 5.0, -10.0, 0.0];
530        let indices = minmax(&x, &y, 4);
531        assert!(!indices.contains(&2), "NaN index should not be selected");
532    }
533
534    // -- Large synthetic dataset smoke test --------------------------------
535
536    #[test]
537    fn lttb_large_dataset_smoke() {
538        let n = 100_000;
539        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
540        let y: Vec<f64> = x.iter().map(|v| (v * 0.01).sin() + (v * 0.1).cos()).collect();
541
542        let threshold = 500;
543        let indices = lttb(&x, &y, threshold);
544
545        assert_eq!(indices.len(), threshold);
546        assert_eq!(indices[0], 0);
547        assert_eq!(*indices.last().unwrap(), n - 1);
548
549        // Indices should be strictly increasing (no duplicates, monotonic).
550        for w in indices.windows(2) {
551            assert!(w[0] < w[1], "indices must be strictly increasing");
552        }
553    }
554
555    #[test]
556    fn minmax_large_dataset_smoke() {
557        let n = 100_000;
558        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
559        let y: Vec<f64> = x.iter().map(|v| (v * 0.01).sin()).collect();
560
561        let threshold = 500;
562        let indices = minmax(&x, &y, threshold);
563
564        assert!(indices.len() <= threshold);
565        assert_eq!(indices[0], 0);
566        assert_eq!(*indices.last().unwrap(), n - 1);
567
568        // Indices should be strictly increasing.
569        for w in indices.windows(2) {
570            assert!(w[0] < w[1], "indices must be strictly increasing");
571        }
572    }
573
574    // -- Bucket boundary correctness ---------------------------------------
575
576    #[test]
577    fn lttb_bucket_boundaries_no_gaps_or_overlaps() {
578        // With 12 points and threshold=6, we have 4 buckets over 10 interior
579        // points. Verify every interior point is in exactly one bucket.
580        let n = 12;
581        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
582        let y: Vec<f64> = x.clone();
583
584        let threshold = 6;
585        let indices = lttb(&x, &y, threshold);
586
587        assert_eq!(indices.len(), threshold);
588        assert_eq!(indices[0], 0);
589        assert_eq!(*indices.last().unwrap(), n as usize - 1);
590
591        // All selected indices should be within range.
592        for &idx in &indices {
593            assert!(idx < n as usize);
594        }
595    }
596
597    #[test]
598    fn lttb_all_nan_returns_empty() {
599        let x = vec![f64::NAN; 5];
600        let y = vec![f64::NAN; 5];
601        let indices = lttb(&x, &y, 3);
602        assert!(indices.is_empty());
603    }
604
605    #[test]
606    fn minmax_all_nan_returns_empty() {
607        let x = vec![f64::NAN; 5];
608        let y = vec![f64::NAN; 5];
609        let indices = minmax(&x, &y, 3);
610        assert!(indices.is_empty());
611    }
612
613    #[test]
614    fn lttb_indices_are_sorted() {
615        let n = 200;
616        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
617        let y: Vec<f64> = x.iter().map(|v| (v * 0.1).sin()).collect();
618        let indices = lttb(&x, &y, 20);
619        for w in indices.windows(2) {
620            assert!(w[0] < w[1], "LTTB indices must be strictly increasing");
621        }
622    }
623
624    // -- DecimateMode resolution -------------------------------------------
625
626    #[test]
627    fn mode_default_is_auto() {
628        assert_eq!(DecimateMode::default(), DecimateMode::Auto);
629    }
630
631    #[test]
632    fn mode_auto_decimates_above_threshold_to_default() {
633        let n = DEFAULT_DECIMATE_THRESHOLD + 1;
634        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
635        let y: Vec<f64> = x.iter().map(|v| (v * 0.01).sin()).collect();
636        let indices = DecimateMode::Auto.resolve_indices(&x, &y);
637        assert_eq!(indices.len(), DEFAULT_DECIMATE_THRESHOLD);
638        // Endpoints always preserved.
639        assert_eq!(*indices.first().unwrap(), 0);
640        assert_eq!(*indices.last().unwrap(), n - 1);
641        // Strictly increasing.
642        for w in indices.windows(2) {
643            assert!(w[0] < w[1]);
644        }
645    }
646
647    #[test]
648    fn mode_auto_is_noop_exactly_at_threshold() {
649        let n = DEFAULT_DECIMATE_THRESHOLD;
650        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
651        let y = x.clone();
652        let indices = DecimateMode::Auto.resolve_indices(&x, &y);
653        assert_eq!(indices, (0..n).collect::<Vec<_>>());
654    }
655
656    #[test]
657    fn mode_auto_is_noop_below_threshold() {
658        let n = 10;
659        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
660        let y = x.clone();
661        let indices = DecimateMode::Auto.resolve_indices(&x, &y);
662        assert_eq!(indices, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
663    }
664
665    #[test]
666    fn mode_off_never_decimates() {
667        let n = DEFAULT_DECIMATE_THRESHOLD * 4;
668        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
669        let y = x.clone();
670        let indices = DecimateMode::Off.resolve_indices(&x, &y);
671        assert_eq!(indices.len(), n);
672        assert_eq!(*indices.first().unwrap(), 0);
673        assert_eq!(*indices.last().unwrap(), n - 1);
674    }
675
676    #[test]
677    fn mode_explicit_lttb_uses_given_threshold() {
678        let n = 4000;
679        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
680        let y: Vec<f64> = x.iter().map(|v| (v * 0.05).sin()).collect();
681        let indices =
682            DecimateMode::Explicit(250, DecimateMethod::Lttb).resolve_indices(&x, &y);
683        assert_eq!(indices.len(), 250);
684        assert_eq!(*indices.first().unwrap(), 0);
685        assert_eq!(*indices.last().unwrap(), n - 1);
686    }
687
688    #[test]
689    fn mode_explicit_minmax_uses_given_method() {
690        let n = 4000;
691        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
692        let y: Vec<f64> = x.iter().map(|v| (v * 0.05).sin()).collect();
693        let indices =
694            DecimateMode::Explicit(250, DecimateMethod::MinMax).resolve_indices(&x, &y);
695        assert!(indices.len() <= 250);
696        assert_eq!(*indices.first().unwrap(), 0);
697        assert_eq!(*indices.last().unwrap(), n - 1);
698    }
699
700    #[test]
701    fn mode_explicit_is_noop_below_its_threshold() {
702        // Series of 100 with explicit threshold 5000 → no decimation.
703        let n = 100;
704        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
705        let y = x.clone();
706        let indices =
707            DecimateMode::Explicit(5000, DecimateMethod::Lttb).resolve_indices(&x, &y);
708        assert_eq!(indices, (0..n).collect::<Vec<_>>());
709    }
710
711    #[test]
712    fn mode_resolve_is_deterministic() {
713        // Same input must yield identical indices across repeated calls.
714        let n = 20_000;
715        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
716        let y: Vec<f64> = x.iter().map(|v| (v * 0.003).sin() + (v * 0.07).cos()).collect();
717        let a = DecimateMode::Auto.resolve_indices(&x, &y);
718        let b = DecimateMode::Auto.resolve_indices(&x, &y);
719        assert_eq!(a, b);
720    }
721}