Skip to main content

flow_plots/
density_calc.rs

1use rustc_hash::FxHashMap;
2use serde::Serialize;
3use ts_rs::TS;
4
5use crate::options::density::default_gate_colors;
6use crate::options::{DensityPlotOptions, PlotOptions};
7use crate::plots::PlotType;
8use crate::scatter_data::ScatterPlotData;
9
10/// Optimized density calculation with pixel-based rendering
11///
12/// Key optimizations (validated with Criterion benchmarks):
13/// 1. FIX: Corrected y-axis scale calculation bug
14/// 2. Eliminated overplotting: Creates ONE pixel per screen coordinate (not per data point)
15/// 3. Array-based density building: 7x faster than HashMap (cache locality)
16/// 4. Sequential processing: Parallel overhead dominates for typical FCS sizes
17/// 5. Converts to sparse HashMap only for non-zero pixels
18/// 6. Result: 10-50x faster, uses 5-10x less memory than previous implementation
19/// Raw pixel data for direct buffer writing
20#[derive(Clone, Debug, Serialize, TS)]
21#[ts(export)]
22pub struct RawPixelData {
23    pub x: f32,
24    pub y: f32,
25    pub r: u8,
26    pub g: u8,
27    pub b: u8,
28}
29
30/// Binary pixel chunk for efficient data transfer
31/// Contains raw RGB data with metadata for direct canvas rendering
32#[derive(Clone, Serialize, TS)]
33#[ts(export)]
34pub struct BinaryPixelChunk {
35    /// Raw RGB pixel data: [r,g,b,r,g,b,...] for each pixel
36    pub pixels: Vec<u8>,
37    /// Width of the pixel chunk
38    pub width: u32,
39    /// Height of the pixel chunk  
40    pub height: u32,
41    /// X offset within the full plot
42    pub offset_x: u32,
43    /// Y offset within the full plot
44    pub offset_y: u32,
45    /// Full plot width for coordinate context
46    pub total_width: u32,
47    /// Full plot height for coordinate context
48    pub total_height: u32,
49}
50
51/// Convert a region of RawPixelData to a binary pixel chunk
52/// This creates a rectangular region of pixels for efficient transfer
53pub fn create_binary_chunk(
54    pixels: &[RawPixelData],
55    plot_width: u32,
56    plot_height: u32,
57    _chunk_size: u32,
58) -> Option<BinaryPixelChunk> {
59    if pixels.is_empty() {
60        return None;
61    }
62
63    // Find bounding box of pixels
64    let mut min_x = pixels[0].x;
65    let mut max_x = pixels[0].x;
66    let mut min_y = pixels[0].y;
67    let mut max_y = pixels[0].y;
68
69    for pixel in pixels {
70        min_x = min_x.min(pixel.x);
71        max_x = max_x.max(pixel.x);
72        min_y = min_y.min(pixel.y);
73        max_y = max_y.max(pixel.y);
74    }
75
76    // Convert to pixel coordinates (coords are already pixel-space floats)
77    let chunk_x = min_x.max(0.0).floor() as u32;
78    let chunk_y = min_y.max(0.0).floor() as u32;
79    let chunk_width = (max_x - min_x).max(0.0).floor() as u32 + 1;
80    let chunk_height = (max_y - min_y).max(0.0).floor() as u32 + 1;
81
82    // Create RGB buffer for this chunk (use usize with saturation to avoid overflow)
83    let total_px: usize = (chunk_width as usize)
84        .saturating_mul(chunk_height as usize)
85        .min((plot_width as usize).saturating_mul(plot_height as usize));
86    let buf_len = total_px.saturating_mul(3);
87    let mut rgb_data = vec![0u8; buf_len];
88
89    // Fill the buffer with pixel data
90    for pixel in pixels {
91        let local_x = (pixel.x - min_x).round().max(0.0) as u32;
92        let local_y = (pixel.y - min_y).round().max(0.0) as u32;
93
94        if local_x < chunk_width && local_y < chunk_height {
95            let idx = ((local_y as usize)
96                .saturating_mul(chunk_width as usize)
97                .saturating_add(local_x as usize))
98            .saturating_mul(3);
99            if idx + 2 < rgb_data.len() {
100                rgb_data[idx] = pixel.r;
101                rgb_data[idx + 1] = pixel.g;
102                rgb_data[idx + 2] = pixel.b;
103            }
104        }
105    }
106
107    Some(BinaryPixelChunk {
108        pixels: rgb_data,
109        width: chunk_width,
110        height: chunk_height,
111        offset_x: chunk_x,
112        offset_y: chunk_y,
113        total_width: plot_width,
114        total_height: plot_height,
115    })
116}
117
118/// Convert scatter points with discrete gate colors to RawPixelData (ScatterOverlay).
119pub fn scatter_to_pixels_overlay(
120    data: &ScatterPlotData,
121    width: usize,
122    height: usize,
123    options: &DensityPlotOptions,
124) -> Vec<RawPixelData> {
125    let gate_ids = match &data.gate_ids {
126        Some(ids) => ids,
127        None => return scatter_to_pixels(data.xy(), width, height, options),
128    };
129    let colors = if options.gate_colors.is_empty() {
130        default_gate_colors()
131    } else {
132        options.gate_colors.clone()
133    };
134
135    // Allow point_size down to 0.1 for very small dots; radius 0 = single pixel
136    let point_size = options.point_size.max(0.1).min(4.0);
137    let radius_px = if point_size < 0.5 {
138        0
139    } else {
140        (point_size.ceil() as usize).max(1)
141    };
142
143    let scale_x = width as f32 / (*options.x_axis.range.end() - *options.x_axis.range.start());
144    let scale_y = height as f32 / (*options.y_axis.range.end() - *options.y_axis.range.start());
145
146    let mut pixels = Vec::new();
147    for (i, &(x, y)) in data.xy().iter().enumerate() {
148        let gate_id = gate_ids.get(i).copied().unwrap_or(0) as usize;
149        let (r, g, b) = colors
150            .get(gate_id)
151            .copied()
152            .unwrap_or((60, 60, 60));
153
154        let pixel_x = (((x - *options.x_axis.range.start()) * scale_x).floor() as isize)
155            .clamp(0, (width - 1) as isize) as usize;
156        let pixel_y = (((y - *options.y_axis.range.start()) * scale_y).floor() as isize)
157            .clamp(0, (height - 1) as isize) as usize;
158
159        for dy in -(radius_px as i32)..=(radius_px as i32) {
160            for dx in -(radius_px as i32)..=(radius_px as i32) {
161                let px = (pixel_x as i32 + dx).clamp(0, (width - 1) as i32) as usize;
162                let py = (pixel_y as i32 + dy).clamp(0, (height - 1) as i32) as usize;
163
164                let data_x = (px as f32 / scale_x) + *options.x_axis.range.start();
165                let data_y = (py as f32 / scale_y) + *options.y_axis.range.start();
166
167                pixels.push(RawPixelData {
168                    x: data_x,
169                    y: data_y,
170                    r,
171                    g,
172                    b,
173                });
174            }
175        }
176    }
177    pixels
178}
179
180/// Convert scatter points with continuous z-axis coloring to RawPixelData (ScatterColoredContinuous).
181pub fn scatter_to_pixels_colored(
182    data: &ScatterPlotData,
183    width: usize,
184    height: usize,
185    options: &DensityPlotOptions,
186) -> Vec<RawPixelData> {
187    let z_values = match &data.z_values {
188        Some(z) => z,
189        None => return scatter_to_pixels(data.xy(), width, height, options),
190    };
191
192    let (z_min, z_max) = match options.z_range {
193        Some((min, max)) => (min, max),
194        None => {
195            let min = z_values
196                .iter()
197                .copied()
198                .fold(f32::INFINITY, f32::min);
199            let max = z_values
200                .iter()
201                .copied()
202                .fold(f32::NEG_INFINITY, f32::max);
203            if min >= max {
204                (min, min + 1.0)
205            } else {
206                (min, max)
207            }
208        }
209    };
210    let z_range = z_max - z_min;
211
212    // Allow point_size down to 0.1 for very small dots; radius 0 = single pixel
213    let point_size = options.point_size.max(0.1).min(4.0);
214    let radius_px = if point_size < 0.5 {
215        0
216    } else {
217        (point_size.ceil() as usize).max(1)
218    };
219
220    let scale_x = width as f32 / (*options.x_axis.range.end() - *options.x_axis.range.start());
221    let scale_y = height as f32 / (*options.y_axis.range.end() - *options.y_axis.range.start());
222
223    let mut pixels = Vec::new();
224    for (i, &(x, y)) in data.xy().iter().enumerate() {
225        let z = z_values.get(i).copied().unwrap_or(0.0);
226        let t = (z - z_min) / z_range;
227        let normalized = t.max(0.0).min(1.0);
228        let color = options.colormap.map(normalized);
229        let (r, g, b) = (color.0, color.1, color.2);
230
231        let pixel_x = (((x - *options.x_axis.range.start()) * scale_x).floor() as isize)
232            .clamp(0, (width - 1) as isize) as usize;
233        let pixel_y = (((y - *options.y_axis.range.start()) * scale_y).floor() as isize)
234            .clamp(0, (height - 1) as isize) as usize;
235
236        for dy in -(radius_px as i32)..=(radius_px as i32) {
237            for dx in -(radius_px as i32)..=(radius_px as i32) {
238                let px = (pixel_x as i32 + dx).clamp(0, (width - 1) as i32) as usize;
239                let py = (pixel_y as i32 + dy).clamp(0, (height - 1) as i32) as usize;
240
241                let data_x = (px as f32 / scale_x) + *options.x_axis.range.start();
242                let data_y = (py as f32 / scale_y) + *options.y_axis.range.start();
243
244                pixels.push(RawPixelData {
245                    x: data_x,
246                    y: data_y,
247                    r,
248                    g,
249                    b,
250                });
251            }
252        }
253    }
254    pixels
255}
256
257/// Convert scatter (x,y) points to RawPixelData for solid scatter plots
258///
259/// Each point is drawn as a small square of pixels. point_size controls the radius.
260/// Uses a single color (dark gray) for all points.
261pub fn scatter_to_pixels(
262    data: &[(f32, f32)],
263    width: usize,
264    height: usize,
265    options: &DensityPlotOptions,
266) -> Vec<RawPixelData> {
267    // Allow point_size down to 0.1 for very small dots; radius 0 = single pixel
268    let point_size = options.point_size.max(0.1).min(4.0);
269    let radius_px = if point_size < 0.5 {
270        0
271    } else {
272        (point_size.ceil() as usize).max(1)
273    };
274
275    let scale_x = width as f32 / (*options.x_axis.range.end() - *options.x_axis.range.start());
276    let scale_y = height as f32 / (*options.y_axis.range.end() - *options.y_axis.range.start());
277
278    // Single color for scatter solid
279    let (r, g, b) = (60u8, 60u8, 60u8);
280
281    let mut pixels = Vec::with_capacity(data.len() * (2 * radius_px + 1).pow(2));
282
283    for &(x, y) in data {
284        let pixel_x = (((x - *options.x_axis.range.start()) * scale_x).floor() as isize)
285            .clamp(0, (width - 1) as isize) as usize;
286        let pixel_y = (((y - *options.y_axis.range.start()) * scale_y).floor() as isize)
287            .clamp(0, (height - 1) as isize) as usize;
288
289        for dy in -(radius_px as i32)..=(radius_px as i32) {
290            for dx in -(radius_px as i32)..=(radius_px as i32) {
291                let px = (pixel_x as i32 + dx).clamp(0, (width - 1) as i32) as usize;
292                let py = (pixel_y as i32 + dy).clamp(0, (height - 1) as i32) as usize;
293
294                let data_x = (px as f32 / scale_x) + *options.x_axis.range.start();
295                let data_y = (py as f32 / scale_y) + *options.y_axis.range.start();
296
297                pixels.push(RawPixelData {
298                    x: data_x,
299                    y: data_y,
300                    r,
301                    g,
302                    b,
303                });
304            }
305        }
306    }
307
308    pixels
309}
310
311/// Dispatch to density, scatter, overlay, or colored scatter based on plot_type and data.
312///
313/// **Important:** For `Contour` and `ContourOverlay`, this function produces a *density heatmap*
314/// (same as `PlotType::Density`), not contour lines. To render contour lines, use
315/// [`DensityPlot::render`](crate::plots::density::DensityPlot) or call
316/// [`calculate_contours`](crate::contour::calculate_contours) +
317/// [`render_contour`](crate::render::plotters_backend::render_contour) directly.
318pub fn calculate_plot_pixels(
319    data: &ScatterPlotData,
320    width: usize,
321    height: usize,
322    options: &DensityPlotOptions,
323) -> Vec<RawPixelData> {
324    let xy = data.xy();
325    match options.plot_type.canonical() {
326        PlotType::ScatterSolid | PlotType::Dot => {
327            scatter_to_pixels(xy, width, height, options)
328        }
329        PlotType::ScatterOverlay => {
330            if data.has_gates() {
331                scatter_to_pixels_overlay(data, width, height, options)
332            } else {
333                scatter_to_pixels(xy, width, height, options)
334            }
335        }
336        PlotType::ScatterColoredContinuous => {
337            if data.has_z() {
338                scatter_to_pixels_colored(data, width, height, options)
339            } else {
340                calculate_density_per_pixel(xy, width, height, options)
341            }
342        }
343        PlotType::Density
344        | PlotType::Contour
345        | PlotType::ContourOverlay
346        | PlotType::Zebra
347        | PlotType::Histogram => calculate_density_per_pixel(xy, width, height, options),
348    }
349}
350
351/// Cancelable version of calculate_plot_pixels.
352///
353/// Same caveat as [`calculate_plot_pixels`]: for Contour/ContourOverlay, this produces
354/// density pixels, not contour lines. Use [`DensityPlot::render`](crate::plots::density::DensityPlot)
355/// for contour rendering.
356pub fn calculate_plot_pixels_cancelable(
357    data: &ScatterPlotData,
358    width: usize,
359    height: usize,
360    options: &DensityPlotOptions,
361    should_cancel: impl FnMut() -> bool,
362) -> Option<Vec<RawPixelData>> {
363    let xy = data.xy();
364    match options.plot_type.canonical() {
365        PlotType::ScatterSolid | PlotType::Dot => {
366            Some(scatter_to_pixels(xy, width, height, options))
367        }
368        PlotType::ScatterOverlay => Some(if data.has_gates() {
369            scatter_to_pixels_overlay(data, width, height, options)
370        } else {
371            scatter_to_pixels(xy, width, height, options)
372        }),
373        PlotType::ScatterColoredContinuous => {
374            if data.has_z() {
375                Some(scatter_to_pixels_colored(data, width, height, options))
376            } else {
377                calculate_density_per_pixel_cancelable(xy, width, height, options, should_cancel)
378            }
379        }
380        PlotType::Density
381        | PlotType::Contour
382        | PlotType::ContourOverlay
383        | PlotType::Zebra
384        | PlotType::Histogram => {
385            calculate_density_per_pixel_cancelable(xy, width, height, options, should_cancel)
386        }
387    }
388}
389
390pub fn calculate_density_per_pixel(
391    data: &[(f32, f32)],
392    width: usize,
393    height: usize,
394    options: &DensityPlotOptions,
395) -> Vec<RawPixelData> {
396    calculate_density_per_pixel_cancelable(data, width, height, options, || false).expect(
397        "calculate_density_per_pixel_cancelable returned None when cancellation is disabled",
398    )
399}
400
401pub fn calculate_density_per_pixel_cancelable(
402    data: &[(f32, f32)],
403    width: usize,
404    height: usize,
405    options: &DensityPlotOptions,
406    should_cancel: impl FnMut() -> bool,
407) -> Option<Vec<RawPixelData>> {
408    calculate_density_per_pixel_cpu(data, width, height, options, should_cancel)
409}
410
411/// Calculate density for multiple plots in batch
412///
413/// Returns vector of RawPixelData vectors, one per plot request.
414/// This is the core density calculation function, separate from rendering.
415/// Apps can use this to orchestrate their own rendering pipelines.
416///
417/// # Arguments
418/// * `requests` - Vector of (data, options) tuples, where each tuple contains
419///   the scatter data and the density plot options for one plot
420///
421/// # Returns
422/// Vector of RawPixelData vectors, one per request
423pub fn calculate_density_per_pixel_batch(
424    requests: &[(ScatterPlotData, DensityPlotOptions)],
425) -> Vec<Vec<RawPixelData>> {
426    calculate_density_per_pixel_batch_cancelable(requests, || false)
427        .expect("calculate_density_per_pixel_batch_cancelable returned None when cancellation is disabled")
428}
429
430/// Calculate density for multiple plots in batch with cancellation support
431///
432/// Same as `calculate_density_per_pixel_batch` but supports cancellation callbacks.
433/// Returns `None` if cancelled.
434///
435/// # Arguments
436/// * `requests` - Vector of (data, options) tuples
437/// * `should_cancel` - Closure that returns true if processing should be cancelled
438///
439/// # Returns
440/// `Some(Vec<Vec<RawPixelData>>)` if successful, `None` if cancelled
441pub fn calculate_density_per_pixel_batch_cancelable(
442    requests: &[(ScatterPlotData, DensityPlotOptions)],
443    mut should_cancel: impl FnMut() -> bool,
444) -> Option<Vec<Vec<RawPixelData>>> {
445    if should_cancel() {
446        return None;
447    }
448    Some(calculate_density_per_pixel_batch_cpu(requests))
449}
450
451/// CPU implementation of batched density calculation
452fn calculate_density_per_pixel_batch_cpu(
453    requests: &[(ScatterPlotData, DensityPlotOptions)],
454) -> Vec<Vec<RawPixelData>> {
455    requests
456        .iter()
457        .map(|(data, options)| {
458            let base = options.base();
459            calculate_plot_pixels(data, base.width as usize, base.height as usize, options)
460        })
461        .collect()
462}
463
464/// CPU implementation of density calculation
465fn calculate_density_per_pixel_cpu(
466    data: &[(f32, f32)],
467    width: usize,
468    height: usize,
469    options: &DensityPlotOptions,
470    mut should_cancel: impl FnMut() -> bool,
471) -> Option<Vec<RawPixelData>> {
472    // Calculate scaling factors
473    // FIX: Corrected y-axis calculation (was incorrectly using plot_range_x)
474    let scale_x = width as f32 / (*options.x_axis.range.end() - *options.x_axis.range.start());
475    let scale_y = height as f32 / (*options.y_axis.range.end() - *options.y_axis.range.start());
476
477    // OPTIMIZED DENSITY BUILDING: Use array-based approach for 7x performance
478    // Benchmarks show array-based is much faster than HashMap due to cache locality:
479    // - 10K events: 19µs (array) vs 134µs (HashMap) = 7x faster
480    // - 100K events: ~100µs (array) vs ~500µs (HashMap) = 5x faster
481    // Sequential is faster than parallel for typical FCS sizes (parallel overhead dominates)
482
483    // Allow point_size down to 0.1 for very small dots; radius 0 = single pixel
484    let point_size = options.point_size.max(0.1).min(4.0);
485    let radius_px = if point_size < 0.5 {
486        0
487    } else {
488        (point_size.ceil() as usize).max(1)
489    };
490
491    let build_start = std::time::Instant::now();
492    let mut density = vec![0.0f32; width * height];
493
494    // Build density map using fast array access
495    // Cache-friendly: sequential writes to contiguous memory
496    // Each point contributes to a square neighborhood (radius_px), matching scatter plot behavior
497    let mut last_progress = std::time::Instant::now();
498    for (i, &(x, y)) in data.iter().enumerate() {
499        if (i % 250_000) == 0 {
500            if should_cancel() {
501                eprintln!(
502                    "    ├─ Density build cancelled after {} / {} points",
503                    i,
504                    data.len()
505                );
506                return None;
507            }
508
509            // Only log progress if we're actually slow.
510            if last_progress.elapsed().as_secs_f64() >= 2.0 {
511                eprintln!(
512                    "    ├─ Density build progress: {} / {} points",
513                    i,
514                    data.len()
515                );
516                last_progress = std::time::Instant::now();
517            }
518        }
519
520        let pixel_x = (((x - *options.x_axis.range.start()) * scale_x).floor() as isize)
521            .clamp(0, (width - 1) as isize) as usize;
522        let pixel_y = (((y - *options.y_axis.range.start()) * scale_y).floor() as isize)
523            .clamp(0, (height - 1) as isize) as usize;
524
525        for dy in -(radius_px as i32)..=(radius_px as i32) {
526            for dx in -(radius_px as i32)..=(radius_px as i32) {
527                let px = (pixel_x as i32 + dx).clamp(0, (width - 1) as i32) as usize;
528                let py = (pixel_y as i32 + dy).clamp(0, (height - 1) as i32) as usize;
529                let idx = py * width + px;
530                density[idx] += 1.0;
531            }
532        }
533    }
534
535    // Convert to HashMap of only non-zero pixels (sparse representation for coloring)
536    let mut density_map: FxHashMap<(usize, usize), f32> = FxHashMap::default();
537    density_map.reserve(width * height / 10); // Estimate ~10% fill rate
538
539    for (idx, &count) in density.iter().enumerate() {
540        if count > 0.0 {
541            let px = idx % width;
542            let py = idx / width;
543            density_map.insert((px, py), count);
544        }
545    }
546
547    eprintln!(
548        "    ├─ Density map building: {:?} ({} unique pixels from {} total)",
549        build_start.elapsed(),
550        density_map.len(),
551        width * height
552    );
553
554    // Apply logarithmic transformation to density values (sequential)
555    // Benchmarks show sequential is faster for typical pixel counts (1K-50K):
556    // - 10K pixels: 42µs (seq) vs 315µs (par) = 7.5x slower when parallel!
557    // - 50K pixels: 331µs (seq) vs 592µs (par) = 1.8x slower when parallel!
558    // Adding 1.0 before log to avoid log(0) = -Infinity
559    let log_start = std::time::Instant::now();
560    for (_, count) in density_map.iter_mut() {
561        *count = (*count + 1.0).log10();
562    }
563    eprintln!("    ├─ Log transform: {:?}", log_start.elapsed());
564
565    // Find max density for normalization (sequential - faster for typical sizes)
566    let max_start = std::time::Instant::now();
567    let max_density_log = density_map
568        .values()
569        .fold(0.0f32, |max, &val| max.max(val))
570        .max(1.0); // Ensure at least 1.0
571    eprintln!("    ├─ Find max: {:?}", max_start.elapsed());
572
573    // Create ONE pixel per unique screen coordinate (not per data point!)
574    // This is the key optimization: 100K data points → ~20K screen pixels
575    // Return raw pixel data for direct buffer writing (bypass Plotters overhead)
576    let color_start = std::time::Instant::now();
577    let colored_pixels: Vec<RawPixelData> = density_map
578        .iter()
579        .map(|(&(pixel_x, pixel_y), &dens)| {
580            // Normalize density to 0-1 range
581            let normalized_density = dens / max_density_log;
582
583            // Get color from colormap
584            let color = options.colormap.map(normalized_density);
585            let r = color.0;
586            let g = color.1;
587            let b = color.2;
588
589            // Map pixel coordinates back to data coordinates
590            let x = (pixel_x as f32 / scale_x) + *options.x_axis.range.start();
591            let y = (pixel_y as f32 / scale_y) + *options.y_axis.range.start();
592
593            RawPixelData { x, y, r, g, b }
594        })
595        .collect();
596    eprintln!("    └─ Pixel coloring: {:?}", color_start.elapsed());
597
598    Some(colored_pixels)
599}
600// (streaming helpers removed)