Skip to content

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, Scenario model in place.
  • DO Spaces bucket provisioned with tiles/ prefix reserved.
  • PostGIS spatial indexes on exposure.geometry verified.

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 --review rather than auto-merge for this item.
  • 4.12 redesign ADR is a judgment call the /build agent can only partially make. Expect user review on the wireframes before merge.