MapLibre, Mapbox-Draw, Google Maps and Azure Maps in one app: the boundary we drew
MapLibre, Mapbox-Draw, Google Maps and Azure Maps in one app: the boundary we drew
Four map engines in
package.jsonsounds like a smell. Here is the case for which one owns rendering, which one owns drawing, which one owns geocoding, and which one earned its place.
When you open package.json on this enterprise GIS project, you see four map-related dependencies sitting next to each other:
"@googlemaps/js-api-loader": "^1.16.8",
"@mapbox/mapbox-gl-draw": "^1.5.0",
"azure-maps-control": "^3.6.1",
"maplibre-gl": "^5.6.2",
"@turf/turf": "^7.2.0",
"maplibre-gl-basemaps": "^0.1.3",
"proj4": "^2.19.5",
That list is the first thing a new engineer notices. The instinct, every time, is to call it bloat and propose collapsing it to one. I have run that argument and lost it, and I have run it and won it. Both endings cost real time, so I want to write down the decision rules I would have wanted on day one.
The decision and why it cannot be deferred
The reason this question keeps coming back is that "a map" is not one product. It is at least four:
- The base raster/vector renderer that paints tiles.
- The drawing primitive a user grabs to outline a polygon ("show me everything inside this boundary").
- The 3D extrusion / WebGL surface where you stack analysis on top of geometry.
- The geocoder / search-and-place box that turns "Sector 4, north" into a coordinate.
If you collapse them too early, you either ship a stunted drawing UX, or pay a licensed-renderer bill that scales with the wrong dimension (active users, not map loads), or you fight a 3D abstraction that was built for indoor venue maps when you needed urban-scale extrusion. So this team carried four engines on purpose. The interesting story is the boundary between them, not the count.
MapLibre as the primary renderer
The default map a planner stares at all day is MapLibre. It is open source, the style spec is portable, and there is no API key meter ticking when a user pans for forty minutes. The renderer is set up in components/viewer/MaplibreMapViewer.tsx:
const map = new maplibregl.Map({
container: mapContainer.current!,
attributionControl: false,
center: [/* project AOI */],
zoom: 13,
pitch: 45,
bearing: -10,
style: {
version: 8,
sources: {
base_default: {
type: 'raster',
tiles: ['https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}@2x.png'],
tileSize: 256,
maxzoom: 19
}
},
layers: [{ id: 'base-raster', type: 'raster', source: 'base_default' }],
sky: {}
},
maxZoom: 18,
maxPitch: 85
})
Three details that matter here. The base tiles come from Carto's CDN, not from MapLibre — MapLibre is the engine, the tiles are decoupled, and you can swap them per environment without touching the viewer code. pitch: 45 and maxPitch: 85 mean this map was always going to host 3D extrusion. And the style spec is a JSON literal, not a URL — which means style fragments can be composed at runtime when the analysis panel adds a thematic layer.
The same file does something less obvious. It patches map.addLayer and map.removeLayer to fire custom events:
if (!(map as any)._addLayerPatched) {
const originalAddLayer = map.addLayer;
map.addLayer = function (...args) {
const layer = args[0];
if (this.getLayer(layer.id)) {
console.warn(`Layer "${layer.id}" already exists, skipping addLayer.`);
return this;
}
this.fire('layer-added', { layer });
return originalAddLayer.apply(this, args);
};
(map as any)._addLayerPatched = true;
}
That patch is the reason MapLibre is not interchangeable in this app. The rest of the system listens for layer-added and layer-removed to keep the Zustand-backed layers panel in sync. Swap MapLibre for a "drop-in" alternative and you lose this hook surface — the side panels go blind, layer ordering breaks, and the duplicate-add guard disappears. Whichever engine sits in this slot has to be hackable at the prototype level. MapLibre is; most managed SDKs are not.
MapboxDraw owns drawing — even though MapLibre renders
The most common question this app gets from new engineers is: "If MapLibre is in, why is @mapbox/mapbox-gl-draw also a dependency? Isn't that a different ecosystem?" Yes and no. MapboxDraw is a control plugin that talks to the Mapbox GL JS API, and MapLibre is a fork that kept the API surface compatible enough that the draw plugin still attaches and works. So you get Mapbox's polished polygon-drawing UX without paying for the Mapbox renderer.
The attachment lives in components/viewer/essentials/LeftInsiderMenu/partials/LayersPanel/index.tsx:
const draw = new MapboxDraw({
displayControlsDefault: false,
controls: {
polygon: true,
point: false,
line_string: false,
trash: true
},
styles: getFixedDrawStyles()
});
map.addControl(draw, 'top-right');
drawRef.current = draw;
draw.changeMode('draw_polygon');
(map.getCanvas() as HTMLCanvasElement).style.cursor = 'crosshair';
map.on('draw.create', (e: any) => {
console.log('Shape created:', e.features);
getHexIntersection(e.features[0]);
});
Notice what is disabled. Only polygon and trash are exposed; point and line_string are off. The drawing tool is not "draw anything" — it is the entry point to one workflow: outline an area, get the analysis that falls inside it. That single intent is why MapboxDraw was kept instead of being rolled by hand. Writing a polygon editor that handles vertex drag, escape-to-cancel, and trash-to-delete is two engineering weeks; copying a 1.5.x release of a maintained plugin is one afternoon.
The other side of the bridge is @turf/turf. The moment a polygon is finalized, the app feeds it back into MapLibre as a query source and intersects it against the rendered hex grid:
const getHexIntersection = (polygon_intersection: any) => {
const map = globalMap as any
let features: any[] = []
if (map?.getLayer?.('3level-thematic')) {
features = map.queryRenderedFeatures({ layers: ['3level-thematic'] }) as any[]
}
const intersectedFeatures = features.filter((feature: any) => {
return turf.booleanIntersects(feature.geometry, polygon_intersection);
});
intersectedFeatures.unshift(polygon_intersection);
map.addSource(intersectedLayerId, {
type: 'geojson',
data: { type: 'FeatureCollection', features: intersectedFeatures, properties: {} }
} as any)
map.addLayer({
id: intersectedLayerId, type: 'fill', source: intersectedLayerId,
paint: { 'fill-color': '#088', 'fill-opacity': 0.6 }
} as any)
}
Three libraries, one continuous interaction. MapboxDraw produces the polygon, Turf does the geometry math against queryRenderedFeatures results from MapLibre, and MapLibre paints the intersected hex cells back as a new layer. None of those three would do all three jobs well on its own. That is the trade-off you accept when you keep them all: more deps, but each tool stays in its native lane.
Azure Maps owns 3D and the model overlay
Azure Maps shows up twice — once in the project viewer at components/viewer/AzureMapViewer.tsx, and once on the dashboard at components/dashboard/CitiesMap/index.tsx to drop animated GLTF pointers on city markers. The reason it is there at all is the WebGLLayer + Three.js renderer interop:
const map = new atlas.Map(mapRef.current!, {
center: [/* AOI */],
zoom: 13,
antialias: true,
view: 'auto',
authOptions: {
authType: atlas.AuthenticationType.subscriptionKey,
subscriptionKey: process.env.NEXT_PUBLIC_AZURE_MAPS_KEY!
}
})
map.events.add('ready', () => {
const webglLayer = new atlas.layer.WebGLLayer('3d-model', { renderer })
map.layers.add(webglLayer)
const buildingTileSource = new atlas.source.VectorTileSource('buildingSource', {
tiles: ['/api/tiles/buildings/{z}/{x}/{y}.pbf'],
minZoom: 0, maxZoom: 16
})
map.sources.add(buildingTileSource)
})
The Azure SDK gives you atlas.layer.WebGLLayer with hooks for onAdd(map, gl) and render(gl, matrix). MapLibre has a custom-layer interface too, but Azure's pairs better with the Mercator transform helpers the team needs — atlas.data.MercatorPoint.fromPosition returns the projected coordinates the Three.js camera expects, in one call, without the team writing their own projection math. That helper alone explains why the Azure dep stayed.
You will also notice in the same file: a building tile vector source pointed at /api/tiles/buildings/{z}/{x}/{y}.pbf — a Next.js API route on this app, not an Azure tile service. The 3D viewer rents Azure's WebGL container and its compass/pitch/style controls, but the actual data still comes from the team's own backend. That is the right way to use a managed SDK: take the parts that are hard to write, refuse to be tile-locked.
The same pattern repeats on the dashboard at components/dashboard/CitiesMap/index.tsx, where Azure hosts animated map_pointer.glb models over city centroids. Different surface, same reason: 3D-over-map is hard to do well, Azure does it well, and the surrounding code is small enough that swapping later is a week of work, not a quarter.
Google Maps is in package.json and (almost) nowhere else
Here is the part that looks like dead weight. @googlemaps/js-api-loader@^1.16.8 is declared in package.json. Grep the components/, services/, app/, libs/ and helper/ folders for @googlemaps or google.maps, and you will find no usage in committed source. That is not an oversight; it is an option being held open.
Why hold it? Two reasons that come up in this app's domain:
- Geocoding and place search. Google's Places API is the de-facto answer when a user types "Sector 4, north" and expects an autocomplete dropdown that actually works in the local language. Azure Maps has search, MapLibre has none, and Mapbox costs per-request. Holding the loader as a dep means the day a designer adds a search box, the integration is a one-file change, not a procurement cycle.
- Street View and roof-level imagery. When a planner wants to inspect a single building, Google's imagery is denser than Azure's in most non-US cities. Wiring it in later is straightforward because the loader is already approved and audited.
That is a defensible reason to carry a dependency you do not import yet. The cost of one unused npm package is a few hundred KB in node_modules and one line in the dependency review. The cost of needing it on a Friday and not having it provisioned is a week of approval emails. I would not generalize this rule — "just install everything in advance" is the wrong takeaway. The specific shape that justifies it: a feature you have committed to ship within the next two quarters, where the vendor relationship is the gate, not the code.
The seam that makes four engines tolerable: a registry on the Map prototype
The reason this many engines do not turn into a swamp is that the team did not let each one carry its own state model. There is a single extension point that pins the rest of the app to MapLibre's Map instance, and it is in components/viewer/core/extensions/MapLibreExtensions.ts:
declare module 'maplibre-gl' {
interface Map {
controllers: ControllerRegistry;
components: ComponentRegistry;
registerController(controller: IController): Promise<void>;
getController<T extends IController = IController>(name: string): T | undefined;
removeController(name: string): Promise<void>;
createComponent<T extends IComponent>(component: T): T;
setActiveComponent(component: IComponent | undefined): void;
getActiveComponent(): IComponent | undefined;
}
}
export function extendMapLibre(): void {
const MapProto = maplibregl.Map.prototype as any;
if (MapProto._controllersExtended) return;
MapProto.registerController = async function (controller: IController) {
await this.controllers.register(controller, this);
};
// ... component & controller methods attached to the prototype
MapProto._controllersExtended = true;
}
That extendMapLibre() call runs once when MaplibreMapViewer.tsx is imported. After it runs, every MapLibre Map instance has a controllers and components registry hanging off it. The mouse, keyboard, pointer, and memory controllers all register through it — and a Zustand hook (components/viewer/hooks/useMap.ts) holds a reference to the active Map so any panel can grab it without prop-drilling:
const useMap = create<MapState>((set, get) => ({
map: undefined,
setMap: (map) => set({ map }),
getMap: () => get().map,
getActiveComponent: () => get().map?.getActiveComponent(),
isReady: () => !!get().map,
}));
This is the seam. Drawing tools, layer panels, scenario analysis modes — they all reach for the MapLibre Map through this hook, and the controller/component pattern is what stops each new feature from inventing its own state shape. Azure and MapboxDraw plug into the viewer but never claim ownership of the registry. That single design choice is what makes four engines tolerable instead of four parallel apps.
The trade-off this stack accepts
I want to be honest about what we gave up. Four engines means four release cadences to watch, four sets of breaking changes to read on upgrade days, and four authentication and billing surfaces. The Azure subscription key has to live somewhere safe. The Google loader has to be initialized lazily so it does not pull maps.googleapis.com/maps/api/js on first paint for users who never open the search box. MapboxDraw drags in CSS that you have to neutralize for the dark theme. And every onboarding engineer asks the same question we answered at the top of this post.
What we got back: the renderer cost stays flat (MapLibre, open source), the drawing UX is on a maintained plugin instead of hand-rolled code, the 3D overlay uses the SDK that has the best Mercator helpers, and the geocoder slot is pre-approved when product gets there. Each engine is in the slot where its incremental cost is smallest. That is the only framing that justifies the dep count.
Business impact
In dollars: the renderer choice (MapLibre instead of Mapbox-as-renderer) keeps the per-user map bill at zero. For an enterprise GIS dashboard where analysts have it open eight hours a day, that is the single highest-leverage decision on the page. The MapboxDraw plugin saved roughly two engineering weeks on day one and another week per year in maintenance. Azure Maps' WebGL layer is the reason the 3D extrusion shipped in the original sprint instead of becoming a six-month research project. And the Google loader, even unused, removed a procurement blocker from the next quarter's roadmap. The cost of carrying it is rounding error against any one of those benefits.
What to do next
If you are looking at your own package.json and seeing two or three map deps, do not collapse them on instinct. Run the four-jobs check first: renderer, drawing, 3D / overlay, geocoding-and-search. Open the file where each dep is imported and write down which of the four jobs it does. If two deps do the same job, that is a real smell and worth a week of consolidation. If each dep does a different job, the count is probably correct and the right move is to write down the seam (the equivalent of MapLibreExtensions.ts here) so future engineers stop asking why.
The check that surprises people: search your codebase for the dep that has zero call sites. If you find one and cannot name the feature that will activate it, remove it. If you find one and can name the feature with a date attached, leave it.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox