Skip to main content

flow_plots/
contour.rs

1//! Contour plot calculation using KDE2D and marching squares.
2//!
3//! Extracts contour lines from 2D density estimates at multiple threshold levels.
4
5use flow_utils::KernelDensity2D;
6use ndarray::Array2;
7
8/// Contour plot data: contour paths and optional outlier points
9#[derive(Clone, Debug)]
10pub struct ContourData {
11    /// Contour paths at each level (outermost first). Each path is a closed polygon.
12    pub contours: Vec<ContourPath>,
13    /// Points outside all contours (when draw_outliers is true)
14    pub outliers: Vec<(f64, f64)>,
15}
16
17/// A single contour path - ordered (x, y) points forming a closed loop
18pub type ContourPath = Vec<(f64, f64)>;
19
20/// Extract contour paths from (x, y) data using KDE and marching squares.
21///
22/// # Arguments
23/// * `data` - (x, y) points
24/// * `level_count` - Number of contour levels
25/// * `smoothing` - KDE bandwidth adjustment (higher = more smooth)
26/// * `draw_outliers` - If true, compute points outside outermost contour
27/// * `x_range` - Plot x range (reserved for future clipping)
28/// * `y_range` - Plot y range (reserved for future clipping)
29pub fn calculate_contours(
30    data: &[(f32, f32)],
31    level_count: u32,
32    smoothing: f64,
33    draw_outliers: bool,
34    _x_range: (f32, f32),
35    _y_range: (f32, f32),
36) -> Result<ContourData, flow_utils::KdeError> {
37    if data.len() < 3 {
38        return Ok(ContourData {
39            contours: Vec::new(),
40            outliers: data
41                .iter()
42                .map(|&(x, y)| (x as f64, y as f64))
43                .collect(),
44        });
45    }
46
47    let data_x: Vec<f64> = data.iter().map(|(x, _)| *x as f64).collect();
48    let data_y: Vec<f64> = data.iter().map(|(_, y)| *y as f64).collect();
49
50    // KDE grid resolution - use enough for smooth contours
51    let n_grid = 128usize;
52    let kde = KernelDensity2D::estimate(&data_x, &data_y, smoothing, n_grid)?;
53
54    let max_density = kde
55        .z
56        .iter()
57        .cloned()
58        .fold(f64::NEG_INFINITY, f64::max)
59        .max(1e-300);
60
61    // Contour levels: from low (outer) to high (inner)
62    // Use geometric spacing for better visual distribution
63    let levels: Vec<f64> = if level_count <= 1 {
64        vec![0.1 * max_density]
65    } else {
66        (1..=level_count as usize)
67            .map(|i| {
68                let t = i as f64 / (level_count as f64 + 1.0);
69                // Logarithmic spacing: low values near 0.01, high near 0.99
70                let frac = 0.01 + 0.98 * t;
71                frac * max_density
72            })
73            .collect()
74    };
75
76    let mut contours = Vec::with_capacity(levels.len());
77    for &threshold in &levels {
78        let paths = marching_squares_contours(&kde.x, &kde.y, &kde.z, threshold);
79        contours.extend(paths);
80    }
81
82    let outliers = if draw_outliers {
83        let min_threshold = levels.first().copied().unwrap_or(0.01 * max_density);
84        data.iter()
85            .filter(|&&(x, y)| {
86                let d = kde.density_at(x as f64, y as f64);
87                d < min_threshold
88            })
89            .map(|&(x, y)| (x as f64, y as f64))
90            .collect()
91    } else {
92        Vec::new()
93    };
94
95    Ok(ContourData {
96        contours,
97        outliers,
98    })
99}
100
101/// Marching squares: extract contour paths from a 2D scalar grid.
102///
103/// Grid layout: x[i], y[j], z[[i,j]] - first index is x, second is y.
104fn marching_squares_contours(
105    x: &[f64],
106    y: &[f64],
107    z: &Array2<f64>,
108    threshold: f64,
109) -> Vec<ContourPath> {
110    let nx = x.len();
111    let ny = y.len();
112    if nx < 2 || ny < 2 {
113        return Vec::new();
114    }
115
116    // Collect line segments: each is ((x0,y0), (x1,y1))
117    let mut segments = Vec::new();
118
119    for i in 0..nx - 1 {
120        for j in 0..ny - 1 {
121            let v00 = z[[i, j]];
122            let v10 = z[[i + 1, j]];
123            let v01 = z[[i, j + 1]];
124            let v11 = z[[i + 1, j + 1]];
125
126            let c00 = v00 >= threshold;
127            let c10 = v10 >= threshold;
128            let c01 = v01 >= threshold;
129            let c11 = v11 >= threshold;
130
131            let case = (c00 as u8) | ((c10 as u8) << 1) | ((c01 as u8) << 2) | ((c11 as u8) << 3);
132            if case == 0 || case == 15 {
133                continue; // All below or all above
134            }
135
136            // Linear interpolation for edge crossings
137            let lerp = |a: f64, b: f64, va: f64, vb: f64| -> f64 {
138                if (va >= threshold) == (vb >= threshold) {
139                    return a; // No crossing
140                }
141                let t = (threshold - va) / (vb - va).max(1e-300);
142                a + t * (b - a)
143            };
144
145            let x0 = x[i];
146            let x1 = x[i + 1];
147            let y0 = y[j];
148            let y1 = y[j + 1];
149
150            // Edge crossing points (x,y)
151            let bottom = (lerp(x0, x1, v00, v10), y0);
152            let right = (x1, lerp(y0, y1, v10, v11));
153            let top = (lerp(x1, x0, v11, v01), y1);
154            let left = (x0, lerp(y1, y0, v01, v00));
155
156            match case {
157                1 => segments.push((left, bottom)),
158                2 => segments.push((bottom, right)),
159                3 => segments.push((left, right)),
160                4 => segments.push((top, right)),
161                5 => {
162                    segments.push((left, bottom));
163                    segments.push((top, right));
164                }
165                6 => segments.push((bottom, top)),
166                7 => segments.push((left, top)),
167                8 => segments.push((top, left)),
168                9 => segments.push((bottom, top)),
169                10 => {
170                    segments.push((bottom, left));
171                    segments.push((top, right));
172                }
173                11 => segments.push((bottom, top)),
174                12 => segments.push((right, left)),
175                13 => segments.push((right, bottom)),
176                14 => segments.push((top, left)),
177                _ => {}
178            }
179        }
180    }
181
182    // Chain segments into closed paths
183    chain_segments_into_paths(segments)
184}
185
186/// Chain line segments into closed contour paths
187fn chain_segments_into_paths(segments: Vec<((f64, f64), (f64, f64))>) -> Vec<ContourPath> {
188    const EQ_EPS: f64 = 1e-10;
189
190    fn points_eq(a: (f64, f64), b: (f64, f64)) -> bool {
191        (a.0 - b.0).abs() < EQ_EPS && (a.1 - b.1).abs() < EQ_EPS
192    }
193
194    let mut used = vec![false; segments.len()];
195    let mut paths = Vec::new();
196
197    for start_idx in 0..segments.len() {
198        if used[start_idx] {
199            continue;
200        }
201
202        let (p0, p1) = segments[start_idx];
203        used[start_idx] = true;
204        let mut path = vec![p0, p1];
205
206        loop {
207            let mut found = false;
208            for (idx, &(a, b)) in segments.iter().enumerate() {
209                if used[idx] {
210                    continue;
211                }
212                let last = *path.last().unwrap();
213                if points_eq(last, a) {
214                    path.push(b);
215                    used[idx] = true;
216                    found = true;
217                    break;
218                } else if points_eq(last, b) {
219                    path.push(a);
220                    used[idx] = true;
221                    found = true;
222                    break;
223                }
224            }
225            if !found {
226                break;
227            }
228            // Check if we closed the loop
229            if path.len() >= 3 && points_eq(path.first().copied().unwrap(), *path.last().unwrap()) {
230                path.pop(); // Remove duplicate closing point
231                break;
232            }
233        }
234
235        if path.len() >= 2 {
236            // If path closed on itself, remove duplicate endpoint
237            if path.len() >= 3 && points_eq(path[0], path[path.len() - 1]) {
238                path.pop();
239            }
240            paths.push(path);
241        }
242    }
243
244    paths
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_contour_empty_data() {
253        let data: Vec<(f32, f32)> = vec![];
254        let result = calculate_contours(&data, 5, 1.0, false, (0.0, 100.0), (0.0, 100.0));
255        assert!(result.is_ok());
256        let contour_data = result.unwrap();
257        assert!(contour_data.contours.is_empty());
258        assert!(contour_data.outliers.is_empty());
259    }
260
261    #[test]
262    fn test_contour_small_data() {
263        let data = vec![(1.0f32, 2.0f32), (2.0, 3.0), (3.0, 4.0)];
264        let result = calculate_contours(&data, 3, 1.0, false, (0.0, 10.0), (0.0, 10.0));
265        assert!(result.is_ok());
266    }
267
268    #[test]
269    fn test_contour_clustered_data() {
270        // Dense grid of points - guarantees high density region for contours
271        let mut data: Vec<(f32, f32)> = Vec::new();
272        for xi in 0..20 {
273            for yi in 0..20 {
274                data.push((100.0 + xi as f32 * 2.0, 100.0 + yi as f32 * 2.0));
275            }
276        }
277        let result = calculate_contours(&data, 5, 0.8, false, (0.0, 200.0), (0.0, 200.0));
278        assert!(result.is_ok());
279        let contour_data = result.unwrap();
280        // 400 points in tight cluster should produce contour paths
281        assert!(
282            !contour_data.contours.is_empty(),
283            "dense cluster should produce at least one contour path"
284        );
285    }
286}