Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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);
}