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.

TypeScript
// 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.

TypeScript
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 needs

Pushed, 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.

TypeScript
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.

JSON
{ "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.

Note

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:

TypeScript
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
}): MapView

Coordinates 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.

Note

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:

TypeScript
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 runtime

The 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 / glyphs against the style's own address: the style loads, onReady fires, your layers draw and the basemap stays empty. A tileserver-gl instance emits relative URLs until its publicUrl (--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:

TypeScript
// 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.

TypeScript
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 style

Each 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.

TypeScript
map.line(name, options?: { color?, width?, opacity? }): LineLayer
line.set(points: [lng, lat][]): this      // replaces the line
line.clear(): this

color 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.

TypeScript
// 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:

TypeScript
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.

TypeScript
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:

TypeScript
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 reload

A 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": "*" }.

TypeScript
map.setUserLocation(lngLat: [lng, lat] | null, options?: { accuracy?, heading? }): this   // null hides the dot

accuracy 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.

TypeScript
// 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:

TypeScript
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 order

A 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.

TypeScript
// ✗ 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