maps_engine_rust/
heatmap.rs1use crate::geo::LonLat;
4use crate::projection::to_pixels;
5
6#[derive(Debug, Clone)]
8pub struct Heatmap {
9 pub cols: usize,
10 pub rows: usize,
11 pub cell_px: f64,
12 pub grid: Vec<f64>,
14}
15
16#[derive(Debug, Clone)]
18pub struct HeatmapParams<'a> {
19 pub points: &'a [LonLat],
20 pub weights: Option<&'a [f64]>,
21 pub zoom: u8,
22 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 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 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 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 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(¶ms(&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(¶ms(&[], 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(¶ms(&pts, None, origin, 30.0));
139 let b = Heatmap::build(¶ms(&pts, Some(&[1.0, 5.0]), origin, 30.0));
140 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 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}