Skip to main content

Map

import { Map, osmVectorSource } from '@react-x11/components/maps';

// Nothing in this package fetches. You supply the request; the adapter
// supplies the URL, the schema and the attribution.
const source = osmVectorSource({
fetch: async (url, signal) => {
const response = await fetch(url, {
signal: signal as AbortSignal,
headers: { 'user-agent': 'my-app/1.0 (me@example.com)' },
});
if (response.status === 404) return null;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return new Uint8Array(await response.arrayBuffer());
},
});

<Map
sources={[source]}
defaultCamera={{ center: { lon: -0.1281, lat: 51.508 }, zoom: 13 }}
markers={[{ id: 'home', position: { lon: -0.1281, lat: 51.508 } }]}
onMarkerClick={(marker) => select(marker.id)}
style={{ height: 400 }}
/>;

A 2D slippy map: Mapbox Vector Tiles decoded and drawn, panned and zoomed, with markers, lines, areas and circles over the top. Whichever of its two renderers draws it — GL where the connection has direct GL, the retained renderer everywhere else (see "Renderers") — the whole map is drawn by one element, the rule <Flow> established and this follows: when the viewport is a transform, the element draws and does not compose.

docs/prd-maps.md is the design record: which formats are actually used, which providers serve what, what the extra layers (traffic, routes, transit) really are, and the measurements behind every performance decision here.

Sources

sources is an array, drawn in order, so a basemap and an overlay pyramid are two entries. A source is an object with a load function:

field
load(request) => TileData | Promise<TileData>. The whole seam.
idWhat load requests and onTileError call it; source-<index> by default. A name, not a cache key — see below.
minZoomShallowest level it has data for. 0 by default.
maxZoomDeepest. 14 by default; a view past it overzooms, scaling the coarser tiles.
tileSizeLogical pixels per tile edge — 512 for vector, 256 for the older raster services. Says which level is read, not just how big the image is: a 256-px source answers a zoom-12 view with its level 13, so each image is drawn at its own size rather than stretched to twice it. Get it wrong and the map is misplaced, not just soft.
attributionWhat the licence requires. Drawn in the corner; see below.

A source object is its own cache. Tiles are filed under the object you pass, not under its id: two providers can share a name, and a map switched from one to the other must not draw one's tiles as the other's. Switching back finds the first source's tiles still cached. The flip side is that a source made anew on every render starts from an empty cache on every render and fetches every tile again — so make each source once, at module scope or in useMemo. Under a controlled camera that is a render per pan step, and the map is blank for the whole of a drag. In development the map warns, once, when a slot is handed a new source that looks like the one before it three renders in a row.

TileData is { kind: 'vector', data } for MVT bytes (gzip is unwrapped for you), { kind: 'raster', width, height, data } for straight RGBA pixels, or null for "there is no tile here" — which is an ordinary answer, not an error, and is what an ocean tile in a land-only pyramid returns.

request.signal is a real AbortSignal, so it goes straight to fetch. In TypeScript it needs a cast — signal as AbortSignal — because src/ compiles with no DOM lib and cannot name the class.

It is aborted when the map stops wanting the tile before it has arrived: the tile has been panned or zoomed out of what the map is loading (the view, plus a 256-pixel margin so that an ordinary flick finds its tiles), its source has been taken out of sources, or the map has unmounted. Whatever an aborted load answers after that, its AbortError included, is ignored rather than reported to onTileError, and a tile that comes back is asked for again with a new signal. Only loads are cancelled: a tile that has arrived stays cached, so panning back to it, like switching back to its provider, is not another request.

A load that throws is an error, and errors are on a backoff. The tile is retried 0.5 s later, then 1, 2, 4 … up to 30 s, rather than on every frame; onTileError fires each time. Wire that prop up early, because nothing is drawn for a failed tile and a map whose tiles all fail looks exactly like a map that is still loading — an empty background and nothing else. MapFrameStats.errors is the same information per frame.

Two adapters ship, both for OpenStreetMap's own keyless endpoints: osmVectorSource() (the Shortbread schema, which shortbreadStyle() is written against) and osmRasterSource() (the classic raster layer). Anything else — MapTiler, Protomaps, Azure Maps, a local tile server, a PMTiles archive — is a load of your own; tileUrl() does the {z}/{x}/{y} substitution.

Providers that work

osmVectorSource and osmRasterSource ship because OpenStreetMap's own endpoints need no key and no signup, but nothing is tied to them. Anything that serves MVT over {z}/{x}/{y} is a load of a few lines.

Keyless, no registration, free:

providerschemanotes
OpenStreetMapShortbread, z0–14What the two adapters point at. Read the tile usage policy before shipping.
VersaTilesShortbread, z0–14https://tiles.versatiles.org/tiles/osm/{z}/{x}/{y}. Same schema, so shortbreadStyle() reads it unchanged — the example has it as a second layer. Its tileset merges ESA WorldCover, which the attribution has to say.
OpenFreeMapOpenMapTiles, z0–14No key, no limits. Its tile URL is dated (…/planet/20260830_080001_pt/…) and lives in a TileJSON at https://tiles.openfreemap.org/planet, so read it at startup — the example does. Pair with openMapTilesStyle().

Free tiers behind a key — MapTiler, Stadia, Geoapify, Thunderforest, Azure Maps, Mapbox, TomTom — are all the same four lines plus an environment variable; the example carries MapTiler's behind MAPTILER_KEY. Satellite imagery is only ever one of these: OpenStreetMap is map data and has none, so imagery means a provider you have the rights to — Google's mapType: 'satellite' below, Esri, Bing, Maxar, or OpenAerialMap, which is open (CC BY 4.0) but per-image rather than a global pyramid.

The schema matters more than the provider, and there is a style for each of the two open ones:

  • shortbreadStyle() — OpenStreetMap's own server, VersaTiles.
  • openMapTilesStyle() — MapTiler, Stadia, Geoapify, OpenFreeMap, and most self-hosted planets.

Same palette, same layer ids where they mean the same thing, same casing-then-fill ordering, so moving a source between schemas changes which style you pass and nothing else about how the map looks. Passing the wrong one matches no layer names and draws an empty map — no error, because a style naming a layer a tile does not have is ordinary — and that is the one failure to expect when pointing this at a new provider.

Google, and the other closed providers

googleTileSource() reads Google's Map Tiles API. It sits apart from everything above for one reason: Google publishes no vector tiles and no schema. Its own documentation describes roadmap tiles as "image tiles based on vector topographic data with Google's cartographic styling" — the vector data is Google's, the rasterizing happens on their servers, and what crosses the wire is a PNG. So there is nothing for a mapStyle to name, and passing one changes nothing.

What you choose instead is mapTyperoadmap, satellite or terrain, optionally with layerTypes: ['layerRoadmap'] for the hybrid — plus language and region, because Google localizes its cartography server-side. All of it is fixed when the session is created, which is the other thing this API does that no other source here does: one POST returns a token, good for two weeks, spent on every tile. googleTileSource creates it lazily, shares it between concurrent first tiles, and replaces it when it expires.

const google = googleTileSource({
mapType: 'satellite',
layerTypes: ['layerRoadmap'],
createSession: (body) =>
fetch(`https://tile.googleapis.com/v1/createSession?key=${KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((r) => r.json()),
fetch: (url, signal) => fetchBytes(`${url}&key=${KEY}`, signal),
decode: decodeImage,
});

The key lives in those two callbacks and never reaches the component. The example carries roadmap, satellite and hybrid behind GOOGLE_MAPS_KEY.

Three obligations come with it, and they are the application's, because they are about what is on screen rather than what this code does:

  • Attribution is required and specific. The Google Maps logo, or the text "Google Maps" where space is limited, plus the data providers ("Map data: Google, Maxar Technologies"), not obscured by anyone else's attribution. This component draws attribution as text, so the text form is what it can satisfy; a logo is an overlay of your own — <Map> takes children for exactly this.
  • The terms restrict caching. <Map> holds decoded tiles and rendered surfaces in memory for the session, which is what any renderer must do to put a tile on screen. Persisting tiles to disk is a different thing, and the policies forbid it — do not point the bench corpus scripts at Google.
  • Showing it beside another map is governed too. The terms cover displaying Google content "on, next to, or in a manner that is visually associated with" another map, so a side-by-side comparison or a Google basemap under a non-Google overlay is a question for the terms rather than for this component.

Apple and Microsoft land in the same place for the same reason. Apple's MapKit JS is a rendered map rather than a tile service and has no server tile endpoint to point at. Azure Maps does serve MVT to {z}/{x}/{y} in its own schema — neither Shortbread nor OpenMapTiles — so it needs a style written against microsoft.maps.* layer names, which is a MapStyle literal and no new code here.

Read the terms before shipping any of them: Map Tiles API policies and the Maps Platform terms.

Tile size decides which level is read

A source's tileSize is not a rendering detail. Zoom here is defined against a 512-px cell, the way MapLibre and every vector style define it, so a source whose tiles are 256 px covers one cell with two of them each way and is read one level deeper: a zoom-12 view asks a 256-px raster service for its level 13. The ground shown is identical; the grid is finer and each image lands at its natural size.

The practical consequence is at the deep end. osmRasterSource cuts to level 19, so the sharpest view of it is zoom 18, and past that each of its level-19 images is drawn larger — an image has no finer detail to draw. A 512-px vector source cutting to 14 is sharp to zoom 14 and sub-tiles past it.

Nothing here fetches, and that is the feature

A component that fetched by default would decide, on your behalf, whose servers your application talks to, what its user agent says, and whose usage policy it is now bound by. So load is yours, exactly as <Html onResource> is. Two consequences worth knowing before you go looking for the missing prop:

  • A raster source needs a decoder. PNG and JPEG need a codec, and this package will not grow one or guess which a provider serves. ntk has one and it is already installed:

    import * as ntk from 'react-x11/ntk';

    // A **named** export — `react-x11/ntk` re-exports ntk with `export *`,
    // so it is not a property of the default one — and it is not in the
    // declarations either, hence the structural cast.
    const { decodeImage } = ntk as unknown as {
    decodeImage(b: Uint8Array): {
    width: number;
    height: number;
    data: Uint8Array;
    };
    };

    const decode = (bytes: Uint8Array) => {
    const image = decodeImage(bytes);
    return { width: image.width, height: image.height, data: image.data };
    };

    OpenStreetMap has no satellite layer, and neither does this component: OSM is map data, and the Foundation serves the vector tiles and the standard raster style and nothing else. Aerial imagery in an OSM editor comes from third parties — Esri, Bing, Maxar, Mapbox — under terms that permit tracing for OSM rather than redisplay, or from OpenAerialMap, which is genuinely open (CC BY 4.0) but per-image rather than a global pyramid. An imagery layer is a MapSource of your own, pointed at whichever provider you have the rights to use, carrying their attribution.

  • The attribution is not optional. For OpenStreetMap-derived tiles it is a licence condition, so a source carries it and the map draws it in the corner. attribution="" says you have put it somewhere else yourself; there is no way to say nothing needs saying.

Props

prop
sourcesWhere tiles come from, drawn in order. Empty draws the style's background, which is what a map with only markers on it wants.
mapStyleHow to draw them. shortbreadStyle() in the theme's light or dark palette by default. Named mapStyle so that style stays react-x11's.
renderer'auto' (the default): GL where this connection has direct GL, the retained renderer everywhere else. 'gl' or 'retained' pins one. See "Renderers".
onRendererChange(renderer, reason): the map landed on a renderer it did not ask for, or changed renderer. reason is 'no-direct-gl', 'gl-failed' or 'forced'.
onErrorGL failed on a map that asked for renderer="gl", which never falls back.
camera{ center: { lon, lat }, zoom }, controlled. Leave it out and the element owns it — see "The camera" below.
defaultCameraWhere an element-owned camera starts. Read once.
onCameraChangeEvery camera move, gesture steps included.
onMoveEndOnce, after a gesture settles — the moment to fetch what is now on screen.
minZoom0 by default.
maxZoom22 by default.
markersPoints the user can click. See below.
overlaysLines, areas and circles: a route, a traffic segment, a transit shape, a GeoJSON layer.
onMapClickA click anywhere, with the position in every space that could be wanted.
onMarkerClick…and the marker, when there was one under it.
onMarkerHoverThe marker under the pointer, or null. The event is null for the leave that comes from the pointer leaving the map.
interactivefalse freezes the camera — no drag, no wheel, no keys. The map still draws and still reports clicks.
progressiveRetained renderer. Show a tile as it is drawn rather than when it is finished, and a style change as it is redrawn rather than swapped in whole. false by default, which is what every other map client does.
rasterBudgetMsRetained renderer. Milliseconds a frame may spend rasterizing tiles. 8 by default; 0 suspends it. See "Why a map fills in".
rasterScaleRetained renderer. Device pixels per logical pixel for the tile surfaces. The display's by default; 1 on a retina panel is ~1.6× quicker and correspondingly softer.
surfaceBudgetRetained renderer. Bytes of rendered tile surfaces to keep. 128 MB by default.
batchVerticesRetained renderer. The rasterizer's path-flush size, 12,000. A real trade on X11; set it only with a profile in hand.
levelFadeGL renderer. Milliseconds to cross-fade from one pyramid level to the next as the zoom crosses it; 0 (the default) cuts.
adaptiveGL renderer. true or { budgetMs } (12 by default): draw moving frames with less detail when the full one would not fit the budget — the fill edge pass first, then the style's newest detail layers, then a coarser level.
buildWorkersGL renderer. Worker threads that build tiles' geometry. 0 (the default) builds on this thread, a few milliseconds of each frame.
antialiasGL renderer. The half-pixel edge pass that antialiases fills; true by default.
fillRuleGL renderer. 'nonzero' (the default — the retained renderer's rule) or 'evenodd', a stencil pass cheaper on a table without stencilOpSeparate, and wrong wherever two features of a layer overlap.
onAfterDrawGL renderer. (gl, { width, height }) at the end of each frame, inside it: a readback, or drawing of your own over the map.
attributionOverrides what the sources say. '' removes it.
onFrameCalled once per painted frame with MapFrameStats — what it cost, how many tiles are still sharpening, how many failed, and whether a style change is still being drawn behind the old one. renderer says which renderer drew it, and a GL frame carries its GPU-side figures in gl.
onTileErrorCalled per failed tile load, with whatever the source threw. Worth wiring up first: a map whose tiles fail looks identical to one still loading.
stylereact-x11's, on the box around the pane. Fills its parent unless you give it a height or a flexGrow.
childrenAnything absolutely positioned over the map — a legend, a control panel. Over a GL map, drawn above the surface; see "Renderers".

Markers

The minimum a map API owes you, and the only thing on the map that is an object rather than cartography — which is why markers, and only markers, are what a screen reader meets.

field
idStable across renders. What an event names and what a hit test returns.
position{ lon, lat }.
shape'pin' (the default) stands on its position; 'circle' is centred on it.
sizeLogical pixels: a pin's width, a circle's diameter. 14 by default.
color / outlineThe theme's accent and background by default.
selectedDrawn above the unselected markers of its zIndex, with the selected ring, and reported to an assistive technology.
zIndexHigher draws later, and is hit first. Ties break on selected, then on array order.
interactivefalse skips it in hit testing — for a marker that is decoration.
titleWhat a screen reader announces. The position, formatted, when there is none.
dataHanded back on an event. Never read here.

markers is diffed into damage: a vehicle moving across a city claims two marker-sized boxes rather than the pane, so a live feed of a few hundred markers is a few hundred small rectangles per update and not a full repaint.

Overlays

One discriminated union, because a route, a traffic segment, a transit shape and a GeoJSON layer are all the same three shapes underneath:

  • { kind: 'line', path, color, width, dash, casing }casing is the wider stroke drawn under the line, which is what makes a route readable over a road of the same colour.
  • { kind: 'polygon', rings, fill, outline } — exterior ring first, every ring after it a hole.
  • { kind: 'circle', center, radiusMetres, fill, outline } — in ground metres, so it grows with the zoom the way a real radius does. The pixel radius is taken from the projection rather than from a metres-per-pixel constant, so it stays right at high latitudes.

Two format helpers come with them, because these are the two shapes real data actually arrives in:

  • decodePolyline(encoded, precision = 5) — Google's Encoded Polyline Algorithm Format, which is what Google Directions, OSRM, Valhalla, GraphHopper and Mapbox Directions all answer with. Precision 6 is Valhalla's and OSRM's polyline6; passing the wrong one puts the route in the Atlantic, which is the traditional way to discover this parameter.
  • geoJsonOverlays(geojson, style?) — points become markers, everything else becomes an overlay. style is asked once per feature, so colouring by a property (a traffic feed's congestion, a transit feed's route colour) needs no expression language.

Styling

mapStyle is a subset of the Mapbox/MapLibre GL style specification — shaped after that spec because every provider documents their schema in its terms, so a fragment from Shortbread's, OpenMapTiles' or Azure Maps' docs reads the same way here.

Four layer types (fill, line, circle, symbol), the spec's legacy filter syntax (['all', ['==', 'kind', 'motorway'], …]), and { stops } for anything that varies with zoom. What is deliberately absent is the expression language: it is a typed interpreter with a cost per feature, and a dense tile has twelve thousand of those. Everything here is resolved once per layer per frame.

const style: MapStyle = {
background: '#f2efe9',
layers: [
{
id: 'water',
type: 'fill',
sourceLayer: 'water_polygons',
color: '#aad3df',
},
{
id: 'roads',
type: 'line',
sourceLayer: 'streets',
minZoom: 10,
filter: ['in', 'kind', 'motorway', 'trunk', 'primary'],
color: '#fff',
width: {
stops: [
[10, 1],
[14, 3],
[18, 10],
],
},
},
],
};

SymbolLayer has one field worth knowing about before you wonder why a street is labelled once: repeatDistance (250 px by default) is how far apart the same text may repeat within a layer. A street is one feature per segment in every schema there is, so street_labels offers "Oxford Street" a dozen times over for one street, none of them overlapping any other; without the rule a placement accepts all twelve.

icon sets a pictogram on a point label — 'bus', 'tram', 'train', 'ferry' or 'airport' (MAP_ICONS) — with the text beside it. The two are placed as one box and give way as one, so a stop's name is never set without the thing that says it is a stop. iconColor, iconGlyphColor and iconSize colour and size it; a name along a street has no point to stand one on and ignores it. Both renderers draw it from the same paths.

The stock styles use it for public transport — a layer per pictogram (transport-bus, transport-tram, transport-rail, transport-ferry, transport-airport), coloured by the palette's transit — and set house numbers (house-numbers) from zoom 18, a 256-pixel map's 19, where Google Maps sets them.

A number is drawn where the address data puts it, and that is often not on the house: an address point sits in the lot, metres from the building. shortbreadStyle({ snapBuildingNumbers: true }) (and the OpenMapTiles style's) moves each number into its building's middle — SymbolLayer.snapInto on the house-numbers layer. A point inside a footprint belongs to it; one outside, to the nearest footprint within 15 m; and it moves only when it is the one number that building is claimed by, so a duplex's two numbers, or a number whose house is not mapped, stay where the data put them. Off by default, and off moves nothing.

shortbreadStyle({ dark, palette, nameField, labels, buildings }) is the default, written against OpenStreetMap's own schema: twenty-six layers, in paint order, with road casings as one pass and road fills as another — which is what makes a junction look like a junction rather than two roads crossing.

The handle

const map = useRef<MapHandle>(null);
map.current?.fitMarkers();

getCamera / setCamera / panBy / zoomIn / zoomOut / zoomTo, fitBounds(bounds, { padding, maxZoom }), fitMarkers(ids?), getBounds(), project / unproject (geography ↔ pane-local logical pixels), markerAt(x, y), refresh() (redraw every tile, after a style you edited in place — swapped in whole, exactly like a new mapStyle) and stats().

fitBounds called before layout has run — which fitBounds in an effect always is — is remembered and applied at the first paint that has a size.

The handle is one object for the life of the map, whichever renderer draws it — across a fall back from GL too — so a ref taken once keeps working.

Renderers

<Map> draws through one of two renderers and, unless told, chooses:

  • GL draws every frame from the vector data through a <glarea>, with no bitmap cache: a pan, a fractional zoom and a style change are all the same thing — a frame — at the display's rate.
  • Retained rasterizes each tile into a surface once and composites the surfaces. It runs wherever a 2D context does, and it is what a window capture sees.

renderer="auto", the default, chooses GL when both of these hold:

  1. neither the renderer prop nor REACT_X11_MAP_RENDERER in the environment says otherwise;
  2. the connection draws through the direct backend — useSupports('shaders'). On the Cocoa backend it does. On X11 it does only if the app asked, with createRoot({ glPolicy: 'auto' }) on a DRI3 or Apple-DRI server: X11's default policy is indirect GLX, which has no shaders, and a map cannot raise its connection's policy after the fact.

Otherwise it chooses the retained renderer, and it moves a map there if GL fails at run time — no surface, no context, a shader that will not build — with the camera and the handle as they were. onRendererChange(renderer, reason) says when either happens, and stats.renderer in onFrame says which renderer drew each frame. renderer="gl" never falls back: it gets GL, or onError. REACT_X11_MAP_RENDERER=retained, gl or auto overrides every map, for debugging and for CI.

The GL renderer is loaded when it is chosen, by dynamic import, so a bundle that never draws a map through GL carries none of it; while it loads, the pane shows the style's background.

What differs. Everything above the drawing is shared — the camera, the gestures, the handle and the events, the marker hit test, the label anchors, the order things are drawn in, the attribution's place — so a map looks and behaves the same on both, with these exceptions:

retainedGL
a line layer's cap and joinas the style saysround caps and round joins, always
dashany patternfour dashes at most; a longer pattern is cut
label placementin world pixels: a pan never moves a labelin screen space: names fade in and out as the view changes
a translucent strokecomposited as one patheach pixel once, under the stencil
window capturesees the mapsees nothing where the surface is
props readprogressive, rasterBudgetMs, rasterScale, surfaceBudget, batchVerticeslevelFade, adaptive, buildWorkers, antialias, fillRule, onAfterDraw

A prop only one renderer reads is accepted by both and ignored by the other, so switching renderers is never a type error and never a rewrite.

Children over a GL map. A <glarea> is stacked above every 2D thing in its window, so a legend beside it would be under it. <Map> puts its children inside the GL surface instead, and core draws a surface's children above it (useSupports('glOverlay')) — on X11 that overlay is opaque, so give a legend a background of its own.

The decisions

These are the paragraphs that look like gaps and are not.

The camera is the element's unless you take it. With camera given, the element only ever asks to move and your state is the truth. Without it, the element owns the camera and a pan never reaches React at all — which is not a convenience but the performance model: a drag step moves two numbers, blits the band that survives and repaints the strip that was exposed. Routed through useState instead, every pointer step would be a render, a commit and a full-pane damage claim.

A wheel notch is eased; a touchpad's fractions are not. A wheel clicks — one event carrying a whole notch, 0.384 of a level, with nothing in between — so applying it where it lands is a jump, and a scroll is a staircase of them. A notch therefore sets a target and the frames after it glide to it, and a second notch arriving mid-glide moves the target rather than starting again, so a fast scroll is one continuous zoom rather than six jolts. A touchpad reports what it measured instead, in fractions of a notch (ev.smooth, which rides react-x11's XI2 selection — it takes it on the first wheel a window sees), and that stream is already as smooth as the hand making it: easing it would only add lag. What it needs is somewhere to keep the fractions, because the zoom is quantized to a sixteenth of a level and a twenty-fourth of a notch is a quarter of one of those — rounded against the camera as it arrives it is nothing at all, event after event. The target holds the remainder, so a slow two-finger scroll moves a step every few events rather than never. Both of these live with the camera, in the controller, so both renderers glide the same way and a fallback in the middle of a gesture keeps the one it was in.

A tile appears whole, not layer by layer. Rasterization is resumable a style layer at a time, so a surface that exists is not a surface that is done — composited as soon as it exists, a dense tile arrives as water, then landuse, then road casings, then roads, over a dozen frames. That is honest about what the renderer is doing and it does not look like a map, so a tile is shown when it is finished and the coarser one already in the cache is scaled up until then. progressive turns the reveal back on.

And a tile being re-drawn keeps showing the old picture. Each tile has up to two renderings — the one on screen and the one being drawn — and they swap only when the new one is finished. Without that, every re-rasterization blanks the tile for the several frames a redraw takes, and above a source's maxZoom that is every integer zoom, because the same z14 tile serves 15, 16, 17 and on: a flash per zoom step, and many more of them on a backend that paints more frames a second.

The cost is memory: a tile being redrawn holds two surfaces, so a viewport mid-redraw peaks at about twice its resident bytes. surfaceBudget counts both, and eviction never touches a tile the current frame is using.

But a style change swaps the whole map at once. A new mapStyle — or refresh() after you edited one in place — redraws every tile, which at 50–140 ms a dense tile against an 8 ms budget takes a second or more. Kept per tile, the pair above would show each old tile until its own replacement landed, under a background and labels already in the new style: a patchwork of both styles for that second. So the whole previous picture stays up — its tiles, its background and its labels — while the new style is drawn behind it, and the view swaps in one frame once every tile in it that has data is redrawn. Nothing on screen changes before that, so the frames in between claim a pixel each and the swap claims the pane once; MapFrameStats's restyling is true until it has happened.

  • A tile still loading does not hold the swap. It has nothing to draw yet; it shows the new background until it arrives.
  • Moving the camera in the meantime is fine. The previous style keeps covering the view — a tile that comes into view borrows its old-style ancestor or descendants, as on any zoom — and the tiles that came in are redrawn in the new style before the swap. But a view that keeps moving can bring tiles in as fast as they are drawn, so once the camera has moved the wait is bounded: after a second and a half of drawing time (a gesture draws nothing, so it does not count) the swap happens anyway, and a tile not yet redrawn shows the new background until it is.
  • A picture in a style the map has left is never shown again. The swap releases every surface of the old style, those of tiles off screen included, and a tile last drawn before a switch is redrawn when it comes back into view rather than shown the way it was.
  • Raster tiles take no part in any of it. A provider's image is the same in every style, so a switch does not redraw one: it stays up through the switch, keeps covering whatever it covered — a tile the provider has no image for shows its parent, scaled — and one that arrives while a switch is held goes up at once.
  • progressive turns all of this off, which is what it is for: the new background and labels go up at once, and each tile goes up as it is redrawn.

A display-scale change is not a style change and swaps nothing: each tile keeps its old picture, at the old resolution, until its sharper one is drawn.

What none of this helps is a first load — a tile nothing has ever drawn has no old picture of its own to keep. What covers it is whatever is cached nearby, and which direction that lies in says which way the camera moved:

  • Zooming in, the tile in hand is the target's ancestor — one composite, scaled up, blurry but complete.
  • Zooming out, the tiles in hand are its descendants — several composites, scaled down, sharp but only as complete as the pieces that are cached. Without this a zoom-out shows the background, with the labels and markers still drawn over it, until the coarser tile has been fetched, rasterized and composited.

Descendants win when they cover the whole square, because they are sharper and they are the level the camera is coming from; the ancestor wins when they do not, because a complete blurry picture beats a sharp one with holes in it. A cold map with neither shows its background. MapFrameStats counts both as fromAncestor and fromDescendant.

A frame that only continues a redraw claims one pixel. There is no "call me next frame" on the element seam — damage is what schedules a paint — so a rasterization in progress asks for its next frame with a single-pixel claim. Nothing on screen changes until the tile lands (it is being drawn into a second surface), and the frame where it does lands claims that tile's box. Claiming the pane instead repaints the whole map at the refresh rate for the several frames a redraw takes: invisible work on X11, and a visible burst of repaints at the end of every zoom on the Cocoa backend, which paints many more frames a second. MapFrameStats.damage is how to see this.

Why a map fills in. A dense city tile is 50–140 ms to rasterize (the PRD has the measurements, on both backends). That is a software rasterizer drawing a hundred thousand vertices, and no arrangement of this component makes it free. What it does instead is make sure it is never in a frame: rasterization is resumable by style layer and a frame spends at most rasterBudgetMs on it, so a slow tile is drawn over a dozen frames and no frame is late. At least one tile is drawn per frame whatever the budget, because a budget smaller than one unit of work is not "do less" but "do nothing", and a frame that finished nothing asks for another one. While a tile is incomplete the map shows the coarser ancestor already in the cache, scaled — which is why zooming in sharpens rather than flashing empty.

A gesture rasterizes nothing. The budget is zero for the length of a drag or a wheel and for a moment after it, so a gesture is composites only. This is what the per-tile surfaces buy: a pan composites the same surfaces at new offsets, a fractional zoom composites them scaled, and neither touches the rasterizer. Only crossing an integer zoom does.

Labels are not in the tiles. They are collected from the symbol layers, placed against each other in world pixels, and drawn into the frame. Collision is global (two labels in different tiles overlap as readily as two in one), text must not be stretched by a fractional zoom, and a tile's labels would be clipped at its edge — which is where half of them sit. Placing in world rather than screen pixels is what lets a pan translate an existing placement rather than recompute it, which is what keeps the pan a blit.

A street's name follows its street — straight. Both renderers take their anchors from src/maps/anchors.ts: a street's pieces are merged into the polylines they were cut from and walked into straight runs, and a name goes at the middle of a run or of a block between two crossings, turned to lie along it and never upside down. A name longer than its straight stretch is not set there, and no name bends around a corner — the curved labels a winding road would want are what is not done. An area's name goes at the middle of its largest ring's box, which is inside every convex shape and not at the pole of inaccessibility. See docs/prd-maps-gl.md, "Labels".

Rotation and pitch are not here. This is a north-up 2D map. Both are real features and neither is a small one — a rotated viewport changes the tile cover, the label placement and every hit test — and the PRD records what they would take.

maxZoom on a source is the data, not the map — and past it the map sub-tiles rather than stretches. OpenStreetMap cuts Shortbread to z14. A view at zoom 20 does not draw one z14 tile at sixty-four times its size: the cover synthesizes z20 tiles, 4,096 of them share that one z14 fetch, and each is rasterized at its own natural size with the parent's geometry clipped to it. So a zoom-20 view is drawn at screen resolution from vector data, not upscaled from a bitmap — 1:1 up to about zoom 21, where the surface cap finally bites.

What does run out is the data: at zoom 20 one unit of a z14 tile's 4,096-unit grid is already 16 device pixels across, so there is no more shape in the tile to draw. Six levels of synthesis is the cap, for that reason rather than for a rendering one.

Cost, measured on a dense central-London tile: the whole z14 tile is 56 ms to rasterize; one of its four z15 cells is 37 ms, one of 256 z18 cells is 7.7 ms, and one of 4,096 z20 cells is 6 ms — because a feature whose box misses the cell is skipped before it becomes a path. Deep zoom is cheaper per tile than shallow zoom, and only the handful on screen are ever built.

A raster source is the exception: past its depth it stretches. An image has no detail finer than its pixels, so there is nothing to draw a cell of one with but the whole image. Past a raster source's maxZoom each of its deepest images is drawn whole and larger, from one surface, however many cells of the view it spans.

A missing tile, a 404 and an empty ocean are the same answer. null from load, and the map draws its background there. Only a load that throws is an error, and it is retried on a backoff rather than on the next frame — a source that is down would otherwise be asked for every visible tile sixty times a second, which is a retry storm pointed at somebody else's servers.

Everything is clipped to the viewport, and it has to be. Two separate limits, both in XRender and both reached in ordinary use:

  • Tile composites are int16. An overzoomed tile dwarfs the pane — at zoom 22 against a pyramid that stops at 14, one tile is 512 · 2^8 = 131,072 logical pixels across — so a tile that overlaps the pane can start 73,000 pixels outside it. The destination rectangle is clipped and the source rectangle moved to match, which leaves the scale factor exactly what it was.
  • Overlay geometry is 16.16 fixed point, which overflows a signed 32-bit word at 32,768.

Overlay geometry, in particular. An overlay is geography, so a route's far end stays where it is when the camera zooms in on one corner of it — and a world is 512 · 2^zoom pixels across, which at zoom 20 is 134 million. ntk hands a stroke's geometry to XRender in 16.16 fixed point, which overflows a signed 32-bit word at 32,768, so an unclipped overlay is a RangeError thrown from inside paint a few zoom steps in. Lines are cut segment by segment, rings are clipped as rings (so a fill keeps a closed boundary), and a circle too large to draw as an arc becomes a clipped ring.

Running it

npm run examples:maps # needs a real $DISPLAY and a network
npm run examples:maps -- --gl # the same map, started on GL
REACT_X11_MAP_RENDERER=retained npm run examples:maps # pinned, whatever the code says
npx tsx scripts/bench/tiles.ts # fetch the profiling corpus (real OSM tiles)
npx tsx scripts/bench/maps.ts # profile decode and raster on both backends
npx tsx scripts/bench/maps-gl-live.tsx --markers=200 --overlays=on # GL, live