Maps β
The app supports exactly three map providers and no others. All maps render through a single engine β MapLibre GL (maplibre_gl; MapLibre Native on Android/iOS, MapLibre GL JS on web) via the shared AppMap widget. The providers are modelled as switchable layers composited into one MapLibre style document, driven by one shared, persisted controller so the choice is consistent across every map screen.
| Provider | Role | Source | Credentials |
|---|---|---|---|
| PACI | Default basemap | ArcGIS PACIKFBasemap VectorTileServer (vector, rendered natively by MapLibre on all platforms) | None (public) |
| Basemap option (roadmap + satellite) | Google Map Tiles API via the google-tiles edge proxy (?mapType=satellite for imagery) | GOOGLE_MAPS_API_KEY (server-side) | |
| Baladia | Overlay (parcels, toggle + opacity) and basemap (multi-year satellite) | Kuwait Municipality ParcelLocation_MKM MapServer + giss Basemap services (export per tile) | None (public) |
OpenStreetMap, Mapbox, Leaflet, etc. are intentionally absent. The old OSM raster fallback was removed, and the previous
flutter_mapengine is retired (see βLegacy engineβ below).
Where it lives β
lib/shared/widgets/app_map.dartβAppMap, the one map widget every screen uses: markers (rasterised icon symbols), data-driven fill polygons, GeoJSON clustering (bubbles + k-notation counts), tap callbacks, camera control, and overlay re-application after style switches.lib/shared/maps/maplibre_style_builder.dartβbuildMapLibreStyle(state, paciRootJson): pure function compositing the selected basemap + Baladia overlay into a MapLibre GL style JSON. This is the cross-platform reference implementation β the web app ports it 1:1 (see βCross-platform parityβ).lib/shared/widgets/app_map_view.dartβAppMapView, the one overlay host every full map screen composes over itsAppMap. Places the chrome slots (layers switcher, bookmarks, annotations, area-alerts bell, control cluster, legend) at standard offsets β so the old top-start collision between the annotations panel and the alerts bell can't recur (the bell auto-stacks below the annotations chip) β and owns immersive mode (see "Chrome & immersive mode" below). It is a region widget, not aScaffold; the screen keeps its own app bar / async handling. Also exportsmapImmersiveToggleAction(ref, l)for the app-bar enter-immersive button.lib/shared/maps/map_spatial_tools.dartβ the shared spatial-tools chrome (openMapSpatialToolsSheet,MapSpatialDrawBar,MapZoneStatsSheet,formatMapMeters), parametrized by an i18nkeyPrefixso the property and valuation maps share one implementation across their own translation namespaces (was duplicated per-screen).lib/shared/maps/map_camera.dartβ sharedfitCameraToLatLngs/recenterMapOn(the fit-to-pins bounds math, previously copied into each map screen).lib/shared/maps/app_basemap.dartβAppBasemap { paci, google, googleSatellite, baladiaSatellite },MapLayersState, and the persistedmapLayersControllerProvider(SharedPreferences keysmaps.basemap,maps.baladiaOverlay,maps.baladiaOpacity,maps.baladiaImageryEpoch,maps.immersive).lib/shared/maps/paci_basemap_config.dartβ provider URLs, Kuwait bounds/centre,isValidKuwaitCoordinate,paciUrlViaProxy(Kuwait-proxy rewriting for thepaci/baladia/gissorigins), Baladia imagery epochs (2018/2021/2022/2024).lib/shared/maps/paci_style_loader.dartβ fetches and caches the PACIroot.json(Mapbox GL Style Spec v8) through the proxy.AppMapcallsloadPaciKfBasemapRootJson(), which serves an in-memory session cache, then a fresh-enough SharedPreferences copy, and only hits the network on a miss β so repeat map opens don't re-fetch.warmPaciBasemapStyle()primes it at app boot (see "Caching" below).lib/shared/widgets/map_basemap_switcher.dartβ the Layers popover: basemap choice, Baladia satellite year chips, parcels overlay toggle + opacity slider, availability hints.lib/shared/maps/parcel_info_sheet.dart+lib/data/repositories/paci_geocode_repository.dartβ parcel identify (giss rich attributes + polygon highlight + building permits) and PACI search / reverse-geocode (via thepaci-proxyedge function).supabase/functions/baladia-tiles/β the tile shim both Baladia raster layers (parcels overlay + satellite epochs) route through, reprojecting each tile into the PACI basemap's own parcel fabric before serving it. See "Coordinate systems" below.lib/shared/maps/map_frame_correction.dartβMapFrameCorrections, the Flutter-side counterpart of the shim's correction grid, used byPaciGeocodeRepository's point-identify methods.tool/build_map_frame_corrections.pyβ regeneratescontracts/map_frame_corrections.json(mirrored automatically into the edge function + Flutter assets).web/index.htmlβ pinsmaplibre-gl@5.24.0(GL JS) and registers the RTL text plugin for Arabic labels. The pinned version must match what themaplibre_glplugin expects β a mismatch throws on dispose.
Map surfaces (all on AppMap): brokerage property map (property_heatmap_screen.dart), public price map, residents map (admin), add-property location picker, property/resident detail location cards, parcel location sheet, and the dev-only main_map_preview.dart entrypoint.
Chrome & immersive mode β
The four full map screens β brokerage property map, valuation map, residents map, public price map β compose their map region through AppMapView (app_map_view.dart), which standardizes where the overlay chrome sits and owns a shared immersive ("hide all the controls") mode.
- Immersive is the persisted, app-wide
mapLayersControllerProvider.immersiveflag (pref keymaps.immersive), so the choice is remembered across screens and sessions. When on,AppMapViewhides every overlay control and leaves a single restore button (Icons.fullscreen_exit, top-end); each screen also hides its app bar (appBar: (immersive && async.hasValue) ? null : β¦) and any above/below-map chrome (if (!immersive) β¦), so the map goes edge-to-edge. The enter-immersive app-bar button ismapImmersiveToggleAction(ref, l). The app bar is deliberately kept during loading/error (its retry + the immersive exit path) β hence thehasValueguard. - The screen passes its
AppMap(built once) toAppMapView.map; the app bar is toggled in a separateScaffoldslot, so entering/leaving immersive never remounts the map β the MapLibre controller, camera, and layers survive. - Display-only maps (detail-card previews, the add-property / parcel-location picker sheets) do not use
AppMapViewor immersive mode β they carry no overlay chrome (or just the sharedMapBasemapSwitcher), and immersive has no meaning inside a card/sheet.
Caching (making maps load faster) β
- PACI
root.jsoncache (free, shipped).AppMapused to re-fetch the (large) PACI style document on every map-screen mount.paci_style_loader.dartnow serves it from an in-memory session cache, then a fresh-enough SharedPreferences copy (maps.paciRootJson+β¦At, 24 h TTL), hitting the proxy only on a genuine miss.warmPaciBasemapStyle()is fired fire-and-forget at app boot (main.dart) so the first map opens with the style already resident. - Cloudflare edge tile cache (free, verify). The clientβproxy hop should be edge-cached by a Cloudflare Cache Rule on
paci-kw.<domain>(host-match, Edge TTL ~7 d) β confirmCf-Cache-Status: HITon a repeat tile. Setup + the two gotchas that silently keep itDYNAMICare indocs/infra/paci-kw-proxy.md. - Tile
Cache-Control(free).baladia-tilessets it already (max-age=3600parcels,max-age=86400, immutableimagery). The canonicalgoogle-tilessource issupabase/functions/google-tiles/in this repo. - Self-host the PACI basemap as PMTiles (paid, follow-up β not built). A one-time extraction of the PACI KF vector tileset (Kuwait bbox, z0β16) into a
.pmtilesarchive on Cloudflare R2, served via a Worker (HTTP range) or the pmtiles protocol (sprite/glyphs hosted too), would drop the Kuwait-proxy round trip for the basemap entirely and give global-edge latency at pennies of storage. Caveats: confirm PACI redistribution/attribution is acceptable, and re-extract periodically for freshness. Lighter paid options: Cloudflare Tiered Cache / Argo Smart Routing; a managed vector host (MapTiler/Mapbox) is rejected as primary because it loses the PACI Arabic parcel basemap.
Web note (PACI via the Kuwait proxy) β
MapLibre GL JS renders the real PACI vector basemap on web when PACI_PROXY_BASE is set at build time (the proxy adds permissive CORS; PACI direct is CORS-blocked in browsers β F-007). Without the proxy, PACI cannot load on web and the map falls back to Google raster tiles so it is never blank. If PACI fails on web with the proxy configured, check the browser console for a root.json fetch failure β usually invalid CORS headers on the proxy (duplicate Access-Control-Allow-Origin, or Allow-Credentials: true paired with *). Reload nginx with the snippet in docs/infra/paci-kw-proxy.cloud-init.sh and remove any Cloudflare Transform Rule that adds a second ACAO header.
PACI Kuwait proxy (geo-bypass) β
PACI + Baladia geo-block non-Kuwait IPs. A Kuwaiti-VPS reverse proxy relays the requests from a KW IP and adds permissive CORS. It is opt-in: set PACI_PROXY_BASE=https://paci-kw.<domain>/<secret> and paciUrlViaProxy(...) rewrites every PACI (/paci), Baladia gismaps (/baladia), and Baladia giss (/giss) URL through it; empty (default) keeps the direct upstream URLs. Provisioning, the nginx cloud-init, Cloudflare/HTTPS, and app + edge-fn wiring are documented in docs/infra/paci-kw-proxy.md. If the basemap renders blank while pins still show, see the paci-map-blank-recovery runbook (nginx on the VPS wedges into a login-page redirect; fix is systemctl restart nginx).
Google basemap β one-time setup (prerequisite) β
The embedded Google basemap needs a Google Maps Platform key; none ships in the repo. It is provisioned via Supabase secrets (verified serving as of 2026-07-14):
supabase secrets set GOOGLE_MAPS_API_KEY=<key>
supabase functions deploy google-tilesThe function lives at supabase/functions/google-tiles/ in this repository. The client points at ${SUPABASE_URL}/functions/v1/google-tiles/{z}/{x}/{y} (?mapType=satellite for imagery).
Baladia has two ArcGIS deployments (gismaps vs giss) β
There are two Kuwait Municipality GIS hosts, and they are different servers:
| Host | Version | Path root | What we use it for |
|---|---|---|---|
gismaps.baladia.gov.kw | ArcGIS 10.4 | /arcgis/rest/services | The parcels overlay (KA/ParcelLocation_MKM, raster export) and the point-identify fallback. |
giss.baladia.gov.kw | ArcGIS Enterprise 10.81 | /server/rest/services | The richer deployment: parcels + land-use, building licences, multi-year satellite (the Baladia satellite basemap + year selector). |
The full service-by-service audit (all 23 folders / ~50 services + security findings) is in baladia-giss-audit.md.
giss is public (no token) for everything real-estate-relevant; only 8 folders are token-gated. We consume it read-only (some giss FeatureServers wrongly advertise anonymous write β never exercise those). Both hosts geo-block non-Kuwait IPs intermittently, so both route through the Kuwait proxy.
Coordinate systems. gismaps folder services use a custom Kuwait grid KTM_KM (KUDAMS datum, no EPSG WKID) β we avoid client-side reprojection by preferring the EPSG:3857 root composite services (KM_MapViewerNew_MIL1, KM_Paci_MIL1) which honour outSR=4326, and by using dynamic /export with bboxSR=3857 for imagery (the server reprojects the KTM_KM caches). Baladia does expose parcel polygons + MeasuredArea (both hosts). See "Three frames, one correction grid" below for how the two hosts' small remaining disagreement β and their disagreement with the PACI basemap itself β is reconciled before anything reaches the screen.
Three frames, one correction grid (PACI β giss β gismaps) β
Measured 2026-07-19 (see the map-frame-corrections design session): Baladia's gismaps /export reprojects its native KTM_KM grid to EPSG:3857 with a null datum transform, so every gismaps coordinate is displaced from true WGS84 by a constant, country-wide offsete = (-2.622 m, +5.797 m) (EPSG:3857 x/y β measured identical to 3 decimal places across 8 areas spanning Kuwait). giss is the true-WGS84 reference frame (outSR=4326 honoured correctly).
Separately, and independently of e: the PACI KF basemap's own parcel fabric is not in either Baladia frame. Ground-truthed 2026-07-20 against 46 paci_parcels address points across 16 areas country-wide: PACI sits a near-constant ~(-28, +1) m from the gismaps frame (β1.4 residential parcel widths), with only a few metres of local variation on top. Since PACI address points (paci_parcels, i.e. every pin and location the app shows) live in the PACI basemap's own frame, that is the frame everything Baladia-sourced must be corrected into:
giss = true EPSG:3857
gismaps = giss + e (country-wide constant)
paci = gismaps + g(cell) (per-z15-cell correction, "g";
β default_g everywhere, Β±local)History / hazard. The 2026-07-19 exploration believed the PACI offset was a Β±2β9 m block-to-block "wobble". That was a one-parcel-pitch nearest-neighbour alias: at a true offset of ~28 m on a ~20 m uniform parcel grid, NN matching locks onto the neighbouring parcel and reports the small residual as the offset β and visually, an uncorrected overlay lines up almost-perfectly with the wrong neighbour, which reads as "a few metres of doubling". The aliased v1 grid under-corrected Al-Rehab by a full parcel width (owner-reported bug, 2026-07-20). Never measure this fabric with plain nearest-neighbour matching.
g(cell) is measured by tool/build_map_frame_corrections.py (estimator v2) for every ~1.2 km z15 web-mercator cell containing paci_parcels data (candidate list: tool/data/map_frame_cell_seed.json, a committed snapshot β the script's docstring has the regeneration SQL). Two estimators, both immune to the aliasing above:
- Plot-number identity matching (primary) β the PACI vector tiles carry a
Parcel Number GREEN 2klabel layer; each label is joined to its containingParcelspolygon, and matched to the same-numberedgismapsparcel (unique within 120 m). Same number = same parcel, at any offset. - Hough translation voting (fallback) β all pairwise centroid deltas vote into 1 m bins; every peak is refined and the winner must beat the runner-up 2:1 in matched parcels (a uniform lattice whose modes tie is rejected, not silently aliased) and agree with whatever identity pairs exist.
A cell needs β₯20 pairs with β€2.5 m MAD; anything weaker stays absent and consumers apply the grid's default_g (the country-wide constant β right to a few metres everywhere, unlike the (0,0) or regional-median fallbacks v1 used, which were catastrophically wrong locally). The committed ground truth (tool/data/map_frame_sentinel_truth.json, DB address points vs exact-ParcelNo gismaps parcels) gates every full regeneration: β₯80% of truth parcels within 6 m plus an exact Al-Rehab check, or the run refuses to write the contract. Deltas are true EPSG:3857 projected metres (not geographic ground-distance metres β the two differ by the local Mercator scale factor, ~14% at Kuwait's latitude; conflating them was a real bug caught by test/shared/maps/map_frame_correction_test.dart's exact-fixture check).
The output, contracts/map_frame_corrections.json ({version, e_giss_to_gismaps_3857_m, default_g_3857_m, cell_zoom, cells: {"15/x/y": [dx, dy]}}), is the single source of truth, and the tool mirrors it automatically on every run into its two consumers β never hand-edit those copies:
supabase/functions/baladia-tiles/corrections.tsβ a generated TypeScript module the edge function imports. Deliberately code, not a JSON asset: the 2026-07-20 CLI deploy silently dropped a static JSON from the bundle and boot-crashed the function in production (every tile request 500'd, while the deploy workflow stayed green β it now smoke-tests the live function and drift-checks this file against the contract).assets/data/map_frame_corrections.json(Flutter asset, listed inpubspec.yaml; loaded byMapFrameCorrections, which also refreshes it once per session from the edge function's/correctionsroute so grid re-measurements reach installed builds without a store release).
Where the correction is applied:
- Raster overlay + satellite basemap β
supabase/functions/baladia-tiles/(routes:/parcels/{z}/{x}/{y},/imagery/{epoch}/{z}/{x}/{y}, plus/correctionsserving the grid itself to app clients). For a tile requested in the PACI (target) frame, the upstream ArcGISexportbbox is shifted by-g(cell)(parcels, source=gismaps) or-(e + g(cell))(imagery, source=giss) before fetching β i.e. "go get the server-frame content that will land at this target-frame screen position."AppMapnever talks to ArcGIS directly for these two layers;baladiaRasterTileTemplate()/baladiaSatelliteRasterTileTemplate()inmaplibre_style_builder.dartjust point MapLibre at the shim with a conventional{z}/{x}/{y}XYZ template (not{bbox-epsg-3857}β the shim computes the bbox itself so it can apply the correction first). - Parcel identify (tap-to-highlight) β
PaciGeocodeRepositoryinlib/data/repositories/paci_geocode_repository.dart, viaMapFrameCorrections(lib/shared/maps/map_frame_correction.dart, which loads the same bundled JSON): the tapped point (PACI frame) is converted togiss/gismapsframe before querying, and any returned geometry (polygon rings, a searched-parcel centroid) is converted back to PACI frame before it reaches the UI β otherwise the highlight sits visibly off the parcel the user tapped, worst near a boundary where it can resolve the wrong parcel entirely. - Google tiles and the PACI basemap itself are untouched β Google is already true WGS84, and PACI is the target frame by definition.
Regenerating the grid (PACI re-cuts its basemap tiles occasionally, which stales the grid): pip install mapbox-vector-tile, then python tool/build_map_frame_corrections.py (reads PACI_PROXY_BASE from env/dev.json; takes a few seconds once its response cache in tool/.cache/map_frame_corrections/ β gitignored β is warm from a prior run). It writes a QA report (contracts/map_frame_corrections_qa.md, committed alongside the grid) with coverage stats and the worst-measured cells; check it before committing a refreshed grid.
Client ambient tile cache. maplibre_gl persists an on-disk tile cache (mbgl-offline.db, keyed by request URL, independent of HTTP Cache-Control) that the app never invalidated β a tile hit during any past outage (e.g. the 2026-07-20 baladia-tiles boot-dead window, before the v2 fix) can cache a blank/failed response forever, even after the server is fixed. Confirmed on-device: a valid tile served fine over curl but never rendered in-app at that exact z/x/y. Fixed by lib/shared/maps/map_tile_cache_epoch.dart β clearStaleMapTileCacheIfNeeded(), a one-time clearAmbientCache() gated by a persisted epoch, called from AppMapState.initState(). Bump _epoch there if a future incident needs every device to clear its cache again.
What's wired vs. planned β
- Wired β richer parcel identify.
PaciGeocodeRepository.parcelAtPointRichqueries the gissKM_MapViewerNew_MIL1/3parcel layer first β land-use (Subtype/EnglishSubtype),CadastalPlanNo,GISNo, English neighbourhood names, and the parcel polygon (outlined viahighlightedParcelProvider) β falling back togismapsparcelAtPoint. - Wired β building permits (live, per-parcel). After a parcel resolves, the sheet queries
POC/IssuedBuildingLicenses/0(exactGISNomatch, else spatial Β±35 m) and shows recent permits. SeelicensesForGisNo/licensesNearPoint. Licence Ψ±ΩΩ Ψ§ΩΨΉΩΨ§Ψ±Ω == parcelGISNo. - Wired β Baladia satellite basemap.
AppBasemap.baladiaSatelliterenders the municipality's imagery via gissexportwith a persisted year selector (2018/2021/2022/2024) for before/after change detection. - Built, pending activation β bulk building-licence ingestion.
public.baladia_building_licenses+ thesync-baladia-building-licensesedge function + cron trigger. Join to properties byproperty_no(Ψ§ΩΨ±ΩΩ Ψ§ΩΨΉΩΨ§Ψ±Ω), not block/parcel (block formats differ between services). - Wired β parcel geocoder fallback.
searchParcel(area/block/plot β centroid): staffpaci_parcelstable first, then the public giss parcel service (parcelCentroidGiss, area-weighted centroid) so public callers and missing parcels still geocode.
PACI search / reverse-geocode β
The add-property picker searches PACI (civil number / address) to drop a pin and reverse-geocodes a tapped point to its Arabic address, via the paci-proxy edge function (geocode, reverseGeocode, search actions).
Cross-platform parity (web β mobile) β
Flutter MapLibre is canonical on Web, iOS, and Android. The frozen React MapLibre implementation remains audit and rollback evidence only during the compatibility window. Shared map data (annotations, area alerts, bookmarks) lives in Supabase, so every Flutter surface observes the same records and permissions.
Legacy engine (removed) β
The flutter_map-era engine β flutter_map, vector_map_tiles, flutter_map_marker_cluster, plus lib/shared/widgets/paci_flutter_map_layers.dart and lib/shared/maps/basemap_layer_builder.dart β was deleted in the maps-unification Phase 5 cleanup (2026-07-15). Every map surface has rendered through MapLibre GL (AppMap) since the migration shipped; the availability probes/notifiers those files hosted (probeBaladiaSatellite, probeBaladiaParcels) were relocated to basemap_availability.dart in an earlier phase. See MAPS_MAPLIBRE_MIGRATION.md for the migration history.
