facett-geomap/tests/osm_pin_clusters.rs
//! **Robot proof (LAW #4) — pin CLUSTERING in the 2D NAVIGATOR.**
//!
//! Pins were already labelled, stably-identified and click-selectable; what they
//! had no answer for was density. Zoomed out over a city, several hundred markers
//! pile into a few hundred pixels — overlapping teardrops, unreadable labels, and
//! a click that resolves to whichever marker painted last. `osm2d::cluster`
//! collapses co-located pins into one count badge and splits them back apart on
//! zoom-in.
//!
//! What this asserts, all through the PUBLIC observable surface (`state_json` +
//! `clusters_for`), never internals:
//!
//! 1. zooming OUT compresses markers, zooming IN separates them — the property
//! that makes clustering worth having, asserted as a monotone sweep across
//! zoom levels rather than at one magic zoom;
//! 2. the count badge never lies — the clusters' counts sum to the pins actually
//! on screen at every zoom in the sweep;
//! 3. clicking a group selects the GROUP's stable id, clicking a lone pin still
//! selects the PIN's id (no regression to the pre-clustering contract);
//! 4. `state_json` publishes both numbers (`visible_pins` = pins represented,
//! `visible_markers` = badges+singles painted) so the compression is a matrix
//! oracle, not a claim;
//! 5. clustering OFF is lossless (the escape hatch really escapes).
//!
//! Run: `cargo test -p facett-geomap --test osm_pin_clusters`.
use facett_core::Facet;
use facett_geomap::osm2d::{Msg, OsmView, Pin, cluster};
const RECT: egui::Rect =
egui::Rect { min: egui::pos2(0.0, 0.0), max: egui::pos2(900.0, 700.0) };
/// A dense block of address pins over central Sigtuna — 400 markers inside
/// ~0.02° × 0.01°, i.e. exactly the "a pile of teardrops" case.
fn dense_pins() -> Vec<Pin> {
let mut v = Vec::new();
for i in 0..400u32 {
let (r, c) = (i / 20, i % 20);
let lon = 17.715 + c as f64 * 0.0010;
let lat = 59.615 + r as f64 * 0.0005;
v.push(Pin::new(lon, lat, format!("addr-{i}")));
}
v
}
/// Frame the pin block: fit to the pins' own bbox so the sweep below is about
/// zoom, not framing. Uses only the public camera API.
fn center_on_pins(v: &mut OsmView) {
let pins = dense_pins();
let (mut lo, mut hi) = (pins[0].lonlat, pins[0].lonlat);
for p in &pins {
lo.0 = lo.0.min(p.lonlat.0);
lo.1 = lo.1.min(p.lonlat.1);
hi.0 = hi.0.max(p.lonlat.0);
hi.1 = hi.1.max(p.lonlat.1);
}
let (x0, y0) = facett_geomap::mercator(lo.0, hi.1);
let (x1, y1) = facett_geomap::mercator(hi.0, lo.1);
v.camera_mut().fit_merc_bbox([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], RECT);
// A host that parks the camera HAS navigated: without this flag a first paint (or
// a paint into a differently sized pane) replays the data fit onto the paint copy
// and the pins — outside the data's bbox — project off-screen. Before the pins were
// painted through the frame camera this went unnoticed, because the pins alone
// were projected through the Model camera (two cameras in one frame — the defect
// `clusters_for_cam` retired).
v.update(Msg::PanPx(0.0, 0.0));
}
fn view() -> OsmView {
let (points, ways) = facett_geomap::osm2d::sample_dataset(2_000);
let mut v = OsmView::new("navigator", points, ways).with_pins(dense_pins());
v.fit_to(RECT);
v
}
/// (1) + (2): zooming in must MONOTONICALLY separate the pile, and the badges
/// must always account for exactly the pins on screen.
#[test]
fn zooming_in_splits_clusters_and_the_counts_never_lie() {
let mut v = view();
// Frame the pin block, then back off 8x so the sweep starts genuinely zoomed
// OUT (the pile-of-teardrops case) and ends fully separated.
center_on_pins(&mut v);
v.zoom_to_cursor(0.125, RECT.center(), RECT);
let mut markers_by_zoom = Vec::new();
for step in 0..7 {
if step > 0 {
v.zoom_to_cursor(2.0, RECT.center(), RECT);
}
let nodes = v.clusters_for(RECT);
let pins_shown: usize = nodes.iter().map(|n| n.count).sum();
// (2) CONSERVATION — every badge's count is real: the clusters account for
// exactly the pins the camera puts on screen, no double-counting, no drops.
let mut members: Vec<usize> = nodes.iter().flat_map(|n| n.members.iter().copied()).collect();
members.sort_unstable();
let before = members.len();
members.dedup();
assert_eq!(before, members.len(), "step {step}: no pin is in two clusters");
assert_eq!(pins_shown, before, "step {step}: counts sum to the member set");
assert!(
nodes.len() <= pins_shown,
"step {step}: markers ({}) never exceed the pins they stand for ({pins_shown})",
nodes.len()
);
markers_by_zoom.push((nodes.len(), pins_shown));
}
// (1) SEPARATION: at the tightest zoom every on-screen pin is its own marker;
// at the widest the same block is compressed into far fewer badges.
let (m_out, p_out) = markers_by_zoom[0];
let (m_in, p_in) = *markers_by_zoom.last().unwrap();
assert!(p_out > 0 && p_in > 0, "the block is on screen at both ends ({p_out} / {p_in})");
assert!(
m_out * 4 <= p_out,
"zoomed OUT the pile must compress at least 4x ({m_out} markers for {p_out} pins)"
);
assert_eq!(m_in, p_in, "zoomed IN every visible pin is its own marker ({m_in}/{p_in})");
}
/// (3): a click on a GROUP selects the group's stable id; a click on a LONE pin
/// still selects the pin id. RED-when-broken for the pre-clustering contract.
#[test]
fn clicking_a_group_selects_the_group_and_a_lone_pin_still_selects_the_pin() {
// A lone pin, far from anything: it must NOT become a badge, and selecting it
// must yield its own id.
let mut solo = OsmView::new("solo", vec![(17.0, 59.0)], vec![])
.with_pins(vec![Pin::new(17.0, 59.0, "Storgatan 1")]);
solo.fit_to(RECT);
let nodes = solo.clusters_for(RECT);
assert_eq!(nodes.len(), 1);
assert!(nodes[0].is_single(), "a lone pin is never a badge");
assert_eq!(nodes[0].id, "Storgatan 1");
solo.update(Msg::SelectPin(nodes[0].id.clone()));
assert_eq!(solo.state_json()["selected_pin"], "Storgatan 1");
// A dense block: the markers are groups, and selecting one lands the GROUP id.
let mut v = view();
center_on_pins(&mut v);
let nodes = v.clusters_for(RECT);
let group = nodes.iter().find(|n| !n.is_single()).expect("the dense block clusters");
assert!(group.id.starts_with("cluster:"), "a group carries a cluster id: {}", group.id);
assert_eq!(group.label, group.count.to_string(), "the badge shows its count");
v.update(Msg::SelectPin(group.id.clone()));
assert_eq!(v.state_json()["selected_pin"], group.id);
// ...and the group knows where its members are, so a host can zoom into it.
let bbox = cluster::member_lonlat_bbox(&group.members, v.pins()).expect("members exist");
assert!(bbox[2] >= bbox[0] && bbox[3] >= bbox[1], "a sane bbox {bbox:?}");
for &i in &group.members {
let (lon, lat) = v.pins()[i].lonlat;
assert!(lon >= bbox[0] && lon <= bbox[2] && lat >= bbox[1] && lat <= bbox[3]);
}
}
/// (4): the compression is published, so the matrix can read it.
#[test]
fn state_json_publishes_both_the_pin_count_and_the_marker_count() {
let mut v = view();
center_on_pins(&mut v);
// Render once so the ephemeral paint counters are populated.
let report = facett_core::harness::headless_render(&mut v);
assert!(report.vertices > 0, "the navigator draws");
let st = v.state_json();
let pins = st["visible_pins"].as_u64().unwrap();
let markers = st["visible_markers"].as_u64().unwrap();
assert!(pins > 0, "pins are on screen");
assert!(markers > 0, "markers were painted");
assert!(markers <= pins, "markers ({markers}) stand for pins ({pins})");
assert_eq!(st["cluster_px"].as_f64().unwrap(), cluster::CLUSTER_BIN_PX as f64);
assert_eq!(st["pins"].as_u64().unwrap(), 400, "the whole overlay is reported");
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-geomap.pin_clusters",
"dense_pins_compress_into_badges",
markers > 0 && markers < pins && report.vertices > 0,
&format!("visible_pins={pins} visible_markers={markers} vertices={}", report.vertices),
);
}
/// (5): `with_cluster_px(0)` is the escape hatch and it is LOSSLESS — every pin
/// paints as itself with its own id, exactly as before clustering existed.
#[test]
fn clustering_can_be_turned_off_losslessly() {
let mut on = view();
center_on_pins(&mut on);
let mut off = view().with_cluster_px(0.0);
center_on_pins(&mut off);
let on_nodes = on.clusters_for(RECT);
let off_nodes = off.clusters_for(RECT);
let on_pins: usize = on_nodes.iter().map(|n| n.count).sum();
let off_pins: usize = off_nodes.iter().map(|n| n.count).sum();
assert_eq!(on_pins, off_pins, "the same pins are on screen either way");
assert_eq!(off_nodes.len(), off_pins, "OFF: one marker per pin");
assert!(off_nodes.iter().all(cluster::ClusterNode::is_single), "OFF: nothing is a badge");
assert!(on_nodes.len() < off_nodes.len(), "ON: strictly fewer markers ({} < {})", on_nodes.len(), off_nodes.len());
assert_eq!(off.cluster_px(), 0.0);
}