Skip to content

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.

ProviderRoleSourceCredentials
PACIDefault basemapArcGIS PACIKFBasemap VectorTileServer (vector, rendered natively by MapLibre on all platforms)None (public)
GoogleBasemap option (roadmap + satellite)Google Map Tiles API via the google-tiles edge proxy (?mapType=satellite for imagery)GOOGLE_MAPS_API_KEY (server-side)
BaladiaOverlay (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_map engine 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 its AppMap. 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 a Scaffold; the screen keeps its own app bar / async handling. Also exports mapImmersiveToggleAction(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 i18n keyPrefix so the property and valuation maps share one implementation across their own translation namespaces (was duplicated per-screen).
  • lib/shared/maps/map_camera.dart β€” shared fitCameraToLatLngs / 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 persisted mapLayersControllerProvider (SharedPreferences keys maps.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 the paci / baladia / giss origins), Baladia imagery epochs (2018/2021/2022/2024).
  • lib/shared/maps/paci_style_loader.dart β€” fetches and caches the PACI root.json (Mapbox GL Style Spec v8) through the proxy. AppMap calls loadPaciKfBasemapRootJson(), 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 the paci-proxy edge 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 by PaciGeocodeRepository's point-identify methods.
  • tool/build_map_frame_corrections.py β€” regenerates contracts/map_frame_corrections.json (mirrored automatically into the edge function + Flutter assets).
  • web/index.html β€” pins maplibre-gl@5.24.0 (GL JS) and registers the RTL text plugin for Arabic labels. The pinned version must match what the maplibre_gl plugin 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.immersive flag (pref key maps.immersive), so the choice is remembered across screens and sessions. When on, AppMapView hides 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 is mapImmersiveToggleAction(ref, l). The app bar is deliberately kept during loading/error (its retry + the immersive exit path) β€” hence the hasValue guard.
  • The screen passes its AppMap (built once) to AppMapView.map; the app bar is toggled in a separate Scaffold slot, 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 AppMapView or immersive mode β€” they carry no overlay chrome (or just the shared MapBasemapSwitcher), and immersive has no meaning inside a card/sheet.

Caching (making maps load faster) ​

  • PACI root.json cache (free, shipped). AppMap used to re-fetch the (large) PACI style document on every map-screen mount. paci_style_loader.dart now 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) β€” confirm Cf-Cache-Status: HIT on a repeat tile. Setup + the two gotchas that silently keep it DYNAMIC are in docs/infra/paci-kw-proxy.md.
  • Tile Cache-Control (free). baladia-tiles sets it already (max-age=3600 parcels, max-age=86400, immutable imagery). The canonical google-tiles source is supabase/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 .pmtiles archive 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):

sh
supabase secrets set GOOGLE_MAPS_API_KEY=<key>
supabase functions deploy google-tiles

The 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:

HostVersionPath rootWhat we use it for
gismaps.baladia.gov.kwArcGIS 10.4/arcgis/rest/servicesThe parcels overlay (KA/ParcelLocation_MKM, raster export) and the point-identify fallback.
giss.baladia.gov.kwArcGIS Enterprise 10.81/server/rest/servicesThe 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:

  1. Plot-number identity matching (primary) β€” the PACI vector tiles carry a Parcel Number GREEN 2k label layer; each label is joined to its containing Parcels polygon, and matched to the same-numbered gismaps parcel (unique within 120 m). Same number = same parcel, at any offset.
  2. 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 in pubspec.yaml; loaded by MapFrameCorrections, which also refreshes it once per session from the edge function's /corrections route 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 /corrections serving the grid itself to app clients). For a tile requested in the PACI (target) frame, the upstream ArcGIS export bbox 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." AppMap never talks to ArcGIS directly for these two layers; baladiaRasterTileTemplate() / baladiaSatelliteRasterTileTemplate() in maplibre_style_builder.dart just 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) β€” PaciGeocodeRepository in lib/data/repositories/paci_geocode_repository.dart, via MapFrameCorrections (lib/shared/maps/map_frame_correction.dart, which loads the same bundled JSON): the tapped point (PACI frame) is converted to giss/gismaps frame 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.parcelAtPointRich queries the giss KM_MapViewerNew_MIL1/3 parcel layer first β€” land-use (Subtype/EnglishSubtype), CadastalPlanNo, GISNo, English neighbourhood names, and the parcel polygon (outlined via highlightedParcelProvider) β€” falling back to gismaps parcelAtPoint.
  • Wired β€” building permits (live, per-parcel). After a parcel resolves, the sheet queries POC/IssuedBuildingLicenses/0 (exact GISNo match, else spatial Β±35 m) and shows recent permits. See licensesForGisNo / licensesNearPoint. Licence Ψ±Ω‚Ω… Ψ§Ω„ΨΉΩ‚Ψ§Ψ±ΩŠ == parcel GISNo.
  • Wired β€” Baladia satellite basemap. AppBasemap.baladiaSatellite renders the municipality's imagery via giss export with a persisted year selector (2018/2021/2022/2024) for before/after change detection.
  • Built, pending activation β€” bulk building-licence ingestion.public.baladia_building_licenses + the sync-baladia-building-licenses edge function + cron trigger. Join to properties by property_no (Ψ§Ω„Ψ±Ω‚Ω… Ψ§Ω„ΨΉΩ‚Ψ§Ψ±ΩŠ), not block/parcel (block formats differ between services).
  • Wired β€” parcel geocoder fallback. searchParcel (area/block/plot β†’ centroid): staff paci_parcels table 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.

Aldilaijan & Khobara Real Estate Platform