Skip to main content

maps_engine_rust/
heatmap.rs

1//! Heatmap intensity grid. Port of `heatmap.ts`.
2
3use crate::geo::LonLat;
4use crate::projection::to_pixels;
5
6/// A heatmap rendered as a coarse intensity grid over pixel space.
7#[derive(Debug, Clone)]
8pub struct Heatmap {
9    pub cols: usize,
10    pub rows: usize,
11    pub cell_px: f64,
12    /// Row-major intensity values in `[0, 1]`.
13    pub grid: Vec<f64>,
14}
15
16/// Parameters for [`Heatmap::build`].
17#[derive(Debug, Clone)]
18pub struct HeatmapParams<'a> {
19    pub points: &'a [LonLat],
20    pub weights: Option<&'a [f64]>,
21    pub zoom: u8,
22    /// Top-left corner of the area in pixel space.
23    pub origin_px: (f64, f64),
24    pub width_px: f64,
25    pub height_px: f64,
26    pub cell_px: f64,
27    pub radius_px: f64,
28}
29
30impl Heatmap {
31    /// Build a heatmap from points, aggregating into `cell_px` cells.
32    pub fn build(p: &HeatmapParams) -> Self {
33        let points = p.points;
34        let weights = p.weights;
35        let zoom = p.zoom;
36        let origin_px = p.origin_px;
37        let (width_px, height_px, cell_px, radius_px) =
38            (p.width_px, p.height_px, p.cell_px, p.radius_px);
39        let cols = (width_px / cell_px).ceil() as usize;
40        let rows = (height_px / cell_px).ceil() as usize;
41        let mut grid = vec![0.0; cols * rows];
42
43        for (i, p) in points.iter().enumerate() {
44            let w = weights.map(|ws| ws[i]).unwrap_or(1.0);
45            let (px, py) = to_pixels(p.lon, p.lat, zoom);
46            let lx = px - origin_px.0;
47            let ly = py - origin_px.1;
48            // Splat with a simple linear falloff kernel.
49            let r_cells = (radius_px / cell_px).ceil() as i64;
50            let cc = (lx / cell_px).floor() as i64;
51            let cr = (ly / cell_px).floor() as i64;
52            for dc in -r_cells..=r_cells {
53                for dr in -r_cells..=r_cells {
54                    let dist_px = ((dc as f64).hypot(dr as f64)) * cell_px;
55                    if dist_px > radius_px {
56                        continue;
57                    }
58                    let c = cc + dc;
59                    let r_ = cr + dr;
60                    if c < 0 || r_ < 0 || c >= cols as i64 || r_ >= rows as i64 {
61                        continue;
62                    }
63                    let falloff = 1.0 - dist_px / radius_px;
64                    grid[r_ as usize * cols + c as usize] += w * falloff;
65                }
66            }
67        }
68
69        // Normalize to [0, 1].
70        let max = grid.iter().cloned().fold(0.0_f64, f64::max);
71        if max > 0.0 {
72            for v in grid.iter_mut() {
73                *v /= max;
74            }
75        }
76
77        Heatmap {
78            cols,
79            rows,
80            cell_px,
81            grid,
82        }
83    }
84
85    /// Intensity at cell (col, row).
86    pub fn at(&self, col: usize, row: usize) -> Option<f64> {
87        if col < self.cols && row < self.rows {
88            Some(self.grid[row * self.cols + col])
89        } else {
90            None
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    fn params<'a>(
100        points: &'a [LonLat],
101        weights: Option<&'a [f64]>,
102        origin: (f64, f64),
103        radius_px: f64,
104    ) -> HeatmapParams<'a> {
105        HeatmapParams {
106            points,
107            weights,
108            zoom: 12,
109            origin_px: origin,
110            width_px: 400.0,
111            height_px: 400.0,
112            cell_px: 40.0,
113            radius_px,
114        }
115    }
116
117    #[test]
118    fn single_point_peaks_at_its_cell() {
119        let p = LonLat::new(105.85, 21.02);
120        let (px, py) = to_pixels(p.lon, p.lat, 12);
121        let pts = [p];
122        let h = Heatmap::build(&params(&pts, None, (px - 200.0, py - 200.0), 60.0));
123        let max = h.grid.iter().cloned().fold(0.0_f64, f64::max);
124        assert!((max - 1.0).abs() < 1e-9, "normalized peak should be 1");
125    }
126
127    #[test]
128    fn empty_input_gives_zero_grid() {
129        let h = Heatmap::build(&params(&[], None, (0.0, 0.0), 60.0));
130        assert!(h.grid.iter().all(|&v| v == 0.0));
131    }
132
133    #[test]
134    fn weights_shift_peak_toward_heavier_point() {
135        let pts = vec![LonLat::new(105.85, 21.02), LonLat::new(105.90, 21.06)];
136        let (px, py) = to_pixels(105.85, 21.02, 12);
137        let origin = (px - 200.0, py - 200.0);
138        let a = Heatmap::build(&params(&pts, None, origin, 30.0));
139        let b = Heatmap::build(&params(&pts, Some(&[1.0, 5.0]), origin, 30.0));
140        // With equal weights both peaks are 1.0; with [1,5] the second point dominates.
141        // Peak cell of b must be nearer to pts[1] than peak cell of a is.
142        fn peak_pos(h: &Heatmap) -> (usize, usize) {
143            let mut bi = 0;
144            for (i, &v) in h.grid.iter().enumerate() {
145                if v > h.grid[bi] {
146                    bi = i;
147                }
148            }
149            (bi % h.cols, bi / h.cols)
150        }
151        let (ax, ay) = peak_pos(&a);
152        let (bx, by) = peak_pos(&b);
153        // second point is east/south-east of first; heavier weight pulls peak that way
154        assert!(bx >= ax, "peak should shift east with heavier 2nd point");
155        assert!(by >= ay, "peak should shift south with heavier 2nd point");
156    }
157}