makepad/layers.md
andodeki d6d1f99ca9 Update fork to upstream dev 5d4483f
- Sync with latest upstream dev branch
- Include all map improvements: 2D/3D toggle, shadows, labels, overlays
- Include platform updates: location API, audio echo cancellation
- Preserve fork-specific re-exports (gltf, csg, test)
2026-07-31 18:47:03 +00:00

14 KiB
Raw Permalink Blame History

Overlay data layers — work log & status

Status 2026-07-28. This documents the overlay-layer track: the libs/geodata crate, the nine built layer databases, the live rain-radar sync, and the LLM query surface. Companions: datasources.md (source survey + licenses, the research this implements), gps.md (interaction layer), libs/geodata/README.md (the crate's own contract-level docs). The map renderer is a separate track — nothing in here touches widgets/src/map.

TL;DR

All planned NL layers are built, integrity-checked, and query-verified in local/overlays/nl-<layer>.mbtiles (one file per layer, never merged into the base Europe archive). Every vector layer is simultaneously:

  1. renderable — standard gzipped MVT 2.1 tiles the renderer's existing decoder already parses, and
  2. LLM-queryable — a grid-indexed features sidecar table + query::LayerDb (point / radius / bbox → structured JSON) for "reason with the map" tool calls.

Raster layers use terrarium RGB (elevation) or gray8 class-index encoding with a geodata_classmap metadata table (class → label + suggested color), so the shader colormaps and the query side can name values.

layer content zooms size build verified by
nature Natura 2000 + Ramsar wetland polygons 612 6.2 MB 0.6 s polygons + rings in sidecar
chargers 66,583 EV charging locations (operator, kW) 814 26 MB 2.9 s Krasnapolsky charger @128 m from Dam
demographics CBS 500 m + 100 m grid stats (442k cells) 813 275 MB 9.4 s population at point
wijkbuurt gemeente/wijk/buurt polygons + kerncijfers 613 91 MB 4.8 s Dam → Amsterdam → Burgwallen-NZ → Nieuwe Kerk e.o.
transit all NL stops + 1,457 non-bus route shapes 714 14 MB 6.1 s streamed from 234 MB GTFS
buildings-age all 11,407,303 BAG buildings, bouwjaar+status 1314 1.6 GB 91 s Royal Palace → bouwjaar 1655
terrain GLO-30 elevation, terrarium, NL bbox 612 330 MB 57 s 18/20 cells; 2 sea-only 404s by design
noise RIVM 10 m Lden → 5 dB classes 613 34 MB 23 min A10 tile: 6575 dB spine over 5055 dB city
flood JRC 1-in-100y river flood depth classes 611 2.7 MB 3.6 min class histogram plausible

Plus the live source: rain radar (RadarSync) — pulled a real KNMI +2h nowcast minutes after publication; poll-gated, cache-pruning, app-embeddable.

CLI quick reference (cargo run -p makepad-geodata --bin geodata or the release binary):

geodata list | fetch <layer|all> | build <layer|all> | status
geodata query <layer> <lon> <lat> [--radius m] [--limit n]
geodata radar-sync [forecast|reflectivity]

Architecture

libs/geodata is a library first (the map app will embed it for radar sync, periodic source refresh, and the query surface), with a thin geodata CLI. Workspace member; depends only on makepad-mbtile-reader, makepad-map-nav, flate2, serde_json.

Data flow per layer:

bulk source file  ──fetch.rs──▶  local/overlays/cache/  (+ .meta.json)
      (GPKG / zip / json.gz / GeoTIFF, verified URLs, polite)
                 ──layers/<id>.rs──▶  parse + reproject to WGS84
                 ──tiler.rs / spool.rs──▶  clipped MVT features per (z,x,y)
                                      └──▶ sidecar.rs features records
                 ──MbtilesWriter──▶  local/overlays/nl-<id>.mbtiles
                                      ├─ tiles     (gzip MVT | PNG)
                                      ├─ features  (query sidecar)
                                      └─ metadata  (license, classmap, TileJSON)

Key decisions and why:

  • One .mbtiles per layer. Independently rebuildable/shippable/deletable; the base-map archive (owned by the import-pipeline track) is never touched.
  • Bulk downloads only. No API paging/spidering — search-shaped APIs (Wikipedia, Overpass, NDW live traffic, GTFS-RT) will be integrated in the map app, queried by viewport context at runtime. Radar is the one live source here and polls its official file API at most once per data refresh.
  • Reuse over reimplementation (per the "don't duplicate the map engine's math" rule): mercator projection comes from makepad_map_nav::geo; SQLite read and write go through makepad-mbtile-reader — GeoPackages are just SQLite files, so the reader ingests PDOK/CBS/BAG data directly. Genuinely new code only where nothing existed: RD New ↔ WGS84 polynomials, WKB parsing, MVT encoding, a GeoTIFF subset reader, a PNG codec, clipping/tiling.

Politeness (the "don't get banned" layer)

Enforced inside fetch.rs/radar.rs so no layer module can violate it:

  • descriptive User-Agent with contact address; one transfer at a time; 1 s pause after every network hit
  • a cached file is not even revalidated before its recheck_days age (BAG 45 d, CBS 90 d, chargers 2 d, DEM ~forever); afterwards If-Modified-Since revalidation makes an unchanged file cost one 304, zero bytes
  • interrupted downloads resume (.part + -C -); optional --limit-rate for small government origin servers
  • radar: the poll gate is armed before the request (a failing API cannot be hammered — this was found and fixed when the shared KNMI anonymous key 429'd), plus request pacing and a single 429 backoff-retry
  • every cached file carries a .meta.json (url, license, fetch time)

Database contract (what the renderer AI and app consume)

Written by MbtilesWriter: 64 KB pages, deterministic block-major rowids (direct-seek tile lookup), passes PRAGMA integrity_check — verified on all nine outputs. Standard SQLite tooling reads everything.

  • tiles — gzip MVT 2.1, extent 4096 (vector; format=pbf) or PNG 256 px (raster; format=png). The renderer's gzip sniff + MVT decode work as-is; MVT winding follows the spec (exterior CW in y-down tile coords).
  • metadata — mbtiles standard keys, attribution + license (for the attribution screen), TileJSON-style json.vector_layers (field names and types per MVT layer — the renderer can style data-driven without guessing), geodata_encoding + geodata_classmap for rasters, geodata_built_unix.
  • features — the query sidecar (vector layers): cell, layer, name, min_lon, min_lat, max_lon, max_lat, attrs (JSON), ring (JSON|null) with rowid = (z12 grid cell << 24) | seq. Bbox queries become pruned b-tree range scans — no SQL engine, no extra index. Polygon layers opt into storing a simplified exterior ring (≤96 pts, ~10 m tolerance) for exact point-in-polygon.

Per-layer notes

nature — PDOK Natura 2000 + wetlands GPKGs (CC0, ~15 MB). MVT layers natura2000 (naam_n2k, sitecodes, status…) and wetlands; every feature also tagged kind. Rings stored.

chargers — NDW national OCPI aggregate (charging_point_locations_ocpi.json.gz, open data, refreshed ~daily; recheck 2 d makes this the natural app-side periodic re-sync candidate). Attrs: name, operator, city, evses, connectors, max_kw (from max_electric_power or volts×amps fallback).

demographics — CBS Vierkantstatistieken 500 m (z8z11) + 100 m (z12z13) grids; 139/134 columns of per-cell stats carried through with CBS suppression sentinels (negatives) dropped. Square cells mean bbox containment is exact — no rings needed.

wijkbuurt — CBS Wijk- en Buurtkaart 2025: gemeenten (z68), wijken (z910), buurten (z1113) with kerncijfers. Rings stored → exact administrative lookup for any coordinate.

transit — OVapi static GTFS (CC0, no key). CSVs are streamed out of the 234 MB zip (unzip -p, never extracted). stops points (stations from z8, stops from z10) + routes lines for rail/tram/metro/ferry (bus shapes excluded deliberately: they follow roads already on the map and triple the size). Live vehicle positions are explicitly app-side (GTFS-RT), not here.

buildings-age — PDOK bag-light.gpkg (CC0, 7.8 GB, monthly). The one layer that can't fit the in-memory tiler: SpoolTiler clips features in one pass and spools compact records to per-(zoom, 256×256-block) disk files — NL is only 5 blocks at z13/z14 — then loads one block at a time in writer rowid order. 11.4M polygons → 20,229 tiles + an 11.4M-row sidecar in 91 s, peak memory one block. MVT layer bag: bouwjaar, status.

terrain — Copernicus GLO-30 DSM COGs from anonymous AWS S3, 20 one-degree cells for NL; all-ocean cells 404 upstream and are treated as sea level (2 of 20 did — as expected). Terrarium encoding is the shared contract: renderer hillshade/3D terrain later, and map_nav samples the same file to bake per-edge climb/descent for EV routing (the elevation thread from datasources.md).

noise — RIVM "Geluid in Nederland" Lden all-sources 10 m GeoTIFF (CC0), EPSG:28992 — sampled through the WGS84→RD inverse polynomial. dB values are binned into 5 dB classes (1: <45 … 8: ≥75) at encode time; the classmap metadata carries labels + colors. 23-minute build (≈800M bilinear samples of a deflate-tiled national raster through the pure-Rust TIFF reader) — fine for a yearly-refresh batch job.

flood — JRC Europe river flood hazard RP100 depth GeoTIFF (CC BY, WGS84 ~90 m), binned to depth classes (<0.5 m … ≥4 m). Small and cheap. Follow-ups noted: JRC's spurious_depth_areas mask, and PDOK's official ROR zone polygons (CC0) as a vector companion.

radar (live)radar.rs: KNMI Data Platform datasets radar_forecast (+2 h nowcast, whole animation in one ~0.5 MB HDF5 file every 5 min; the map-app default) and radar_reflectivity_composites (5-min frames, keeps ~1 h). RadarSync::sync() is safe to call arbitrarily often — network at most once per min_poll_secs (240 s default), downloads only missing files, prunes old ones, state() never touches the network. API key: config → KNMI_API_KEY env → the documented shared anonymous key (dev-only; its quota is shared — register a free personal key for real use).

The query surface (LLM goal)

let mut db = query::LayerDb::open("local/overlays/nl-wijkbuurt.mbtiles")?;
db.query_point(4.8926, 52.3731, 10)?;       // exact point-in-polygon
db.query_radius(4.8926, 52.3731, 400., 5)?; // nearest-first + distances
db.query_bbox(min_lon, min_lat, max_lon, max_lat, 100)?;

Live-verified answers (via geodata query):

  • Dam Square → gemeente Amsterdam (pop 934,526) → wijk Burgwallen-Nieuwe Zijde (4,345) → buurt Nieuwe Kerk e.o. (830, 9,827 /km²) — exact containment via stored rings.
  • Chargers near Dam: Krasnapolsky at 128 m (Eneco, 22 kW), with distances.
  • Royal Palace → bouwjaar 1655, "Pand in gebruik" — out of 11.4M buildings.
  • Population grid cell at Dam: 1,735 inhabitants (vk500).

The intended wiring: the map app exposes these as LLM tool calls (and as tap-to-inspect UI). Every layer answers from the same three query shapes, and attrs come back as JSON with the source's own column names.

Infrastructure added to shared crates (all additive)

libs/mbtile_reader:

  • open_sqlite / schema_entries / for_each_row — generic SQLite table reading; what lets GeoPackages be ingested with zero new dependencies.
  • for_each_row_in_range — pruned b-tree descent for rowid ranges; the engine under the sidecar's spatial queries.
  • Writer: begin_extra_table / write_extra_row (+ WriterValue, float and NULL record support) — arbitrary extra tables alongside tiles/metadata.
  • All pre-existing writer/reader tests still pass; new outputs pass PRAGMA integrity_check.

libs/geodata internals worth knowing:

  • geo.rs — RD New ↔ WGS84 both directions (Schreutelkamp/Strang van Hees polynomials), round-trip tested < 1 m at Amsterdam/Rotterdam/Maastricht/ Groningen; tile_order_key replicating the writer's rowid order.
  • tiff.rs — GeoTIFF subset: tiled + striped, deflate/LZW/uncompressed, predictors 1/2/3 (incl. the floating-point predictor), int/float samples, ModelPixelScale/Tiepoint georeferencing, GDAL nodata, block cache. Proven against Copernicus COGs, the JRC Europe raster, and RIVM's RD raster.
  • png.rs — encoder + decoder for gray8/RGB8 (hand-rolled CRC32, round-trip tested). raster.rs — sampler → 256 px tile pyramid.
  • mvt.rs — spec-correct MVT 2.1 encoder with key/value dedup.
  • spool.rs — the country-scale streaming tiler described above.

Test suite: 7 unit tests in geodata (projections, round-trips, ordering, PNG, ISO-8601), 9 in mbtile_reader. Byte-level MVT/gzip verification and SQLite integrity checks run against every produced artifact.

Open follow-ups (in rough priority order)

  1. Radar HDF5 → raster decode so frames render (and a small RadarFrame::to_grid() for querying "rain at my location in 30 min"); OPERA/MeteoGate (CC-BY COGs) after that for Europe-wide radar.
  2. Raster point-query helper in query.rs (PNG-decode a tile, return elevation / class + classmap label) — completes the LLM surface for terrain/noise/flood ("how high / how loud / what flood depth here").
  3. EV grade baking: map_nav's nav-build samples nl-terrain.mbtiles per edge (the datasources.md EV thread).
  4. Flood vector companion (PDOK ROR, CC0) + JRC spurious-areas mask.
  5. Scale-out to Europe per country as sources allow (the machinery is region-agnostic; NL-specific parts are just the RD transform and source URLs).
  6. Minor: neighbor-tile point buffering, Douglas-Peucker simplification, BigTIFF, replacing shelled-out unzip.

Verification ledger

  • All 9 mbtiles: PRAGMA integrity_check = ok (validates the hand-rolled SQLite writer end-to-end, incl. extra tables and >1 GB files).
  • MVT: tile payloads gzip-wrapped (renderer sniff), protobuf structure byte-checked, winding per spec.
  • Geo math: RD origin exact; RD↔WGS84 round trip < 1 m ×4 cities; Amsterdam sanity; ISO-8601 epoch math vs hand-computed constants.
  • Content spot-checks: Royal Palace 1655; Dam admin hierarchy; charger distances; A10 noise spine; terrain cell coverage 18/20 with sea handling.
  • Radar: real nowcast downloaded; poll gate verified (second call → zero network); 429 backoff exercised against the live saturated anonymous tier.