MapView
A vector map with your markers, route lines and the user's dot — MapLibre on iOS and Android,
maplibre-gl on the web. A map that fills the screen is a destination of its own:
Router.push(map), its buttons and cards in map.widgets(...); embed a map among a screen's
children only when it is a part of that screen, a preview in a card.
- show places as markers on a map, with a card for the tapped one
- open a full-screen map with a back button and panels over it
- put a small map preview inside a screen
- draw a route line between points
- move the map to a place, or zoom to fit several places
- show the user's position as a dot on the map
- group nearby markers into clusters
- use another map style, or your own tile server
- add the map to the app and check the device has one (
MapView.isSupported)
Platforms: iOS, Android, the web and the web player; not on desktop or in headless tests — check MapView.isSupported.
At a glance
A full-screen map pushed as a destination: markers for places, a card for the tapped one, a back button — the card and the button are the map's widgets.
// app.json: "plugins": { "map": "*" }
const places = [
{ id: "opera", name: "Opera house", lngLat: [56.2415, 58.0105] as LngLat },
{ id: "embankment", name: "Embankment", lngLat: [56.2280, 58.0150] as LngLat },
]
const placeName = UIText("").style({ color: "white", fontSize: 18 })
const card = UIBottomSheet(placeName) // the place card, hidden until a marker tap
.style({ display: "none", overlayColor: null, bgColor: "#1c1c1e", p: 24 }) // no scrim: the map stays live
const back = UIWidget(UIButton(UIText("Back")).onClick(() => Router.pop()))
.style({ top: "safe-top", left: 16 })
const map = MapView({ center: [56.235, 58.012], zoom: 13 }) // the default "liberty" style
.widgets(back, card) // its controls, over it while it is open
.onTap(() => card.hide()) // a tap on the map itself closes the card
map.markers("places", { cluster: true })
.set(places.map(p => ({ id: p.id, lngLat: p.lngLat, title: p.name })))
.onTap(m => { placeName.text = places.find(p => p.id === m.id)!.name; card.show() })
Router.init(UIScreen(
MapView.isSupported
? UIButton(UIText("Show the map")).onClick(() => Router.push(map))
: UIText("No map on this device"),
))Full-screen map with buttons and panels over it: Router.push + map.widgets
A map that fills the screen — a store finder, a delivery tracker, a place picker — is a
destination of its own, like a screen: create it with MapView(...), give it its controls (a back
button, a search field, a place card) with map.widgets(...), and open it with
Router.push(map). Never make it a full-size child of a screen with panels positioned over it.
Router.push(map) // full screen; the system back button and back swipe pop it
map.widgets(back, card) // its controls: laid out over it, in its box, while it is open
map.onOpen(cb) / map.onClose(cb) // on screen / covered or popped: start and stop what it needsPushed, the map is the router's top destination: onOpen / onClose fire as it is shown, covered
by another push and popped, and its widgets are laid out over it — a tap outside a widget's box
reaches the map. The widgets are the map's own: they show while the map is the destination, never
while it is embedded in a screen. map.open() also shows it full screen, but outside the router:
it replaces the current destination and suspends the router until Router.restore().
A place card made with UIBottomSheet comes with a dimming scrim that takes every tap on the map
while the card is up (and turns the back swipe off); overlayColor: null keeps the map live under
it, as in At a glance.
Small map preview inside a screen
A map that is a part of a screen — the shop's location on its details page, a preview in a card —
is embedded among the screen's children: a box in the screen's layout, sized by its style. It
shows none of its own widgets there; for a full map with controls, push a MapView of its own.
const office: LngLat = [56.2415, 58.0105]
const preview = MapView({ center: office, zoom: 15 })
.style({ height: 200 }) // a box in the screen's layout, sized by its style
preview.markers("office").set([{ id: "office", lngLat: office, title: "Our office" }])
UIScreen(
UIText("Visit us").style({ fontSize: 22, fontWeight: 700 }),
MapView.isSupported ? preview : UIText("No map on this device"),
UIText("Lenina 1, Perm · open 10:00–19:00"),
).style({ p: 16, pt: "safe-top", gap: 12 }).open()Add the map to the app: app.json and isSupported
The map comes from the map plugin: list it in the project's app.json and guard the feature
with MapView.isSupported, which is false on desktop, in headless tests and in a build whose
app.json does not list the plugin.
{ "plugins": { "map": "*" } }The list is one for every platform (the bundle's // plugins: header): a plugin it does not name
is not there — in a shell built from the project, on the web and in the web player alike — and the
compile warns about one the app uses and does not list. The le.codes viewer app has the map
whatever the list says; its iOS App Clip has none.
On the web and in the web player the map is maplibre-gl, loaded with the first map (about 600 kB): the same styles, layers and calls as on the phones, a close but not pixel-identical look — check the look on a device.
headless tests (lecodes render / lecodes test) have no map. With
--native-view map=Map the run draws a labeled placeholder in its place — isSupported is then
true, and pushing the map and its widgets work — but every call to it rejects and onReady
never fires: test the screens and widgets around the map there, the map itself on a device.
Create a map: MapView(options)
Create the map with its style and its first view — the place it is centered on and how close:
MapView.isSupported: boolean // static — did this host register a "map" view?
MapView(options?: {
style?: string | FetchResponse | object // the map style (default "liberty") — see below
center?: [lng, lat], zoom? // zoom 0 = the whole world, about 15 = streets; 12 with a center
minZoom?, maxZoom?
bearing?, pitch? // degrees: clockwise from north / from the vertical
rotate?: boolean // the two-finger rotate gesture, default true
tilt?: boolean // the two-finger tilt gesture, default false
}): MapViewCoordinates are [longitude, latitude] everywhere (GeoJSON order — the order of the style,
the tiles and the GeoJSON data you already have). Give a center, or move the camera at once
(fitPoints, flyTo): the first view of a map without one is not the same on every host. A
MapView is a NativeView: a
destination (Router.push, onOpen / onClose / onBack) and an element of a layout
(.style()), with the raw call / on channel underneath.
the native map exists only once it is on screen and its style has loaded. Data and
camera calls made before that wait, so build the map at once and push it whenever: when the
style loads, the layers, their data, the padding and the user's dot are applied, then the waiting
camera moves in order, then onReady fires. A style reload wipes what the app put in the style,
so the map applies them again each time it reports ready, and onReady fires again.
getCamera() is the one call that does not wait: it rejects until the map is ready.
Map style and your own tile server: style
The style option is the MapLibre style — how the map looks and where its tiles come from.
Without it the map draws "liberty", a ready-made world map; another name, a style URL, a style
file bundled with the app or the style object itself work too:
MapView({ style: "dark" }) // ready-made: liberty (default), bright, positron, dark, fiord
MapView({ style: "https://tiles.example.com/styles/city/style.json" }) // a URL: the map fetches it
MapView({ style: asset("./map/style.json") }) // bundled: the style ships inside the app
fetch(styleUrl).then(res => MapView({ style: res })) // downloaded with fetch: a FetchResponse
MapView({ style: styleObject }) // the style as an object, built or edited at runtimeThe ready-made names are styles on OpenFreeMap, which serves OpenStreetMap data with no key, no registration and no request limits, commercial use included. Before you ship on it: credit it — the map draws no logo or attribution of its own on any host, so put "© OpenFreeMap © OpenMapTiles, data from OpenStreetMap" in a small widget over the map (OSM data needs the credit whatever the style) — and know it is a donation-funded public service with no SLA: a product with real traffic runs its own tiles (the whole stack is open-source) and passes that style's URL.
A URL reaches the map as a URL, and the map fetches it. Every other form crosses as the style's
text, once, at creation: asset("./map/style.json") puts the parsed style inside the bundle (a
.json asset is its data), so the style itself needs no server, and an object is the way to set a
tile server's address at runtime. The tiles, sprites and glyphs are fetched from the URLs the
style names.
Every URL inside the style must be absolute. maplibre-native — unlike maplibre-gl-js — never resolves a relative
sources[…].url/sprite/glyphsagainst the style's own address: the style loads,onReadyfires, your layers draw and the basemap stays empty. A tileserver-gl instance emits relative URLs until itspublicUrl(--public_url) is set. A style passed as a file or an object has no address at all, so the same rule holds.
Your own tile server, with the credit and the error handler:
// your tile server (tileserver-gl started with --public_url, so the urls in its style are absolute)
const map = MapView({ style: "https://tiles.example.com/styles/basic/style.json", center: [37.62, 55.75], zoom: 11 })
.onError(e => console.error("map:", e.message)) // a style or a source the map could not load
const credit = UIWidget(UIText("© OpenMapTiles © OpenStreetMap contributors").style({ fontSize: 10 }))
.style({ bottom: "safe-bottom", right: 8 }) // the map draws no attribution of its own
map.widgets(credit)
Router.init(UIScreen(UIButton(UIText("Open the map")).onClick(() => Router.push(map))))Markers for places: map.markers
Put places on the map as markers: map.markers(name) is a named layer of points, and
set(items) replaces what it shows.
map.markers(name, options?: { cluster?, clusterRadius?, clusterMaxZoom? }): MarkerLayer
markers.set(items: { id, lngLat, title?, color?, icon?, ...props }[]): this // replaces the markers
markers.clear(): this
markers.onTap(cb: (m: { id, lngLat, properties }) => void): this
map.setData(source: string, geojson: object): this // raw: any GeoJSON source of the styleEach item is a place: id comes back in onTap, lngLat is where it stands, title a label
under it, color the dot (default #DD4B40), icon an image of the style's sprite; any other key
is kept and comes back in onTap's properties. In onTap, m.lngLat is the point tapped, not
the marker's own — look the place up by m.id, as At a glance does. Calling
map.markers(name) again returns the same layer.
cluster: true groups markers that are close on screen into one circle with their count; a tap on
a cluster zooms in and calls nothing. clusterRadius is in px (default 50), clusterMaxZoom the
zoom above which markers no longer group.
Style first: when the style already declares a GeoJSON source with the layer's name, its own
layers draw it as designed (clusters with per-color counts, label rules, icon expressions) and
only the data is pushed; otherwise the plugin adds plain default layers — a dot with a white ring,
the icon, the title, the cluster circles. A multi-sprite style ("sprite": [{ id, url }, …])
names an icon by its sprite id: "place:tag_bear" is image tag_bear of the place sprite.
map.setData(source, geojson) replaces the data of any GeoJSON source the style declares.
Draw a route line: map.line
Draw a route — a path through a list of points — with map.line(name); set(points) replaces
the line.
map.line(name, options?: { color?, width?, opacity? }): LineLayer
line.set(points: [lng, lat][]): this // replaces the line
line.clear(): thiscolor is a hex string, #RRGGBB or #RRGGBBAA (default #FF4033, red): iOS and Android read no
other form, and a name like "blue" draws the default red there. width is in px (default 4),
opacity 0–1 (default 1). A layer is one line — a second route is a second map.line(name) — and,
as with markers, a source of that name in the style draws with the style's own layers. The map
plans no route: the points come from your data or a routing service.
// the points of a route: from your server or a routing service
const route: LngLat[] = [[56.2280, 58.0150], [56.2330, 58.0128], [56.2380, 58.0118], [56.2415, 58.0105]]
const map = MapView()
map.line("route", { color: "#1A73E8", width: 5 }).set(route)
map.markers("ends").set([
{ id: "start", lngLat: route[0], title: "Start", color: "#34A853" },
{ id: "finish", lngLat: route[route.length - 1], title: "Finish" },
])
map.fitPoints(route, { padding: 48 }) // the whole route in view
Router.init(UIScreen(UIButton(UIText("Show the route")).onClick(() => Router.push(map))))Move and zoom the map: flyTo, fitPoints, setPadding
Move the map to a place, or zoom it to fit several places, with the camera calls:
map.flyTo(center: [lng, lat], options?: { zoom?, bearing?, pitch?, duration? }): this // animated; duration ms, default 600
map.jumpTo(center, options?): this // at once
map.fitPoints(points: [lng, lat][], options?: { padding?, maxZoom?, animate? }): this
map.setPadding({ top?, left?, bottom?, right? }): this // px or "40%": the part of the map your UI covers
map.getCamera(): Promise<{ center, zoom, bearing, pitch }>Camera calls chain and return at once; only getCamera waits for an answer. fitPoints frames
every point, padding px from the edges (one number, or per edge), animated unless
animate: false; for a single point it zooms no closer than maxZoom, 16 by default.
setPadding is for a panel or a sheet over the map: with { bottom: "40%" } every later flyTo
and fitPoints centers in the visible upper 60 %. A zoom step doubles the scale.
const stores = [
{ id: "tverskaya", lngLat: [37.6050, 55.7650] as LngLat },
{ id: "arbat", lngLat: [37.5900, 55.7500] as LngLat },
{ id: "kurskaya", lngLat: [37.6590, 55.7580] as LngLat },
]
const panel = UIWidget(UIText("3 stores nearby").style({ fontSize: 18 }))
.style({ left: 0, right: 0, bottom: 0, height: "40%", bgColor: "white", p: 16 })
const map = MapView()
.widgets(panel)
.setPadding({ bottom: "40%" }) // the panel covers the lower 40 %: center above it
.fitPoints(stores.map(s => s.lngLat), { padding: 32 }) // every store in view, 32 px from the edges
map.markers("stores").set(stores.map(s => ({ id: s.id, lngLat: s.lngLat })))
.onTap(m => map.flyTo(stores.find(s => s.id === m.id)!.lngLat, { zoom: 16 })) // animated, 600 ms
Router.init(UIScreen(UIButton(UIText("Stores")).onClick(() => Router.push(map))))Map taps, camera moves and load errors: onTap, onMove, onError
Know when the person taps the map, when the camera stops moving, and when the style or a source fails to load:
map.onTap(cb: (tap: { lngLat, point }) => void): this // a tap that hit none of your layers; point: px in the map
map.onMove(cb: (camera: { center, zoom, bearing, pitch }) => void): this // the camera came to rest
map.onError(cb: (e: { message }) => void): this // a style or a source the map could not load
map.onReady(cb: () => void): this // the style loaded; again after every style reloadA tap on a marker goes to its layer's onTap, not to the map's, and a double tap zooms without
being a tap. onMove fires once the camera comes to rest after a gesture or an animation, not on
every frame of a drag. onError is never fatal — the map stays usable; with no handler the message
goes to console.error, so a style that did not load says so instead of leaving a blank map.
The user's dot on the map: setUserLocation
The map draws the user's dot — a blue dot, a halo for the accuracy, an arrow for the heading —
where setUserLocation puts it; the position itself, as numbers, comes from
Geolocation (getCurrent / watch). The map plugin never reads the location
and asks for no permission: an app that shows the user's position lists both plugins —
"plugins": { "map": "*", "geolocation": "*" }.
map.setUserLocation(lngLat: [lng, lat] | null, options?: { accuracy?, heading? }): this // null hides the dotaccuracy is in meters — the halo's radius on the ground, hidden while it is smaller than the
dot; heading is degrees clockwise from north — the arrow, hidden when null. The dot stays above
your layers. Start the watch when the map opens and stop it when it closes: a running watch keeps
the location hardware on.
// app.json: "plugins": { "map": "*", "geolocation": "*" }
const map = MapView({ center: [37.62, 55.75], zoom: 10 }) // the app's city, until the first fix
let watch: GeoWatch | null = null
let shown = false
map.onOpen(() => { // the map is on screen: follow the user
shown = true
let first = true
Geolocation.watch(pos => {
const here: LngLat = [pos.longitude, pos.latitude]
map.setUserLocation(here, { accuracy: pos.accuracy, heading: pos.heading })
if (first) { first = false; map.flyTo(here, { zoom: 15 }) } // fly to the first fix
})
.then(w => { if (shown && !watch) watch = w; else w.stop() }) // closed while it started: stop it
.catch(e => toast(`No location: ${e.message}`)) // "denied" | "unavailable"
})
map.onClose(() => { shown = false; watch?.stop(); watch = null }) // the location hardware goes off
Router.init(UIScreen(UIButton(UIText("Where am I?")).onClick(() => Router.push(map))))Pitfalls
Coordinates are longitude first — the reverse of the "lat, lng" habit of other map APIs:
map.flyTo([58.0105, 56.2415]) // ✗ lat, lng: a point 224 km from the opera house
map.flyTo([56.2415, 58.0105]) // ✓ lng, lat: GeoJSON orderA full-screen map is not a screen's child with panels positioned over it (a web habit): it is the destination, and the panels are its widgets.
// ✗ a full-screen map among a screen's children, a panel positioned over it
UIScreen(MapView().style({ flexGrow: 1 }), UIText("3 stores").style({ position: "absolute", bottom: 24 }))
// ✓ the map is the destination, the panel its widget
Router.push(MapView().widgets(UIWidget(UIText("3 stores")).style({ bottom: 24 })))See also
- NativeView — the channel underneath, and how to reach it raw
- UIWidget — the header / sheet over the map (
map.widgets(...)) - Geolocation — the position for
setUserLocation - Screens & Router — pushing the map as a destination