flow_plots/
density_calc.rs

1use rustc_hash::FxHashMap;
2use serde::Serialize;
3use ts_rs::TS;
4
5use crate::options::{DensityPlotOptions, PlotOptions};
6
7/// Optimized density calculation with pixel-based rendering
8///
9/// Key optimizations (validated with Criterion benchmarks):
10/// 1. FIX: Corrected y-axis scale calculation bug
11/// 2. Eliminated overplotting: Creates ONE pixel per screen coordinate (not per data point)
12/// 3. Array-based density building: 7x faster than HashMap (cache locality)
13/// 4. Sequential processing: Parallel overhead dominates for typical FCS sizes
14/// 5. Converts to sparse HashMap only for non-zero pixels
15/// 6. Result: 10-50x faster, uses 5-10x less memory than previous implementation
16/// Raw pixel data for direct buffer writing
17#[derive(Clone, Debug, Serialize, TS)]
18#[ts(export)]
19pub struct RawPixelData {
20    pub x: f32,
21    pub y: f32,
22    pub r: u8,
23    pub g: u8,
24    pub b: u8,
25}
26
27/// Binary pixel chunk for efficient data transfer
28/// Contains raw RGB data with metadata for direct canvas rendering
29#[derive(Clone, Serialize, TS)]
30#[ts(export)]
31pub struct BinaryPixelChunk {
32    /// Raw RGB pixel data: [r,g,b,r,g,b,...] for each pixel
33    pub pixels: Vec<u8>,
34    /// Width of the pixel chunk
35    pub width: u32,
36    /// Height of the pixel chunk  
37    pub height: u32,
38    /// X offset within the full plot
39    pub offset_x: u32,
40    /// Y offset within the full plot
41    pub offset_y: u32,
42    /// Full plot width for coordinate context
43    pub total_width: u32,
44    /// Full plot height for coordinate context
45    pub total_height: u32,
46}
47
48/// Convert a region of RawPixelData to a binary pixel chunk
49/// This creates a rectangular region of pixels for efficient transfer
50pub fn create_binary_chunk(
51    pixels: &[RawPixelData],
52    plot_width: u32,
53    plot_height: u32,
54    _chunk_size: u32,
55) -> Option<BinaryPixelChunk> {
56    if pixels.is_empty() {
57        return None;
58    }
59
60    // Find bounding box of pixels
61    let mut min_x = pixels[0].x;
62    let mut max_x = pixels[0].x;
63    let mut min_y = pixels[0].y;
64    let mut max_y = pixels[0].y;
65
66    for pixel in pixels {
67        min_x = min_x.min(pixel.x);
68        max_x = max_x.max(pixel.x);
69        min_y = min_y.min(pixel.y);
70        max_y = max_y.max(pixel.y);
71    }
72
73    // Convert to pixel coordinates (coords are already pixel-space floats)
74    let chunk_x = min_x.max(0.0).floor() as u32;
75    let chunk_y = min_y.max(0.0).floor() as u32;
76    let chunk_width = (max_x - min_x).max(0.0).floor() as u32 + 1;
77    let chunk_height = (max_y - min_y).max(0.0).floor() as u32 + 1;
78
79    // Create RGB buffer for this chunk (use usize with saturation to avoid overflow)
80    let total_px: usize = (chunk_width as usize)
81        .saturating_mul(chunk_height as usize)
82        .min((plot_width as usize).saturating_mul(plot_height as usize));
83    let buf_len = total_px.saturating_mul(3);
84    let mut rgb_data = vec![0u8; buf_len];
85
86    // Fill the buffer with pixel data
87    for pixel in pixels {
88        let local_x = (pixel.x - min_x).round().max(0.0) as u32;
89        let local_y = (pixel.y - min_y).round().max(0.0) as u32;
90
91        if local_x < chunk_width && local_y < chunk_height {
92            let idx = ((local_y as usize)
93                .saturating_mul(chunk_width as usize)
94                .saturating_add(local_x as usize))
95            .saturating_mul(3);
96            if idx + 2 < rgb_data.len() {
97                rgb_data[idx] = pixel.r;
98                rgb_data[idx + 1] = pixel.g;
99                rgb_data[idx + 2] = pixel.b;
100            }
101        }
102    }
103
104    Some(BinaryPixelChunk {
105        pixels: rgb_data,
106        width: chunk_width,
107        height: chunk_height,
108        offset_x: chunk_x,
109        offset_y: chunk_y,
110        total_width: plot_width,
111        total_height: plot_height,
112    })
113}
114
115pub fn calculate_density_per_pixel(
116    data: &[(f32, f32)],
117    width: usize,
118    height: usize,
119    options: &DensityPlotOptions,
120) -> Vec<RawPixelData> {
121    calculate_density_per_pixel_cancelable(data, width, height, options, || false).expect(
122        "calculate_density_per_pixel_cancelable returned None when cancellation is disabled",
123    )
124}
125
126pub fn calculate_density_per_pixel_cancelable(
127    data: &[(f32, f32)],
128    width: usize,
129    height: usize,
130    options: &DensityPlotOptions,
131    should_cancel: impl FnMut() -> bool,
132) -> Option<Vec<RawPixelData>> {
133    calculate_density_per_pixel_cpu(data, width, height, options, should_cancel)
134}
135
136/// Calculate density for multiple plots in batch
137///
138/// Returns vector of RawPixelData vectors, one per plot request.
139/// This is the core density calculation function, separate from rendering.
140/// Apps can use this to orchestrate their own rendering pipelines.
141///
142/// # Arguments
143/// * `requests` - Vector of (data, options) tuples, where each tuple contains
144///   the data points and the density plot options for one plot
145///
146/// # Returns
147/// Vector of RawPixelData vectors, one per request
148pub fn calculate_density_per_pixel_batch(
149    requests: &[(Vec<(f32, f32)>, DensityPlotOptions)],
150) -> Vec<Vec<RawPixelData>> {
151    calculate_density_per_pixel_batch_cancelable(requests, || false)
152        .expect("calculate_density_per_pixel_batch_cancelable returned None when cancellation is disabled")
153}
154
155/// Calculate density for multiple plots in batch with cancellation support
156///
157/// Same as `calculate_density_per_pixel_batch` but supports cancellation callbacks.
158/// Returns `None` if cancelled.
159///
160/// # Arguments
161/// * `requests` - Vector of (data, options) tuples
162/// * `should_cancel` - Closure that returns true if processing should be cancelled
163///
164/// # Returns
165/// `Some(Vec<Vec<RawPixelData>>)` if successful, `None` if cancelled
166pub fn calculate_density_per_pixel_batch_cancelable(
167    requests: &[(Vec<(f32, f32)>, DensityPlotOptions)],
168    mut should_cancel: impl FnMut() -> bool,
169) -> Option<Vec<Vec<RawPixelData>>> {
170    if should_cancel() {
171        return None;
172    }
173    Some(calculate_density_per_pixel_batch_cpu(requests))
174}
175
176/// CPU implementation of batched density calculation
177fn calculate_density_per_pixel_batch_cpu(
178    requests: &[(Vec<(f32, f32)>, DensityPlotOptions)],
179) -> Vec<Vec<RawPixelData>> {
180    requests
181        .iter()
182        .map(|(data, options)| {
183            let base = options.base();
184            calculate_density_per_pixel(data, base.width as usize, base.height as usize, options)
185        })
186        .collect()
187}
188
189/// CPU implementation of density calculation
190fn calculate_density_per_pixel_cpu(
191    data: &[(f32, f32)],
192    width: usize,
193    height: usize,
194    options: &DensityPlotOptions,
195    mut should_cancel: impl FnMut() -> bool,
196) -> Option<Vec<RawPixelData>> {
197    // Calculate scaling factors
198    // FIX: Corrected y-axis calculation (was incorrectly using plot_range_x)
199    let scale_x = width as f32 / (*options.x_axis.range.end() - *options.x_axis.range.start());
200    let scale_y = height as f32 / (*options.y_axis.range.end() - *options.y_axis.range.start());
201
202    // OPTIMIZED DENSITY BUILDING: Use array-based approach for 7x performance
203    // Benchmarks show array-based is much faster than HashMap due to cache locality:
204    // - 10K events: 19µs (array) vs 134µs (HashMap) = 7x faster
205    // - 100K events: ~100µs (array) vs ~500µs (HashMap) = 5x faster
206    // Sequential is faster than parallel for typical FCS sizes (parallel overhead dominates)
207
208    let build_start = std::time::Instant::now();
209    let mut density = vec![0.0f32; width * height];
210
211    // Build density map using fast array access
212    // Cache-friendly: sequential writes to contiguous memory
213    let mut last_progress = std::time::Instant::now();
214    for (i, &(x, y)) in data.iter().enumerate() {
215        if (i % 250_000) == 0 {
216            if should_cancel() {
217                eprintln!(
218                    "    ├─ Density build cancelled after {} / {} points",
219                    i,
220                    data.len()
221                );
222                return None;
223            }
224
225            // Only log progress if we're actually slow.
226            if last_progress.elapsed().as_secs_f64() >= 2.0 {
227                eprintln!(
228                    "    ├─ Density build progress: {} / {} points",
229                    i,
230                    data.len()
231                );
232                last_progress = std::time::Instant::now();
233            }
234        }
235
236        let pixel_x = (((x - *options.x_axis.range.start()) * scale_x).floor() as isize)
237            .clamp(0, (width - 1) as isize) as usize;
238        let pixel_y = (((y - *options.y_axis.range.start()) * scale_y).floor() as isize)
239            .clamp(0, (height - 1) as isize) as usize;
240
241        let idx = pixel_y * width + pixel_x;
242        density[idx] += 1.0;
243    }
244
245    // Convert to HashMap of only non-zero pixels (sparse representation for coloring)
246    let mut density_map: FxHashMap<(usize, usize), f32> = FxHashMap::default();
247    density_map.reserve(width * height / 10); // Estimate ~10% fill rate
248
249    for (idx, &count) in density.iter().enumerate() {
250        if count > 0.0 {
251            let px = idx % width;
252            let py = idx / width;
253            density_map.insert((px, py), count);
254        }
255    }
256
257    eprintln!(
258        "    ├─ Density map building: {:?} ({} unique pixels from {} total)",
259        build_start.elapsed(),
260        density_map.len(),
261        width * height
262    );
263
264    // Apply logarithmic transformation to density values (sequential)
265    // Benchmarks show sequential is faster for typical pixel counts (1K-50K):
266    // - 10K pixels: 42µs (seq) vs 315µs (par) = 7.5x slower when parallel!
267    // - 50K pixels: 331µs (seq) vs 592µs (par) = 1.8x slower when parallel!
268    // Adding 1.0 before log to avoid log(0) = -Infinity
269    let log_start = std::time::Instant::now();
270    for (_, count) in density_map.iter_mut() {
271        *count = (*count + 1.0).log10();
272    }
273    eprintln!("    ├─ Log transform: {:?}", log_start.elapsed());
274
275    // Find max density for normalization (sequential - faster for typical sizes)
276    let max_start = std::time::Instant::now();
277    let max_density_log = density_map
278        .values()
279        .fold(0.0f32, |max, &val| max.max(val))
280        .max(1.0); // Ensure at least 1.0
281    eprintln!("    ├─ Find max: {:?}", max_start.elapsed());
282
283    // Create ONE pixel per unique screen coordinate (not per data point!)
284    // This is the key optimization: 100K data points → ~20K screen pixels
285    // Return raw pixel data for direct buffer writing (bypass Plotters overhead)
286    let color_start = std::time::Instant::now();
287    let colored_pixels: Vec<RawPixelData> = density_map
288        .iter()
289        .map(|(&(pixel_x, pixel_y), &dens)| {
290            // Normalize density to 0-1 range
291            let normalized_density = dens / max_density_log;
292
293            // Get color from colormap
294            let color = options.colormap.map(normalized_density);
295            let r = color.0;
296            let g = color.1;
297            let b = color.2;
298
299            // Map pixel coordinates back to data coordinates
300            let x = (pixel_x as f32 / scale_x) + *options.x_axis.range.start();
301            let y = (pixel_y as f32 / scale_y) + *options.y_axis.range.start();
302
303            RawPixelData { x, y, r, g, b }
304        })
305        .collect();
306    eprintln!("    └─ Pixel coloring: {:?}", color_start.elapsed());
307
308    Some(colored_pixels)
309}
310// (streaming helpers removed)