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
199            .iter()
200            .enumerate()
201            .skip(bucket_start)
202            .take(bucket_end - bucket_start)
203        {
204            let area = triangle_area(px, py, x[orig], y[orig], avg_x, avg_y);
205            if area > best_area {
206                best_area = area;
207                best_valid_idx = vi;
208            }
209        }
210
211        selected.push(valid[best_valid_idx]);
212        prev_selected_idx = best_valid_idx;
213    }
214
215    // Always select the last valid point.
216    selected.push(valid[valid_n - 1]);
217
218    selected
219}
220
221/// Min-max decimation: keeps the min and max y-value in each bucket.
222/// Faster than LTTB, good for dense time series where peaks matter.
223///
224/// Returns indices into the original arrays. The first and last points
225/// are always preserved. Within each bucket, the min and max points are
226/// returned in their original order (preserving temporal coherence).
227///
228/// # Panics
229///
230/// Panics if `x.len() != y.len()`.
231pub fn minmax(x: &[f64], y: &[f64], threshold: usize) -> Vec<usize> {
232    assert_eq!(x.len(), y.len(), "x and y must have the same length");
233
234    let n = x.len();
235
236    if threshold == 0 {
237        return vec![];
238    }
239    if n == 0 {
240        return vec![];
241    }
242    if threshold == 1 {
243        return vec![0];
244    }
245    if threshold >= n {
246        return (0..n).collect();
247    }
248
249    // Filter out NaN points.
250    let valid: Vec<usize> = (0..n)
251        .filter(|&i| x[i].is_finite() && y[i].is_finite())
252        .collect();
253    let valid_n = valid.len();
254
255    if valid_n == 0 {
256        return vec![];
257    }
258    if threshold >= valid_n {
259        return valid;
260    }
261
262    let mut selected = Vec::with_capacity(threshold);
263
264    // Always select the first valid point.
265    selected.push(valid[0]);
266
267    // Each bucket produces two points (min and max), so we need
268    // (threshold - 2) / 2 buckets. Handle odd thresholds gracefully.
269    let pairs = (threshold - 2) / 2;
270    let bucket_count = if pairs == 0 { 1 } else { pairs };
271    let interior = valid_n - 2; // points between first and last
272
273    for bucket_i in 0..bucket_count {
274        let bucket_start = 1 + (bucket_i * interior) / bucket_count;
275        let bucket_end = 1 + ((bucket_i + 1) * interior) / bucket_count;
276
277        if bucket_start >= bucket_end {
278            continue;
279        }
280
281        let mut min_idx = bucket_start;
282        let mut max_idx = bucket_start;
283        let mut min_val = y[valid[bucket_start]];
284        let mut max_val = y[valid[bucket_start]];
285
286        for vi in bucket_start..bucket_end {
287            let yv = y[valid[vi]];
288            if yv < min_val {
289                min_val = yv;
290                min_idx = vi;
291            }
292            if yv > max_val {
293                max_val = yv;
294                max_idx = vi;
295            }
296        }
297
298        // Add min and max in original order.
299        if min_idx == max_idx {
300            selected.push(valid[min_idx]);
301        } else if min_idx < max_idx {
302            selected.push(valid[min_idx]);
303            selected.push(valid[max_idx]);
304        } else {
305            selected.push(valid[max_idx]);
306            selected.push(valid[min_idx]);
307        }
308    }
309
310    // Always select the last valid point.
311    let last = valid[valid_n - 1];
312    // Avoid duplicating the last point if it was already selected.
313    if selected.last() != Some(&last) {
314        selected.push(last);
315    }
316
317    selected
318}
319
320/// Computes twice the area of the triangle formed by three points.
321///
322/// Using the shoelace formula:
323///   area = |x_a(y_b - y_c) + x_b(y_c - y_a) + x_c(y_a - y_b)| / 2
324///
325/// We return the value without the `/2` since we only need relative
326/// comparisons and avoiding the division is faster.
327#[inline]
328fn triangle_area(x_a: f64, y_a: f64, x_b: f64, y_b: f64, x_c: f64, y_c: f64) -> f64 {
329    (x_a * (y_b - y_c) + x_b * (y_c - y_a) + x_c * (y_a - y_b)).abs()
330}
331
332// ---------------------------------------------------------------------------
333// Tests
334// ---------------------------------------------------------------------------
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use std::f64::consts::PI;
340
341    // -- LTTB tests --------------------------------------------------------
342
343    #[test]
344    fn lttb_identity_when_threshold_ge_len() {
345        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
346        let y = vec![0.0, 1.0, 4.0, 9.0, 16.0];
347        let indices = lttb(&x, &y, 5);
348        assert_eq!(indices, vec![0, 1, 2, 3, 4]);
349
350        let indices = lttb(&x, &y, 100);
351        assert_eq!(indices, vec![0, 1, 2, 3, 4]);
352    }
353
354    #[test]
355    fn lttb_first_and_last_always_preserved() {
356        let x: Vec<f64> = (0..100).map(|i| i as f64).collect();
357        let y: Vec<f64> = x.iter().map(|v| v * v).collect();
358        let indices = lttb(&x, &y, 10);
359        assert_eq!(*indices.first().unwrap(), 0);
360        assert_eq!(*indices.last().unwrap(), 99);
361        assert_eq!(indices.len(), 10);
362    }
363
364    #[test]
365    fn lttb_threshold_2_returns_first_and_last() {
366        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
367        let y = vec![10.0, 20.0, 30.0, 40.0, 50.0];
368        let indices = lttb(&x, &y, 2);
369        assert_eq!(indices, vec![0, 4]);
370    }
371
372    #[test]
373    fn lttb_threshold_3_returns_first_middle_last() {
374        let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
375        let y = vec![0.0, 5.0, 0.0, 5.0, 0.0];
376        let indices = lttb(&x, &y, 3);
377        assert_eq!(indices.len(), 3);
378        assert_eq!(indices[0], 0);
379        assert_eq!(*indices.last().unwrap(), 4);
380    }
381
382    #[test]
383    fn lttb_known_triangle_area() {
384        // Triangle with vertices (0,0), (1,0), (0,1) has area 0.5.
385        // Our internal function returns 2*area = 1.0.
386        let area = triangle_area(0.0, 0.0, 1.0, 0.0, 0.0, 1.0);
387        assert!((area - 1.0).abs() < 1e-12);
388
389        // Degenerate (collinear) triangle has area 0.
390        let area = triangle_area(0.0, 0.0, 1.0, 1.0, 2.0, 2.0);
391        assert!(area.abs() < 1e-12);
392    }
393
394    #[test]
395    fn lttb_linear_data_any_subset_looks_identical() {
396        // For perfectly linear data y = 2x + 1, all triangles have zero area.
397        // LTTB should still return `threshold` points, all lying on the line.
398        let n = 50;
399        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
400        let y: Vec<f64> = x.iter().map(|v| 2.0 * v + 1.0).collect();
401        let indices = lttb(&x, &y, 10);
402        assert_eq!(indices.len(), 10);
403
404        // All selected points should satisfy y = 2x + 1.
405        for &idx in &indices {
406            let expected = 2.0 * x[idx] + 1.0;
407            assert!(
408                (y[idx] - expected).abs() < 1e-12,
409                "point at index {} deviates from y = 2x + 1",
410                idx
411            );
412        }
413    }
414
415    #[test]
416    fn lttb_sinusoidal_peaks_preserved() {
417        // Generate a sine wave and verify that LTTB keeps the peaks.
418        let n = 1000;
419        let x: Vec<f64> = (0..n).map(|i| i as f64 * 2.0 * PI / 100.0).collect();
420        let y: Vec<f64> = x.iter().map(|v| v.sin()).collect();
421
422        let indices = lttb(&x, &y, 50);
423
424        // The global max (~1.0) and min (~-1.0) should be close to the selected set.
425        let selected_y: Vec<f64> = indices.iter().map(|&i| y[i]).collect();
426        let max_selected = selected_y.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
427        let min_selected = selected_y.iter().cloned().fold(f64::INFINITY, f64::min);
428
429        assert!(
430            max_selected > 0.95,
431            "peak not preserved: max = {}",
432            max_selected
433        );
434        assert!(
435            min_selected < -0.95,
436            "trough not preserved: min = {}",
437            min_selected
438        );
439    }
440
441    #[test]
442    fn lttb_single_point() {
443        let x = vec![42.0];
444        let y = vec![7.0];
445        let indices = lttb(&x, &y, 5);
446        assert_eq!(indices, vec![0]);
447    }
448
449    #[test]
450    fn lttb_two_points() {
451        let x = vec![1.0, 2.0];
452        let y = vec![3.0, 4.0];
453        let indices = lttb(&x, &y, 5);
454        assert_eq!(indices, vec![0, 1]);
455    }
456
457    #[test]
458    fn lttb_nan_handling() {
459        let x = vec![0.0, 1.0, f64::NAN, 3.0, 4.0, 5.0];
460        let y = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
461        let indices = lttb(&x, &y, 4);
462        // NaN at index 2 should be skipped entirely.
463        assert!(!indices.contains(&2), "NaN index should not be selected");
464        // First valid (0) and last valid (5) should be present.
465        assert_eq!(*indices.first().unwrap(), 0);
466        assert_eq!(*indices.last().unwrap(), 5);
467    }
468
469    #[test]
470    fn lttb_empty_input() {
471        let x: Vec<f64> = vec![];
472        let y: Vec<f64> = vec![];
473        let indices = lttb(&x, &y, 10);
474        assert!(indices.is_empty());
475    }
476
477    #[test]
478    fn lttb_threshold_zero() {
479        let x = vec![0.0, 1.0, 2.0];
480        let y = vec![0.0, 1.0, 2.0];
481        let indices = lttb(&x, &y, 0);
482        assert!(indices.is_empty());
483    }
484
485    #[test]
486    fn lttb_threshold_one() {
487        let x = vec![0.0, 1.0, 2.0];
488        let y = vec![0.0, 1.0, 2.0];
489        let indices = lttb(&x, &y, 1);
490        assert_eq!(indices, vec![0]);
491    }
492
493    // -- MinMax tests ------------------------------------------------------
494
495    #[test]
496    fn minmax_preserves_extremes() {
497        // Data with a clear spike and dip.
498        let x: Vec<f64> = (0..20).map(|i| i as f64).collect();
499        let mut y: Vec<f64> = vec![0.0; 20];
500        y[5] = 100.0; // spike
501        y[15] = -100.0; // dip
502
503        let indices = minmax(&x, &y, 10);
504        let selected_y: Vec<f64> = indices.iter().map(|&i| y[i]).collect();
505
506        assert!(selected_y.contains(&100.0), "spike not preserved");
507        assert!(selected_y.contains(&-100.0), "dip not preserved");
508    }
509
510    #[test]
511    fn minmax_identity_when_threshold_ge_len() {
512        let x = vec![0.0, 1.0, 2.0, 3.0];
513        let y = vec![1.0, 2.0, 3.0, 4.0];
514        let indices = minmax(&x, &y, 10);
515        assert_eq!(indices, vec![0, 1, 2, 3]);
516    }
517
518    #[test]
519    fn minmax_first_and_last_preserved() {
520        let x: Vec<f64> = (0..50).map(|i| i as f64).collect();
521        let y: Vec<f64> = x.iter().map(|v| v.sin()).collect();
522        let indices = minmax(&x, &y, 10);
523        assert_eq!(*indices.first().unwrap(), 0);
524        assert_eq!(*indices.last().unwrap(), 49);
525    }
526
527    #[test]
528    fn minmax_empty_input() {
529        let indices = minmax(&[], &[], 5);
530        assert!(indices.is_empty());
531    }
532
533    #[test]
534    fn minmax_threshold_zero() {
535        let indices = minmax(&[1.0, 2.0], &[3.0, 4.0], 0);
536        assert!(indices.is_empty());
537    }
538
539    #[test]
540    fn minmax_nan_handling() {
541        let x = vec![0.0, 1.0, f64::NAN, 3.0, 4.0];
542        let y = vec![0.0, 10.0, 5.0, -10.0, 0.0];
543        let indices = minmax(&x, &y, 4);
544        assert!(!indices.contains(&2), "NaN index should not be selected");
545    }
546
547    // -- Large synthetic dataset smoke test --------------------------------
548
549    #[test]
550    fn lttb_large_dataset_smoke() {
551        let n = 100_000;
552        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
553        let y: Vec<f64> = x
554            .iter()
555            .map(|v| (v * 0.01).sin() + (v * 0.1).cos())
556            .collect();
557
558        let threshold = 500;
559        let indices = lttb(&x, &y, threshold);
560
561        assert_eq!(indices.len(), threshold);
562        assert_eq!(indices[0], 0);
563        assert_eq!(*indices.last().unwrap(), n - 1);
564
565        // Indices should be strictly increasing (no duplicates, monotonic).
566        for w in indices.windows(2) {
567            assert!(w[0] < w[1], "indices must be strictly increasing");
568        }
569    }
570
571    #[test]
572    fn minmax_large_dataset_smoke() {
573        let n = 100_000;
574        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
575        let y: Vec<f64> = x.iter().map(|v| (v * 0.01).sin()).collect();
576
577        let threshold = 500;
578        let indices = minmax(&x, &y, threshold);
579
580        assert!(indices.len() <= threshold);
581        assert_eq!(indices[0], 0);
582        assert_eq!(*indices.last().unwrap(), n - 1);
583
584        // Indices should be strictly increasing.
585        for w in indices.windows(2) {
586            assert!(w[0] < w[1], "indices must be strictly increasing");
587        }
588    }
589
590    // -- Bucket boundary correctness ---------------------------------------
591
592    #[test]
593    fn lttb_bucket_boundaries_no_gaps_or_overlaps() {
594        // With 12 points and threshold=6, we have 4 buckets over 10 interior
595        // points. Verify every interior point is in exactly one bucket.
596        let n = 12;
597        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
598        let y: Vec<f64> = x.clone();
599
600        let threshold = 6;
601        let indices = lttb(&x, &y, threshold);
602
603        assert_eq!(indices.len(), threshold);
604        assert_eq!(indices[0], 0);
605        assert_eq!(*indices.last().unwrap(), n as usize - 1);
606
607        // All selected indices should be within range.
608        for &idx in &indices {
609            assert!(idx < n as usize);
610        }
611    }
612
613    #[test]
614    fn lttb_all_nan_returns_empty() {
615        let x = vec![f64::NAN; 5];
616        let y = vec![f64::NAN; 5];
617        let indices = lttb(&x, &y, 3);
618        assert!(indices.is_empty());
619    }
620
621    #[test]
622    fn minmax_all_nan_returns_empty() {
623        let x = vec![f64::NAN; 5];
624        let y = vec![f64::NAN; 5];
625        let indices = minmax(&x, &y, 3);
626        assert!(indices.is_empty());
627    }
628
629    #[test]
630    fn lttb_indices_are_sorted() {
631        let n = 200;
632        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
633        let y: Vec<f64> = x.iter().map(|v| (v * 0.1).sin()).collect();
634        let indices = lttb(&x, &y, 20);
635        for w in indices.windows(2) {
636            assert!(w[0] < w[1], "LTTB indices must be strictly increasing");
637        }
638    }
639
640    // -- DecimateMode resolution -------------------------------------------
641
642    #[test]
643    fn mode_default_is_auto() {
644        assert_eq!(DecimateMode::default(), DecimateMode::Auto);
645    }
646
647    #[test]
648    fn mode_auto_decimates_above_threshold_to_default() {
649        let n = DEFAULT_DECIMATE_THRESHOLD + 1;
650        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
651        let y: Vec<f64> = x.iter().map(|v| (v * 0.01).sin()).collect();
652        let indices = DecimateMode::Auto.resolve_indices(&x, &y);
653        assert_eq!(indices.len(), DEFAULT_DECIMATE_THRESHOLD);
654        // Endpoints always preserved.
655        assert_eq!(*indices.first().unwrap(), 0);
656        assert_eq!(*indices.last().unwrap(), n - 1);
657        // Strictly increasing.
658        for w in indices.windows(2) {
659            assert!(w[0] < w[1]);
660        }
661    }
662
663    #[test]
664    fn mode_auto_is_noop_exactly_at_threshold() {
665        let n = DEFAULT_DECIMATE_THRESHOLD;
666        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
667        let y = x.clone();
668        let indices = DecimateMode::Auto.resolve_indices(&x, &y);
669        assert_eq!(indices, (0..n).collect::<Vec<_>>());
670    }
671
672    #[test]
673    fn mode_auto_is_noop_below_threshold() {
674        let n = 10;
675        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
676        let y = x.clone();
677        let indices = DecimateMode::Auto.resolve_indices(&x, &y);
678        assert_eq!(indices, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
679    }
680
681    #[test]
682    fn mode_off_never_decimates() {
683        let n = DEFAULT_DECIMATE_THRESHOLD * 4;
684        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
685        let y = x.clone();
686        let indices = DecimateMode::Off.resolve_indices(&x, &y);
687        assert_eq!(indices.len(), n);
688        assert_eq!(*indices.first().unwrap(), 0);
689        assert_eq!(*indices.last().unwrap(), n - 1);
690    }
691
692    #[test]
693    fn mode_explicit_lttb_uses_given_threshold() {
694        let n = 4000;
695        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
696        let y: Vec<f64> = x.iter().map(|v| (v * 0.05).sin()).collect();
697        let indices = DecimateMode::Explicit(250, DecimateMethod::Lttb).resolve_indices(&x, &y);
698        assert_eq!(indices.len(), 250);
699        assert_eq!(*indices.first().unwrap(), 0);
700        assert_eq!(*indices.last().unwrap(), n - 1);
701    }
702
703    #[test]
704    fn mode_explicit_minmax_uses_given_method() {
705        let n = 4000;
706        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
707        let y: Vec<f64> = x.iter().map(|v| (v * 0.05).sin()).collect();
708        let indices = DecimateMode::Explicit(250, DecimateMethod::MinMax).resolve_indices(&x, &y);
709        assert!(indices.len() <= 250);
710        assert_eq!(*indices.first().unwrap(), 0);
711        assert_eq!(*indices.last().unwrap(), n - 1);
712    }
713
714    #[test]
715    fn mode_explicit_is_noop_below_its_threshold() {
716        // Series of 100 with explicit threshold 5000 → no decimation.
717        let n = 100;
718        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
719        let y = x.clone();
720        let indices = DecimateMode::Explicit(5000, DecimateMethod::Lttb).resolve_indices(&x, &y);
721        assert_eq!(indices, (0..n).collect::<Vec<_>>());
722    }
723
724    #[test]
725    fn mode_resolve_is_deterministic() {
726        // Same input must yield identical indices across repeated calls.
727        let n = 20_000;
728        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
729        let y: Vec<f64> = x
730            .iter()
731            .map(|v| (v * 0.003).sin() + (v * 0.07).cos())
732            .collect();
733        let a = DecimateMode::Auto.resolve_indices(&x, &y);
734        let b = DecimateMode::Auto.resolve_indices(&x, &y);
735        assert_eq!(a, b);
736    }
737}