Skip to content

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 AppMap widget; 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.json fetch fails (blank basemap); paciBasemapGoogleFallback hint stuck on web; baladiaOverlayUnavailable hint 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_cluster deps and the paci_flutter_map_layers.dart / basemap_layer_builder.dart files were deleted β€” see MAPS.md. The cross-repo unification program lives at aldilaijanre/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.json is a clean Mapbox GL Style Spec v8 (245 layers, one vector source esri) β€” MapLibre-native.
  • Required style rewrite (same as paci_style_loader already does): the source's url: "../../" returns ArcGIS HTML (not TileJSON), so replace it with explicit proxied tiles: ["<proxy>/…/VectorTileServer/tile/{z}/{y}/{x}.pbf"] (note ArcGIS {z}/{y}/{x} order) and make sprite/glyphs absolute-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. styleString accepts a URL / asset / raw JSON.
  • maplibre (v0.3.x) β€” newer FFI/JNI rewrite, more platforms, less battle-tested. Alternative; default to maplibre_gl.

What maps onto what (from the code inventory) ​

flutter_map todayMapLibre GL targetRisk
PACI VectorTileLayer (vector_map_tiles)setStyle(<PACI root.json, rewritten>) β€” native GL vectorLow (the whole point)
Google roadmap/satellite TileLayer (via google-tiles proxy)GL raster source + layer (same proxied XYZ URLs)Low
Baladia ArcgisExportTileProvider raster overlayGL raster source w/ custom dynamic-URL scheme (ArcGIS export bbox) + raster-opacityMedium
MarkerLayer (single pins)GeoJSON source + symbol/circle layer, or MapLibre annotationsLow–Med
flutter_map_marker_clusterGAP β€” GL-native clustering (cluster: true + count-badge layers) or superclusterHigh
PolygonLayer (price neighborhoods)GeoJSON source + fill/line, data-driven colorMedium
MapController.move/fitCamera/rotateMaplibreMapController.animateCamera / newLatLngBounds / setBearingLow–Med
mapEventStream (rotation)onCameraIdle / camera-change callbacksMedium
tap onTap→reverse-geocode; onMarkerTaponMapClick + queryRenderedFeatures hit-testHigh
RichAttributionWidgetAttributionControl / custom overlayLow
Geolocator dot, PACI search/geocode, presentation-mode filterunchanged (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).
  • 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 shared AppMap widget (wraps MaplibreMap) + a runtime GL-style builder assembling PACI/Google/Satellite/Baladia sources from MapLayersState; bridge the existing MapLayersController β†’ 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 paciCanLoadVectorBasemap so web uses PACI (via MapLibre) not Google; retire the isolate fallback; update/replace tests + goldens.

Key risks ​

  1. Clustering β€” biggest feature gap; no drop-in for flutter_map_marker_cluster.
  2. Marker & parcel taps move to queryRenderedFeatures hit-testing (different model; re-prove the tap-to-identify UX).
  3. 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_switcher UI-chrome goldens still work.)
  4. Web perf & bundle β€” MapLibre GL JS adds JS weight; validate on the deployed Cloudflare Pages build.
  5. 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.

Aldilaijan & Khobara Real Estate Platform