Works with Expo • Read the Documentation • Report Issues
![]() Starship launch |
![]() Traffic |
![]() Jets over the Golden Gate |
![]() Satellites on the globe |
![]() Friends on their floor |
Recorded on an iPhone 17 Pro. Open any shot in the example app with munimmapsexample://demo/launch, traffic, friends, orbit or jets.
munim-maps is one React Native map API over five engines: Apple MapKit, Google Maps, Mapbox, MapLibre (open maps: OpenStreetMap data, no key) and Cesium, on iOS and Android. Pick the engine per map with provider. The shared props, markers, shapes, camera, events and 3D models work the same on every engine, and each engine's own features are there too: Google's indoor maps, Street View and photorealistic 3D; Mapbox's Standard style, globe, terrain and offline packs; MapLibre's full style spec, and its globe, 3D terrain and sky through MapLibre GL JS; Cesium's 3D Tiles, CZML and time-dynamic scenes. See Map Providers.
Animated 3D is built in: vehicles, people on the floor of a building they are really on, rocket launches, satellites on the globe, zone walls and your own glTF or USDZ models, anchored to real coordinates and moving in the same frame as the map. Engines that draw 3D models themselves (Mapbox, Cesium, Google's 3D map) render them natively, so buildings and terrain hide them; the others use munim-maps' own 3D layer (modelRendering). The 57-model vehicle catalogue is the separate munim-maps-vehicles package, loaded from a CDN and cached on the device, or bundled one model at a time.
Already using react-native-maps, expo-maps or @rnmapbox/maps? MapModelLayer draws the 3D layer over it. See Use Your Own Map.
Fully compatible with Expo! Works with Expo managed (prebuild) and bare workflows.
Built with React Native's Nitro modules architecture for high performance and reliability.
Comes with a catalogue of 57 detailed models: cars, trucks, buses, bikes, motorcycles, trains, boats, airliners, fighter jets (F-16, F-22, F-35, YF-23), a helicopter, a hot air balloon, rockets (Starship, Falcon 9, Saturn V, Space Shuttle), Starbase's launch tower and mount, and spacecraft (ISS, Starlink, Hubble, GPS, CubeSat, Crew Dragon, James Webb), all recolourable at runtime.
Or bring your own: any USDZ, USD, glTF / GLB, SceneKit, OBJ, PLY, STL or Alembic file, bundled, downloaded or from disk. See Bring Your Own Model.
Hidden behind buildings: with occlusion="buildings", a car driving behind a tower disappears behind it, the way it would in real life.
Terrain height: give a friend's GPS altitude with altitudeReference: 'sea' and munim-maps takes off the ground height there, and followTerrain keeps models on MapKit's 3D satellite terrain. See Terrain.
The globe on the standard map: zoomed far out, the normal map becomes a globe like it does in Apple Maps, and models, satellites and 3D paths follow it.
Not using React Native? The same map and 3D layer are a Swift package for UIKit and SwiftUI apps; see Swift Package Manager.
Note: on Android, MapKit is not available; MunimMapView defaults to Google Maps when it is built in, otherwise MapLibre. See the Platform Support Matrix.
npx expo install munim-maps react-native-nitro-modules
npx expo install munim-maps-vehicles # optional: the vehicle cataloguenpm install munim-maps react-native-nitro-modules
npm install munim-maps-vehicles # optional: the vehicle cataloguemunim-maps is native code, so it ships in a new app build, not an over-the-air update. It ships no 3D models: the vehicle catalogue is the separate munim-maps-vehicles package, which loads models from a CDN (cached on the device) or bundles the ones you pick.
To require() model files (your own, or munim-maps-vehicles/bundled/<name>), add their extensions to Metro:
// metro.config.js
config.resolver.assetExts.push('usdz', 'glb', 'gltf', 'obj', 'scn')For native iOS apps without React Native. In Xcode, File → Add Package Dependencies… and enter https://github.com/munimtechnologies/munim-maps, or in Package.swift:
.package(url: "https://github.com/munimtechnologies/munim-maps", from: "0.5.0")Add the MunimMaps product, and MunimMapsVehicles for the vehicle catalogue (its names and URLs: the models load from the munim-maps-vehicles package on jsDelivr and are cached on the device; set MunimVehicles.baseURL to self-host). iOS 16 or later.
import MunimMaps
import MunimMapsVehicles
// SwiftUI
MunimMap(
initialCamera: MunimCamera(latitude: 41.8838, longitude: -87.6305, distance: 2600, pitch: 60),
models: [
MunimModel(id: "car", coordinate: .init(latitude: 41.8841, longitude: -87.6244),
uri: MunimVehicles.url("car-ev")!.absoluteString, tintColor: "#E5484D", screenSize: 15),
],
globe: true
)
// UIKit: a full map...
let map = MunimMapKitView(frame: view.bounds)
map.models = models
map.markers = [MunimMarker(id: "cafe", coordinate: cafe, title: "Cafe")]
// ...or 3D over an MKMapView you already have
let layer = MunimModelLayer()
layer.install(over: mapView)
layer.models = models
layer.onModelPress = { id in print(id) }MunimMapKitView has the same props, events and methods as MunimMapView (setCamera, fit(coordinates:), point(for:), snapshot, address(for:), openLookAround(at:)…). Ground heights are try await MunimTerrain.shared.groundElevations(for: coordinates).
- 📚 Documentation
- 🚀 Features
- 🗺️ Map Providers
- 🗺️ Use Your Own Map
- 🧊 Bring Your Own Model
- 🚗 Vehicle Catalogue
- Platform Support Matrix
- ⚡ Quick Start
- 🔧 API Reference
- 📖 Usage Examples
- ⚙️ How It Works
- 🔍 Troubleshooting
- 🛣️ Roadmap
- 👏 Contributing
- 📄 License
Learn about putting 3D on maps in our documentation!
- 🧊 3D models at real coordinates: USDZ, USD, glTF / GLB, SCN, OBJ, PLY, STL or Alembic files, bundled with
require(), fromfile://or downloaded and cached fromhttp(s)://(details) - 🏙️ Hidden behind buildings:
occlusion="buildings"hides models behind real building footprints and heights, which MapKit cannot do on its own - 🔥 Exhaust and smoke: particle effects for rocket launches and fires, stopping at the ground
- 🔷 Built-in shapes: box, sphere, cylinder, cone, capsule, pyramid and gem, with colour and glow
- 🎨 Runtime paint:
tintrecolours a model's paint, so one file comes in any colour - 🧭 Heading, altitude and scale, plus
spinDegreesPerSecondand looping USDZ animations - ⛰️ Terrain height: altitudes above the ground or above sea level (
altitudeReference: 'sea', such as a phone's GPS altitude), models that stay on MapKit's 3D terrain (followTerrain), andgroundElevation()for the height of the ground anywhere, which MapKit does not expose (details) - 📏 Screen-size models:
screenSizekeeps a model the same height on screen at any zoom, like a marker - 🌑 Ground shadows and day/night lighting that follows the map's appearance
- 🏢 People in buildings: round avatars that always face the camera float at their real height, with a stem down to the spot below and a floor badge such as
5F - 🚴 Riders:
liftfloats an avatar over a vehicle model at any zoom - 🏷️ Labels: text pills that float above any model, for power-ups or names
- 👆 Taps:
onModelPresswith the model's id; the map keeps every gesture
- 🌍 Globe on the standard map:
globeturns the normal map into a globe when zoomed far out, as Apple Maps does (MapKit only does this for satellite imagery). See the note on how - 🛰️ Models on the globe: when MapKit draws a globe (the standard map with
globe, orhybrid/imagerywith realistic elevation), models are placed on the sphere and hidden behind the Earth when they go round the far side - 🪐 Orbits and flight paths:
pathsare lines drawn in 3D, a fixed number of points wide, that can sit at any height and follow the globe; MapKit's own polylines stay flat even on the globe
- 🧱 Zone walls: circles or polygons stand up as see-through walls with solid top and bottom edges, like a map outline turned into a fence
- 🔄 Live updates: change a zone's radius or points and the wall rebuilds (shrinking zones)
- 🗺️
MunimMapView: everything react-native-maps does on iOS, without a second library: markers, polylines, polygons, circles, tile overlays, every map event and the camera API - 📍 Markers: MapKit pins and balloons (with emoji or text), images, round avatars with a ring and corner badges, label pills and dots; clustering, dragging, callouts, z-order
- ✏️ Shapes: polylines (dashed, geodesic), polygons with holes, circles, and tile overlays (your own tiles, over or instead of Apple's map)
- 🍎 New MapKit:
standard,muted,hybridandimagerystyles, realistic elevation, point-of-interest filters, traffic, tappable map features (onMapFeaturePress), Look Around, camera distance limits and boundaries - 🧭 Camera and conversions:
setCamera,setRegion,fitToCoordinates,fitToMarkers,pointForCoordinate,coordinateForPoint, snapshots and reverse geocoding - 🔦 Follow with heading:
userTrackingMode="followWithHeading"is MapKit's own tracking with the heading beam; MapKit owns the following andonUserTrackingModeChangetells you when the user pans away (details) - 🎛️ Controls: compass and scale that are always visible or adaptive, MapKit's tracking and 2D/3D buttons, and standalone
MapCompass,MapScaleandMapUserTrackingButtonyou can place anywhere - 🪪 Place cards: tap a place on Apple's map and get Apple's own place card (
selectionAccessory, iOS 18+) - 🧷 React Native views as markers:
<MarkerView>turns any React Native view into a real MapKit marker that clusters, collides and selects - 🌈 Routes and overlays: gradient polylines,
strokeStart/strokeEndto animate a route being drawn, line joins, overlays under or over labels, andonOverlayPressfor taps on lines and shapes - 🔎 MapKit services: search and autocomplete, points of interest, directions and travel times, geocoding, places by id, Apple Maps hand-off and map images, without a map on screen (details)
- 👀 Look Around:
<LookAroundView>embeds Apple's street-level imagery, andlookAroundSnapshot()makes a picture of it - 🧩
MapModelLayer: or keep your map and draw the 3D over it:react-native-mapsandexpo-mapson iOS,react-native-maps(Google) and@rnmapbox/mapson Android
- 🎯 Matched to MapKit's own camera to under a point, measured on device against
MKMapView.convert(on the globe, checked against MapKit's city labels) - ⏱️ Same-frame motion: models stay within 0.2 px of MapKit's own overlays while MapKit animates the camera
- ⚡ High performance: Nitro modules, Metal rendering, redraws only when the camera moves or something animates
MunimMapView draws with the engine in provider. MapKit is built in on iOS and MapLibre on Android; the others are opt-in at build time, so an app only ships the SDKs it uses. Full details, the per-engine feature matrix and the engine interfaces are in docs/providers.md.
| Provider | provider= |
iOS | Android | Key |
|---|---|---|---|---|
| Apple MapKit | 'mapkit' |
✅ Built in, the default | — | None |
| Google Maps | 'google' |
✅ NitroMunimMaps/Google (Maps SDK 10 + Utils); photorealistic 3D with NitroMunimMaps/Google3D (Maps 3D SDK 1.0) |
✅ munimMaps.google=true (Maps SDK 20 + maps-utils), the default when on; photorealistic 3D with munimMaps.googleMaps3d=true |
Google Maps SDK key |
| Mapbox | 'mapbox' |
✅ NitroMunimMaps/Mapbox (SDK 11.32) |
🔨 munimMaps.mapbox=true: built, device check pending |
Mapbox public token |
| MapLibre (open maps) | 'maplibre' |
✅ NitroMunimMaps/MapLibre subspec: MapLibre Native, and MapLibre GL JS for the globe, 3D terrain and sky |
✅ Built in, the default without Google (Native and GL JS) | None (OpenStreetMap data from OpenFreeMap; AWS Terrain Tiles) |
| Cesium | 'cesium' |
✅ Opt-in (CesiumJS from jsDelivr or bundled, in a WKWebView) | ✅ Opt-in (CesiumJS in a WebView) | None (OpenStreetMap, ellipsoid); a Cesium ion token adds terrain, imagery, buildings |
import { MunimMapView, configureMunimMaps } from 'munim-maps'
configureMunimMaps({ mapboxAccessToken: 'pk.…', cesiumIonToken: '…' }) // or the config plugin
<MunimMapView provider="maplibre" styleUrl="https://tiles.openfreemap.org/styles/liberty" initialCamera={camera} models={models} />Pick engines and keys with the Expo config plugin:
["munim-maps", { "providers": ["google", "mapbox"], "googleMapsApiKey": "…", "mapboxAccessToken": "pk.…" }]Without Expo: the NitroMunimMaps/Google, /Mapbox, /MapLibre and /Cesium subspecs on iOS, and munimMaps.google=true (and so on) in android/gradle.properties; with Mapbox on Android, add Mapbox's Maven repository to allprojects.repositories (the config plugin does it). The Google engine needs iOS 16, and an app that also uses react-native-maps needs pod 'react-native-maps/Google' once the GoogleMaps pod is in. Options only one engine has go in that engine's prop: google={{ mapId }}, mapbox={{ projection: 'globe' }}, maplibre={{ … }}, cesium={{ terrain: 'world' }}. availableProviders() tells you which engines the build has; one that is not built in shows a placeholder and reports onError.
The same API on every engine, including:
modelRendering="auto" | "native" | "overlay": who drawsmodels.autolets Mapbox (its model layer), Cesium (entities) and Google's photorealistic 3D map draw them natively, lit and hidden by their own buildings and terrain, and uses munim-maps' 3D layer on MapKit, MapLibre and the Google 2D map. What an engine cannot draw (avatars, labels, effects, USDZ files…) stays on the 3D layer, except on Cesium, which draws those too.onMarkerDrag(continuous, betweenonMarkerDragStartandonMarkerDragEnd),MarkerViewon iOS and Android, and react-native-maps'region,initialRegion,onRegionChangeStart,onRegionChangeCompleteandanimateToRegion.- Methods and events only one engine has go through
ref.current.providerCommand(command, argsJson)andonProviderEvent({ provider, name, data }); each engine wraps them with types (googleMap(ref),mapboxMap(ref),maplibreCommands(ref),cesiumCommands(ref)).
What an app coming from react-native-maps or @rnmapbox/maps needs, engine by engine, is in docs/providers.md.
provider="google" draws with the Maps SDK for iOS (GoogleMaps 10, CocoaPods) and the Maps SDK for Android (play-services-maps 20), with Google Maps Utils for clustering, heatmaps, KML and GeoJSON, and munim-maps' 3D layer on Google's camera. Every shared prop, event and method works; everything only Google has is in google={{ … }}, onProviderEvent and googleMap(ref). The full capability list is in docs/providers.md.
Setup. Get a key with the Maps SDK for iOS and Maps SDK for Android APIs on (restrict it to your bundle ID and package + SHA-1). With Expo: ["munim-maps", { "providers": ["google"], "googleMapsApiKey": { "ios": "…", "android": "…" } }], then npx expo prebuild. Without Expo: pod 'NitroMunimMaps/Google', :path => '../node_modules/munim-maps' plus configureMunimMaps({ googleMapsApiKey }) (or Info.plist MunimMapsGoogleMapsApiKey) on iOS, and munimMaps.google=true in android/gradle.properties plus com.google.android.geo.API_KEY meta-data on Android. Showing the user's location needs NSLocationWhenInUseUsageDescription (iOS) and ACCESS_FINE_LOCATION in the manifest (Android); munim-maps asks for the permission when showsUserLocation or tracking turns on. With Google built in, Android maps default to it. For the photorealistic 3D map add "googleMaps3d": true to the plugin and turn on the Map Tiles API and the Maps 3D SDK for iOS / for Android (see below).
import { MunimMapView, googleMap, googleEvent, googleMapsServices } from 'munim-maps'
<MunimMapView
ref={ref}
provider="google"
initialCamera={{ latitude: 41.88, longitude: -87.63, distance: 1200, pitch: 55, heading: 30 }}
colorScheme="dark"
showsTraffic
markers={[{ id: 'hq', coordinate, style: 'marker', glyph: 'G', callout: true, title: 'HQ' }]}
google={{
mapId: 'DEMO_MAP_ID', // cloud styling, advanced markers, data-driven styling
mapType: 'hybrid', // normal | satellite | hybrid | terrain | none
indoorEnabled: true,
zoomControls: true, mapToolbar: true, // Android
markers: { hq: { pin: { background: '#0A84FF', glyph: 'HQ' }, collisionBehavior: 'required' } },
polylines: { route: { pattern: [{ type: 'dash', length: 12 }, { type: 'gap', length: 6 }], stamp: { imageUri } } },
heatmaps: [{ id: 'heat', points, radius: 30 }],
groundOverlays: [{ id: 'plan', imageUri, bounds: { southwest, northeast } }],
geoJsonLayers: [{ id: 'zones', url: 'https://…/zones.geojson' }],
kmlLayers: [{ id: 'trail', url: 'https://…/trail.kml' }],
featureLayers: [{ featureType: 'LOCALITY', placeStyles: { [placeId]: { fillColor: '#0A84FF55' } } }],
}}
onProviderEvent={(event) => {
const e = googleEvent(event) // typed: indoorLevelActivated, poiClick, featureClick, streetViewChange…
}}
/>
const google = googleMap(ref.current!)
await google.animateCamera({ zoom: 18, tilt: 60, bearing: 90 }, 800) // Google's own units
await google.streetView.open({ latitude, longitude, heading: 90 }) // Street View over the map
await ref.current!.openLookAround(coordinate) // full-screen Street ViewPlaces, Geocoding, Routes. These are Google web services, not part of the Maps SDKs: turn the APIs on for a key, then googleMapsServices({ apiKey }) gives places.autocomplete, places.details, places.searchText, places.searchNearby, places.photoUrl, geocoding.geocode / reverseGeocode, routes.computeRoutes (lines decoded) and routes.computeRouteMatrix. They are billed per request; call them from your server where you can, or use a separate app-restricted key (iosBundleId, androidPackage + androidCertSha1 are sent as Google's app-restriction headers). googleGeometry has Google's distance, heading, offset, area and polyline encoding in JavaScript.
With react-native-maps in the same iOS app: react-native-maps registers its Google map whenever the GoogleMaps pod is present, so also add pod 'react-native-maps/Google' (it pins GoogleMaps 9.4, which munim-maps accepts; transitEnabled needs GoogleMaps 10), or the app stops at launch with RCTThirdPartyComponentsProvider inserting nil (the example's app.config.js shows the Podfile line).
Caveats: Google publishes a zoom level, not a camera distance, so munim-maps measures Google's field of view from its own projection (the 3D layer then lines up with the map to within a point; measureAlignment() reports it). JSON styles (styleJson, styleUrl, mapStyle="muted", pointsOfInterest) and cloud styling (mapId) do not mix. Google draws info windows as pictures, so a whole callout is one tap (onCalloutPress). The iOS SDK has no stroke patterns, caps or joints: dashes are drawn as spans in metres at the current zoom. Android uses android-maps-utils 3.20 by default (it builds with React Native's Kotlin 2.1); apps on Kotlin 2.3 can set munimMaps.googleMapsUtilsVersion. Google has no globe, scale bar or tracking modes (munim-maps follows the user itself). Photorealistic 3D: google={{ mode: '3d' }} (the Maps 3D SDK on iOS and Android, googleMaps3d: true in the Expo plugin; the key needs the Map Tiles API and the Maps 3D SDK for iOS / for Android) draws models as Google's own glTF models (modelRendering: 'auto' | 'native' | 'overlay'), hidden by Google's buildings, and polylines, polygons and markers natively, with googleMap(ref).flyTo, flyAround, stopCameraAnimation, getCamera3d and setCamera3d. Google ships the iOS SDK only as a SwiftUI Swift package (GoogleMaps3D): munim-maps hosts it in the Google engine's view, the podspec adds the package to the pod with React Native's spm_dependency, and the config plugin adds a build phase that embeds its framework in the app (without Expo, add a Run Script phase at the end of the app target that runs bash "${SRCROOT}/../node_modules/munim-maps/scripts/google3d/embed-google-maps-3d.sh"; see docs/providers.md).
Mapbox Maps SDK 11.32 on iOS and Android, with everything the SDK offers: Mapbox Standard and Standard Satellite with light presets and themes, globe, terrain, atmosphere, lights, every layer and source type from the style spec, featureset taps, annotations, view annotations, the location puck, the viewport, snapshots and offline maps, plus munim-maps' 3D models on top.
Setup. A public token (pk.…) from your Mapbox account. Both SDKs download without a secret token.
["munim-maps", { "providers": ["mapbox"], "mapboxAccessToken": "pk.…" }]Without Expo: pod 'NitroMunimMaps/Mapbox', :path => '../node_modules/munim-maps' (pulls MapboxMaps ~> 11.32) and munimMaps.mapbox=true in android/gradle.properties (adds com.mapbox.maps:android-ndk27; pin another version with munimMaps.mapboxVersion), then configureMunimMaps({ mapboxAccessToken: 'pk.…' }).
import { MunimMapView, MAPBOX_STYLES, MarkerView, mapboxMap, MapboxOffline } from 'munim-maps'
<MunimMapView
ref={ref}
provider="mapbox"
styleUrl={MAPBOX_STYLES.standardSatellite} // or leave empty for Mapbox Standard; mapStyle="hybrid" also picks Satellite
initialCamera={{ latitude: 41.8826, longitude: -87.6278, distance: 1400, pitch: 55, heading: 30 }}
models={models} // munim-maps' 3D layer, aligned to Mapbox's camera
markers={markers} // point annotations; clusteringId clusters them
polylines={[{ id: 'walk', coordinates, dashPattern: [4, 10] }]}
showsUserLocation
userTrackingMode="followWithHeading" // Mapbox's follow-puck viewport
onMarkerDrag={(e) => console.log(e.latitude, e.longitude)} // continuous while dragging
mapbox={{
standard: { lightPreset: 'dusk', show3dObjects: true },
projection: 'globe',
terrain: { exaggeration: 1.5 },
puck: { bearing: 'heading', pulsing: { enabled: true, radius: 'accuracy' } },
sources: { stops: { type: 'geojson', data: stops, cluster: true } },
layers: [
{ id: 'stops', type: 'circle', source: 'stops', slot: 'top', paint: { 'circle-color': '#0A84FF', 'circle-radius': 6 } },
],
interactions: [{ id: 'poi', type: 'tap', featureset: { featuresetId: 'poi' } }],
events: ['mapIdle', 'sourceDataLoaded'],
}}
onProviderEvent={({ name, data }) => console.log(name, data)}
>
<MarkerView id="me" coordinate={me} anchor={{ x: 0.5, y: 1 }} draggable>
<Avatar /> {/* a Mapbox view annotation */}
</MarkerView>
</MunimMapView>
const features = await mapboxMap(ref.current).queryRenderedFeatures({ point: { x: 100, y: 200 } })
await MapboxOffline.loadTileRegion({ id: 'loop', bounds, minZoom: 10, maxZoom: 16 })mapbox={{ … }}(MapboxMapOptions) is declarative: sources, layers, images, models, imports, terrain, lights and the rest are written exactly as in the Mapbox Style Specification (kebab-case keys, expressions) and are added, updated and removed as the prop changes. Standard's slots (bottom,middle,top) go in a layer'sslot.mapboxMap(ref.current)(MapboxMapMethods):queryRenderedFeatures,querySourceFeatures, cluster expansion, feature state, partial GeoJSON updates, runtime style edits, style imports, featuresets, Mapbox's camera in zoom levels (easeTo,flyTo,cameraForCoordinates), the free camera,setViewport, theSnapshotter,getElevation,setLocationOverride(simulated positions),tileCover, performance statistics.MapboxOffline: style packs and tile regions with progress (addListener).MapboxServices: Geocoding, Search Box, Directions, Matrix and Isochrone web APIs with the public token (each request counts against your Mapbox account).- Native models. With
mapbox={{ modelRendering: 'auto' }}(the default) Mapbox draws glTF / GLBmodelsitself in itsmodellayer, so they are lit and shadowed with the map and hidden by Mapbox's 3D buildings and terrain. Drawn natively: the model body with its position, altitude (altitudeReferenceground or sea),heading, spin,motion,scale,screenSizeandtint(on the model'spaint*materials), andonModelPress. Always drawn by munim-maps' 3D layer: USDZ and built-in shapes, avatars (image), labels, stems, effects, occluders, zones and paths.autokeeps a model with a label, stem, effect,liftor animations whole on the 3D layer;'native'draws every glTF body natively and leaves only those extras on the 3D layer;'overlay'draws everything on the 3D layer. The catalogue (munim-maps-vehicles) resolves to GLB on Mapbox, so its models are drawn natively.modelRenderingonMunimMapViewis the shared name for this option. - Mapbox's own glTF
modellayer is also yours to use directly:mapbox={{ models: { bus: uri }, layers: [{ type: 'model', … }] }}.
Caveats: Mapbox's terms keep the logo and attribution on the map. Models on munim-maps' 3D layer are drawn over the map (Mapbox does not share its depth buffer), so Mapbox's 3D buildings do not hide them unless occlusion="buildings"; natively drawn models are hidden properly. On iOS the debug wireframes are not offered by the SDK. The full checklist is in docs/providers.md.
MapLibre Native draws any MapLibre style; the default is OpenFreeMap's Liberty style: OpenStreetMap data, free, no key, no account. On iOS add "maplibre" to the config plugin's providers (or pod 'NitroMunimMaps/MapLibre'); on Android it is built in.
MapLibre Native has no globe and no 3D terrain, so the maplibre provider also has MapLibre GL JS 5, in a WebView: maplibre={{ renderer: 'auto' }} (the default) uses MapLibre Native unless the map asks for the globe (globe, maplibre.projection), 3D terrain (maplibre.terrain) or a sky (maplibre.sky), and GL JS then; 'web' and 'native' force one. GL JS and three.js load from jsDelivr the first time and stay on the device (or bundle them with maplibre: { bundledWeb: true }); munim-maps' 3D layer is drawn inside GL JS, so models stand on the terrain and on the globe:
<MunimMapView
provider="maplibre"
globe // a globe when zoomed out, Mercator from zoom 12
maplibre={{ terrain: { exaggeration: 1.2 }, sky: true, hillshade: true }}
models={[{ id: 'heli', coordinate: { latitude: 46.5775, longitude: 7.9605 }, source: VEHICLES['heli-light'], screenSize: 30 }]}
/>import { MunimMapView, maplibreCommands } from 'munim-maps'
<MunimMapView
ref={ref}
provider="maplibre"
initialCamera={camera}
markers={markers} // pins, balloons, avatars, labels, dots, clusters, callouts, dragging
polylines={routes} // gradients, dashes, geodesic, partial strokes
models={vehicles} // the 3D layer, on MapLibre's camera
maplibre={{
style: 'liberty', // 'bright' | 'positron' | 'dark' | 'fiord' | 'demotiles' | 'maptiler-…' | 'stadia-…' (with apiKey)
hillshade: true, // shaded relief from keyless AWS Terrain Tiles
sources: { stops: { type: 'geojson', data: stopsGeoJSON, cluster: true } },
layers: [{ id: 'stops', type: 'circle', source: 'stops', paint: { 'circle-radius': ['step', ['get', 'point_count'], 6, 10, 12] } }],
labelLanguage: 'en',
ornaments: { scaleBar: { visible: true, position: 'bottomLeft' } },
}}
onProviderEvent={({ name, data }) => {}} // styleLoaded, idle, offlineProgress…
/>
const maplibre = maplibreCommands(ref.current)
await maplibre.queryRenderedFeatures({ point: { x, y }, layers: ['stops'] })
await maplibre.setFeatureState({ source: 'stops', id: 7, state: { selected: true } })
await maplibre.offlineCreatePack({ name: 'Loop', bounds: { south, west, north, east }, minZoom: 10, maxZoom: 16 })- The whole style spec: sources (vector, raster, raster-dem, GeoJSON with clustering, image,
pmtiles://, MLT) and all ten layer types with expressions and filters, written exactly as in a style JSON, at load (maplibre.sources,layers,images,light) or at runtime (addSource,addLayer,setPaintProperty,setLayoutProperty,setFilter,moveLayer,setFeatureState…).styleJsontakes a whole style. - Commands (
maplibreCommands(ref)): feature queries, cluster leaves and expansion zoom,flyTo,resetNorth, an offscreen snapshotter, offline packs with progress events, the ambient cache, database merges. - Markers and shapes are GeoJSON sources with style layers, so they sit in MapLibre's own layer stack, cluster natively and come back after a style change; munim-maps draws the callouts and the drag.
- Services:
addressForCoordinateandopenMapsServices(Nominatim, Photon, OSRM, Valhalla). The public servers are for light use only (Nominatim: one request a second); set your own endpoints withconfigureOpenMapsServicesandmaplibre.nominatimUrlbefore shipping. - Globe, 3D terrain, sky: through the GL JS renderer (above); with
renderer: 'native'MapLibre Native reports them unsupported and stays flat. GL JS has no offline packs (its commands reject saying so). - Not in MapLibre: traffic (no data in OpenStreetMap), Apple's place cards and Look Around. Satellite imagery needs your own tiles (
maplibre.satelliteTilesUrl) or a keyed style. - Attribution: OpenStreetMap's licence asks for it, so the attribution button stays on unless you move or hide it (
maplibre.ornaments.attribution).
Every MapLibre option, command and event, with what is left out and why, is in the MapLibre checklist.
provider="cesium" draws a 3D globe with CesiumJS 1.146 (Apache-2.0) in a WebView the engine owns (WKWebView / android.webkit.WebView; no react-native-webview). CesiumJS (13 MB) loads from jsDelivr at that pinned version the first time and is kept on the device, so neither munim-maps nor the app carries it. To put it in the app instead (offline from the first launch), add cesium@1.146.0 to the app's dependencies and turn on cesium: { bundled: true } in the config plugin (munimMaps.cesiumBundled=true, MUNIM_MAPS_CESIUM_BUNDLED=1 without Expo): the build copies the minified CesiumJS, with its licence and third-party notices, from the app's cesium package. Without a key it shows OpenStreetMap imagery on a smooth globe; a Cesium ion token adds Cesium World Terrain, Bing imagery, Cesium OSM Buildings and every ion asset.
Setup: "providers": ["cesium"] in the config plugin (and "cesiumIonToken": "…" if you have one), or the NitroMunimMaps/Cesium subspec and munimMaps.cesium=true without Expo. The catalogue (munim-maps-vehicles) resolves to GLB on Cesium, so Cesium draws it; USDZ models still work, drawn by munim-maps' native 3D layer on Cesium's camera.
import { MunimMapView, cesiumCommands, parseCesiumEvent } from 'munim-maps'
import { VEHICLES } from 'munim-maps-vehicles'
<MunimMapView
ref={ref}
provider="cesium"
initialCamera={{ latitude: 41.88, longitude: -87.63, distance: 1100, pitch: 55, heading: 30 }}
models={[{ id: 'bus', coordinate, source: VEHICLES['bus-city'], tint: '#0A84FF', screenSize: 40 }]}
markers={markers}
cesium={{
sceneMode: '3d', // '2d', 'columbus'
imagery: 'aerial', // or { type: 'wms', url, layers }, imageryLayers: [...]
terrain: 'world', // ion token; 'ellipsoid' without
photorealistic: true, // Google Photorealistic 3D Tiles (ion or a Map Tiles API key)
tilesets: [{ url: 'https://…/tileset.json', style: { color: "color('white', 0.8)" } }],
entities: czmlPackets, // every Cesium entity type, time-dynamic
dataSources: [{ type: 'geojson', url: 'https://…/parks.geojson', options: { clampToGround: true } }],
clock: { multiplier: 60, shouldAnimate: true },
shadows: true,
globe: { enableLighting: true },
widgets: { timeline: true, animation: true },
}}
onProviderEvent={(e) => {
const event = parseCesiumEvent(e) // undefined for other engines' events
if (event?.name === 'pick') console.log(event.data.kind, event.data.properties)
}}
/>
const cesium = cesiumCommands(ref.current)
await cesium.flyTo({ destination: { latitude: 48.8584, longitude: 2.2945, height: 1500 }, orientation: { pitch: -35 } })
const { meters } = await cesium.measureDistance({ points })
const heights = await cesium.sampleHeights({ points, includeTiles: true })
await cesium.loadDataSource({ type: 'kml', url: 'https://…/tour.kml', flyTo: true })Everything CesiumJS offers is reachable: imagery and terrain providers, 3D Tiles (OSM Buildings, Google Photorealistic, I3S, voxels, vector tiles, iTwin, Gaussian splats), CZML / GeoJSON / KML / GPX, the clock and timeline, scene modes, lighting, atmosphere, shadows, fog, clouds, post-processing, picking, measuring, terrain heights, particle systems, panoramas, screenshots and the Viewer widgets, and cesium.evaluate({ script }) (with allowEvaluate) for anything else. The checklist, with what is left out and why, is in docs/providers.md. Caveats: WebGL in a WebView is heavier than a native SDK; Cesium renders on demand to save battery; data attributions stay on screen.
munim-maps does not need its own map. Pick whichever fits your app; models, vehicles, avatars, labels and zones work the same on all of them.
| Map | How | Status |
|---|---|---|
| Apple MapKit, built in | <MunimMapView> |
✅ Tested on device |
| react-native-maps (iOS, Apple Maps provider) | <MapView testID="map"> then <MapModelLayer mapTestID="map"> |
✅ Tested on device (self-test) |
expo-maps AppleMaps.View (iOS 17+) |
Wrap it in <View testID="map" collapsable={false}>, then <MapModelLayer mapTestID="map"> |
✅ Tested on device (self-test); see Over expo-maps |
Any other React Native map built on MapKit (MKMapView, including SwiftUI's Map) |
<MapModelLayer> after it; give the map (or a view around it) a testID, or let the layer find the nearest MapKit map |
Supported: the layer looks for the MKMapView inside the tagged view, so it works with any library that uses one |
| react-native-maps (Android: Google Maps) | <MapView testID="map"> then <MapModelLayer mapTestID="map"> |
✅ Tested on a phone: within 0.35 pt of Google's own projection; see On Android |
| @rnmapbox/maps (Android) | <MapView testID="map"> then <MapModelLayer mapTestID="map"> |
✅ Tested on a phone: within 0.09 pt of Mapbox's own projection; see On Android |
| UIKit or SwiftUI, no React Native | MunimMapKitView, MunimMap or MunimModelLayer from the Swift package |
✅ Builds with Swift Package Manager |
| Google Maps or Mapbox on iOS, MapLibre React Native | Not supported: on iOS the layer draws over MapKit maps only, and MapLibre React Native has no adapter yet. Use MunimMapView with provider |
❌ |
<View style={{ flex: 1 }}>
<MapView style={StyleSheet.absoluteFill} testID="map" pitchEnabled />
<MapModelLayer mapTestID="map" models={models} zones={zones} />
</View>The layer sits on top of the map, never takes touches (the map keeps every gesture, and onModelPress still fires for taps on models), and draws with MapKit's own camera, so you keep all of react-native-maps' markers, polylines and callouts alongside the 3D.
import { AppleMaps } from 'expo-maps'
<View style={{ flex: 1 }}>
<View testID="map" collapsable={false} style={StyleSheet.absoluteFill}>
<AppleMaps.View style={StyleSheet.absoluteFill} cameraPosition={{ coordinates, zoom: 16 }} />
</View>
<MapModelLayer mapTestID="map" models={models} />
</View>AppleMaps.View is SwiftUI's Map, which draws with an MKMapView inside, so the layer finds it and reads its camera the same way. Its props have no testID, so put the testID on a View around it (collapsable={false} keeps React Native from flattening that view away), or leave out mapTestID and the layer takes the nearest map. On an iPad Air, models stayed within 1.6 points of MapKit at zoom 15, 16 and 17. expo-maps' own camera API only sets a centre and a zoom level, so pitched and rotated views (with gestures) were checked by eye, not measured.
import Mapbox from '@rnmapbox/maps'
<View style={{ flex: 1 }}>
<Mapbox.MapView testID="map" style={StyleSheet.absoluteFill}>
<Mapbox.Camera defaultSettings={{ centerCoordinate: [-87.6278, 41.8826], zoomLevel: 16, pitch: 55 }} />
</Mapbox.MapView>
<MapModelLayer mapTestID="map" models={models} zones={zones} />
</View>react-native-maps is the same as on iOS: <MapView testID="map" /> (Google Maps on Android, with or without provider="google"), then <MapModelLayer mapTestID="map" /> next to it.
Nothing to turn on: munim-maps compiles its adapter for each library when that library is in the app (it finds the library's Gradle project), against the map SDK the library already brings, so it adds nothing to the APK and needs no Gradle property (munimMaps.googleView / munimMaps.mapboxView=false turn one off). The layer finds the library's own map view inside the view with that testID (or the nearest one when there is no mapTestID), keeps its 3D view exactly over it, and reads the map's camera every frame:
- @rnmapbox/maps: Mapbox's camera exactly (centre, zoom on 512-point tiles, pitch, bearing, padding, Mapbox's 36.87° field of view, and its centre of perspective moved by padding). On a Galaxy A14 (Android 15), models were within 0.09 pt of Mapbox's own
pixelForCoordinateat seven cameras, one with 160 points of camera padding, and during a camera animation. While the map is dragged, the models follow within a frame: on screenshots during a 2.5-second drag a model stayed within 2 pt of the spot Mapbox draws under it (Mapbox renders on its own thread, so a fast fling can be one frame apart, about 4 pt), and on the spot (0 to 0.4 pt) as soon as the map stops. Taps on models fireonModelPress(through Mapbox's gestures plugin), and@rnmapbox/maps' ownonPressstill fires. - react-native-maps (Google): Google publishes a zoom level, not a camera, so its distance and field of view (30° on Android) are measured from Google's own projection, as munim-maps' Google engine does, and the centre comes from where Google draws the target, so
mapPaddingis followed. Within 0.35 pt of Google's projection at the same seven cameras (0.19 pt with padding) and during an animation; within 2 pt on screenshots while dragging.onModelPressdoes not fire over react-native-maps on Android: Google's map takes a single click listener, which react-native-maps owns.
<MunimMapView style={{ flex: 1 }} initialCamera={camera} models={models} zones={zones} />Any 3D file works as a model's source, not just the catalogue. Files are read in metres, and munim-maps puts the model's lowest point on the ground at its coordinate.
| Format | Extensions | Loaded with | Notes |
|---|---|---|---|
| USDZ | .usdz |
SceneKit | Recommended. Materials, textures and embedded animations. Export from Reality Composer, Blender or Reality Converter. |
| USD | .usd, .usda, .usdc |
SceneKit | |
| glTF 2.0 | .glb, .gltf |
munim-maps' own loader | PBR materials and textures, skins and the first animation. Prefer .glb (one file). Not supported: Draco or meshopt compression, KTX2 textures, morph targets. |
| SceneKit | .scn |
SceneKit | |
| OBJ | .obj (+ .mtl) |
Model I/O | The .mtl and textures must sit next to the .obj, so load it from a URL or a folder on disk; a bundled .obj comes without materials. |
| PLY, STL, Alembic | .ply, .stl, .abc |
Model I/O | Meshes and vertex colours. |
// Bundled with the app (add the extension to Metro's assetExts, see Installation)
{ id: 'fox', coordinate, source: require('./assets/Fox.glb'), screenSize: 60 }
// Downloaded once and cached
{ id: 'balloon', coordinate, source: 'https://example.com/models/balloon.usdz', altitude: 40 }
{ id: 'statue', coordinate, source: { uri: 'https://example.com/statue.gltf' } } // its .bin and textures are fetched too
// A file on the device, such as a download or a LiDAR scan
{ id: 'scan', coordinate, source: `file://${documentsPath}/room.usdz` }// Swift
MunimModel(id: "fox", coordinate: c, uri: Bundle.main.url(forResource: "Fox", withExtension: "glb")!.absoluteString, screenSize: 60)How models are placed
- Size: metres;
scalemultiplies it, orscreenSizekeeps the model a number of points tall at any zoom, like a marker. - Facing: at
heading: 0the model's front faces north. USD and SceneKit files face -Z; glTF files face +Z and are turned for you. - Colour:
tintrecolours every material whose name starts withpaint, so name the body materialpaintin your 3D tool to make a model recolourable. - Animation: animations embedded in USDZ and glTF play on a loop; turn them off with
playAnimations: false. - Tips: apply transforms and set the origin before exporting, keep files to a few MB, and bake textures. Sketchfab and the Khronos glTF samples are good sources of GLB files; Apple's Reality Converter turns glTF, OBJ and FBX into USDZ.
57 models in their own package, munim-maps-vehicles, so munim-maps itself carries no models and apps only get the ones they show. Each model's paint can be recoloured with tint; models face north at heading 0, are sized in real metres and sit on the ground.
import { VEHICLES } from 'munim-maps-vehicles'
{ id: 'ride', coordinate, source: VEHICLES['car-ev'], tint: '#E5484D', heading: 90, screenSize: 15 }- From a CDN (
VEHICLES[name]): each model is on jsDelivr at the package's version, as USDZ and GLB. munim-maps picks the format the engine draws (USDZ for its SceneKit layer on iOS, GLB for Mapbox's and Cesium's own models and on Android), downloads it the first time it is shown and keeps it in the app's cache folder, so it works offline afterwards.configureMunimMapsVehicles({ baseUrl })loads them from your own server instead (a copy of the package'susdz/andglb/folders).VEHICLE_NAMESlists them;VehicleNameis their type. - Inside the app, offline from the first launch: import one model at a time, and only those are bundled (add
usdzandglbto Metro'sassetExts):
import carEv from 'munim-maps-vehicles/bundled/car-ev' // USDZ on iOS, GLB on Android
import carEvGlb from 'munim-maps-vehicles/bundled/glb/car-ev' // GLB everywhere (Mapbox or Cesium on iOS)
{ id: 'ride', coordinate, source: carEv, tint: '#E5484D', screenSize: 15 }Moving from 0.4: munim-maps/vehicles and munim-maps/vehicles-glb now throw an error that says where the catalogue went. Install munim-maps-vehicles and change the import to import { VEHICLES } from 'munim-maps-vehicles' (or bundled imports for the models you ship). VEHICLES_GLB is gone: the same sources carry GLB, and munim-maps uses it where an engine draws glTF.
| Name | Vehicle | Modelled on |
|---|---|---|
car-sedan |
Four-door sedan: beltline crease, glass, door seams and lamps on the body | Mid-size sedan proportions |
car-ev |
Electric fastback, closed nose with light bar | Model 3-style EV |
car-hatchback |
Five-door hatchback | Compact hatchback |
car-wagon |
Estate / station wagon | Mid-size wagon |
car-suv |
Mid-size SUV | Two-row SUV |
car-offroader |
Boxy off-roader with spare wheel and roof rack | Wrangler-style 4x4 |
car-sports |
Sports coupe with spoiler and twin exhausts | Front-engine coupe |
car-supercar |
Low wide supercar with wing | Mid-engine supercar |
car-convertible |
Roadster with an open cabin, shaped seats and a framed windscreen | Two-seat convertible |
car-pickup |
Full-size pickup with open bed | F-150-style pickup |
car-minivan |
Minivan | Three-row minivan |
car-taxi |
Taxi with roof sign and stripe | Yellow cab |
car-police |
Police car with light bar | Patrol sedan |
| Name | Vehicle |
|---|---|
van-delivery |
High-roof delivery van: raked windscreen, sliding door, twin rear doors, wrap-round bumpers and arch flares |
van-ambulance |
Type II ambulance: roof light bar, corner flashers, red stripe and stars of life |
truck-box |
Cab-over box truck: 20 ft box with aluminium rails, roll-up door, DOT tape and dual rear wheels |
truck-semi |
Long-hood tractor (chrome grille, swept fenders, air cleaners, stacks, sleeper) with a 53 ft dry van, side skirts and swing doors |
truck-fire |
Custom-cab pumper: crew cab, white roof cap, roll-up compartments, pump panel, ladders, hose bed and chevrons |
bus-city |
40 ft low-floor bus: wraparound windscreen, LED sign, glazed doors, flush window band and roof fairing |
bus-school |
Conventional school bus: split-sash windows, rub rails, warning lamps, stop and crossing arms, crossover mirrors |
| Name | Vehicle |
|---|---|
bike-road |
Road bike with drop bars, wire-spoked wheels and drivetrain |
bike-mountain |
Mountain bike with suspension fork and wide tyres |
bike-city |
City bike with basket, rack and fenders |
scooter-kick |
Electric kick scooter |
scooter-moped |
Step-through scooter: leg shield, bulbous side cowls, round headlamp, two-tone seat and rack |
motorcycle-sport |
Superbike: twin-spar frame, gold fork, inline-four, full fairing and screen, split five-spoke wheels, twin discs |
motorcycle-cruiser |
Cruiser: finned 45-degree V-twin, chrome nacelle and headlamp, teardrop tank, deep fenders, shotgun pipes, laced wheels |
motorcycle-dirt |
MX bike: long-travel fork, knobbly tyres on laced wheels, shrouds, flat seat, number plates and a high pipe |
| Name | Vehicle |
|---|---|
rail-tram |
Two-section low-floor tram: raked cabs, flush glazing, glazed double doors, bellows, pantograph and bogies |
rail-highspeed |
High-speed train: sculpted power-car nose, trailer car, livery sweep, window band, pantograph and bogies |
boat-speed |
Bowrider: deep-V hull, open bow seating, walk-through windscreen, consoles, bow rails and an outboard |
boat-sail |
33 ft sloop: coachroof with ports, teak decks, mainsail and jib, shrouds, lifelines and wheel |
boat-yacht |
35 m superyacht: dark hull, three decks with swept window bands, hardtop, radar mast, rails and a tender |
boat-jetski |
Personal watercraft: deep-V hull with chines, footwells, stepped seat, steering pod and jet nozzle |
| Name | Aircraft | Modelled on |
|---|---|---|
plane-airliner |
Narrow-body twinjet with winglets, window rows and landing gear | A320/737-class |
plane-widebody |
Wide-body twinjet with six-wheel bogies | 777/787-class |
plane-jet |
Business jet with big oval windows, rear engines and a T-tail | Long-range business jet |
plane-prop |
High-wing single: strut-braced wing, wheel fairings, windows and control surfaces | Cessna 172-style |
jet-f16 |
Single-engine fighter with chin intake | F-16 |
jet-f22 |
Stealth fighter, diamond wing, twin canted tails | F-22 |
jet-f35 |
Stealth fighter, single engine | F-35 |
jet-yf23 |
Stealth prototype, diamond wing, V-tails | YF-23 |
heli-light |
Light helicopter: bubble windscreen, four-blade rotor, endplate stabiliser, tail rotor and skids | Bell 407-style |
balloon |
Hot air balloon with basket |
| Name | Rocket |
|---|---|
rocket-starship |
Starship on Super Heavy: stainless steel rings and welds, four grid fins, chines, the vented hot-staging ring, 33 Raptors, the ship's black hexagonal heat shield and flaps (123 m) |
rocket-falcon9 |
Falcon 9: octaweb and nine Merlins, folded legs, black interstage with grid fins, fairing with its seam (70 m) |
rocket-saturnv |
Saturn V: roll pattern, fins and engine fairings, five F-1s, S-IVB stripes, service module and escape tower (111 m) |
rocket-shuttle |
Space Shuttle stack: double-delta orbiter with black tiles, OMS pods and three main engines; ribbed intertank, feedline and the two boosters (56 m) |
starbase-tower |
Starbase's launch tower: steel lattice, the "chopsticks" catch arms and the ship quick-disconnect arm (146 m) |
starbase-mount |
Starbase's orbital launch mount: six legs, the ring with hold-down clamps, the booster quick-disconnect and the deluge plate on a concrete pad |
Rockets stand upright; animate a launch by raising altitude and add effect: 'exhaust'. To stand a Starship on its pad, give the tower, mount and ship the same heading (the bearing from the tower to the mount), put the ship at the mount's coordinate with altitude: 20, and the tower 22 m behind the mount.
| Name | Spacecraft |
|---|---|
satellite-iss |
International Space Station: truss, eight solar array wings with roll-out arrays, radiators, the modules, Canadarm2 and docked visitors (109 m) |
satellite-starlink |
Starlink satellite: flat bus with two long solar wings |
satellite-hubble |
Hubble Space Telescope with its aperture door open, solar arrays and antennas |
satellite-gps |
GPS III satellite in gold foil with two solar wings |
satellite-cubesat |
3U CubeSat with folding panels and antenna whips |
satellite-dragon |
Crew Dragon capsule and trunk, nose cone open |
satellite-jwst |
James Webb Space Telescope: 18 gold mirror segments and the five-layer sunshield |
Spacecraft lie flat, facing their direction of travel. screenSize sets a model's height on screen, so for flat craft use a small value: Starlink is about 1 m tall and 31 m wide, so screenSize: 1.3 draws it about 40 points wide. Put them in orbit with altitude (the ISS flies at about 420 km) and a globe map; see Satellites in orbit.
The models are generated from code (scripts/vehicles/make-vehicles.swift) and have no logos or brand names. Bodies are skinned through measured cross-sections, wings and tails are airfoil sections, and windows, seams, lights and stripes are laid onto the skin so they follow its curves.
A column per engine and platform. ✅ works (checked on a device; Android on an arm64 Google Play emulator, API 35) · 🟡 partly (see the notes) · 🔨 built, not yet checked on a device · — does not apply. The full per-feature matrix is in docs/providers.md.
| Capability | MapKit iOS | Google iOS | Google Android | Mapbox iOS | Mapbox Android | MapLibre iOS | MapLibre Android | Cesium iOS | Cesium Android | Notes |
|---|---|---|---|---|---|---|---|---|---|---|
MunimMapView |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | MapLibre: OpenFreeMap, no key; its own options, commands and events in MapLibre (open maps). |
MapModelLayer over react-native-maps |
✅ | — | ✅ | — | — | — | — | — | — | iOS react-native-maps uses MapKit; on Android it is Google Maps (no munim-maps engine needed). onModelPress is iOS only here. |
MapModelLayer over @rnmapbox/maps |
— | — | — | — | ✅ | — | — | — | — | Android; within 0.09 pt of Mapbox, padding included. See On Android. |
MapModelLayer over expo-maps |
✅ | — | — | — | — | — | — | — | — | AppleMaps.View (SwiftUI Map, iOS 17+). |
| GLB / glTF models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Android: Filament (gltfio). See Bring Your Own Model. |
| USDZ / USD / SCN, OBJ, PLY, STL models | ✅ | ✅ | — | ✅ | — | ✅ | — | ✅ native layer | — | SceneKit / Model I/O, iOS only. |
Heading, altitude, scale, screenSize, tint, spin, motion |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
Models drawn by the engine (modelRendering) |
— | ✅ 3D map | 🔨 3D map | ✅ | ✅ | ✅ GL JS | ✅ GL JS | ✅ | ✅ | Mapbox: its model layer; Cesium: entities; Google: the photorealistic 3D map. MapLibre: the GL JS renderer (three.js inside GL JS). Elsewhere munim-maps' 3D layer draws them. |
| Vehicle catalogue | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | The separate munim-maps-vehicles package (57 models): from jsDelivr (cached on the device) or bundled per model; munim-maps picks USDZ or GLB per engine. |
| Avatars, labels, stems, shapes, effects, zones, paths | ✅ | ✅ | ✅ | ✅ | 🔨 | ✅ | ✅ | ✅ | ✅ | Android's Filament layer draws all of them over any engine; see docs/providers.md. |
| Globe | ✅ | — | — | ✅ | 🔨 | ✅ GL JS | ✅ GL JS | ✅ | ✅ | MapKit: a private switch on the standard map; see Troubleshooting. Cesium is always a globe. MapLibre: the GL JS renderer (MapLibre Native has no globe). |
| 3D terrain (the map's own), sky | ✅ | — | — | ✅ | 🔨 | ✅ GL JS | ✅ GL JS | ✅ ion token | ✅ ion token | MapLibre: the GL JS renderer, keyless AWS Terrain Tiles; models stand on it. |
| Hidden behind buildings | ✅ | ✅ | 🔨 | ✅ | 🔨 | ✅ | ✅ | ✅ | ✅ | occlusion="buildings": OpenStreetMap footprints and heights. |
| Terrain height | ✅ | ✅ | 🔨 | ✅ | 🔨 | ✅ | ✅ | ✅ | ✅ | Public elevation tiles: altitudeReference: 'sea', followTerrain, groundElevation(). See Terrain. |
| Camera API, regions, conversions, gestures | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | setCamera, animateCamera, getCamera, setRegion, fitToCoordinates, pointForCoordinate… |
| Map events | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | onMapReady, onPress, onLongPress, onCameraMove, onCameraChange, onModelPress. |
Markers, clustering, callouts, MarkerView |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | MarkerView on Android draws its views into an image marker (Mapbox: a view annotation). |
Continuous marker drag (onMarkerDrag) |
🔨 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🔨 | ✅ | Between onMarkerDragStart and onMarkerDragEnd. |
| react-native-maps' region API | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | region, initialRegion, onRegionChangeStart, onRegionChangeComplete, animateToRegion. |
| Polylines, polygons, circles, tile overlays, overlay taps | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
| User location and tracking (follow, follow with heading) | ✅ | ✅ | 🔨 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | MapKit's own MKUserTrackingMode, reported back with onUserTrackingModeChange. |
| Compass, scale, tracking and 2D/3D buttons | ✅ | 🟡 | 🔨 | 🟡 | 🔨 | ✅ | ✅ | ✅ | ✅ | MapKit: built in or standalone (MapCompass, MapScale, MapUserTrackingButton); 2D/3D button iOS 17+. Mapbox: compass, scale bar and a tracking button, no 2D/3D button. |
| Place cards for tapped places | ✅ iOS 18+ | — | — | — | — | — | — | — | — | selectionAccessory. |
| Search, autocomplete, points of interest, directions, geocoding, places by id | ✅ | 🟡 googleMapsServices |
🟡 googleMapsServices |
🟡 MapboxServices |
🟡 MapboxServices |
🟡 openMapsServices |
🟡 openMapsServices |
— | — | MapKit services (MKLocalSearch, MKDirections…), usable with any engine on iOS; the Google, Mapbox and OpenStreetMap web services work with any engine. |
| Look Around view and snapshots | ✅ iOS 16+ | 🟡 Street View | 🟡 Street View | — | — | — | — | — | — | LookAroundView, lookAroundSnapshot(). |
| Map images without a view | ✅ | — | — | — | — | — | — | — | — | mapSnapshot() (MKMapSnapshotter). |
Everything in MapKit's iOS 26 and 27 SDK that a React Native app can use is available. Left out on purpose:
- macOS-only controls:
MKZoomControl,MKPitchControl,showsZoomControlsandshowsPitchControlare not on iOS. There is no standalone 2D/3D button on iOS either (SwiftUI'sMapPitchTogglehas no UIKit version), so the 2D/3D button is the map's own (pitchButtonVisibility). MKUserTrackingBarButtonItem: a navigation-bar item for UIKit; useMapUserTrackingButtonanywhere in your layout instead.- Place cards on your own markers: MapKit only shows place cards (
MKSelectionAccessory) for Apple's own places (selectableMapFeatures). For a place you found,openInMaps([place])shows its card in Apple Maps. MKGeoJSONDecoder: turns GeoJSON into the same polylines and polygons any GeoJSON library gives you in JavaScript; pass the coordinates topolylinesandpolygons.MKMultiPolyline/MKMultiPolygon: a drawing optimisation only; use several entries.MKOverlayRenderer.blendMode,MKAnnotationView.accessoryOffset, drag and drop ofMKMapItem,NSUserActivitymap items andMKDirections.Request(contentsOf:)(handling Apple Maps' directions URLs, which needs app-level URL routing): rarely needed from React Native.- Glyph images on cluster balloons: MapKit draws the member count or text, never an image, so
clusterStylestake text and emoji. - Background location: CoreLocation, not MapKit.
Render MapModelLayer right after the map, in the same parent. It covers the map, lets every touch through, and finds the map by testID.
import MapView from 'react-native-maps'
import { MapModelLayer, type MapModel } from 'munim-maps'
import { VEHICLES } from 'munim-maps-vehicles'
const models: MapModel[] = [
{
id: 'friend',
coordinate: { latitude: 41.8853, longitude: -87.6318 },
altitude: 15, // metres above the ground
image: { uri: 'https://example.com/avatar.jpg' },
imageBorder: { color: '#0A84FF', width: 3 },
badge: '5F',
stem: '#0A84FF',
},
{
id: 'car',
coordinate: { latitude: 41.8841, longitude: -87.6244 },
source: VEHICLES['car-sedan'],
tint: '#2E6FD8',
heading: 0,
screenSize: 15,
},
]
export function Map() {
return (
<View style={{ flex: 1 }}>
<MapView style={StyleSheet.absoluteFill} testID="map" pitchEnabled />
<MapModelLayer
mapTestID="map"
models={models}
onModelPress={(id) => console.log('tapped', id)}
/>
</View>
)
}import { MunimMapView } from 'munim-maps'
<MunimMapView
style={{ flex: 1 }}
initialCamera={{ latitude: 41.8838, longitude: -87.6305, distance: 2600, pitch: 62, heading: 20 }}
mapStyle="muted"
models={models}
zones={[{ id: 'park', circle: { center: { latitude: 41.8826, longitude: -87.6226 }, radius: 260 }, color: '#FF3B3040' }]}
/>| Prop | Type | Default | |
|---|---|---|---|
models |
MapModel[] |
required | |
zones |
MapZone[] |
[] |
|
mapTestID |
string |
nearest map | testID of the map to draw over (or of a view around it). iOS: MapKit maps; Android: react-native-maps and @rnmapbox/maps. |
lighting |
'auto' | 'day' | 'night' |
auto |
auto follows the map's light or dark appearance. |
paths |
MapPath[] |
[] |
Lines in 3D: at any height, and on the globe. |
maxCameraDistance |
number |
50000 |
Hide everything when the camera is farther away, in metres. Raise it for the globe. |
realisticElevation |
boolean |
false |
Keep the map on realistic elevation even when the map library sets a flat style. |
globe |
boolean |
false |
The standard map as a globe when zoomed out. Private MapKit switch. |
occlusion |
'none' | 'buildings' |
none |
Hide models behind buildings. See Hidden behind buildings. |
buildingTilesUrl |
string |
OpenFreeMap | {z}/{x}/{y} vector tiles with an OpenMapTiles building layer. |
followTerrain |
boolean |
false |
Keep models, paths and zones on MapKit's 3D terrain (satellite imagery with realistic elevation). See Terrain. |
onModelPress |
(id: string) => void |
Not over react-native-maps on Android (Google's map has one click listener, react-native-maps') | |
onAttachChange |
(attached: boolean) => void |
Fires when the map is found or lost. | |
onError |
(message: string) => void |
Load failures and other problems. | |
style |
ViewStyle |
fills the parent |
Ref (MapModelLayerRef): isAttached(), measureAlignment().
| Prop | Type | Default |
|---|---|---|
initialCamera |
MapCamera |
required |
models |
MapModel[] |
[] |
zones |
MapZone[] |
[] |
paths |
MapPath[] |
[] |
globe |
boolean |
false |
occlusion / buildingTilesUrl / followTerrain |
as above | |
mapStyle |
'standard' | 'muted' | 'hybrid' | 'imagery' |
standard |
elevation |
'flat' | 'realistic' |
realistic |
colorScheme |
'system' | 'light' | 'dark' |
system |
showsBuildings |
boolean |
true |
showsUserLocation |
boolean |
false |
lighting, maxCameraDistance, onModelPress, onError |
as above | |
onCameraChange |
(camera: MapCamera) => void |
fires when the camera stops |
Map features
| Prop | Type | Default |
|---|---|---|
markers |
MapMarker[] |
[] |
polylines |
MapPolyline[] |
[] |
polygons |
MapPolygon[] |
[] |
circles |
MapCircle[] |
[] |
tileOverlays |
MapTileOverlay[] |
[] |
clusterStyles |
MapClusterStyle[] |
MapKit's |
showsCompass / showsScale / showsTraffic |
boolean |
true / false / false |
compassVisibility / scaleVisibility |
'adaptive' | 'visible' | 'hidden' |
adaptive / hidden (override showsCompass / showsScale) |
showsUserTrackingButton |
boolean |
false |
pitchButtonVisibility |
'adaptive' | 'visible' | 'hidden' |
hidden (iOS 17+) |
mapScope |
string |
|
pointsOfInterest |
'all' | 'none' | string[] |
all (MKPOICategory… values or short names such as 'cafe') |
userTrackingMode |
'none' | 'follow' | 'followWithHeading' |
none (details) |
zoomEnabled / scrollEnabled / rotateEnabled / pitchEnabled |
boolean |
true |
cameraDistanceRange |
{ min?, max? } (metres) |
MapKit's |
cameraBoundary |
MapRegion |
none |
mapPadding |
{ top, left, bottom, right } |
0 |
selectableMapFeatures |
('pointsOfInterest' | 'territories' | 'physicalFeatures')[] |
[] |
selectionAccessory |
'none' | 'automatic' | 'callout' | 'calloutCompact' | 'calloutFull' | 'sheet' | 'openInMaps' |
none: Apple's place card for a tapped place (iOS 18+); see Place cards |
children |
MarkerView elements |
Events: onMapReady, onPress, onLongPress, onCameraMove (every frame), onCameraChange (when it stops), onMarkerPress, onMarkerDeselect, onCalloutPress, onCalloutAccessoryPress ({ id, side: 'left' | 'right' }), onClusterPress ({ clusteringId, markerIds, latitude, longitude }, markerIds comma-separated), onOverlayPress ({ id, kind, latitude, longitude } for tappable polylines, polygons and circles; taken instead of onPress), onMarkerDragStart, onMarkerDragEnd, onUserLocationChange, onUserTrackingModeChange ('none' | 'follow' | 'followWithHeading'), onMapFeaturePress (with an id for mapItemForFeature), onModelPress, onError.
Ref (MunimMapViewRef): setCamera(camera, animated), animateCamera(camera, durationMs, easing), flyCamera(keyframes, start, loop) (camera keyframes { t, camera } on the same clock as motion, stepped natively every frame), stopFlight(), getCamera(), setRegion(region, durationMs), getVisibleRegion(), fitToCoordinates(coordinates, padding, animated), fitToMarkers(ids, padding, animated) (comma-separated ids, empty for all), pointForCoordinate(coordinate), coordinateForPoint(point), selectMarker(id), deselectMarker(id), takeSnapshot(width, height) (PNG path), addressForCoordinate(coordinate), hasLookAround(coordinate), openLookAround(coordinate), mapItemForFeature(id) (the full MapItem behind a tapped map feature: phone, website, address), overlayAtPoint(point) (id of the tappable overlay a tap there would hit), measureAlignment().
Camera moves from code (setCamera, animateCamera, flyCamera, setRegion, fitTo…) end user tracking, as a pan does, and report 'none' through onUserTrackingModeChange.
| Field | Default | |
|---|---|---|
id, coordinate |
required | |
title, subtitle |
Shown in the callout, and as the text of a label. |
|
style |
marker |
pin, marker (balloon), image, avatar, label, dot. |
color |
Pin, balloon, dot or label colour. | |
glyph |
Text or emoji inside a marker balloon. |
|
image |
require(), URL or { uri } for image and avatar. |
|
size |
Width (image) or diameter (avatar, dot) in points. |
|
border |
ring 2 for avatars | { color, width }. |
badges |
{ text, position, color, textColor }[]: pills at top-left, top-right, bottom-left, bottom-right or bottom. |
|
anchor |
centre (bottom for images) | { x, y } in 0...1. |
zIndex, draggable, clusteringId, callout, opacity, visible |
||
displayPriority |
'required' |
'required' (1000, never hidden), 'high' (750), 'low' (250) or 0...1000: what MapKit hides first where markers overlap. |
collisionMode |
'rectangle' |
'rectangle', 'circle' or 'none'. |
titleVisibility, subtitleVisibility |
'adaptive' |
marker style: when the title and subtitle show under the balloon. |
glyphSymbol, selectedGlyphSymbol |
marker style: an SF Symbol in the balloon, and while selected ('cup.and.saucer.fill'). |
|
glyphColor |
white | marker style. |
animatesWhenAdded |
false |
marker style: MapKit's drop-in animation. |
calloutLeft, calloutRight |
none, 'detail' |
'detail', 'info', { text } or { symbol } (a button), { image } or { symbol, button: false } (a picture); null for none. Taps fire onCalloutAccessoryPress. |
calloutDetail |
Several lines of text in the callout, in place of the subtitle. |
On iOS 26 and 27 MapKit shows no callout bubble for the balloon (marker) style: selecting one enlarges it and shows its title and subtitle under it. Pins, images, avatars, labels and dots show their callout.
MapClusterStyle (clusterStyles): { clusteringId, color?, glyphColor?, glyph? ('{count}' is the number of markers, emoji welcome), title? ('{count} cafés'), subtitle?, displayPriority? }. Tapping a cluster fires onClusterPress; ref.fitToMarkers(event.markerIds, padding, true) zooms in on it.
MapPolyline:coordinates,strokeColor,strokeColors(a gradient along the line,MKGradientPolylineRenderer) withstrokeColorLocations(0...1, default evenly spaced),strokeWidth,dashPattern([4, 10]),geodesic,lineCap,lineJoin('round','bevel','miter'),strokeStart/strokeEnd(draw part of the line, 0...1; changestrokeEndover time to animate a route, it updates in place),level,tappable,zIndex.MapPolygon:coordinates,holes,strokeColor,fillColor,strokeWidth,dashPattern,lineJoin,level,tappable,zIndex.MapCircle:center,radius(metres),strokeColor,fillColor,strokeWidth,dashPattern,level,tappable,zIndex.MapTileOverlay:urlTemplate({z}/{x}/{y}),replacesMap,minimumZoom,maximumZoom,opacity,level,zIndex.level:'aboveLabels'(default for shapes) draws over MapKit's labels;'aboveRoads'(default for tiles) draws under labels and buildings, like Apple Maps' routes.tappable(defaulttrue): taps on the shape fireonOverlayPresswhile that prop is set (the topmost shape wins: lines within a few points, polygons inside with holes cut out, circles inside the radius).
React Native views as a marker, like SwiftUI's Annotation { … }. Put it inside MunimMapView:
<MunimMapView initialCamera={camera} onMarkerPress={(id) => console.log(id)}>
<MarkerView id="bean" coordinate={{ latitude: 41.8827, longitude: -87.6233 }} anchor={{ x: 0.5, y: 1 }}>
<View style={styles.bubble}>
<Text>🫘 Cloud Gate</Text>
</View>
</MarkerView>
</MunimMapView>It takes the MapMarker fields that are not about the marker's look (id, coordinate, anchor (default the centre), title, subtitle, callout…, zIndex, draggable, clusteringId, displayPriority, collisionMode, opacity, visible), plus tracksViewChanges.
How it works, and the trade-off: Nitro views can hold React Native children, but MapKit positions markers itself, so the children are laid out off screen and drawn into the picture of a real MKAnnotationView. That marker moves with the map in the same frame, clusters, collides, selects, drags and shows callouts like any other, and its events are the map's marker events. But it is a picture, not live views: buttons inside it do not get taps (the whole marker does), and changes show when the MarkerView re-renders (and for a second after, so images can load), or continuously with tracksViewChanges (15 redraws a second, so turn it off once the content is stable, as with react-native-maps).
MapKit's own controls placed anywhere in your layout, like SwiftUI's MapCompass(scope:). Give the map a mapScope and the controls the same name; hide the map's own (compassVisibility="hidden") so there is only one.
<MunimMapView mapScope="main" compassVisibility="hidden" … />
<View style={styles.toolbar}>
<MapUserTrackingButton mapScope="main" />
<MapCompass mapScope="main" visibility="visible" />
<MapScale mapScope="main" visibility="visible" alignment="leading" style={{ width: 160 }} />
</View>| Prop | ||
|---|---|---|
mapScope |
required | The map's mapScope. |
visibility |
'adaptive' |
MapCompass, MapScale: 'adaptive' (while rotated, while zooming), 'visible', 'hidden'. |
alignment |
'leading' |
MapScale: 'leading', 'trailing', 'center' (iOS 26). |
style |
44 × 44 (scale 150 × 24) |
Apple's Look Around (MKLookAroundViewController) inside your layout, like SwiftUI's LookAroundPreview. Tap it to go full screen. iOS 16+.
| Prop | Default | |
|---|---|---|
coordinate |
Where to look, or | |
mapItemId |
a place id (MapItem.identifier, iOS 18+), which wins. |
|
showsRoadLabels |
true |
|
pointsOfInterest |
'all' |
'all', 'none' or categories. |
navigationEnabled |
true |
Let the user move along the street. |
badgePosition |
'topLeading' |
'topLeading', 'topTrailing', 'bottomTrailing'. |
onSceneChange |
(available: boolean): whether Apple has imagery there. |
|
onFullScreenChange |
(fullScreen: boolean) |
|
onError, style |
Functions, no map needed. All return promises and reject on Android.
| Function | MapKit | Returns |
|---|---|---|
searchPlaces({ query, region?, regionRequired?, resultTypes?, pointsOfInterest? }) |
MKLocalSearch |
MapItem[] |
createSearchCompleter({ region?, resultTypes?, pointsOfInterest?, onResults, onError? }) |
MKLocalSearchCompleter |
{ setQuery, setRegion, setResultTypes, setPointsOfInterest, resolve(completion), cancel } |
pointsOfInterest({ center, radius? | region, categories? }) |
MKLocalPointsOfInterestRequest |
MapItem[] (radius up to 2 km) |
directions({ from, to, transportType?, alternates?, departureDate?, arrivalDate?, avoidTolls?, avoidHighways? }) |
MKDirections |
Route[] |
eta(sameOptions) |
MKDirections.calculateETA |
{ expectedTravelTime, distance, expectedArrivalDate, expectedDepartureDate, transportType } |
geocode(address, region?) |
MKGeocodingRequest (iOS 26), CLGeocoder |
MapItem[] |
reverseGeocode(coordinate) |
MKReverseGeocodingRequest (iOS 26), CLGeocoder |
MapItem[] |
mapItem(identifier) |
MKMapItemRequest (iOS 18) |
MapItem |
openInMaps(items, { directionsMode?, camera?, region?, mapStyle?, showsTraffic? }) |
MKMapItem.openMaps |
boolean |
mapSnapshot({ region | camera, width, height, mapStyle?, elevation?, colorScheme?, pointsOfInterest?, showsBuildings?, showsTraffic? }) |
MKMapSnapshotter |
PNG path |
hasLookAround(coordinate) |
MKLookAroundSceneRequest |
boolean |
lookAroundSnapshot({ coordinate | mapItemId, width, height, pointsOfInterest?, colorScheme? }) |
MKLookAroundSnapshotter |
PNG path |
formatDistance(meters, { units?, style? }) |
MKDistanceFormatter |
"1.2 mi" |
routePolyline(route, { id, strokeColor?, strokeColors?, strokeWidth? }) |
a polylines entry drawn like Apple Maps (under labels, round caps) |
MapItem:identifier(MKMapItem.Identifier, iOS 18+, stable between launches),name,phoneNumber,url,category(MKPOICategory…),timeZone,latitude,longitude,isCurrentLocation, andaddress(name,street,city,region,postalCode,country,countryCode,formatted,shortAddress).Route:name,distance(m),expectedTravelTime(s),transportType,advisoryNotices,hasTolls,hasHighways,coordinates(the full line) andsteps(instructions,notice,distance,transportType,coordinates).- Waypoints (
from,to): a coordinate, aMapItem,{ mapItemId }or'currentLocation'. transportType:'automobile'(default),'walking','cycling','transit'(travel times only: MapKit gives no transit routes) or'any'.directionsMode(openInMaps):'none'(show the places),'automatic'(the person's preferred mode),'driving','walking','transit','cycling'.- Categories (
pointsOfInterest,categories):MKPOICategory…raw values or their short names, such as'cafe','evCharger','nationalPark'(pointOfInterestCategory(name)converts).
| react-native-maps | munim-maps |
|---|---|
<MapView provider={PROVIDER_DEFAULT}> (iOS) |
<MunimMapView> |
<Marker coordinate title description pinColor> |
markers={[{ id, coordinate, title, subtitle, style: 'pin', color }]} |
<Marker> with a custom child view |
<MarkerView> with children (drawn into a native marker; tracksViewChanges works the same), or style: 'avatar' / 'image' / 'label' with badges |
<Marker> <Callout> with buttons |
callout, calloutLeft / calloutRight (onCalloutAccessoryPress), calloutDetail |
followsUserLocation (and patching it to follow with heading) |
userTrackingMode="followWithHeading" + onUserTrackingModeChange: MapKit owns the following, nothing recentres from JavaScript |
showsMyLocationButton / showsCompass / showsScale |
showsUserTrackingButton / compassVisibility / scaleVisibility, or MapUserTrackingButton / MapCompass / MapScale anywhere |
<Polyline strokeColors> |
strokeColors (+ strokeColorLocations), a real MapKit gradient |
<Polyline tappable onPress> |
tappable + onOverlayPress on the map |
react-native-map-clustering |
clusteringId + clusterStyles + onClusterPress (MapKit's own clustering) |
<Geojson> |
parse the GeoJSON in JavaScript and pass polylines / polygons / markers |
onPoiClick |
selectableMapFeatures + onMapFeaturePress, or Apple's place card with selectionAccessory |
| Google Places / Directions APIs | searchPlaces, createSearchCompleter, directions, geocode (MapKit, no API key) |
<Polyline> / <Polygon> / <Circle> |
polylines / polygons / circles |
<UrlTile urlTemplate> |
tileOverlays |
mapType="mutedStandard" / "hybridFlyover" |
mapStyle="muted" / mapStyle="hybrid" elevation="realistic" |
region / initialRegion |
same names: region is controlled (store the region from onRegionChangeComplete in the same state) |
onRegionChangeStart / onRegionChangeComplete |
same names; onRegionChange is onCameraMove (a MapCamera every frame) |
<Marker draggable onDragStart onDrag onDragEnd> |
draggable + onMarkerDragStart / onMarkerDrag / onMarkerDragEnd on the map |
animateToRegion(region, ms) / fitToCoordinates(coords, { edgePadding, animated }) |
same names (fitToCoordinates(coords, edgePadding, animated)) |
animateCamera({ pitch }, { duration }) |
animateCamera({ ...(await getCamera()), pitch }, ms, 'easeInOut') (a whole camera, in metres) |
pointForCoordinate / coordinateForPoint / addressForCoordinate / takeSnapshot |
same names |
MapCamera: { latitude, longitude, distance, pitch, heading } (metres from the camera to the centre, degrees).
| Field | Default | |
|---|---|---|
id |
required | Unique per layer. |
coordinate |
required | { latitude, longitude } of the model's base. |
altitude |
0 |
Metres above the ground, or above sea level with altitudeReference: 'sea'. |
altitudeReference |
'ground' |
'sea': altitude (and motion altitudes) are metres above sea level, such as a phone's GPS altitude; the ground height there is looked up and taken off, and the model shows once it has loaded. See Terrain. |
heading |
0 |
Degrees clockwise from north. |
scale |
1 |
Multiplier. Files are read in metres. |
source |
require()d asset, file:// path or http(s):// URL of a USDZ, USD, glTF / GLB, SCN, OBJ, PLY, STL or Alembic file. Remote files are cached. |
|
shape |
box |
Used without source or image: box, sphere, cylinder, cone, capsule, pyramid, gem. |
size |
10 × 10 × 10 |
Shape size in metres: { width, height, length }. |
color |
#0A84FF |
Shape colour, #RRGGBB or #RRGGBBAA. |
tint |
Recolours an asset's paint (materials named paint…). |
|
emissive |
false |
Makes a shape glow. |
image |
PNG or JPEG drawn as a round picture that always faces the camera. Replaces source and shape. |
|
imageBorder |
{ color, width } ring around the picture. |
|
badge |
Short text in a pill under the picture, such as 5F. |
|
label |
Text in a pill floating above any model. | |
stem |
false |
A line from the ground up to the model. true or a colour. |
lift |
0 |
Raises the model this many points above altitude, at any zoom. |
screenSize |
0 (44 for images) |
Keeps the model this many points tall at any zoom. |
spinDegreesPerSecond |
0 |
|
playAnimations |
true |
Loops animations embedded in a USDZ. |
groundShadow |
true (false for images) |
|
effect |
'exhaust': an engine plume pointing down from the model's base, sized to the model, stopping at the ground. 'smoke': a billowing cloud size.width metres across, for use without source. 'contrail': two white trails left in the sky behind a model moving with motion. |
|
effectIntensity |
1 |
0...1, to throttle up or let the smoke clear. |
effectOrigins |
[x, y, z][] in the model's metres (x right, y up, z back): where the effect starts, such as one contrail per engine. |
|
occluder |
false |
Draws nothing but hides other models behind it, like buildings do: stand-ins for things on the map, such as a bridge's railings. |
motion |
{ keyframes: [{ t, coordinate, altitude?, heading? }], start, loop? }: moves the model along keyframes natively every frame, so motion stays smooth whatever JavaScript is doing. start is seconds since 1970 (Date.now() / 1000); without heading the model faces where it is going. |
|
visible |
true |
| Field | Default | |
|---|---|---|
id |
required | |
circle |
{ center, radius } in metres, or |
|
polygon |
{ latitude, longitude }[], closed automatically. |
|
height |
40 |
Wall height in metres. |
color |
#0A84FF40 |
The alpha sets how see-through the wall is; the top and bottom edges are solid. |
visible |
true |
| Field | Default | |
|---|---|---|
id |
required | |
coordinates |
required | { latitude, longitude, altitude? }[], altitude in metres above the ground (or sea level). |
altitudeReference |
'ground' |
'sea': the altitudes are above sea level, as on MapModel. |
color |
#FFFFFF |
#RRGGBB or #RRGGBBAA. |
width |
2 |
Points on screen, at any zoom. |
closed |
false |
Join the last point back to the first (an orbit). |
visible |
true |
Unlike MapPolyline (a MapKit overlay, flat on the ground), a path is drawn by the 3D layer, so it also works over react-native-maps.
isSupported:trueon iOS.groundElevation(coordinates):Promise<number[]>, the height of the ground above sea level in metres at each coordinate, from the same terrain tiles munim-maps uses foraltitudeReference: 'sea'. Negative under the sea (the sea floor) and in places below sea level. Rejects if a tile cannot be downloaded.circleToPolygon(center, radiusMeters, segments?): the outline of a circle on the ground.toNativeModel(model),toNativeZone(zone),toNativePath(path): the native shapes, for testing.munim-maps-vehicles(a separate package):VEHICLES(name → remote source with USDZ and GLB),VEHICLE_NAMES,VehicleName,configureMunimMapsVehicles({ baseUrl }), andmunim-maps-vehicles/bundled/<name>for one bundled model.
MapKit's own tracking: the map follows the user and turns with the device, with the heading beam on the blue dot. MapKit drops it when the user pans or zooms away (and a camera move from code does the same), and says so through onUserTrackingModeChange, so keep the mode in state:
const [tracking, setTracking] = useState<UserTrackingMode>('followWithHeading')
<MunimMapView
initialCamera={camera}
showsUserLocation
userTrackingMode={tracking}
onUserTrackingModeChange={setTracking}
showsUserTrackingButton
/>
<Button title="Follow" onPress={() => setTracking('followWithHeading')} />Nothing recentres the map from JavaScript, so it never fights the user's pan (react-native-maps' followsUserLocation did). munim-maps asks for when-in-use location access the first time it needs it; add NSLocationWhenInUseUsageDescription to your Info.plist (expo.ios.infoPlist in app.json).
const [suggestions, setSuggestions] = useState<SearchCompletion[]>([])
const completer = useMemo(
() => createSearchCompleter({ region, onResults: setSuggestions }),
[region]
)
useEffect(() => () => completer.cancel(), [completer])
<TextInput onChangeText={(text) => completer.setQuery(text)} />
{suggestions.map((s) => (
<Pressable key={s.index} onPress={async () => {
const [place] = await completer.resolve(s)
if (place) mapRef.current?.setCamera({ ...place, distance: 1500, pitch: 45, heading: 0 }, true)
}}>
<Text>{s.title}</Text>
<Text>{s.subtitle}</Text>
</Pressable>
))}
// Or a one-off search:
const cafes = await searchPlaces({ query: 'coffee', region, resultTypes: ['pointOfInterest'] })const [route] = await directions({ from: 'currentLocation', to: place, transportType: 'automobile' })
// route.distance, route.expectedTravelTime, route.steps[0].instructions …
<MunimMapView
polylines={[routePolyline(route, { id: 'route', strokeColors: ['#30D158', '#0A84FF'] })]}
onOverlayPress={(e) => console.log('tapped', e.id)}
/>Animate it being drawn by stepping strokeEnd from 0 to 1; the line updates in place.
Let people tap Apple's own places and see Apple's place card (hours, photos, ratings, call and directions), iOS 18+:
<MunimMapView
selectableMapFeatures={['pointsOfInterest']}
selectionAccessory="automatic"
onMapFeaturePress={async (feature) => {
const place = await mapRef.current?.mapItemForFeature(feature.id)
console.log(place?.phoneNumber, place?.url)
}}
/>'callout' shows the card in a callout over the map, 'sheet' in a sheet, 'openInMaps' as a button. While a selection accessory is set MapKit shows no classic callouts on your markers, so leave it 'none' if you rely on them. For a place you found yourself, openInMaps([place]) opens its card in Apple Maps.
<LookAroundView
style={{ height: 180, borderRadius: 12, overflow: 'hidden' }}
coordinate={place}
onSceneChange={(available) => setHasImagery(available)}
/>
const path = await lookAroundSnapshot({ coordinate: place, width: 320, height: 200 })Phones report altitude above sea level, so pass it as it is with altitudeReference: 'sea' and munim-maps takes off the height of the ground there:
{
id: friend.id,
coordinate: friend.coordinate,
altitude: friend.altitude, // metres above sea level, from the phone
altitudeReference: 'sea',
image: { uri: friend.avatarUrl },
imageBorder: { color: '#0A84FF', width: 3 },
badge: `${floor}F`,
stem: '#0A84FF',
}iOS's CLLocation.altitude is already above sea level. Android's Location.getAltitude() is above the GPS ellipsoid, tens of metres different; use getMslAltitudeMeters() (Android 14+) on the sending phone. For a floor badge, groundElevation([coordinate]) gives the ground height to measure from.
MapKit does not expose terrain height, so munim-maps reads it from the free, public Terrarium elevation tiles on AWS (zoom 14, about 7-10 m per sample, interpolated), cached in memory and in the app's Caches folder.
// A hiker on Half Dome, 2,694 m above sea level, and a balloon over the valley
{ id: 'hiker', coordinate: halfDome, altitude: 2696, altitudeReference: 'sea', image: avatar, stem: true }
{ id: 'balloon', coordinate: valley, altitude: 1800, altitudeReference: 'sea', source: VEHICLES.balloon }
// On satellite imagery in 3D, keep models given above the ground on the mountain too
<MunimMapView mapStyle="hybrid" elevation="realistic" followTerrain models={models} />
// Or just the numbers
const [halfDome] = await groundElevation([{ latitude: 37.74602, longitude: -119.53313 }]) // 2693- Above sea level (
altitudeReference: 'sea'): the ground height under the model is taken off its altitude. The model is hidden until its tile has loaded (usually a fraction of a second, then cached), and moving models load the tiles along theirmotionahead of time. - On 3D terrain (
followTerrain): MapKit draws real 3D terrain for satellite imagery (hybrid,imagery) with realistic elevation, around a camera centred on the ground at the middle of the map. Models then need lifting by the difference between the ground under them and the ground at the centre, or a car on a mountainside floats or sinks.followTerraindoes this for models, paths and zones given above the ground; models above sea level always do it. Thestandardandmutedstyles stay flat (realistic elevation only shades them), so nothing changes there. - Accuracy: within a few metres of surveyed heights in most places (Denver's State Capitol 1608.7 m vs 1609 m, Half Dome 2692.5 m vs 2694 m), but sharp peaks are smoothed (Everest reads about 8,730 m) and MapKit's own terrain mesh can differ a little.
- Water: the tiles carry the sea floor under bays and oceans, so for placing models, ground below sea level counts as sea level (the map draws water there). This puts models in the few places on land below sea level (the Dead Sea, Death Valley, Dutch polders) a little high.
groundElevation()returns the raw values. - Privacy: the tiles for the area of each model, path point and (with 3D terrain) the map's centre are requested from AWS. Nothing is requested unless a model or path uses
'sea',followTerrainis on, orgroundElevation()is called. Swift apps can pointMunimTerrain.shared.tileURLTemplateat their own Terrarium-format tiles.
Two models at the same coordinate: the vehicle on the ground and the avatar lifted above it.
const at = { latitude: 41.8841, longitude: -87.6244 }
const models: MapModel[] = [
{ id: 'car', coordinate: at, source: VEHICLES['car-pickup'], tint: '#8E5A2E', heading: 45, screenSize: 16 },
{ id: 'rider', coordinate: at, image: avatar, imageBorder: { color: '#FFFFFF', width: 3 }, screenSize: 40, lift: 20 },
]{
id: 'revive',
coordinate,
shape: 'gem',
color: '#FF2D55',
emissive: true,
screenSize: 28,
spinDegreesPerSecond: 90,
label: '❤️ Revive',
}<MapModelLayer
mapTestID="map"
models={[]}
zones={[
{ id: 'safe', circle: { center, radius: 250 }, height: 40, color: '#30D15833' },
{ id: 'arena', polygon: corners, height: 60, color: '#FF3B3040' },
]}
/>import { VEHICLES } from 'munim-maps-vehicles'
<MunimMapView
style={{ flex: 1 }}
initialCamera={{ latitude: 22, longitude: -55, distance: 24_000_000, pitch: 0, heading: 0 }}
globe
maxCameraDistance={100_000_000}
models={[{ id: 'iss', coordinate: issGround, altitude: 420_000, heading: issHeading, source: VEHICLES['satellite-iss'], screenSize: 26 }]}
paths={[{ id: 'iss-orbit', coordinates: orbit.map((c) => ({ ...c, altitude: 420_000 })), color: '#FFD60AAA', width: 1.5, closed: true }]}
/>example/orbits.ts moves the ISS, a Starlink train, Hubble, a CubeSat and GPS satellites along circular orbits.
For smooth motion, describe it once as keyframes and let munim-maps move it natively every frame, instead of updating coordinate from JavaScript. Models and the camera share one clock, so a camera can follow a moving model exactly:
const start = Date.now() / 1000 + 1
const plane: MapModel = {
id: 'jet',
coordinate: from,
source: VEHICLES['jet-f22'],
effect: 'contrail',
effectOrigins: [[-0.95, 1.5, 9.1], [0.95, 1.5, 9.1]], // the two nozzles
motion: {
start,
keyframes: [
{ t: 0, coordinate: from, altitude: 300 },
{ t: 20, coordinate: to, altitude: 300 },
],
},
}
mapRef.current?.flyCamera(
[
{ t: 0, camera: { ...from, distance: 2000, pitch: 70, heading: 120 } },
{ t: 20, camera: { ...to, distance: 2000, pitch: 70, heading: 100 } },
],
start,
false
)Update a model from state, for example its altitude or coordinate; only models whose fields changed are touched natively. Use spinDegreesPerSecond or USDZ animations for motion that should not go through JavaScript.
MapKit has no public API for custom 3D content, so munim-maps draws the models itself, in a transparent Metal layer laid exactly over the map.
- Camera. Every frame it reads the map's camera (centre, altitude, pitch, heading). MapKit's camera, fitted against
MKMapView.converton device, is a pinhole camera centred on the view with a 30° vertical field of view; the centre coordinate is drawn at the centre of the map's safe area, so the camera is moved until the ray through that point lands on it. The field of view is measured from the map each frame rather than hard-coded. - Positions. Models are placed in metres around the centre of the map using Web Mercator map points, the projection MapKit draws in at street and city zoom. When MapKit draws a globe, they are placed on a sphere instead, still in metres around the centre, with the camera
distancemetres back along the ray through the centre point, and an invisible Earth hides whatever is on the far side. - Timing. Rendering happens in a run-loop observer at the end of each pass, after the map has moved. SceneKit's transaction is flushed first (otherwise SceneKit draws the previous frame's positions), and the drawable is presented straight from the GPU, the way MapKit presents the map.
- Buildings. With
occlusion="buildings", building footprints and heights are loaded from vector tiles around the camera and their walls are drawn into the depth buffer only: nothing shows, but models behind them are hidden. Avatars, labels and stems are drawn on top, so a person inside a building still shows. - Terrain. Heights come from Terrarium elevation tiles (see Terrain). When MapKit draws 3D terrain, the camera's ground plane is at the height of the ground at the centre of the map, so models are lifted by the difference between the ground under them and that height.
- Touches. The layer never takes touches. Taps are watched by a recognizer on the map that runs alongside the map's own, and hit-tested against each model.
The example app checks all of this on device: a self-test compares every model's ground point with MKMapView.convert at five camera angles on both MunimMapView and react-native-maps (under a point on iPhone 17 Pro), the same close in with the globe switched on, and a lag test (munimmapsexample://lagtest) puts a MapKit MKCircle and a model on the same spot and screenshots MapKit's own camera animation (within 0.2 px mid-animation).
- Nothing draws: check
onAttachChange(is the map found?) andonError. Givereact-native-mapsatestIDand pass it asmapTestID. require('./x.usdz')fails to bundle: add the extension (usdz,glb…) to Metro'sassetExts.- A model is huge or tiny: files are read in metres; use
scale, orscreenSizefor marker-style models. - Models disappear when zoomed out: raise
maxCameraDistance(default 50 km). - Models float or sink on mountains with satellite imagery in 3D: turn on
followTerrain(see Terrain). A model withaltitudeReference: 'sea'that never appears is waiting for its terrain tile; checkonError. - Over-the-air update crashes on an old build: munim-maps is native; ship it in a new build.
- A balloon (
marker) shows no callout bubble on iOS 26 and 27: MapKit enlarges it and shows the title under it. Usepin,imageor another style for callouts with buttons. - No callouts at all: a
selectionAccessoryother than'none'makes MapKit skip classic callouts. MarkerViewcontent looks stale: it is a picture. Re-render theMarkerViewor settracksViewChangeswhile it changes.
MapKit stops following when the user pans or zooms, and munim-maps stops it when you move the camera from code. Both report 'none' through onUserTrackingModeChange; store it in state so setting 'followWithHeading' again turns it back on. Without location access (or without NSLocationWhenInUseUsageDescription) MapKit cannot follow at all.
Hidden behind buildings
MapKit does not share its depth buffer, so by default models draw over buildings. occlusion="buildings" fixes that with OpenStreetMap building footprints and heights, loaded as vector tiles around the camera (z14, cached on the device):
- By default the tiles come from OpenFreeMap, a free service with no API key, so the area being viewed is requested from it. Point
buildingTilesUrlat your own tiles (any OpenMapTiles-schema vector tiles) to keep requests in-house. - Heights come from OpenStreetMap and can differ a little from Apple's 3D buildings, and buildings without a height are treated as about 10 m tall.
- Avatars, labels and stems are never hidden, so people inside buildings still show; vehicles, shapes and effects are.
MapKit's public API shows the globe only for satellite imagery with realistic elevation. Apple Maps shows it for the standard map through a switch on VectorKit, the engine that draws MKMapView; globe turns that switch on. It is not public API, so:
- it can stop working in an iOS update (munim-maps checks that the switch exists before using it, so the map then just stays flat);
- App Review may reject an app that uses it.
Leave globe off (the default) if that matters to you; mapStyle="hybrid" or "imagery" with realistic elevation are globes through public API.
MapKit's convert methods keep answering as if the map were flat even while it draws the globe, so far-out positions from pointForCoordinate / coordinateForPoint are off on the globe. munim-maps' own models and paths are placed on the sphere and are not affected.
Apps built with Xcode 27 must adopt the scene lifecycle or they crash at launch on iOS 27. With Expo, set enableSceneSupport in expo-build-properties; the example app does.
example/ is an Expo app: Starbase launch pads whose Starships launch on a loop, friends on Chicago skyscrapers, vehicles, power-ups, zone walls, satellites orbiting the globe (munimmapsexample://orbit), cities on the globe (munimmapsexample://cities), Yosemite in 3D with heights above sea level (munimmapsexample://terrain), models over expo-maps (munimmapsexample://expomaps), the self-test and the lag test, MapLibre GL JS with the globe, terrain and sky (munimmapsexample://maplibre/web, /checks runs its checks), and an engine picker with the same models on every map engine (munimmapsexample://providers/maplibre; Android starts there).
npm install
cd example && npx expo run:ios --device # or: npx expo run:androidDevelopment keys for Google Maps, Mapbox and Cesium are read at build time from example/.env.local or ~/.config/munim-maps/keys.env (GOOGLE_MAPS_API_KEY, MAPBOX_ACCESS_TOKEN, CESIUM_ION_TOKEN), and MUNIM_MAPS_PROVIDERS=google,mapbox picks the engines to build in; neither is committed.
- MapLibre GL JS 6 (ES modules only) and offline tile packs for MapLibre's GL JS renderer; it runs GL JS 5.24 today.
We welcome contributions! Please open an issue or a pull request on GitHub.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.














