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

nordisk/facett

facett-demo/src/data.rs

//! **The one fake dataset.** A small, deterministic "fleet / issues" world that
//! every facett component renders a different facet of:
//!
//! - the **fleet** (vessels: region, status, score, progress, trend, last-seen
//!   position) → an Arrow [`RecordBatch`] feeding the **grid** + **table**, plus
//!   a flagship **card**/**form** for one vessel and **pop** progress rows.
//! - the **service dependency graph** (which subsystem feeds which) → the
//!   **graph**, **graph3d**, **depgraph** and **pipeline** views.
//! - **telemetry series** (throughput over time per region) → **plot** (line/bars)
//!   and the **plotters** family (box / violin / heatmap / candlestick / treemap).
//! - the vessels' **last-seen lon/lat** → the **map** (and the **osm** adapter
//!   shares `MapView`, exercised here via the same point cloud).
//!
//! All numbers are generated from cheap deterministic formulae (no RNG, no
//! external files) so the self-check test can assert exact counts/values.

use std::sync::Arc;

use arrow_array::{BooleanArray, Date32Array, Float64Array, Int64Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};

/// How many vessels in the fake fleet. Small enough to read, big enough that the
/// grid virtualization, enum-chip detection and sparklines all kick in.
pub const FLEET: usize = 48;

/// The four operating regions (low-cardinality → grid renders them as chips).
pub const REGIONS: [&str; 4] = ["north", "south", "east", "west"];

/// The lifecycle status of a vessel (also low-cardinality → chips).
pub const STATUSES: [&str; 4] = ["patrol", "docked", "transit", "alert"];

/// A few crew/assignees so the card's `User` field has names.
pub const CREW: [&str; 4] = ["Astrid", "Bjørn", "Senna", "Ravn"];

/// One vessel row — the canonical record the whole demo is built from.
#[derive(Clone, Debug)]
pub struct Vessel {
    pub id: i64,
    pub name: String,
    pub region: &'static str,
    pub status: &'static str,
    pub crew: &'static str,
    /// Health score 0..100.
    pub score: f64,
    /// Mission progress 0.0..1.0.
    pub progress: f64,
    /// A 7-point recent trend (feeds the grid sparkline + telemetry).
    pub trend: Vec<f64>,
    pub active: bool,
    /// Days since the unix epoch (Arrow `Date32`) for the "opened" column.
    pub opened: i32,
    /// Last-seen position.
    pub lon: f64,
    pub lat: f64,
}

/// Build the deterministic fleet. Pure: same vessels every run.
pub fn fleet() -> Vec<Vessel> {
    (0..FLEET)
        .map(|i| {
            let id = 1000 + i as i64 * 137;
            // A trend window: a sine wave with a per-vessel phase so each row's
            // sparkline differs.
            let trend: Vec<f64> =
                (0..7).map(|k| ((i as f64 * 0.6 + k as f64 * 0.9).sin() * 40.0 + 55.0).round()).collect();
            Vessel {
                id,
                name: format!("vessel-{i:03}"),
                region: REGIONS[i % REGIONS.len()],
                status: STATUSES[(i * 3 + 1) % STATUSES.len()],
                crew: CREW[(i * 7) % CREW.len()],
                score: ((i * 41 + 7) % 100) as f64,
                progress: ((i * 17) % 100) as f64 / 100.0,
                trend,
                active: i % 3 != 0,
                opened: 18628 + (i as i32 * 7), // counting from 2021-01-01
                // Spread points across the Nordic seas (lon 4..28, lat 54..69).
                lon: 4.0 + (i as f64 * 0.5).sin() * 12.0 + 12.0,
                lat: 54.0 + ((i as f64 * 0.37).cos() * 0.5 + 0.5) * 15.0,
            }
        })
        .collect()
}

/// Materialise the fleet into an Arrow [`RecordBatch`] — built **once** and fed to
/// both the grid (`Grid::from_record_batch`) and the table
/// (`facett::arrow::table_from_batch`), per the D5 brief.
pub fn fleet_batch(fleet: &[Vessel]) -> RecordBatch {
    let id: Vec<i64> = fleet.iter().map(|v| v.id).collect();
    let name: Vec<String> = fleet.iter().map(|v| v.name.clone()).collect();
    let region: Vec<&str> = fleet.iter().map(|v| v.region).collect();
    let status: Vec<&str> = fleet.iter().map(|v| v.status).collect();
    let score: Vec<f64> = fleet.iter().map(|v| v.score).collect();
    let progress: Vec<f64> = fleet.iter().map(|v| v.progress).collect();
    // The sparkline column samples the last trend point so the grid still has a
    // single numeric per row; the per-row "trend" sparkline reads this column's
    // window across rows (that's how facett-grid's sparkline cell works).
    let trend: Vec<f64> = fleet.iter().map(|v| *v.trend.last().unwrap_or(&0.0)).collect();
    let active: Vec<bool> = fleet.iter().map(|v| v.active).collect();
    let opened: Vec<i32> = fleet.iter().map(|v| v.opened).collect();

    RecordBatch::try_new(
        Arc::new(Schema::new(vec![
            Field::new("id", DataType::Int64, false),
            Field::new("name", DataType::Utf8, false),
            Field::new("region", DataType::Utf8, false),
            Field::new("status", DataType::Utf8, false),
            Field::new("score", DataType::Float64, false),
            Field::new("progress", DataType::Float64, false),
            Field::new("trend", DataType::Float64, false),
            Field::new("active", DataType::Boolean, false),
            Field::new("opened", DataType::Date32, false),
        ])),
        vec![
            Arc::new(Int64Array::from(id)),
            Arc::new(StringArray::from(name)),
            Arc::new(StringArray::from(region)),
            Arc::new(StringArray::from(status)),
            Arc::new(Float64Array::from(score)),
            Arc::new(Float64Array::from(progress)),
            Arc::new(Float64Array::from(trend)),
            Arc::new(BooleanArray::from(active)),
            Arc::new(Date32Array::from(opened)),
        ],
    )
    .expect("fleet batch columns are equal length")
}

/// The service subsystems that make up a vessel's stack — the node set for the
/// graph / graph3d / depgraph / pipeline views. `(label, sublabel)`.
pub const SUBSYSTEMS: [(&str, &str); 8] = [
    ("Ingest", "AIS feed"),
    ("Decode", "WKB → geo"),
    ("Fuse", "track merge"),
    ("Score", "risk model"),
    ("Index", "redb store"),
    ("Map", "tile render"),
    ("Alert", "dispatch"),
    ("Audit", "trail log"),
];

/// Directed dependencies between [`SUBSYSTEMS`] (indices): a small DAG.
pub const DEPS: [(usize, usize); 9] =
    [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (4, 6), (3, 6), (6, 7), (5, 7)];

/// The geo point cloud (lon, lat) — the vessels' last-seen positions, shared by
/// `map` and (conceptually) the `osm` adapter, which produces the same `MapView`.
pub fn geo_points(fleet: &[Vessel]) -> Vec<(f64, f64)> {
    fleet.iter().map(|v| (v.lon, v.lat)).collect()
}

/// One [`geo_ways`] row: `(osm_id, tags, lonlat-vertices)`.
pub type GeoWay = (i64, Vec<(String, String)>, Vec<(f64, f64)>);

/// A small **synthetic vector basemap** for the `map` tab — roads, a coastline,
/// and a water body around the fleet's centroid, so the showcase renders a real
/// basemap (lines, not just dots) the way korp's Map will from `ways.parquet`.
/// Returns `(osm_id, tags, lonlat-vertices)` rows; the demo turns each into a
/// `facett::map::Way` via `Way::from_lonlat`. Deterministic.
pub fn geo_ways(fleet: &[Vessel]) -> Vec<GeoWay> {
    let n = fleet.len().max(1) as f64;
    let clon = fleet.iter().map(|v| v.lon).sum::<f64>() / n;
    let clat = fleet.iter().map(|v| v.lat).sum::<f64>() / n;
    let tag = |k: &str, v: &str| vec![(k.to_string(), v.to_string())];
    let mut out: Vec<GeoWay> = Vec::new();

    // A grid of roads (motorway/secondary verticals + residential horizontals).
    for i in 0..18 {
        let f = i as f64 / 18.0 - 0.5; // -0.5..0.5
        out.push((
            i,
            tag("highway", if i % 6 == 0 { "motorway" } else { "secondary" }),
            (0..10).map(|j| (clon + f * 6.0, clat - 3.0 + j as f64 * 0.6)).collect(),
        ));
        out.push((
            100 + i,
            tag("highway", "residential"),
            (0..10).map(|j| (clon - 3.0 + j as f64 * 0.6, clat + f * 6.0)).collect(),
        ));
    }
    // A coastline (a long wavy line) + a water polygon (a lake).
    out.push((
        900,
        tag("natural", "coastline"),
        (0..40).map(|j| (clon - 4.0 + j as f64 * 0.2, clat + 2.5 + (j as f64 * 0.5).sin() * 0.4)).collect(),
    ));
    out.push((
        901,
        tag("natural", "water"),
        vec![
            (clon + 1.0, clat - 1.0),
            (clon + 2.0, clat - 1.0),
            (clon + 2.0, clat - 0.2),
            (clon + 1.0, clat - 0.2),
            (clon + 1.0, clat - 1.0),
        ],
    ));
    out
}

/// The **synthetic test basemap** for the `map` tab: the **znippy rabbit** logo,
/// not a square grid or terrain. The parametric rabbit
/// ([`facett_core::rabbit`]) lives in a normalised `[-1, 1]²` design box (y up);
/// we place it lon/lat-centred on the fleet's centroid and scale it to a few
/// degrees, then turn each silhouette loop into a **filled** [`facett::map::Way`]
/// (the solid mascot) plus the eye as a small dark filled dot. Deterministic — no
/// RNG, so the snapshot is stable. This is *only* the synthetic fallback; the
/// real-Sweden path (loading `ways.parquet`) is untouched and bypasses this.
pub fn rabbit_ways(fleet: &[Vessel]) -> Vec<facett::map::Way> {
    use facett::map::Way;
    let n = fleet.len().max(1) as f64;
    let clon = fleet.iter().map(|v| v.lon).sum::<f64>() / n;
    let clat = fleet.iter().map(|v| v.lat).sum::<f64>() / n;
    // Scale the [-1,1] box to ~±5° lon / ±4° lat around the centroid. Mercator
    // squashes latitude a touch, so use a slightly larger lat span to keep the
    // rabbit upright rather than squat.
    let sx = 5.0;
    let sy = 4.6;
    let to_lonlat = |(x, y): (f64, f64)| (clon + x * sx, clat + y * sy);

    // Facett palette colours: a warm-grey fur fill + a crisp dark outline + a
    // dark eye. These read on any of the demo themes (the silhouette is a solid
    // mass with a high-contrast outline).
    let fur = egui::Color32::from_rgb(196, 201, 214);
    let outline = egui::Color32::from_rgb(28, 32, 44);
    let eye = egui::Color32::from_rgb(20, 22, 30);

    let r = facett::core::rabbit::Rabbit::default();
    let mut ways: Vec<Way> = Vec::new();
    // body, head, ear, ear — each a filled silhouette loop with the outline on top.
    let kinds = ["body", "head", "ear", "ear"];
    for (i, lp) in r.silhouette().into_iter().enumerate() {
        let lonlat: Vec<(f64, f64)> = lp.into_iter().map(to_lonlat).collect();
        let kind = kinds.get(i).copied().unwrap_or("body");
        ways.push(Way::filled(i as i64, kind, lonlat, fur, outline, 1.6));
    }
    // The eye: a small dark filled dot on top of the head.
    let eye_loop: Vec<(f64, f64)> = r.eye().into_iter().map(to_lonlat).collect();
    ways.push(Way::filled(900, "eye", eye_loop, eye, eye, 0.8));
    ways
}

/// Per-region throughput time-series for the `plot` (line + bars) — one named
/// series per region, 24 hourly samples each.
pub fn telemetry() -> Vec<(String, Vec<(f64, f64)>)> {
    REGIONS
        .iter()
        .enumerate()
        .map(|(r, region)| {
            let pts: Vec<(f64, f64)> = (0..24)
                .map(|h| {
                    let y = ((h as f64 * 0.4 + r as f64).sin() * 30.0 + 60.0 + r as f64 * 8.0).max(0.0);
                    (h as f64, y)
                })
                .collect();
            (region.to_string(), pts)
        })
        .collect()
}

/// Grouped score samples per region — feeds the box plot + violin (distribution
/// views in `facett-plotters`).
pub fn score_groups(fleet: &[Vessel]) -> Vec<(String, Vec<f64>)> {
    REGIONS
        .iter()
        .map(|region| {
            let vals: Vec<f64> = fleet.iter().filter(|v| &v.region == region).map(|v| v.score).collect();
            (region.to_string(), vals)
        })
        .collect()
}

/// A region × status count matrix for the heatmap (rows = regions, cols =
/// statuses). Returns `(rows, cols, row_labels, col_labels, data)`.
pub fn region_status_matrix(fleet: &[Vessel]) -> (usize, usize, Vec<String>, Vec<String>, Vec<f64>) {
    let rows = REGIONS.len();
    let cols = STATUSES.len();
    let mut data = vec![0.0f64; rows * cols];
    for v in fleet {
        let r = REGIONS.iter().position(|&x| x == v.region).unwrap();
        let c = STATUSES.iter().position(|&x| x == v.status).unwrap();
        data[r * cols + c] += 1.0;
    }
    (
        rows,
        cols,
        REGIONS.iter().map(|s| s.to_string()).collect(),
        STATUSES.iter().map(|s| s.to_string()).collect(),
        data,
    )
}

/// Total "tonnage" (fake) per region for the treemap — area-weighted tiles.
pub fn region_tonnage(fleet: &[Vessel]) -> Vec<(String, f64)> {
    REGIONS
        .iter()
        .map(|region| {
            let t: f64 = fleet.iter().filter(|v| &v.region == region).map(|v| v.score + 20.0).sum();
            (region.to_string(), t)
        })
        .collect()
}

/// Density **hotspots** (lon, lat, count, label) for the scrollable/zoomable
/// `geomap` — the densest regions of the fleet, drawn as count-sized circles over
/// the point cloud. Deterministic: the four region centroids, weighted by how many
/// vessels sit in each region.
pub fn geo_hotspots(fleet: &[Vessel]) -> Vec<(f64, f64, usize, String)> {
    REGIONS
        .iter()
        .map(|region| {
            let pts: Vec<&Vessel> = fleet.iter().filter(|v| &v.region == region).collect();
            let n = pts.len().max(1);
            let lon = pts.iter().map(|v| v.lon).sum::<f64>() / n as f64;
            let lat = pts.iter().map(|v| v.lat).sum::<f64>() / n as f64;
            (lon, lat, pts.len(), region.to_string())
        })
        .collect()
}

/// Bin a real `(lon, lat)` point cloud into the **`n` densest cells** and return
/// them as `(lon, lat, count, label)` **hotspots** for the `geomap` — the count is
/// the number of points in the cell, the position its centroid. Used when the demo
/// loads real OSM points (Sigtuna): the geomap then shows where the map is dense
/// (town centre vs. fields) as count-sized circles over the dots, instead of the
/// four fake fleet-region hotspots. Deterministic (a fixed grid, stable sort).
pub fn density_hotspots(points: &[(f64, f64)], n: usize) -> Vec<(f64, f64, usize, String)> {
    if points.is_empty() || n == 0 {
        return Vec::new();
    }
    // A coarse ~grid over the cloud's bounds (≈24×24 cells) so popular cells stand
    // out. Quantise to integer cell keys, accumulate count + centroid.
    let (mut min_lon, mut max_lon) = (f64::MAX, f64::MIN);
    let (mut min_lat, mut max_lat) = (f64::MAX, f64::MIN);
    for &(lon, lat) in points {
        min_lon = min_lon.min(lon);
        max_lon = max_lon.max(lon);
        min_lat = min_lat.min(lat);
        max_lat = max_lat.max(lat);
    }
    const CELLS: f64 = 24.0;
    let span_lon = (max_lon - min_lon).max(1e-9);
    let span_lat = (max_lat - min_lat).max(1e-9);
    use std::collections::HashMap;
    // key → (count, sum_lon, sum_lat)
    let mut bins: HashMap<(i64, i64), (usize, f64, f64)> = HashMap::new();
    for &(lon, lat) in points {
        let cx = (((lon - min_lon) / span_lon) * CELLS).floor() as i64;
        let cy = (((lat - min_lat) / span_lat) * CELLS).floor() as i64;
        let e = bins.entry((cx, cy)).or_insert((0, 0.0, 0.0));
        e.0 += 1;
        e.1 += lon;
        e.2 += lat;
    }
    let mut cells: Vec<(f64, f64, usize)> =
        bins.into_iter().map(|(_, (c, sl, sa))| (sl / c as f64, sa / c as f64, c)).collect();
    // Densest first; break ties by position so the result is deterministic.
    cells.sort_by(|a, b| b.2.cmp(&a.2).then(a.0.partial_cmp(&b.0).unwrap()).then(a.1.partial_cmp(&b.1).unwrap()));
    cells
        .into_iter()
        .take(n)
        .map(|(lon, lat, count)| (lon, lat, count, format!("{count} pts")))
        .collect()
}

/// A handful of **system nodes** (id, label, badge, normalized pos) for the
/// `syschart` system-map — the same subsystem set as the graph family, laid out as
/// free-positioned peers (not a layered DAG) with an event-count badge each. The
/// badge counts the vessels whose status routes through that subsystem (a cheap
/// deterministic signal so the chart's badges are non-zero).
pub fn sys_nodes(fleet: &[Vessel]) -> Vec<(&'static str, &'static str, u64, (f32, f32))> {
    // Deterministic ring layout: evenly spaced around the canvas.
    let n = SUBSYSTEMS.len();
    SUBSYSTEMS
        .iter()
        .enumerate()
        .map(|(i, (label, _))| {
            let theta = std::f32::consts::TAU * (i as f32) / (n as f32);
            let pos = (0.5 + 0.38 * theta.cos(), 0.5 + 0.38 * theta.sin());
            // A non-zero badge: vessels whose id mod the subsystem count lands here.
            let badge = fleet.iter().filter(|v| (v.id as usize) % n == i).count() as u64;
            (*label, SUBSYSTEMS[i].1, badge, pos)
        })
        .collect()
}

/// Undirected `syschart` links (id pairs) derived from the directed [`DEPS`] DAG.
pub fn sys_edges() -> Vec<(&'static str, &'static str)> {
    DEPS.iter().map(|&(a, b)| (SUBSYSTEMS[a].0, SUBSYSTEMS[b].0)).collect()
}

/// Two **Iceberg snapshots** of the fleet table for the `icebergview` time-travel
/// browser: an older `v1` (the first half of the fleet) and a newer `v2` (the whole
/// fleet). Returns `[(snapshot_id, timestamp_ms, RecordBatch)]`, oldest first (the
/// view sorts newest-first itself). Selecting v1 vs v2 changes the visible rows —
/// real time travel the robot can drive + assert.
pub fn iceberg_snapshots(fleet: &[Vessel]) -> Vec<(i64, i64, RecordBatch)> {
    let half = fleet.len() / 2;
    let v1 = fleet_batch(&fleet[..half]);
    let v2 = fleet_batch(fleet);
    vec![(100, 1_700_000_000_000, v1), (200, 1_700_000_086_400, v2)]
}

/// A short OHLC candle series (fake "alert price" over 12 sessions).
pub fn candles() -> Vec<(f64, f64, f64, f64)> {
    (0..12)
        .map(|i| {
            let base = (i as f64 * 0.5).sin() * 8.0 + 40.0;
            let open = base;
            let close = base + (i as f64 * 0.9).cos() * 4.0;
            let high = open.max(close) + 2.0;
            let low = open.min(close) - 2.0;
            (open, high, low, close)
        })
        .collect()
}

/// A small **bundled sample manual** for the `docview` tab: a few synthetic
/// "Typst-exported" SVG pages (one SVG per page), each a white A4-ish page with a
/// coloured title bar carrying the **chapter title**, a few **body paragraph
/// lines**, and a **page-number footer** — all as real SVG `<text>`. The `<text>`
/// is what proves the docview pipeline: facett-docview embeds DejaVu Sans into its
/// usvg `fontdb`, so these glyphs shape + rasterize even with **zero system fonts**
/// (native airgap AND the browser/wasm path, where there are none). Earlier this
/// page was pure `<rect>`/`<line>` "stand-in" blocks, so the demo only ever showed
/// boxes — never exercising glyph rendering. Returns `(svg_bytes_per_page, toc)`
/// where the TOC is the `(title, page_index)` list the sidebar shows. Deterministic
/// (no RNG/files) so the self-check test can assert exact page/TOC counts and that
/// every page carries `<text>`.
pub fn sample_manual() -> (Vec<Vec<u8>>, Vec<(String, usize)>) {
    // Page geometry in pt (a portrait page ≈ 1:1.4).
    const W: u32 = 420;
    const H: u32 = 594;
    let chapters = ["Welcome", "Components", "Theming", "Headless tests"];
    // A distinct title-bar tint per page so each rasterized page looks different.
    let tints = ["#1e3a5f", "#3a5f1e", "#5f1e3a", "#5f4a1e"];
    // A couple of body lines per chapter — plain ASCII so any open sans shapes them.
    let bodies = [
        [
            "facett is one egui look across windows, macos and device.",
            "Every component reads the published palette, so a preset",
            "switch re-themes the whole deck coherently (the COH-1 law).",
        ],
        [
            "Graphs, grids, maps, plots, cards and forms compose freely.",
            "The flagship Arrow grid stays O(visible) at a million rows,",
            "and typed copy/paste moves columns zero-copy between facets.",
        ],
        [
            "Colours are authored in OKLCH semantic roles, not literals.",
            "egui widget states derive by lightness steps, and the WCAG",
            "gate keeps body text at 4.5:1 across light and dark presets.",
        ],
        [
            "The whole demo is headless-constructible, so a robot can",
            "drive exactly what a human sees. This very page renders as",
            "real glyphs from an embedded font, even with no system fonts.",
        ],
    ];
    let pages: Vec<Vec<u8>> = chapters
        .iter()
        .enumerate()
        .map(|(i, title)| {
            let tint = tints[i % tints.len()];
            let body = &bodies[i % bodies.len()];
            // A real "page": white ground, a coloured title bar with the chapter
            // title as `<text>`, three body lines as `<text>`, a footer rule and a
            // page-number `<text>`. The glyphs shape against facett-docview's
            // embedded DejaVu Sans — proof the docview font path works headless.
            format!(
                r##"<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">
  <rect x="0" y="0" width="{W}" height="{H}" fill="white"/>
  <rect x="0" y="0" width="{W}" height="64" fill="{tint}"/>
  <text x="32" y="40" font-family="DejaVu Sans, sans-serif" font-size="22" font-weight="bold" fill="white">{title}</text>
  <text x="32" y="120" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#222222">{b0}</text>
  <text x="32" y="144" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#444444">{b1}</text>
  <text x="32" y="168" font-family="DejaVu Sans, sans-serif" font-size="13" fill="#444444">{b2}</text>
  <line x1="32" y1="{fy}" x2="388" y2="{fy}" stroke="#999999" stroke-width="1"/>
  <text x="{px}" y="{py}" font-family="DejaVu Sans, sans-serif" font-size="11" fill="{tint}" text-anchor="middle">Page {pageno}</text>
</svg>"##,
                b0 = body[0],
                b1 = body[1],
                b2 = body[2],
                fy = H - 48,
                px = W / 2,
                py = H - 30,
                pageno = i + 1,
            )
            .into_bytes()
        })
        .collect();
    let toc = chapters.iter().enumerate().map(|(i, c)| (c.to_string(), i)).collect();
    (pages, toc)
}

#[cfg(test)]
mod hotspot_tests {
    use super::density_hotspots;

    /// `density_hotspots` returns the densest cells first, with counts + centroids:
    /// a tight cluster of many points outweighs a lone outlier, and the result is
    /// deterministic + capped at `n`.
    #[test]
    fn density_hotspots_ranks_dense_cells_first() {
        // 100 points jammed into one tiny cell + a single far outlier.
        let mut pts: Vec<(f64, f64)> = (0..100).map(|i| (17.60 + (i as f64) * 1e-5, 59.62 + (i as f64) * 1e-5)).collect();
        pts.push((17.85, 59.68)); // the lone outlier

        let hs = density_hotspots(&pts, 3);
        assert!(!hs.is_empty(), "produced hotspots");
        assert!(hs.len() <= 3, "capped at n");
        // The densest hotspot is the big cluster (count 100), centred near it.
        let (lon, lat, count, label) = &hs[0];
        assert_eq!(*count, 100, "the dense cell carries all 100 points");
        assert!((*lon - 17.60).abs() < 0.01 && (*lat - 59.62).abs() < 0.01, "centroid near the cluster");
        assert_eq!(label, "100 pts");
        // Deterministic: same input → same output.
        assert_eq!(density_hotspots(&pts, 3), hs);
        // Empty input is graceful.
        assert!(density_hotspots(&[], 3).is_empty());
    }
}

#[cfg(test)]
mod docview_tests {
    use super::sample_manual;
    use facett::docview::DocPage;

    /// Count opaque dark pixels — the rendered glyph ink. Body text is dark
    /// (#222/#444) on a white page, so real text rasterizes as dark ink; the old
    /// pure-rect page would still have *some* dark rects, so we render text-only
    /// regions implicitly by demanding a healthy ink count from the glyph lines.
    fn dark_ink(img: &egui::ColorImage) -> usize {
        img.pixels
            .iter()
            .filter(|c| c.a() == 255 && c.r() < 128 && c.g() < 128 && c.b() < 128)
            .count()
    }

    /// REGRESSION (docview shows boxes, not text): every `sample_manual` page must
    /// carry real `<text>` AND rasterize visible glyph ink through facett-docview's
    /// embedded font — with NO reliance on system fonts (the wasm/airgap floor).
    /// The old sample pages were pure `<rect>`/`<line>` stand-ins, so the demo only
    /// ever showed boxes. This drives the actual docview parse+rasterize path the
    /// demo uses and asserts each page parses, keeps `<text>`, and paints ink.
    #[test]
    fn sample_manual_pages_render_real_text() {
        let (pages, toc) = sample_manual();
        assert_eq!(pages.len(), toc.len(), "one TOC entry per page");
        assert!(!pages.is_empty(), "manual has pages");

        for (i, bytes) in pages.iter().enumerate() {
            // The SVG source must actually contain glyph text (not just shapes).
            let src = std::str::from_utf8(bytes).expect("utf8 svg");
            assert!(src.contains("<text"), "page {i} carries <text> elements");

            // It parses against the docview fontdb and rasterizes glyph ink.
            let page = DocPage::from_svg_bytes(bytes).expect("sample page parses");
            let img = page.rasterize(2.0).expect("page rasterizes");
            let ink = dark_ink(&img);
            assert!(
                ink > 400,
                "page {i} rendered visible glyph ink (the embedded font shaped \
                 the <text>); got {ink} dark pixels — boxes-only pages would never \
                 reach this from the text lines"
            );
        }
    }
}