Phase 4 — Map stack & rendering performance¶
Status: Closed 2026-04-24 (started 2026-04-23). See plan.md for the phase overview. Scope: map-stack cluster from the Phase 3+ vision draft — #4, #5, #26, #27b, plus MapLibre migration and UI redesign direction. Predecessor: Phase 3 — Foundations. Successor: Phase 5 — UX quality, i18n, errors.
Goal¶
Turn the Leaflet-based "dots on a map" UI into a MapLibre-backed, zoom-aware, multi-layer rendering surface that stays smooth on 10M-point exposures and continental-scale hazard rasters. This phase is the concrete vehicle for #27b large-dataset read-side performance — the highest-impact Tier 2 performance concern in the whole draft. Nothing user-visible beyond the map surface ships here; the Datasets browser, admin catalog UI, and ingest pipelines are Phase 6.
Entry conditions¶
- Phase 3 closed: ADR-025 (MapLibre) merged,
ADR-029 (DO Spaces layout) merged, docs site live,
Scenariomodel in place. - DO Spaces bucket provisioned with
tiles/prefix reserved. - PostGIS spatial indexes on
exposure.geometryverified.
Out of scope (deferred)¶
- Datasets catalog browser UI (#6, #7) — Phase 6.
- Admin panel + dataset curation (#21, #19) — Phase 6.
- Heatmap / polyline layer types and admin-2 pre-computed aggregates — Phase 7 or Tier 4.
- Map drawing tools (#K) — Tier 3, later.
- Building-level / 3D (#25) — Tier 4.
Scope¶
4.1 — MapLibre migration (existing components)¶
Context. Replace Leaflet in MapView.tsx, ResultMap.tsx,
AssetDetailPanel.tsx (map interactions), and HazardUploadModal.tsx
(preview thumbnail). Migration order per ADR-025. API client, GeoJSON data,
hooks, auth, wizard, and panels are unaffected.
Acceptance criteria.
- [ ] maplibre-gl pinned in climate-lama-ui/package.json; Leaflet removed
from runtime dependencies (keep as dev-only if any test still needs it,
otherwise drop entirely).
- [ ] Each component rewritten against MapLibre's API; style spec for
current point/polygon rendering committed under
climate-lama-ui/src/map/styles/.
- [ ] Base tile source configurable via env var; default to a self-hosted
proxy or MapTiler with a pinned free-tier key.
- [ ] Existing Vitest suites green after migration.
- [ ] Visual parity: current results render the same markers/overlays as
before (verify manually during /build).
Cross-stack: UI only.
Linked ADR: ADR-025.
Suggested /analyze tier: 4 (Opus · high) — cross-component rewrite,
state management nuance (MapLibre vs Leaflet lifecycle differs), risk of
regressing existing flows.
4.2 — LayerSpec model (backbone)¶
Context. Vision #4. A uniform descriptor for what the UI should render
per hazard/exposure: {type: raster|vector_polygon|vector_point|heatmap|line,
source_url, style, interactivity_config}. Lets the backend add rendering
modes without the UI branching per hazard.
Acceptance criteria.
- [ ] Pydantic model LayerSpec in
src/climate_lama/models/layer_spec.py.
- [ ] GET /v1/hazards/{id}/layers and GET /v1/exposures/{id}/layers
return one or more LayerSpec entries.
- [ ] Default specs: river flood → raster; TC → vector_point + vector_line
(track); WF → raster; WS → raster; exposures → vector_point.
- [ ] UI consumes the specs and renders accordingly; no UI branch reads
hazard type directly (the spec is the abstraction).
- [ ] Unit tests: every seeded hazard + exposure returns at least one valid
spec.
Cross-stack: backbone (model, endpoints) + UI (consumer).
Linked ADR: none.
Suggested /analyze tier: 3 (Sonnet · high).
4.3 — Admin-boundary data ingest (GADM)¶
Context. Vision #5 precondition. Zoom-aware aggregation needs authoritative admin-0/1/2 boundaries. GADM is the de-facto standard; licensing is research-use with attribution.
Acceptance criteria.
- [ ] New table admin_boundaries(id UUID, iso3 TEXT, admin_level INT,
name TEXT, geom GEOMETRY(MultiPolygon, 4326), parent_id UUID, source
TEXT, version TEXT) with spatial index on geom.
- [ ] Alembic migration + one-time load script
scripts/load_admin_boundaries.py pulling GADM v4.x shapefiles and
inserting via COPY or bulk upsert.
- [ ] Admin-0 + admin-1 mandatory; admin-2 is optional (gated by env var
because the GADM admin-2 dump is large).
- [ ] Attribution metadata in the table exposes through the
open-data attribution surface (Phase 3 #3.10).
- [ ] Sensitivity note in the docs site explaining the GADM choice and
disputed borders (Crimea, Kashmir, Western Sahara).
Cross-stack: backbone (schema + loader + docs).
Linked ADR: none directly; consider a short ADR documenting the GADM
choice if the /build agent finds non-obvious trade-offs.
Suggested /analyze tier: 4 (Opus · high) — schema/migration change
hits the minimum-tier bump from /analyze rubric; loader script non-trivial.
4.4 — On-the-fly zoom aggregation (#5 lite)¶
Context. Vision #5 "P3 lite": skip pre-computation, compute aggregates
per request via PostGIS ST_Contains + GROUP BY. Scales to ~1M points at
acceptable latencies.
Acceptance criteria.
- [ ] New endpoint GET /v1/results/{id}/geojson?admin_level={0|1|2}&bbox=....
When admin_level is provided, returns aggregated features (polygon per
admin unit with summed EAD/AAI + point count). When omitted, returns raw
points (existing behavior preserved, viewport-bounded via bbox).
- [ ] Repository query uses the boundary table + PostGIS spatial join;
indexes verified.
- [ ] Benchmark added: 100k-point fixture aggregated in <2s at admin-1.
- [ ] UI map zoom handler swaps admin level at breakpoints (0-3: admin-0;
4-7: admin-1; 8-12: admin-2; 13+: raw).
- [ ] Legend updates per aggregation level.
Cross-stack: backbone (query + endpoint) + UI (zoom handler, legend).
Linked ADR: none.
Suggested /analyze tier: 4 (Opus · high) — cross-module, spatial SQL
perf-sensitive.
4.5 — Tile server for MVT (martin)¶
Context. Vision #5 "P4 full" pulled into Phase 3 per R4 B1/#27b. Serves MVT tiles from PostGIS directly, scaling to 100M points. Required for the 30+ FPS / <500ms first-tile acceptance targets.
Acceptance criteria.
- [ ] martin container added to docker-compose.yml (and the production
override), configured to read from the backbone Postgres.
- [ ] SQL functions in a new migration exposing aggregated impact features
per zoom level — martin auto-discovers them.
- [ ] UI MapLibre source configured with the MVT endpoint for impact
aggregates; zoom handler simplified (martin serves the right resolution
per tile).
- [ ] Benchmark: first tile ready <500ms at z4-6 on a 10M-point fixture.
- [ ] Docs site page architecture/tile-serving.md documents the martin
setup, SQL functions, and local development story.
Cross-stack: infra (martin container) + backbone (SQL functions +
migration) + UI (MVT source).
Linked ADR: consider a short ADR "martin for MVT tile serving" during
/build if non-obvious trade-offs surface; otherwise no ADR.
Suggested /analyze tier: 5 (Opus · extra high) — new subsystem (tile
server) + perf-sensitive + PostGIS SQL function design. Load-bearing for
the whole phase.
4.6 — COG raster pipeline + titiler¶
Context. Vision #27b raster side. Hazard rasters (river flood, WF, WS)
publish as Cloud-Optimized GeoTIFF; titiler serves tiles; MapLibre renders
via raster source. Avoids GeoJSON-for-rasters anti-pattern.
Acceptance criteria.
- [ ] Worker Celery task convert_to_cog(hazard_dataset_id) that reads a
raster from DO Spaces raw/, converts to COG (via rio-cogeo or
gdal_translate), writes back to processed/ and registers the path in
the catalog.
- [ ] titiler container added to compose (or embedded library — decide
during /build; container is the simpler path).
- [ ] LayerSpec raster type points UI at titiler tile URLs for the dataset.
- [ ] Conversion auto-triggers on ingest for new raster hazards (Phase 6
pipeline will call this; scaffolded here as a standalone task).
- [ ] Docs site page architecture/raster-serving.md.
Cross-stack: worker (Celery task) + infra (titiler) + backbone
(registration) + UI (raster layer rendering).
Linked ADR: none.
Suggested /analyze tier: 4 (Opus · high) — multi-component, new
worker path.
4.7 — Viewport / bbox loading everywhere¶
Context. Vision #27b viewport-based loading. Never return the whole
dataset unless explicitly requested. Apply the bbox query param to every
dataset-fetch endpoint. Note: partial bbox support already exists in
exposure_repository.list_in_bbox and the hazard ingest endpoint; this
item extends that coverage uniformly rather than adding from scratch.
Acceptance criteria.
- [ ] bbox query param (format minLon,minLat,maxLon,maxLat) accepted on
GET /v1/results/{id}/geojson, GET /v1/hazards/{id}/features, and
GET /v1/exposures/{id}/features.
- [ ] Repository queries apply ST_Intersects with the bbox; absence of
bbox = return all (preserves existing demo scripts).
- [ ] UI map components always pass the current viewport bbox on fetch.
- [ ] Integration test: 1M-point fixture + bbox covering 1% of area
returns <100ms.
Cross-stack: backbone (repos + endpoints) + UI (fetch logic).
Linked ADR: none.
Suggested /analyze tier: 3 (Sonnet · high).
4.8 — Tile caching to DO Spaces¶
Context. Vision #27b caching. Tiles computed on first request are
cached in DO Spaces per ADR-029 layout (tiles/{dataset-id}/{z}/{x}/{y}.pbf
or .tif); invalidated on dataset version bump.
Acceptance criteria.
- [ ] Cache layer in front of martin (for MVT) and titiler (for COG) —
read-through cache: check DO Spaces first, fall through to live
generation, write back.
- [ ] Cache key derivation follows ADR-029 (includes {dataset-id} and
dataset version).
- [ ] Invalidation: a hook on POST /v1/datasets/{id}/invalidate-tiles
(admin-only, locked down via RBAC — admin panel is Phase 6, but the
endpoint ships here) truncates the tiles/{dataset-id}/ prefix.
- [ ] Observability: cache hit / miss counters on the Prometheus endpoint
(Phase 3 #3.9 instrumentation).
Cross-stack: backbone (cache middleware or reverse-proxy layer) + infra
(Nginx or service configuration).
Linked ADR: references ADR-029 (layout).
Suggested /analyze tier: 4 (Opus · high) — cache invalidation is
one of the two hard problems; blast radius is wide.
4.9 — Map layer toggle (#26)¶
Context. Vision #26 Tier 2. Three-layer toggle (Exposure / Hazard /
Impact) with legend, opacity, on/off. Leverages LayerSpec from 4.2.
Acceptance criteria.
- [ ] Layer panel component in the UI with three toggles; each toggle binds
to a LayerSpec source.
- [ ] Opacity slider per layer (0-100%).
- [ ] Legend per layer, styled per layer type (chloropleth ramp for
raster, point symbology for vectors).
- [ ] State persists within a scenario view; resets on scenario change.
- [ ] Vitest: layer panel renders, toggles fire the right MapLibre actions.
Cross-stack: UI.
Linked ADR: none.
Suggested /analyze tier: 3 (Sonnet · high).
4.10 — Time horizon discrete slider (#9)¶
Context. Vision #9 Tier 2. Replace hardcoded {present, 2030, 2050,
2100} with a discrete slider locked to dataset-supported years. Hazard
metadata exposes supported_years: [...].
Acceptance criteria.
- [ ] Hazard datasets gain supported_years INT[] (populated at ingest;
existing rows backfilled with the current hardcoded set).
- [ ] API responses include supported_years.
- [ ] UI replaces the dropdown with a discrete slider (double-handle for
ref_year + future_year); ticks only land on supported years.
- [ ] When the slider changes, refetch and show coverage summary.
Cross-stack: backbone (schema + API) + UI (slider component).
Linked ADR: none.
Suggested /analyze tier: 3 (Sonnet · high).
4.11 — Rendering performance acceptance tests (#27b)¶
Context. Vision #27b acceptance target. Verify the phase delivered the promised performance envelope: 30+ FPS pan/zoom on 10M-point exposure, first tile <500ms at z4-6, tab memory <500MB.
Acceptance criteria.
- [ ] Playwright scenario under tests/e2e/performance/ that loads a
10M-point fixture and measures frame rate over a scripted pan+zoom
sequence; fails if <30 FPS sustained.
- [ ] Tile-latency test: wall-clock from scenario load to first tile
visible, aggregated over 5 runs; fails if p95 >500ms at z4-6.
- [ ] Memory test: long-running session asserts tab RSS stays <500MB for
5 minutes of interaction.
- [ ] Results logged to CI artifacts; not gating on merge initially (too
flaky for hard gates) but alerting on regressions.
- [ ] docs/architecture/performance-budgets.md documents the budgets and
links the tests.
Cross-stack: UI (Playwright) + docs.
Linked ADR: none.
Suggested /analyze tier: 4 (Opus · high) — perf work, measurement
sensitivity, flake management.
4.12 — UI redesign direction (wireframes + layout commitment)¶
Context. Vision #3 Tier 2. Pick 2-3 inspirations, decide map-as-canvas vs document-mode, produce 2-3 wireframes, commit in an ADR. Do the design work now so later phases implement against a fixed target; no code yet.
Acceptance criteria.
- [x] ADR-033 "UI layout commitment" drafted: chosen inspirations (e.g.,
Mapbox Atlas, Kepler.gl, Probable Futures), chosen layout paradigm
(map-as-canvas vs document-mode with rationale), component taxonomy.
- [x] 2-3 wireframes checked in under docs/design/wireframes/ (PNG or
Figma export links — /build agent can use whichever is practical).
- [x] Redesign decisions explicitly call out: what stays (Wizard,
ConfigPanel, ResultMap, AssetDetailPanel are re-skinned, not
re-architected).
- [x] Phase 5/6 work items can be planned against the committed layout.
- [x] **Phase**: 4 header line on ADR-033.
Cross-stack: docs/design only. No code.
Linked ADR: authors ADR-033 (originally ADR-031; renumbered — see ADR-033 header note).
Suggested /analyze tier: 3 (Sonnet · high) — design judgment, not
code; the agent can reason about the inspirations and frame the ADR.
ADRs authored in this phase¶
| ID | Title | Owner work item |
|---|---|---|
| ADR-033 | UI layout commitment (map-as-canvas vs document) | 4.12 (renumbered from ADR-031; see ADR-033 header note) |
Additional short ADRs may be authored opportunistically during /build
(4.5 martin choice, 4.6 COG pipeline, 4.3 GADM trade-offs). Record them
with **Phase**: 4 when they land.
Exit criteria¶
- All 12 work items closed.
- 10M-point fixture renders at 30+ FPS on a reference machine.
- First tile <500ms at z4-6 in CI-reproducible conditions.
- Zero Leaflet imports in the UI production bundle.
- MVT tile serving green in production-parity compose; tile cache round-trip verified end-to-end.
- UI redesign ADR + wireframes merged; subsequent phases reference them.
Risks¶
- 4.5 martin and 4.6 titiler are new subsystems with their own operational profile. If either turns out to be flaky, descope to the 4.4 on-the-fly fallback and file a Phase 6 follow-up to land the full tile server. Keep the option open in 4.5 scope.
- 4.11 performance tests are inherently flaky on CI hardware. Do not gate merges on them until stable for two weeks.
- 4.1 migration risks visual regressions the existing test suite
doesn't catch. Require
/build --reviewrather than auto-merge for this item. - 4.12 redesign ADR is a judgment call the
/buildagent can only partially make. Expect user review on the wireframes before merge.