Map stack migration: flutter_map β MapLibre GL (PACI vector basemap on web) β
Status (2026-07-14): MIGRATION SHIPPED (PR #281 and follow-ups). Every map surface renders through the MapLibre
AppMapwidget; real PACI vector renders on web via the Kuwait proxy. This document is kept as the historical record of the POC and the mapping analysis. Current architecture:MAPS.md.Known post-migration issues (fixed by the maps-unification Phase 1b PR): no Google fallback when the PACI
root.jsonfetch fails (blank basemap);paciBasemapGoogleFallbackhint stuck on web;baladiaOverlayUnavailablehint inert; Daleel teardrop pins not yet rendered as MapLibre symbols (the brokerage map shows generic dots while its legend shows teardrops).Cleanup done (2026-07-15, maps-unification Phase 5): the legacy
flutter_map/vector_map_tiles/flutter_map_marker_clusterdeps and thepaci_flutter_map_layers.dart/basemap_layer_builder.dartfiles were deleted β seeMAPS.md. The cross-repo unification program lives ataldilaijanre/docs/MAPS_UNIFICATION.md.
Why β
The PACI KF Arabic vector basemap cannot render on the Flutter web build. vector_map_tiles parses/renders tiles on dart:isolate isolates, which don't exist on Flutter web release (executor_lib uses a main-thread executor only in debug; release always uses the isolate PoolExecutor, and concurrency: 0 just trips its assert(concurrency > 0)). On web release it throws Unsupported operation: ReceivePort.sendPort β blank map. PACI is vector-only (no raster endpoint).
Interim shipped (a2f3d52b): web falls back to the Google raster basemap; native keeps PACI vector (paciCanLoadVectorBasemap β false on web). This is the stable state until this migration lands.
Chosen direction: migrate the map stack to MapLibre GL (maplibre_gl), which renders PACI's Mapbox-GL style JSON natively on web (MapLibre GL JS) and mobile (MapLibre Native) β true PACI-on-web at highest fidelity. Cost: MapLibre is a full engine that replaces flutter_map, so all map surfaces are rebuilt.
Phase 0 POC β result (PASSED) β
A standalone MapLibre GL JS page ([email protected]) pointed at the proxied PACI style rendered the KF basemap perfectly on web: vector tiles, sprites/POI icons, and correctly-shaped Arabic RTL labels, with zero MapLibre errors, all via the Kuwait proxy (CORS ACAO: *). Findings to bake into Phase 1:
- PACI
root.jsonis a clean Mapbox GL Style Spec v8 (245 layers, one vector sourceesri) β MapLibre-native. - Required style rewrite (same as
paci_style_loaderalready does): the source'surl: "../../"returns ArcGIS HTML (not TileJSON), so replace it with explicit proxiedtiles: ["<proxy>/β¦/VectorTileServer/tile/{z}/{y}/{x}.pbf"](note ArcGIS{z}/{y}/{x}order) and makesprite/glyphsabsolute-proxied. - Arabic needs
maplibregl.setRTLTextPlugin(mapbox-gl-rtl-text)on web. - Benign warning only: "too many glyphs being rendered in a tile" (label-dense).
Conclusion: the migration is viable; the make-or-break assumption holds.
Package choice β
maplibre_gl(v0.26.x, publisher maplibre.org, verified) β recommended. Android/iOS = MapLibre Native, web = MapLibre GL JS.styleStringaccepts a URL / asset / raw JSON.maplibre(v0.3.x) β newer FFI/JNI rewrite, more platforms, less battle-tested. Alternative; default tomaplibre_gl.
What maps onto what (from the code inventory) β
| flutter_map today | MapLibre GL target | Risk |
|---|---|---|
PACI VectorTileLayer (vector_map_tiles) | setStyle(<PACI root.json, rewritten>) β native GL vector | Low (the whole point) |
Google roadmap/satellite TileLayer (via google-tiles proxy) | GL raster source + layer (same proxied XYZ URLs) | Low |
Baladia ArcgisExportTileProvider raster overlay | GL raster source w/ custom dynamic-URL scheme (ArcGIS export bbox) + raster-opacity | Medium |
MarkerLayer (single pins) | GeoJSON source + symbol/circle layer, or MapLibre annotations | LowβMed |
flutter_map_marker_cluster | GAP β GL-native clustering (cluster: true + count-badge layers) or supercluster | High |
PolygonLayer (price neighborhoods) | GeoJSON source + fill/line, data-driven color | Medium |
MapController.move/fitCamera/rotate | MaplibreMapController.animateCamera / newLatLngBounds / setBearing | LowβMed |
mapEventStream (rotation) | onCameraIdle / camera-change callbacks | Medium |
tap onTapβreverse-geocode; onMarkerTap | onMapClick + queryRenderedFeatures hit-test | High |
RichAttributionWidget | AttributionControl / custom overlay | Low |
| Geolocator dot, PACI search/geocode, presentation-mode filter | unchanged (map-agnostic) | Low |
Deps to remove at the end: flutter_map, flutter_map_marker_cluster, vector_map_tiles, vector_tile_renderer, executor_lib. Keep: latlong2, geolocator, shared_preferences, flutter_riverpod, golden_toolkit.
Surfaces (11), grouped by risk β
- Display-only (low):
parcel_location_sheet,property_map_preview_card, resident-detail location card. - Interactive-simple (med): add-property
_MapPickerSheet(tap-to-pin + search- reverse-geocode),
public_price_map_screen(polygons + tap-parcel).
- reverse-geocode),
- Interactive-complex (high):
property_heatmap_screen,residents_map_screen(clustering, drill-down, camera-fit, rotation, live location) β do last.
Phases β
- Phase 0 β POC / de-risk. β DONE (see result above).
- Phase 1 β Foundation (parallel to flutter_map, flag-gated). Add
maplibre_gl; build a sharedAppMapwidget (wrapsMaplibreMap) + a runtime GL-style builder assembling PACI/Google/Satellite/Baladia sources fromMapLayersState; bridge the existingMapLayersControllerβsetStyle/setLayoutProperty/raster-opacity. Keep flutter_map intact behind a flag for A/B + rollback. - Phase 2 β Display-only surfaces (cheapest, validates the foundation).
- Phase 3 β Interactive-simple: add-property picker + public price map.
- Phase 4 β Clustering + interactive-complex: spike clustering (GL-native vs
supercluster), then migrate heatmap + residents (drill-down, fit-to-bounds, rotation, live location). - Phase 5 β Baladia overlay + tap-to-identify (reuse the EPSG:3857 bbox math; tap via
onMapClick+PaciGeocodeRepository, unchanged). - Phase 6 β Cleanup + web PACI on: remove flutter_map/vector deps; flip
paciCanLoadVectorBasemapso web uses PACI (via MapLibre) not Google; retire the isolate fallback; update/replace tests + goldens.
Key risks β
- Clustering β biggest feature gap; no drop-in for
flutter_map_marker_cluster. - Marker & parcel taps move to
queryRenderedFeatureshit-testing (different model; re-prove the tap-to-identify UX). - Testing β MapLibre renders in a platform view / JS canvas, so widget + golden tests can't rasterize the map; map-content tests move to integration/Patrol. (The
map_basemap_switcherUI-chrome goldens still work.) - Web perf & bundle β MapLibre GL JS adds JS weight; validate on the deployed Cloudflare Pages build.
- RTL β confirm Arabic shaping via
mapbox-gl-rtl-text(POC confirmed it works).
Sequencing β
Multi-week but incremental: Phases 2β5 each ship independently while the Google stopgap covers web. One small flag-gated PR per phase β any phase can be paused or rolled back without breaking the app. Phase 0 (done) was the hard gate.
Verification per phase β
- Each surface phase: parity checklist vs the flutter_map version (center/zoom, markers, taps, camera, overlay/opacity) on web + native; deploy to Pages and hard-refresh-verify.
- Phase 6: web shows real PACI (not Google);
flutter analyze+ full test suite green; goldens regenerated.
