flutter_map_vector_tiles 2.10.0
flutter_map_vector_tiles: ^2.10.0 copied to clipboard
Vector tiles for flutter_map: a clean, self-contained MapLibre-style vector tile layer with isolate-based decoding, raster tile caching and screen-space label collision.
πΊοΈ flutter_map_vector_tiles #
Vector tiles for flutter_map.
A clean, self-contained rewrite of the ideas behind
vector_map_tiles, built for
flutter_map β₯ 8 and modern Flutter (Impeller).
Render MapLibre / Mapbox GL styles (MapTiler, OpenFreeMap, OpenMapTiles, Stadia, Protomaps, β¦) straight from MVT sources, as a plain flutter_map layer. flutter_map keeps owning the camera, gestures and your other layers; this package only draws the map.
β¨ Why this package? #
| π¦ One package | MVT decoding, style engine and renderer in one dependency, with no renderer/cache/executor satellites |
| π Smooth interaction | Geometry is rasterized once per tile into GPU-resident images (Picture.toImageSync), so pan, zoom and rotate are just textured quads |
| π Crisp labels | Text and icons drawn per frame in screen space: upright under rotation, sharp at fractional zoom, and one global collision pass, so nothing is duplicated or clipped at a tile seam. Or, with labelMode: LabelMode.tile, drawn into the tiles once, for no per-frame label cost at all β see Choosing a label mode |
| π«οΈ No white flashes | New tiles fade in over retained ancestor imagery. Fast zoom-ins render from already-decoded parent tiles, zoom-outs compose the decoded children until the new level arrives |
| ποΈ Correct MapLibre zoom semantics | The default TileOffset.maplibre renders 512px-convention styles exactly as their authors designed them |
| π§΅ Isolate pipeline | Tiles decode and trim on a worker-isolate pool (a yielding event-loop queue on web), viewport-centre first. Cancellation is a state, never an exception in your crash reporting |
| πΎ Deterministic caching | LRU memory caches with byte budgets, plus a size-capped disk cache with no index files to corrupt. Every ui.Image is disposed on eviction |
| βοΈ Works offline | The style bundle and recently viewed tiles are cached on disk (native), so places you visited keep rendering with no network |
| π All six platforms | Android, iOS, macOS, Linux, Windows and web. See Web support for the browser differences |
| π‘οΈ Tolerant style reader | Unknown layer types and exotic expressions degrade per layer with a warning, so one weird layer never kills your map |
π Quick start #
1. Install #
dependencies:
flutter_map: ^8.2.0
flutter_map_vector_tiles: ^2.10.0
2. Load a style & drop in the layer #
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_vector_tiles/flutter_map_vector_tiles.dart' as vt;
import 'package:latlong2/latlong.dart';
// Load the style once. MapTiler shown, any MapLibre style URL works.
final style = await vt.StyleReader(
uri: 'https://api.maptiler.com/maps/streets-v2/style.json?key={key}',
apiKey: myMapTilerKey,
).read();
// Use it like any other flutter_map layer:
FlutterMap(
options: MapOptions(
initialCenter: style.center ?? const LatLng(48.137, 11.575),
initialZoom: style.zoom ?? 12,
maxZoom: 21,
),
children: [
vt.VectorTileLayer(
theme: style.theme,
tileProviders: style.providers,
rasterSources: style.rasterSources,
sprites: style.sprites,
),
// Show what the style's sources ask for; most providers require it.
SimpleAttributionWidget(
source: Text(style.attributions.map((a) => a.text).join(' Β· ')),
),
// ...your markers, polylines, etc.
],
);
style.attributions comes pre-parsed: each entry pairs the flattened
text shown above with spans, whose runs keep the url of the <a>
tag they came from. Build from those when your provider's terms call for
tappable links.
3. Clean up #
@override
void dispose() {
style.dispose(); // releases HTTP clients & sprite images
super.dispose();
}
βΆοΈ A runnable app lives in example/:
cd example
flutter run --dart-define=MAPTILER_KEY=yourKey
π Tested style providers #
| Provider | Style URL shape | Notes |
|---|---|---|
| π’ MapTiler | https://api.maptiler.com/maps/<mapId>/style.json?key={key} |
works with custom map styles |
| π’ OpenFreeMap | https://tiles.openfreemap.org/styles/liberty |
free, no key needed |
| π’ Stadia Maps | https://tiles.stadiamaps.com/styles/osm_bright.json?api_key={key} |
|
| π’ ArcGIS / Esri | https://β¦/VectorTileServer/resources/styles/root.json |
relative ../../ sources, tile/{z}/{y}/{x} templates and sprites all resolve. Verified against World_Basemap_v2 |
| π’ Self-hosted (TileServer GL, Martin, β¦) | any MapLibre style.json |
relative tile templates supported. Verified against the MapLibre demo tiles |
| π’ Protomaps hosted API | https://api.protomaps.com/styles/v5/light/en.json?key={key} |
verified against the v5 light style, which embeds the key in an absolute β¦/tiles/v4/{z}/{x}/{y}.mvt?key=β¦ template. On web, allow-list your origin per key in the Protomaps portal; localhost is exempt |
| π’ PMTiles archives | pmtiles://https://β¦/planet.pmtiles source URLs in any style |
single-file archives over HTTP range requests, verified against the Protomaps samples. Gzip-internal only; brotli and zstd are rejected |
| π’ MBTiles archives | wired up in code via flutter_map_vector_tiles_mbtiles |
local SQLite archives from QGIS, tilemaker or TileServer GL. A companion package, so this one stays free of dart:ffi. Native only |
βοΈ Configuration #
Everything has sensible defaults; override what you need:
vt.VectorTileLayer(
theme: style.theme,
tileProviders: style.providers,
rasterSources: style.rasterSources, // satellite/hybrid imagery
sprites: style.sprites,
tileOffset: vt.TileOffset.maplibre, // 512px style convention (default)
concurrency: 3, // decoding isolates
diskCacheMaximumSizeInBytes: 50 * 1024 * 1024,
diskCacheTtl: const Duration(days: 14),
memoryCacheMaxBytes: 24 * 1024 * 1024,
// sized for the device by default; a byte count overrides it
rasterCacheMaxBytes: vt.VectorTileLayer.autoRasterCacheBytes,
tileFadeDuration: const Duration(milliseconds: 150),
labelFadeDuration: const Duration(milliseconds: 150),
showLabels: true,
labelMode: vt.LabelMode.screen, // or .tile: labels baked into tiles
logger: const vt.Logger.console(), // see style warnings in debug
)
| Parameter | Default | What it does |
|---|---|---|
tileOffset |
TileOffset.maplibre |
zoom relation between map and style, see below π |
concurrency |
3 |
worker isolates decoding tiles off the UI thread (ignored on web) |
diskCacheMaximumSizeInBytes |
50 MB | 0 disables disk caching (no effect on web) |
diskCacheTtl |
14 days | freshness window: younger tiles skip the network, older ones still paint instantly and refresh in the background. β οΈ Respect your provider's terms |
cachePath |
app support dir | your own directory, to control or clear it (ignored on web) |
memoryCacheMaxBytes |
24 MB | decoded tile budget per source. The caches are shared process-wide, so the most recently mounted layer's value wins |
rasterCacheMaxBytes |
sized for the device | finished-tile budget: zooming back to a recent level, or reopening the same style, paints instantly instead of re-rendering. These are GPU texture bytes, roughly 1 MB per tile at devicePixelRatio 2 and 2.25 MB at 3, and a phone screenful is 25 to 35 tiles, so a single level costs about 80 MB on a large dpr-3 phone. The default measures your viewport and dpr and budgets 2.5 screenfuls, clamped to 64 to 256 MB, so a zoom round trip keeps the level it returns to. Pass a byte count to pin it, or 0 to disable |
tileFadeDuration |
150 ms | fade-in of newly rendered tiles. Tiles from the finished-tile cache fade only when retained imagery lies beneath to cross-fade over; with nothing beneath, as on a reopened map or in the ring a zoom-out exposes, ready imagery appears at once rather than fading over the background. Duration.zero disables |
labelFadeDuration |
150 ms | fade of appearing and departing labels and icons, one fade state per label identity, so a label carried across a zoom level never re-fades or blinks. Also how often collision is re-decided, capped at 300 ms: camera and candidate changes wait at most one interval, while an unchanged map schedules no further placement work. Duration.zero restores instant pops and per-frame collision, and immediately finishes a fade already in progress; on a Nothing Phone (1) that took the peak GPU memory from 391β409 to 317β320 MB and cost frames (compare/RESULTS.md). No effect with LabelMode.tile, where labels fade in with their tile |
showLabels |
true |
disables the whole symbol pass when false; toggling it re-lays-out the tiles already on screen |
labelMode |
LabelMode.screen |
where labels and icons are drawn: every frame in screen space, or once into each tile's image (LabelMode.tile, what vector_map_tiles calls raster mode). Changing it re-renders the tiles already on screen. See Choosing a label mode |
The memory caches are shared process-wide and outlive the layer, which is
why reopening a map paints instantly. If those budgets are too generous
under memory pressure, the static VectorTileLayer.clearMemoryCache()
releases decoded tiles, raster-source images and finished tiles alike,
leaving the disk cache untouched. It also trims the label text each open
map keeps, which is held per map rather than shared, down to the labels
of the tiles it is showing. Call it from a handler such as
WidgetsBindingObserver.didHaveMemoryPressure: visible maps keep their
imagery and their labels, only tiles panned to afterwards are re-read
from disk, and only labels met again are shaped again.
The layer manages those caches itself in one case. Coming back from the background it discards the finished tiles and re-renders, because iOS revokes GPU access for a backgrounded process and a tile rasterized just as that happens comes back solid magenta, indistinguishable from a real image. So it stops rasterizing on the way out and treats what it has as suspect on the way back in. Recovery costs no network and no decoding, since the decoded geometry never left memory, and on-screen imagery is replaced only once the new render is ready. A window that is merely unfocused (a desktop window behind another app, a browser window that lost focus, the other half of an Android split screen) keeps rendering.
StyleReader also takes:
apiKey, substituted for{key}in the style URI and every URL the style references. Formapbox://URIs (style ids, sprite bases and tileset sources, expanded toapi.mapbox.comautomatically) it is the access token.headers, added to the style, TileJSON and sprite requests and forwarded to the created tile providers, for header-authenticated services such asAuthorization.httpClient, your ownhttp.Clientfor proxying, certificate pinning or tests. A passed client stays yours and is never closed for you.language, to show labels in that language ('de','fr','zh-Hans') wherever the tiles carry a translation, and in the style's own text everywhere else. See Label language.
ποΈ Understanding TileOffset #
MapLibre renders 512px tiles, so at the same visual scale a MapLibre zoom is one lower than flutter_map's. Styles from MapTiler and friends are authored against that convention.
TileOffset.maplibre(default) matches the author's intent exactly, for text sizes, road widths and layer zoom ranges.TileOffset.noneevaluates the style at flutter_map's zoom directly, so everything appears one zoom earlier and larger. The legacyvector_map_tilesdefault, if you need parity with it.
βοΈ Offline behaviour #
Everything you looked at recently keeps working without network:
- Style bundle.
StyleReadercaches style.json, TileJSON and sprites on disk, stale-while-revalidate: the cached copy is served instantly, including fully offline, and refreshed in the background once older thanrefreshAfter(12 h default). Opt out withStyleReader(cache: false). - Tiles. Served from disk while fresh. Past
diskCacheTtlthey still paint instantly and revalidate in the background: changed tiles cross-fade to the new imagery, and with no network the old tile simply stays. Stale tiles are deleted only by the size cap, oldest first, never by age alone. - Durable location. Both caches default to the application support directory, which the OS doesn't purge, unlike the temp directory.
This is a visited-places cache, not region pre-download. For a
guaranteed offline region, ship a tile archive: point
PmTilesVectorTileProvider at a .pmtiles file, or
flutter_map_vector_tiles_mbtiles
at a .mbtiles one, alongside an asset:// style. Nothing on that path
touches the network, and a local archive is excluded from the disk cache,
being the local copy already.
Disk caching, and with it everything above, is native-only. See Web support for the browser.
π Web support #
The layer runs on Flutter web with the CanvasKit/Skwasm renderer, the
default since Flutter 3.29. (Do not force the removed HTML renderer on
Flutter 3.27/3.28: it lacks Picture.toImageSync.) What differs from
native:
- No persistent cache.
cachePath,diskCacheTtl,diskCacheMaximumSizeInBytesandStyleReader(cache: β¦)are no-ops. Tiles and the style bundle rely on the in-memory caches plus the browser's HTTP cache instead. - Decoding runs on the event loop. A yielding queue replaces the
worker-isolate pool, and
concurrencyis ignored. - CORS. The browser fetches style.json, TileJSON, sprites and tiles
directly, so every host involved must send
Access-Control-Allow-Origin. MapTiler, OpenFreeMap and Stadia do; self-hosted tile servers need it configured. PMTiles hosts additionally need range requests to pass CORS, meaningRangeinAccess-Control-Allow-Headerswhen preflighted. - PMTiles gunzip uses the browser's native
DecompressionStream, available wherever Flutter web runs. - No MBTiles. The companion package reads SQLite through
dart:ffi, which has no web implementation. PMTiles fills the same role over HTTP range requests.
π Custom tile sources #
No style URL? Any {z}/{x}/{y} MVT endpoint works. Build the theme
yourself and wire providers manually:
vt.VectorTileLayer(
theme: vt.ThemeReader(logger: const vt.Logger.console()).read(myStyleJson),
tileProviders: vt.TileProviders({
'openmaptiles': vt.NetworkVectorTileProvider(
urlTemplate: 'https://tiles.example.com/{z}/{x}/{y}.pbf?key=$key',
maximumZoom: 14, // the source's max; higher zooms overzoom this data
),
}),
)
Those are the two options you'll always set.
NetworkVectorTileProvider also takes headers for header-authenticated
tile servers, minimumZoom, maxRetries (default 2) and an optional
client if you bring your own http.Client, which is shared and never
closed for you.
PMTiles single-file archives work out of the box: styles with
pmtiles://https://β¦/planet.pmtiles source URLs just load, or open an
archive directly:
final provider = await vt.PmTilesVectorTileProvider.open(
'https://tiles.example.com/planet.pmtiles',
);
// β vt.TileProviders({'mySource': provider})
open likewise accepts headers, maxRetries, a shared client, and
minimumZoom/maximumZoom to override the archive header, the same role
a style source's minzoom/maxzoom plays.
MBTiles archives live in a companion package,
flutter_map_vector_tiles_mbtiles,
separate because SQLite through dart:ffi would cost every app here a
native dependency and this package its web support:
final provider = await MbTilesVectorTileProvider.open('β¦/bavaria.mbtiles');
Anything else you write against VectorTileProvider, which is four
members; MemoryVectorTileProvider covers tests and tiles you already
hold. Two hooks exist for custom providers.
resolveProvider substitutes yours into a style by source id, so you keep
the style's theme, sprites and attribution and replace only the tiles.
Handy when the source lives somewhere no style document can name, like a
device-absolute path:
final style = await vt.StyleReader(
uri: 'asset://styles/liberty.json',
resolveProvider: (id) async => id == 'openmaptiles' ? provider : null,
).read();
Returning null falls through to the style's own URL, it applies to raster
sources too, and the Style takes ownership of what you return, so
style.dispose() disposes it.
cacheBytesToDisk => false says your provider is already backed by local
storage, so the disk cache is skipped in both directions instead of
storing a second copy. Implement it on anything reading from local
storage, and use vt.SingleFlight to coalesce concurrent loads, as the
built-in providers do.
Raster imagery (satellite, hillshade) wires up the same way: pass
rasterSources: entries of RasterTileSource(provider: β¦, tileSize: 512), where the provider serves encoded PNG/JPEG/WebP bytes instead of
MVT. NetworkVectorTileProvider works unchanged. 256px sources are
fetched one zoom deeper for the same visual scale.
π¨ Style support #
Layer types
| Type | Support |
|---|---|
background, fill, line, circle |
full, including fill-pattern, line-pattern, dashes and casing |
symbol |
full, including curved line text and text-variable-anchor / text-radial-offset; see Labels below |
raster |
raster sources inside vector styles (satellite/hybrid imagery) draw at their layer position, with raster-opacity and brightness/contrast/saturation/hue-rotate matching MapLibre's shader math |
fill-extrusion |
renders as a flat fill, with fill-extrusion-opacity applied to the whole layer as MapLibre does, patterned ones included, so a building part over its outline does not draw darker; the alpha of fill-extrusion-color is ignored, as the spec says |
hillshade, heatmap, sky |
skipped, with a log line |
Icons. SDF sprite sheets ("sdf": true) are thresholded and tinted
per icon-color, icon-halo-color and icon-halo-width; dark MapLibre
styles ship their icons this way. Ordinary sprites are drawn with the
colours baked into the sheet. StyleReader repacks the sheet with a
transparent gutter around every icon, as MapLibre does, so a scaled icon
never picks up the edge of its neighbour in the sheet. icon-rotate and
icon-rotation-alignment follow MapLibre's viewport/map/line semantics,
including camera bearing, icon anchor/offset rotation and rotated collision
bounds.
Expressions. The practical MapLibre set: get/has, comparisons,
all/any/case/match/coalesce, step/interpolate (linear,
exponential, cubic-bezier), math, string and color operators, let/var,
legacy filters, legacy {stops} functions (with {token} templates in
their text-field / icon-image outputs) and {token} templates.
Unsupported layer types, paint properties and expressions are skipped per layer with a warning, so one weird layer never kills the whole style.
π·οΈ Labels #
By default text and icons are drawn per frame in screen space rather than
baked into the tile rasters, which is what the behaviour below rests on.
LabelMode.tile bakes them in instead; Choosing a label
mode has what changes then.
- Curved along the road. Glyphs are placed one by one with MapLibre
semantics:
text-max-anglerejects sharp bends,text-keep-uprightflips reading direction,text-rotation-alignment: viewportkeeps shield text horizontal. Nearly straight windows are drawn as a single rotated string for speed; scripts with contextual shaping (Arabic, Indic, β¦) fall back to straight placement so glyphs are never mis-joined. One deliberate difference: a kept-upright label whose road turns back on itself (a hairpin) is dropped rather than drawn with the letters past the turn upside down, whichtext-max-anglealone lets through because each step of a smooth turn stays under it. - Only where it fits. Anything placed along a line, curved text, a
road shield, a one-way arrow, needs all of itself on the line,
centred on its anchor, as in MapLibre: a ramp or a crossing shorter
than its shield carries none. An icon alone at
line-centeris exempt, as in MapLibre. The fit is a placement decision, so it is judged when collision is (see Collision is decided on a timer below), not on every frame. - Words stay whole.
text-max-widthwraps between words; a single word wider than it gets a line of its own, as in MapLibre, rather than breaking where the width runs out, and the lines around it still break attext-max-width. - Repeated road names stay spaced. A street arrives as many features
(one per OSM way, sometimes one per road-class style layer), each spaced
on its own. For
symbol-placement: line, a visible label therefore suppresses another copy of the same text from the same source layer within half ofsymbol-spacingalong the same road β the two carriageways of a motorway, neighbouring switchbacks and parallel same-named roads sit beside each other and keep their labels, as in MapLibre. The topmost candidate that fits wins;pointandline-centerlabels are not affected. - Zoom ranges, with a ramp at the top. Nothing claims label space
outside a symbol layer's
[minzoom, maxzoom), but labels ramp out over the last quarter zoom level before a declaredmaxzoominstead of snapping away, and ramp back in on the way out.minzoomgets no ramp: it is inclusive, so one would leave aminzoom: 14layer invisible at exactly zoom 14. - One fade state per label identity. Beyond that ramp, every
appearance and disappearance is animated per label (layer, text, icon),
rising while the label is placed and falling once it is not, whatever
the reason: a feature the tileset drops at the next zoom, a label
crowded out by denser labelling, a whole layer cut at its
minzoom. Each eases out overlabelFadeDurationinstead of vanishing in one frame. A departing label frees its space immediately, so its replacement fades in over it: a crossfade, not a pop at the fade's end. - A placed label always paints. A label that has won its spot is never held invisible waiting on anything; it fades in from the frame it arrives. A zoom crossing delivers a screen's labels over tens of frames, since tiles finish one at a time, and each starts its fade as it lands rather than at some shared moment.
- No blink when a zoom level hands over. A label that survives the change keeps its opacity, because the two levels' copies share one fade state; a crossing can neither blink a label nor fade it into itself. The outgoing level also only ever keeps labels on screen and never introduces ones that weren't already visible, so a label that had been crowded out, a street name under a POI say, cannot flash up just as the level departs.
- Street names stay put across zoom levels. Along-line labels are
spaced out from the middle of their line, so the next level deeper
keeps every position of the current one and only adds names in
between: over the same data (every level past the source's maxzoom) a
street name you are looking at doesn't move when a level hands over.
Where the next level brings its own data tiles, the clipped lines
differ, or where the style's
symbol-spacingchanges with the zoom, a name can genuinely sit elsewhere on its road; each sitting then fades on its own clock, the old position easing out while the new one fades in, instead of the name teleporting at full opacity. - Placement is remembered, not re-derived. A label drawable from more
than one feature, a street name on both carriageways or the same name
from two zoom levels, stays on the one it is already on; a label at a
text-variable-anchorstays at the anchor it took; a road label near vertical keeps reading the way it was reading. A slow pan therefore doesn't walk a street name across its street. Those choices are remembered per label and position rather than per tile, so they survive a level handing over or a tile re-rendering underneath a label that never left the screen. - Collision is decided on a timer. Camera motion and changed label
candidates are picked up at the next
labelFadeDurationinterval, at most 300 ms away, and the previous decision is held in between. Zooming and rotating drag labels through each other constantly, and re-deciding every frame turns each of those brushes past into a label that disappears and comes straight back. Between decisions neighbours are simply allowed to overlap for a moment, as they are in MapLibre. A label along a line accepted by a decision, a curved street name or a road shield, also keeps drawing until the next one, even if zooming makes its road bend too sharply under it or the label outgrow its stretch of road; it then fades out instead of vanishing. An unchanged repaint creates no new placement work, so the animation ticker settles when the last real change has been placed.
Choosing a label mode
The screen-space pass is what makes labels behave as above, and it is
also the one part of a moving map that costs UI-thread time on every
frame: every placed label laid out and drawn again, and, every
labelFadeDuration (300 ms at most), a collision pass over every
candidate. On a label-dense screen and a mid-range phone that cost
decides whether panning, rotating and pinching hold the display's frame
rate.
labelMode: LabelMode.tile removes it. Labels and icons are placed and
drawn once per tile, when the tile is rendered, into the same image as
its roads and buildings, so a moving map draws nothing but tile images,
as vector_map_tiles does in its default raster mode. The same text
engine draws them (curved road names, halos, SDF icons and all), but
they inherit the limits of any pre-rendered tile:
LabelMode.screen (default) |
LabelMode.tile |
|
|---|---|---|
| Per-frame label work | placed labels laid out and drawn every frame, a collision pass every labelFadeDuration (at most 300 ms) |
none |
| Collision | one pass across the whole screen | within each tile |
| At tile seams | seamless | a label that would cross a tile edge is dropped, so labels thin out along tile seams; a road name repeated along a road is thinned out only within a tile, so it can show on both sides of a seam |
| Under rotation | text stays upright | text rotates with the map |
| Between zoom levels | sized for the exact zoom | sized for the tile's zoom level, then scales with the map (up to twice the size) until the next level's tiles replace it |
minzoom/maxzoom |
cut and ramped at the exact zoom | applied at the tile's zoom level: minzoom: 12.2 shows from 13, maxzoom: 16.5 until 17 |
| Fades | per label, labelFadeDuration |
with the tile, tileFadeDuration |
| New tiles | geometry first: every queued tile's geometry before any tile's labels, with the previous level's labels covering the gap | each tile appears complete, labels included; the previous level covers it until then |
Texture memory is the same in both modes: the labels are drawn into the
tile's existing image, so rasterCacheMaxBytes needs no adjustment.
Measured with compare/ on a Nothing Phone (1) at 60 Hz,
same style and tiles, three runs each: with LabelMode.tile the map
got a frame on 94β98% of display refreshes while zooming, panning,
rotating and flinging, against 78β86% in the default mode, and the UI
thread's 90th percentile fell from 15.4β21.7 ms to 3.6β9.3 ms. The first
complete map took 0.95 s, against 1.05 s in the default mode. On an
iPhone 15 Pro Max both modes get 99β100%, on a MacBook Pro (M4 Pro)
they are within a point of each other at 97β100%, and in Chrome the
default gets 91β100%, so the choice matters most on a mid-range
phone.
Label language
StyleReader(language: 'de') shows labels in German wherever the tiles
carry a German name, and in the style's own label text everywhere else:
final style = await vt.StyleReader(
uri: 'https://tiles.openfreemap.org/styles/liberty',
language: 'de',
).read();
Each label looks for name:de first, the tag OpenMapTiles-schema tiles
(MapTiler, OpenFreeMap) use, then for name_de, the one Mapbox Streets
and Shortbread tiles use. A feature with neither keeps exactly the text
the style gives it. So how much of the map changes language depends on
the tiles, not on the style: OpenStreetMap-based tiles have translations
for countries, cities and well-known places, and mostly none for
streets.
Only the name changes. Whatever the style shows beside it, a peak's
elevation or a road's ref, stays, and a style that shows a place in two
scripts (Liberty's name:latin over name:nonlatin) shows the
translation once. The style's resolved-locale expression returns the
language as well; without one it returns en.
The language is fixed per Style. To switch, read the style again with
the other language: the cached style document serves both, and each
language keeps its own finished tiles.
ποΈ Architecture #
style.json ββΊ StyleReader ββΊ compiled Theme (expressions β closures)
camera ββΊ visible display tiles ββΊ data tiles (shared, LRU-cached)
bytes ββ disk cache ββ network ββΊ isolate: decode + trim
PreparedTile ββΊ rasterize once ββΊ GPU image ββΊ textured quad per frame
symbols ββββββΊ per-frame screen-space label pass (global collision)
ββββββΊ or, LabelMode.tile: drawn into that GPU image, once
finished tiles (image + symbols) ββΊ shared LRU ββΊ instant re-crossings
New tiles are rendered by a render pump that runs on a time budget per
frame: 4 ms while the camera moves, from the first frame of a drag or
zoom, and 12 ms once it has stood still for 150 ms. It records a tile
one style layer at a time and shapes its labels one at a time,
checking the budget in between, so a dense tile is spread over several
frames. What it never splits can still take a frame of its own: one
very dense layer (22 ms for the densest buildings layer measured on a
phone), and with LabelMode.tile one tile's label placement, drawing
and snapshot, which wait for the next frame rather than run in one
whose budget is already spent. The still budget takes most of a 60 Hz
frame and more than a 120 Hz one, so an animation of your own running
over a still map that is still loading tiles can drop frames until
they are in.
For profiling, the render pipeline emits DevTools timeline events:
VT render pump, VT rasterize, VT symbols, VT labels, and with
LabelMode.tile VT rasterize with labels and VT tile labels. A
dense tile's VT rasterize and VT symbols repeat over several
frames, one slice per frame.
On web the disk cache tier is absent and decoding runs on a yielding event-loop queue instead of isolates. Everything else is identical.
The full rendering model, and the reasoning behind each departure from
vector_map_tiles, is in
doc/ARCHITECTURE.md. π
π vector_map_tiles #
Compared with 9.0.0-beta.13, the newest release that runs flutter_map 8
on stable Flutter (the
10.0.0 betas need
Flutter's main channel for flutter_gpu, per
their README):
| vector_map_tiles 9.0.0-beta.13 | this package | |
|---|---|---|
| Packages | 3 (vector_map_tiles, vector_tile_renderer, executor_lib), plus idb_shim for its web cache |
1 |
| Labels | raster mode (default): baked into each tile image, collision within the tile; vector mode: redrawn with the tiles every frame | a screen-space pass with one global collision pass and upright text (default), or baked into the tile images with LabelMode.tile |
| Style zoom | evaluated at the flutter_map zoom, one level deeper than MapLibre, so label layers open a level early | TileOffset.maplibre by default, as MapLibre renders the style |
| Tile images | 512 px, Picture.toImage, then PNG-encoded into its disk cache before the tile is shown; a revisit decodes the PNG |
256 px Γ device pixel ratio, Picture.toImageSync, kept as GPU textures in an LRU |
Measured with compare/ on a Nothing Phone (1) (Android,
60 Hz), an iPhone 15 Pro Max (iOS, 120 Hz), a MacBook Pro with an M4 Pro
(macOS, 120 Hz) and Chrome on that Mac: the same style and the same
recorded tiles, a fresh process per run, three runs each (four on the
iPhone, measured a week later on a newer build). "Frames" is the share
of display refreshes that got a frame while the camera zoomed, panned,
rotated or flung. Each cell reads this package / vector_map_tiles.
compare/SUMMARY.md explains the results in plain
words; the full tables, and screenshots next to MapLibre GL JS, are in
compare/RESULTS.md.
| this package / vector_map_tiles | Android | iOS | macOS | Chrome |
|---|---|---|---|---|
Labels baked into the tiles (LabelMode.tile / raster mode): frames |
94β98% / 74β99% | 99β100% / 96.8β100% | 97.5β100% / 99β100% | 94β100% / 39β100% |
| β¦ worst frame gap | 67β84 / 67β251 ms | 8β29 / 8β45 ms | 8β50 / 8β33 ms | 10β34 / 10β159 ms |
| Labels drawn every frame (default / vector mode): frames | 78β86% / 20β40% | 99β100% / 65β99.8% | 97β100% / 57β100% | 91β100% / 3β68% |
| β¦ worst frame gap | 50β134 / 100β1373 ms | 8β18 / 17β182 ms | 8β42 / 8β167 ms | 10β42 / 100β858 ms |
| First complete map (baked / raster) | 0.95 / 3.0 s | 0.36 / 1.74 s | 0.34 / 0.98 s | 1.25 / 2.6 s |
| Downloaded per run (default / raster) | 12.8 / 20.4 MBΒΉ | 26.7 / 50.3β55.0 MB | 26.6 / 51.2β51.6 MB | 29.3 / 32.6 MBΒ² |
ΒΉ Only while opening the map and during the measured passes: Android's whole-run counts were not kept, and the jumps between scenarios are left out. iOS, macOS and Chrome count whole runs, as the lab server saw them.
Β² In the browser the HTTP cache answers a repeated request, so this
counts each URL once; on macOS, with a smaller window and no HTTP
cache, vector_map_tiles fetched some tiles again.
To check these numbers on your own devices, follow Reproducing the results: every session's raw data is in the repository, and the scripts measure each one again. With no setup at all, the web demo runs a short version of the benchmark in your browser.
βοΈ Trade-offs #
- Revisits.
vector_map_tileskeeps every tile it has rendered as a PNG on disk (IndexedDB on web), so a second pass over the same ground decodes tiles instead of drawing them, even after the app restarts. On the repeated pan on the Android phone it got 98.7% of frames, this package 97.7% with baked labels; on the iPhone, on macOS and in Chrome both got 99.7β100%. This package keeps finished tiles as GPU textures in an LRU sized to its screen and draws them again once they fall out; across restarts it keeps the tile data, not the drawn tiles. - Memory. With baked labels this package uses a little less on the
Android phone: 264 against 313 MB of GPU memory at the peak, 391
against 401 MB RSS (336 against 387 MB on the iPhone, 495 against
577 MB on macOS). This package's default, which draws labels every
frame, needs more GPU memory than baked labels (365 MB, vector mode
376 MB), much of it for the label fades: in a separate A/B, turning
them off took its peak from 391β409 to 317β320 MB, and cost frames
instead. It needs far less RSS than vector mode (393 against 892 MB;
335 against 836 MB on the iPhone, 527 against 948 MB on macOS). Its
label text cache keeps up to 8000 shaped labels; after a run through
four dense cities that held 37β49 MB more than the 2500 it used to
keep, and 117β119 MB more with
TileOffset.none.VectorTileLayer.clearMemoryCache()gives 23β43 MB of that back (see the CHANGELOG). - Label density. At the same camera
vector_map_tilesdraws more labels, because it evaluates the style one zoom level deeper than MapLibre.tileOffset: TileOffset.nonemakes this package do the same (see UnderstandingTileOffset). - Not covered yet: the
10.0.0betas.
π Troubleshooting #
- Blank map, no errors. Pass
logger: const vt.Logger.console()to bothStyleReaderandVectorTileLayer. Usually the style's source ids don't match yourTileProviderskeys, or the API key is invalid; HTTP 403s are logged, with keys redacted. read()oropen()throws. The designed failure path for a broken setup.StyleReader.read()throwsStyleReaderExceptionwhen the style can't be loaded or parsed, andPmTilesVectorTileProvider.openthrowsPmTilesExceptionon an invalid or unsupported archive, orhttp.ClientExceptionon network failure. Catch these to show a retry UI. Runtime tile fetches never throw into your code; failures are logged and retried.- Anything MBTiles-related. See the companion package's own troubleshooting section.
- Some labels ignore
language. Those features have noname:<language>orname_<language>in the tiles, so they keep the style's text. That is the tile data, mostly streets and small places; check a feature's tags on openstreetmap.org. - Labels/roads look bigger than in MapLibre. Probably
TileOffset.nonewith a 512px-convention style; use the default. - Stale data after changing styles. The disk cache keys by URL, and a
changed
{key}or map id is a different URL, so usually there is nothing to do. SupplycachePathto wipe it yourself. - Blank map on web. Open the browser console: missing
Access-Control-Allow-Originheaders on the style or tile host block every request (see Web support). - Solid magenta tiles after reopening the app on iOS. Tiles rasterized
while iOS had revoked the process's GPU access. The layer handles this
itself; on 2.6.1 and earlier the corrupted tiles are cached for the life
of the process, and calling
VectorTileLayer.clearMemoryCache()onAppLifecycleState.resumedis the workaround. The underlying engine gap is flutter#191255. Magenta from decoded images, meaning sprites and raster sources, is the same iOS bug one layer down and outside this package's reach (flutter#166668, fixed in 3.32), so keep your Flutter up to date.
π€ Contributing #
Issues and PRs are welcome! Please run dart analyze && flutter test
before submitting; the suite covers the MVT decoder, expression engine,
caches, grid math and tile store. Run flutter test --platform chrome too
when touching anything platform-sensitive (requires Chrome). CI runs
formatting, analysis and both test suites on every PR.
π€ Built with Claude #
This package is developed with
Claude Code, Anthropic's AI coding agent.
Claude writes much of the code, tests and documentation, and most commits
in the history carry a Co-Authored-By: Claude trailer. The maintainer
directs the work and cuts every release. The instructions Claude works
under (architecture notes, testing and release rules) are checked in as
CLAUDE.md
and
AGENTS.md
files, which are left out of the published package.
π License #
BSD 3-Clause Β© 2026 Jonas Grunau
