use crate::geo::LonLat;
use crate::projection::to_pixels;
#[derive(Debug, Clone)]
pub struct Heatmap {
pub cols: usize,
pub rows: usize,
pub cell_px: f64,
pub grid: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct HeatmapParams<'a> {
pub points: &'a [LonLat],
pub weights: Option<&'a [f64]>,
pub zoom: u8,
pub origin_px: (f64, f64),
pub width_px: f64,
pub height_px: f64,
pub cell_px: f64,
pub radius_px: f64,
}
impl Heatmap {
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;
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;
}
}
}
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,
}
}
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(¶ms(&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(¶ms(&[], 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(¶ms(&pts, None, origin, 30.0));
let b = Heatmap::build(¶ms(&pts, Some(&[1.0, 5.0]), origin, 30.0));
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);
assert!(bx >= ax, "peak should shift east with heavier 2nd point");
assert!(by >= ay, "peak should shift south with heavier 2nd point");
}
}