maps-engine-rust 0.1.0

Zero-dependency map engine core in Rust — Web Mercator projection, tiles, geofencing, clustering, navigation math.
Documentation
//! Heatmap intensity grid. Port of `heatmap.ts`.

use crate::geo::LonLat;
use crate::projection::to_pixels;

/// A heatmap rendered as a coarse intensity grid over pixel space.
#[derive(Debug, Clone)]
pub struct Heatmap {
    pub cols: usize,
    pub rows: usize,
    pub cell_px: f64,
    /// Row-major intensity values in `[0, 1]`.
    pub grid: Vec<f64>,
}

/// Parameters for [`Heatmap::build`].
#[derive(Debug, Clone)]
pub struct HeatmapParams<'a> {
    pub points: &'a [LonLat],
    pub weights: Option<&'a [f64]>,
    pub zoom: u8,
    /// Top-left corner of the area in pixel space.
    pub origin_px: (f64, f64),
    pub width_px: f64,
    pub height_px: f64,
    pub cell_px: f64,
    pub radius_px: f64,
}

impl Heatmap {
    /// Build a heatmap from points, aggregating into `cell_px` cells.
    pub fn build(p: &HeatmapParams) -> Self {
        let points = p.points;
        let weights = p.weights;
        let zoom = p.zoom;
        let origin_px = p.origin_px;
        let (width_px, height_px, cell_px, radius_px) =
            (p.width_px, p.height_px, p.cell_px, p.radius_px);
        let cols = (width_px / cell_px).ceil() as usize;
        let rows = (height_px / cell_px).ceil() as usize;
        let mut grid = vec![0.0; cols * rows];

        for (i, p) in points.iter().enumerate() {
            let w = weights.map(|ws| ws[i]).unwrap_or(1.0);
            let (px, py) = to_pixels(p.lon, p.lat, zoom);
            let lx = px - origin_px.0;
            let ly = py - origin_px.1;
            // Splat with a simple linear falloff kernel.
            let r_cells = (radius_px / cell_px).ceil() as i64;
            let cc = (lx / cell_px).floor() as i64;
            let cr = (ly / cell_px).floor() as i64;
            for dc in -r_cells..=r_cells {
                for dr in -r_cells..=r_cells {
                    let dist_px = ((dc as f64).hypot(dr as f64)) * cell_px;
                    if dist_px > radius_px {
                        continue;
                    }
                    let c = cc + dc;
                    let r_ = cr + dr;
                    if c < 0 || r_ < 0 || c >= cols as i64 || r_ >= rows as i64 {
                        continue;
                    }
                    let falloff = 1.0 - dist_px / radius_px;
                    grid[r_ as usize * cols + c as usize] += w * falloff;
                }
            }
        }

        // Normalize to [0, 1].
        let max = grid.iter().cloned().fold(0.0_f64, f64::max);
        if max > 0.0 {
            for v in grid.iter_mut() {
                *v /= max;
            }
        }

        Heatmap {
            cols,
            rows,
            cell_px,
            grid,
        }
    }

    /// Intensity at cell (col, row).
    pub fn at(&self, col: usize, row: usize) -> Option<f64> {
        if col < self.cols && row < self.rows {
            Some(self.grid[row * self.cols + col])
        } else {
            None
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn params<'a>(
        points: &'a [LonLat],
        weights: Option<&'a [f64]>,
        origin: (f64, f64),
        radius_px: f64,
    ) -> HeatmapParams<'a> {
        HeatmapParams {
            points,
            weights,
            zoom: 12,
            origin_px: origin,
            width_px: 400.0,
            height_px: 400.0,
            cell_px: 40.0,
            radius_px,
        }
    }

    #[test]
    fn single_point_peaks_at_its_cell() {
        let p = LonLat::new(105.85, 21.02);
        let (px, py) = to_pixels(p.lon, p.lat, 12);
        let pts = [p];
        let h = Heatmap::build(&params(&pts, None, (px - 200.0, py - 200.0), 60.0));
        let max = h.grid.iter().cloned().fold(0.0_f64, f64::max);
        assert!((max - 1.0).abs() < 1e-9, "normalized peak should be 1");
    }

    #[test]
    fn empty_input_gives_zero_grid() {
        let h = Heatmap::build(&params(&[], None, (0.0, 0.0), 60.0));
        assert!(h.grid.iter().all(|&v| v == 0.0));
    }

    #[test]
    fn weights_shift_peak_toward_heavier_point() {
        let pts = vec![LonLat::new(105.85, 21.02), LonLat::new(105.90, 21.06)];
        let (px, py) = to_pixels(105.85, 21.02, 12);
        let origin = (px - 200.0, py - 200.0);
        let a = Heatmap::build(&params(&pts, None, origin, 30.0));
        let b = Heatmap::build(&params(&pts, Some(&[1.0, 5.0]), origin, 30.0));
        // With equal weights both peaks are 1.0; with [1,5] the second point dominates.
        // Peak cell of b must be nearer to pts[1] than peak cell of a is.
        fn peak_pos(h: &Heatmap) -> (usize, usize) {
            let mut bi = 0;
            for (i, &v) in h.grid.iter().enumerate() {
                if v > h.grid[bi] {
                    bi = i;
                }
            }
            (bi % h.cols, bi / h.cols)
        }
        let (ax, ay) = peak_pos(&a);
        let (bx, by) = peak_pos(&b);
        // second point is east/south-east of first; heavier weight pulls peak that way
        assert!(bx >= ax, "peak should shift east with heavier 2nd point");
        assert!(by >= ay, "peak should shift south with heavier 2nd point");
    }
}