use crate::error::AnalyticsError;
#[derive(Debug, Clone)]
pub struct Heatmap {
pub width: u32,
pub height: u32,
pub cell_size: u32,
cols: u32,
rows: u32,
counts: Vec<u64>,
total: u64,
}
impl Heatmap {
pub fn new(width: u32, height: u32, cell_size: u32) -> Result<Self, AnalyticsError> {
if width == 0 {
return Err(AnalyticsError::InvalidInput("width must be > 0".into()));
}
if height == 0 {
return Err(AnalyticsError::InvalidInput("height must be > 0".into()));
}
if cell_size == 0 {
return Err(AnalyticsError::InvalidInput("cell_size must be > 0".into()));
}
let cols = width.div_ceil(cell_size);
let rows = height.div_ceil(cell_size);
let cells = (cols as usize)
.checked_mul(rows as usize)
.ok_or_else(|| AnalyticsError::InvalidInput("heatmap too large".into()))?;
Ok(Self {
width,
height,
cell_size,
cols,
rows,
counts: vec![0u64; cells],
total: 0,
})
}
pub fn add_point(&mut self, x: f32, y: f32) {
if x < 0.0 || y < 0.0 || x >= self.width as f32 || y >= self.height as f32 {
return;
}
let cx = (x / self.cell_size as f32) as u32;
let cy = (y / self.cell_size as f32) as u32;
let cx = cx.min(self.cols - 1);
let cy = cy.min(self.rows - 1);
let idx = (cy * self.cols + cx) as usize;
self.counts[idx] = self.counts[idx].saturating_add(1);
self.total = self.total.saturating_add(1);
}
#[must_use]
pub fn get_density(&self, cx: u32, cy: u32) -> f32 {
if cx >= self.cols || cy >= self.rows {
return 0.0;
}
let idx = (cy * self.cols + cx) as usize;
let max_count = self.counts.iter().copied().max().unwrap_or(0);
if max_count == 0 {
return 0.0;
}
self.counts[idx] as f32 / max_count as f32
}
#[must_use]
pub fn get_count(&self, cx: u32, cy: u32) -> u64 {
if cx >= self.cols || cy >= self.rows {
return 0;
}
self.counts[(cy * self.cols + cx) as usize]
}
#[must_use]
pub fn total_points(&self) -> u64 {
self.total
}
#[must_use]
pub fn cols(&self) -> u32 {
self.cols
}
#[must_use]
pub fn rows(&self) -> u32 {
self.rows
}
pub fn clear(&mut self) {
self.counts.fill(0);
self.total = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_rejects_zero_width() {
assert!(Heatmap::new(0, 100, 10).is_err());
}
#[test]
fn new_rejects_zero_height() {
assert!(Heatmap::new(100, 0, 10).is_err());
}
#[test]
fn new_rejects_zero_cell_size() {
assert!(Heatmap::new(100, 100, 0).is_err());
}
#[test]
fn empty_heatmap_density_is_zero() {
let h = Heatmap::new(100, 100, 10).expect("valid");
assert_eq!(h.get_density(0, 0), 0.0);
}
#[test]
fn single_point_max_density() {
let mut h = Heatmap::new(100, 100, 10).expect("valid");
h.add_point(5.0, 5.0); assert!((h.get_density(0, 0) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn out_of_bounds_point_ignored() {
let mut h = Heatmap::new(100, 100, 10).expect("valid");
h.add_point(-1.0, 50.0);
h.add_point(100.0, 50.0);
h.add_point(50.0, -1.0);
h.add_point(50.0, 100.0);
assert_eq!(h.total_points(), 0);
}
#[test]
fn density_relative_to_max_cell() {
let mut h = Heatmap::new(100, 100, 50).expect("valid"); for _ in 0..3 {
h.add_point(10.0, 10.0);
}
h.add_point(60.0, 10.0);
assert!((h.get_density(0, 0) - 1.0).abs() < f32::EPSILON);
assert!((h.get_density(1, 0) - 1.0 / 3.0).abs() < 1e-6);
}
#[test]
fn out_of_range_cell_returns_zero() {
let h = Heatmap::new(100, 100, 10).expect("valid");
assert_eq!(h.get_density(999, 999), 0.0);
assert_eq!(h.get_count(999, 999), 0);
}
#[test]
fn clear_resets_counts() {
let mut h = Heatmap::new(100, 100, 10).expect("valid");
h.add_point(5.0, 5.0);
h.clear();
assert_eq!(h.total_points(), 0);
assert_eq!(h.get_density(0, 0), 0.0);
}
#[test]
fn cols_rows_computed_correctly() {
let h = Heatmap::new(105, 95, 10).expect("valid");
assert_eq!(h.cols(), 11); assert_eq!(h.rows(), 10); }
}