Tests hold that a pan keeps a focused cluster, that a cluster away from the view takes no Tab, that a zoom out folding a focused pin hands its focus to the map, and that a press shows members far apart whole. The heat test pins the quartic kernel's value halfway out; a ring of fewer than four positions fails with its own reason, and has a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
3.5 KiB
Rust
121 lines
3.5 KiB
Rust
/// Density over a view in square cells, the densest cell 1.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct Heat {
|
|
pub columns: usize,
|
|
pub rows: usize,
|
|
pub cell: f32,
|
|
pub values: Vec<f32>,
|
|
}
|
|
|
|
impl Heat {
|
|
pub fn at(&self, column: usize, row: usize) -> f32 {
|
|
self.values[row * self.columns + column]
|
|
}
|
|
}
|
|
|
|
/// Each weighted view point spreads a quartic kernel reaching `reach` pixels from it over cells `cell` pixels square across a view of `size`; the sum is scaled so its densest cell is 1. Fails on a negative or non-finite weight.
|
|
pub(crate) fn density(
|
|
points: &[((f32, f32), f32)],
|
|
(w, h): (f32, f32),
|
|
cell: f32,
|
|
reach: f32,
|
|
) -> Heat {
|
|
assert!(
|
|
cell > 0.0 && reach > 0.0,
|
|
"heat cells of {cell} reaching {reach}"
|
|
);
|
|
let (columns, rows) = (
|
|
(w / cell).ceil().max(0.0) as usize,
|
|
(h / cell).ceil().max(0.0) as usize,
|
|
);
|
|
let mut values = vec![0.0f32; columns * rows];
|
|
for &((x, y), weight) in points {
|
|
assert!(
|
|
weight.is_finite() && weight >= 0.0,
|
|
"a heat weight of {weight}"
|
|
);
|
|
let span = |at: f32, count: usize| {
|
|
let first = ((at - reach) / cell).floor().max(0.0) as usize;
|
|
let last = (((at + reach) / cell).ceil().max(0.0) as usize).min(count);
|
|
first..last
|
|
};
|
|
for row in span(y, rows) {
|
|
for column in span(x, columns) {
|
|
let (cx, cy) = ((column as f32 + 0.5) * cell, (row as f32 + 0.5) * cell);
|
|
let near = ((cx - x).powi(2) + (cy - y).powi(2)) / reach.powi(2);
|
|
if near < 1.0 {
|
|
values[row * columns + column] += weight * (1.0 - near).powi(2);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let most = values.iter().copied().fold(0.0f32, f32::max);
|
|
if most > 0.0 {
|
|
values.iter_mut().for_each(|value| *value /= most);
|
|
}
|
|
Heat {
|
|
columns,
|
|
rows,
|
|
cell,
|
|
values,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::density;
|
|
|
|
#[test]
|
|
fn heat_peaks_at_its_point_and_ends_at_its_reach() {
|
|
let heat = density(&[((25.0, 25.0), 2.0)], (100.0, 60.0), 10.0, 20.0);
|
|
assert_eq!((heat.columns, heat.rows), (10, 6));
|
|
assert_eq!(
|
|
heat.at(2, 2),
|
|
1.0,
|
|
"the cell under the point is the densest"
|
|
);
|
|
assert_eq!(
|
|
heat.at(3, 2),
|
|
0.5625,
|
|
"halfway out a quartic kernel keeps (1 - 1/4) squared"
|
|
);
|
|
assert_eq!(heat.at(6, 2), 0.0, "past its reach nothing");
|
|
}
|
|
|
|
#[test]
|
|
fn heavier_points_run_hotter_and_heat_adds_up() {
|
|
let heat = density(
|
|
&[((15.0, 15.0), 1.0), ((85.0, 15.0), 3.0)],
|
|
(100.0, 30.0),
|
|
10.0,
|
|
15.0,
|
|
);
|
|
assert!(
|
|
heat.at(8, 1) > heat.at(1, 1) * 2.5,
|
|
"three times the weight"
|
|
);
|
|
let pair = density(
|
|
&[((45.0, 15.0), 1.0), ((55.0, 15.0), 1.0)],
|
|
(100.0, 30.0),
|
|
10.0,
|
|
15.0,
|
|
);
|
|
assert_eq!(
|
|
pair.at(4, 1),
|
|
pair.at(5, 1),
|
|
"two alike points meet in the middle"
|
|
);
|
|
let none = density(&[], (100.0, 30.0), 10.0, 15.0);
|
|
assert!(
|
|
none.values.iter().all(|value| *value == 0.0),
|
|
"no points, no heat"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "a heat weight of -1")]
|
|
fn a_negative_weight_fails() {
|
|
density(&[((0.0, 0.0), -1.0)], (10.0, 10.0), 5.0, 5.0);
|
|
}
|
|
}
|