add tile caching and rendering for Mapbox vector tiles with custom painter

This commit is contained in:
ImBenji
2026-03-30 18:13:42 +01:00
parent 85c595f99c
commit dc22ce2f46
8 changed files with 1543 additions and 397 deletions
@@ -0,0 +1,71 @@
import 'dart:collection';
import 'dart:typed_data';
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
import 'package:rra_app/pages/map/vector/mvt_parser.dart';
class MapboxVectorTileCache {
MapboxVectorTileCache._();
static final MvtParser _parser = const MvtParser();
static const Set<String> _styleLayerWhitelist = <String>{
'water',
'landuse',
'park',
'building',
'road',
'road_label',
'place_label',
'poi_label',
};
static const int _maxEntries = 220;
static final LinkedHashMap<String, MvtTile> _cache =
LinkedHashMap<String, MvtTile>();
static final Map<String, Future<MvtTile?>> _inFlight =
<String, Future<MvtTile?>>{};
static MvtTile? peek(String url) {
final cached = _cache.remove(url);
if (cached != null) {
_cache[url] = cached;
}
return cached;
}
static Future<MvtTile?> getOrFetch(String url) {
final cached = peek(url);
if (cached != null) return Future<MvtTile?>.value(cached);
final inFlight = _inFlight[url];
if (inFlight != null) return inFlight;
final future = _fetch(url);
_inFlight[url] = future;
future.whenComplete(() => _inFlight.remove(url));
return future;
}
static Future<MvtTile?> _fetch(String url) async {
final bytes = await HiveTileCache.getOrFetch(url);
if (bytes == null || bytes.isEmpty) return null;
final tile = _parse(bytes);
if (tile == null) return null;
_cache[url] = tile;
_trim();
return tile;
}
static MvtTile? _parse(Uint8List bytes) {
try {
return _parser.parse(bytes, layerNames: _styleLayerWhitelist);
} catch (_) {
return null;
}
}
static void _trim() {
while (_cache.length > _maxEntries) {
_cache.remove(_cache.keys.first);
}
}
}
+362
View File
@@ -0,0 +1,362 @@
import 'dart:typed_data';
import 'dart:ui';
enum MvtGeometryType { unknown, point, lineString, polygon }
class MvtTile {
const MvtTile({required this.layers});
final Map<String, MvtLayer> layers;
}
class MvtLayer {
const MvtLayer({
required this.name,
required this.extent,
required this.features,
});
final String name;
final int extent;
final List<MvtFeature> features;
}
class MvtFeature {
const MvtFeature({
required this.type,
required this.properties,
required this.geometry,
});
final MvtGeometryType type;
final Map<String, Object?> properties;
final List<List<Offset>> geometry;
}
class MvtParser {
const MvtParser();
MvtTile parse(Uint8List bytes, {Set<String>? layerNames}) {
final reader = _PbfReader(bytes);
final layers = <String, MvtLayer>{};
while (!reader.isAtEnd) {
final tag = reader.readVarint();
final field = tag >> 3;
final wire = tag & 0x07;
if (field == 3 && wire == 2) {
final layerBytes = reader.readBytes();
final layer = _parseLayer(layerBytes);
if (layer == null) continue;
if (layerNames != null && !layerNames.contains(layer.name)) continue;
layers[layer.name] = layer;
} else {
reader.skipWireType(wire);
}
}
return MvtTile(layers: layers);
}
MvtLayer? _parseLayer(Uint8List bytes) {
final reader = _PbfReader(bytes);
String name = '';
var extent = 4096;
final rawFeatures = <_RawFeature>[];
final keys = <String>[];
final values = <Object?>[];
while (!reader.isAtEnd) {
final tag = reader.readVarint();
final field = tag >> 3;
final wire = tag & 0x07;
switch (field) {
case 1:
if (wire == 2) {
name = reader.readString();
} else {
reader.skipWireType(wire);
}
case 2:
if (wire == 2) {
rawFeatures.add(_parseRawFeature(reader.readBytes()));
} else {
reader.skipWireType(wire);
}
case 3:
if (wire == 2) {
keys.add(reader.readString());
} else {
reader.skipWireType(wire);
}
case 4:
if (wire == 2) {
values.add(_parseValue(reader.readBytes()));
} else {
reader.skipWireType(wire);
}
case 5:
if (wire == 0) {
extent = reader.readVarint();
} else {
reader.skipWireType(wire);
}
default:
reader.skipWireType(wire);
}
}
if (name.isEmpty) return null;
final features = <MvtFeature>[];
for (final raw in rawFeatures) {
final properties = <String, Object?>{};
for (var i = 0; i + 1 < raw.tags.length; i += 2) {
final keyIndex = raw.tags[i];
final valueIndex = raw.tags[i + 1];
if (keyIndex < 0 || keyIndex >= keys.length) continue;
if (valueIndex < 0 || valueIndex >= values.length) continue;
properties[keys[keyIndex]] = values[valueIndex];
}
features.add(
MvtFeature(
type: _decodeFeatureType(raw.type),
properties: properties,
geometry: _decodeGeometry(raw.geometry, _decodeFeatureType(raw.type)),
),
);
}
return MvtLayer(name: name, extent: extent, features: features);
}
_RawFeature _parseRawFeature(Uint8List bytes) {
final reader = _PbfReader(bytes);
var type = 0;
var geometry = <int>[];
final tags = <int>[];
while (!reader.isAtEnd) {
final tag = reader.readVarint();
final field = tag >> 3;
final wire = tag & 0x07;
switch (field) {
case 2:
if (wire == 2) {
tags.addAll(reader.readPackedVarints());
} else {
reader.skipWireType(wire);
}
case 3:
if (wire == 0) {
type = reader.readVarint();
} else {
reader.skipWireType(wire);
}
case 4:
if (wire == 2) {
geometry = reader.readPackedVarints();
} else {
reader.skipWireType(wire);
}
default:
reader.skipWireType(wire);
}
}
return _RawFeature(type: type, tags: tags, geometry: geometry);
}
Object? _parseValue(Uint8List bytes) {
final reader = _PbfReader(bytes);
while (!reader.isAtEnd) {
final tag = reader.readVarint();
final field = tag >> 3;
final wire = tag & 0x07;
switch (field) {
case 1:
if (wire == 2) return reader.readString();
case 2:
if (wire == 5) return reader.readFloat32();
case 3:
if (wire == 1) return reader.readFloat64();
case 4:
if (wire == 0) return reader.readVarint();
case 5:
if (wire == 0) return reader.readVarint();
case 6:
if (wire == 0) return _decodeZigZag(reader.readVarint());
case 7:
if (wire == 0) return reader.readVarint() != 0;
}
reader.skipWireType(wire);
}
return null;
}
MvtGeometryType _decodeFeatureType(int rawType) {
switch (rawType) {
case 1:
return MvtGeometryType.point;
case 2:
return MvtGeometryType.lineString;
case 3:
return MvtGeometryType.polygon;
default:
return MvtGeometryType.unknown;
}
}
List<List<Offset>> _decodeGeometry(List<int> commands, MvtGeometryType type) {
final geometries = <List<Offset>>[];
var cursorX = 0;
var cursorY = 0;
var i = 0;
List<Offset>? current;
while (i < commands.length) {
final commandInteger = commands[i++];
final commandId = commandInteger & 0x7;
final count = commandInteger >> 3;
if (commandId == 1) {
for (var c = 0; c < count; c++) {
if (i + 1 >= commands.length) break;
cursorX += _decodeZigZag(commands[i++]);
cursorY += _decodeZigZag(commands[i++]);
final point = Offset(cursorX.toDouble(), cursorY.toDouble());
if (type == MvtGeometryType.point) {
geometries.add(<Offset>[point]);
current = null;
} else {
current = <Offset>[point];
geometries.add(current);
}
}
} else if (commandId == 2) {
if (current == null) {
current = <Offset>[];
geometries.add(current);
}
for (var c = 0; c < count; c++) {
if (i + 1 >= commands.length) break;
cursorX += _decodeZigZag(commands[i++]);
cursorY += _decodeZigZag(commands[i++]);
current.add(Offset(cursorX.toDouble(), cursorY.toDouble()));
}
} else if (commandId == 7) {
if (type == MvtGeometryType.polygon &&
current != null &&
current.isNotEmpty &&
current.first != current.last) {
current.add(current.first);
}
} else {
break;
}
}
return geometries.where((path) => path.isNotEmpty).toList(growable: false);
}
int _decodeZigZag(int n) => (n >> 1) ^ (-(n & 1));
}
class _RawFeature {
const _RawFeature({
required this.type,
required this.tags,
required this.geometry,
});
final int type;
final List<int> tags;
final List<int> geometry;
}
class _PbfReader {
_PbfReader(Uint8List bytes)
: _bytes = bytes,
_data = ByteData.sublistView(bytes);
final Uint8List _bytes;
final ByteData _data;
int _offset = 0;
bool get isAtEnd => _offset >= _bytes.length;
int readVarint() {
var shift = 0;
var result = 0;
while (true) {
if (_offset >= _bytes.length) {
throw const FormatException('Unexpected EOF while reading varint');
}
final byte = _bytes[_offset++];
result |= (byte & 0x7f) << shift;
if ((byte & 0x80) == 0) break;
shift += 7;
if (shift > 63) {
throw const FormatException('Varint too long');
}
}
return result;
}
Uint8List readBytes() {
final length = readVarint();
if (length < 0 || _offset + length > _bytes.length) {
throw const FormatException('Invalid length-delimited field');
}
final bytes = Uint8List.sublistView(_bytes, _offset, _offset + length);
_offset += length;
return bytes;
}
String readString() {
final bytes = readBytes();
return String.fromCharCodes(bytes);
}
double readFloat32() {
final value = _data.getFloat32(_offset, Endian.little);
_offset += 4;
return value;
}
double readFloat64() {
final value = _data.getFloat64(_offset, Endian.little);
_offset += 8;
return value;
}
List<int> readPackedVarints() {
final bytes = readBytes();
final inner = _PbfReader(bytes);
final values = <int>[];
while (!inner.isAtEnd) {
values.add(inner.readVarint());
}
return values;
}
void skipWireType(int wireType) {
switch (wireType) {
case 0:
readVarint();
case 1:
_offset += 8;
case 2:
final length = readVarint();
_offset += length;
case 5:
_offset += 4;
default:
throw FormatException('Unsupported wire type: $wireType');
}
if (_offset > _bytes.length) {
throw const FormatException('Unexpected EOF while skipping field');
}
}
}
@@ -0,0 +1,188 @@
import 'dart:collection';
import 'dart:ui' as ui;
class _TileCoord {
const _TileCoord(this.z, this.x, this.y);
final int z;
final int x;
final int y;
@override
bool operator ==(Object other) =>
other is _TileCoord && other.z == z && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(z, x, y);
}
class CachedTile {
CachedTile(this.image, this.zoomBucket);
final ui.Image image;
final int zoomBucket;
}
// Static tile pyramid image cache. Stores rasterised ui.Image per (z, x, y).
// Each entry also records the zoom bucket it was rendered at — callers can
// check whether a re-render is warranted, but the old image stays as a
// fallback until the new one arrives.
//
// Ancestor lookup (for fallback blitting) just calls peek() at a coarser z.
class TilePyramidCache {
TilePyramidCache._();
static const int _maxEntries = 384;
static const int _maxApproxBytes = 256 * 1024 * 1024;
// Cap concurrent renders to avoid saturating the main isolate with
// synchronous canvas recording. I/O (network/Hive) is still concurrent
// up to this limit.
static const int _maxConcurrent = 4;
static final LinkedHashMap<_TileCoord, CachedTile> _cache =
LinkedHashMap<_TileCoord, CachedTile>();
static final Set<_TileCoord> _inFlight = <_TileCoord>{};
static final Queue<_Queued> _queue = Queue<_Queued>();
static int _active = 0;
static bool Function(int z, int x, int y)? _queueFilter;
static int _approxBytes = 0;
static CachedTile? peek(int z, int x, int y) {
final coord = _TileCoord(z, x, y);
final tile = _cache.remove(coord);
if (tile == null) return null;
_cache[coord] = tile; // promote to MRU
return tile;
}
static bool isInFlight(int z, int x, int y) =>
_inFlight.contains(_TileCoord(z, x, y));
// Queue a render for (z, x, y). No-op if already in flight.
// render() produces the image; onReady() is called after it's stored.
static void enqueue(
int z,
int x,
int y,
int zoomBucket,
Future<ui.Image?> Function() render,
void Function() onReady,
) {
final coord = _TileCoord(z, x, y);
if (_inFlight.contains(coord)) return;
final filter = _queueFilter;
if (filter != null && !filter(z, x, y)) return;
_inFlight.add(coord);
_queue.add(
_Queued(
coord: coord,
zoomBucket: zoomBucket,
render: render,
onReady: onReady,
),
);
_drain();
}
static void setQueueFilter(bool Function(int z, int x, int y)? filter) {
_queueFilter = filter;
_pruneQueue();
}
static void _drain() {
while (_active < _maxConcurrent && _queue.isNotEmpty) {
_active++;
_run(_queue.removeFirst());
}
}
static Future<void> _run(_Queued item) async {
try {
final image = await item.render();
_inFlight.remove(item.coord);
if (image == null) return;
final filter = _queueFilter;
if (filter != null && !filter(item.coord.z, item.coord.x, item.coord.y)) {
image.dispose();
return;
}
final existing = _cache.remove(item.coord);
if (existing != null) {
_approxBytes -= _estimateBytes(existing.image);
existing.image.dispose();
}
_cache[item.coord] = CachedTile(image, item.zoomBucket);
_approxBytes += _estimateBytes(image);
_trim();
item.onReady();
} catch (_) {
_inFlight.remove(item.coord);
} finally {
_active--;
_drain();
}
}
static int _estimateBytes(ui.Image img) => img.width * img.height * 4;
static void _trim() {
while (_cache.length > _maxEntries || _approxBytes > _maxApproxBytes) {
final key = _cache.keys.first;
final tile = _cache.remove(key);
if (tile == null) continue;
_approxBytes -= _estimateBytes(tile.image);
tile.image.dispose();
}
}
static void _pruneQueue() {
final filter = _queueFilter;
if (filter == null || _queue.isEmpty) return;
final kept = Queue<_Queued>();
while (_queue.isNotEmpty) {
final item = _queue.removeFirst();
if (filter(item.coord.z, item.coord.x, item.coord.y)) {
kept.add(item);
} else {
_inFlight.remove(item.coord);
}
}
_queue.addAll(kept);
}
static void clear() {
for (final tile in _cache.values) {
tile.image.dispose();
}
_cache.clear();
_inFlight.clear();
_queue.clear();
_active = 0;
_approxBytes = 0;
_queueFilter = null;
}
}
class _Queued {
const _Queued({
required this.coord,
required this.zoomBucket,
required this.render,
required this.onReady,
});
final _TileCoord coord;
final int zoomBucket;
final Future<ui.Image?> Function() render;
final void Function() onReady;
}
@@ -0,0 +1,745 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:rra_app/pages/map/vector/mapbox_vector_tile_cache.dart';
import 'package:rra_app/pages/map/vector/mvt_parser.dart';
import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart';
// How many ancestor levels to walk when looking for a fallback image.
const int _kMaxAncestorLookup = 4;
const double _kTileResolutionScale = 2.0;
class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
TilePyramidPainter({
required this.tileUrlTemplate,
required this.tileSize,
required this.minTileZoom,
required this.maxTileZoom,
required this.tileOverscan,
});
final String tileUrlTemplate;
final double tileSize;
final int minTileZoom;
final int maxTileZoom;
final int tileOverscan;
// Viewport state — updated by the map page each frame.
double mapWidth = 0;
double mapHeight = 0;
double zoom = 0;
double centerWorldX = 0;
double centerWorldY = 0;
int activeTileZoom = 0;
int? fallbackTileZoom;
double devicePixelRatio = 1;
bool interactionActive = false;
// Colour filters applied when drawing each tile (contrast + saturation combined).
static const double _contrast = 1.20;
static const double _saturation = 1.12;
late final ui.ColorFilter _tileColorFilter = ui.ColorFilter.matrix(
_multiplyMatrices(
_contrastMatrix(_contrast),
_saturationMatrix(_saturation),
),
);
void markDirty() => notifyListeners();
String _tileUrl(int z, int x, int y) => tileUrlTemplate
.replaceAll('{z}', '$z')
.replaceAll('{x}', '$x')
.replaceAll('{y}', '$y');
@override
void paint(Canvas canvas, Size size) {
if (mapWidth == 0 || mapHeight == 0) return;
final fallbackZ = fallbackTileZoom;
if (fallbackZ != null && fallbackZ != activeTileZoom) {
_drawTileLayer(canvas, fallbackZ, queueTiles: false);
}
_drawTileLayer(canvas, activeTileZoom, queueTiles: true);
}
void _drawTileLayer(Canvas canvas, int z, {required bool queueTiles}) {
final zDelta = z - activeTileZoom;
final factor = math.pow(2, zoom - z).toDouble();
final worldScale = math.pow(2, zDelta).toDouble();
final centerX = centerWorldX * worldScale;
final centerY = centerWorldY * worldScale;
final maxIndex = 1 << z;
final minWX = centerX - (mapWidth / 2) / factor;
final minWY = centerY - (mapHeight / 2) / factor;
final maxWX = centerX + (mapWidth / 2) / factor;
final maxWY = centerY + (mapHeight / 2) / factor;
final minTX = (minWX / tileSize).floor() - tileOverscan;
final maxTX = (maxWX / tileSize).floor() + tileOverscan;
final minTY = (minWY / tileSize).floor() - tileOverscan;
final maxTY = (maxWY / tileSize).floor() + tileOverscan;
if (queueTiles) {
final allowed = _allowedQueuedTileHashes(
z,
minTX,
maxTX,
minTY,
maxTY,
maxIndex,
);
TilePyramidCache.setQueueFilter(
(qz, qx, qy) => allowed.contains(Object.hash(qz, qx, qy)),
);
}
for (var tx = minTX; tx <= maxTX; tx++) {
for (var ty = minTY; ty <= maxTY; ty++) {
if (ty < 0 || ty >= maxIndex) continue;
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
final left = (tx * tileSize - minWX) * factor;
final top = (ty * tileSize - minWY) * factor;
final displaySize = tileSize * factor;
final dst = Rect.fromLTWH(left, top, displaySize, displaySize);
_drawTile(canvas, z, wrappedX, ty, dst);
}
}
// Queue rasterisation for visible tiles when permitted.
if (queueTiles) {
_queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex);
// Pre-warm tiles just outside the viewport so panning feels instant.
if (!interactionActive) {
const guard = 2;
_queueVisibleTiles(
z,
minTX - guard,
maxTX + guard,
minTY - guard,
maxTY + guard,
maxIndex,
skipIfAlreadyQueued: true,
visMinTX: minTX,
visMaxTX: maxTX,
visMinTY: minTY,
visMaxTY: maxTY,
);
}
}
}
Set<int> _allowedQueuedTileHashes(
int z,
int minTX,
int maxTX,
int minTY,
int maxTY,
int maxIndex,
) {
final hashes = <int>{};
for (var tx = minTX; tx <= maxTX; tx++) {
for (var ty = minTY; ty <= maxTY; ty++) {
if (ty < 0 || ty >= maxIndex) continue;
final wx = ((tx % maxIndex) + maxIndex) % maxIndex;
hashes.add(Object.hash(z, wx, ty));
}
}
if (!interactionActive) {
const guard = 2;
final guardMinTX = minTX - guard;
final guardMaxTX = maxTX + guard;
final guardMinTY = minTY - guard;
final guardMaxTY = maxTY + guard;
for (var tx = guardMinTX; tx <= guardMaxTX; tx++) {
for (var ty = guardMinTY; ty <= guardMaxTY; ty++) {
if (ty < 0 || ty >= maxIndex) continue;
if (tx >= minTX && tx <= maxTX && ty >= minTY && ty <= maxTY) {
continue;
}
final wx = ((tx % maxIndex) + maxIndex) % maxIndex;
hashes.add(Object.hash(z, wx, ty));
}
}
}
return hashes;
}
void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) {
final zoomBucket = z;
// Try native level first.
final native = TilePyramidCache.peek(z, x, y);
if (native != null && native.zoomBucket == zoomBucket) {
_blitImage(
canvas,
native.image,
Rect.fromLTWH(
0,
0,
native.image.width.toDouble(),
native.image.height.toDouble(),
),
dst,
);
return;
}
// Walk up the pyramid for an ancestor fallback.
for (int dz = 1; dz <= _kMaxAncestorLookup; dz++) {
final az = z - dz;
if (az < minTileZoom) break;
final subdivision = 1 << dz;
final ancestorX = x >> dz;
final ancestorY = y >> dz;
final ancestor = TilePyramidCache.peek(az, ancestorX, ancestorY);
if (ancestor != null) {
// Compute which sub-region of the ancestor image corresponds to this tile.
final fracX = (x - ancestorX * subdivision).toDouble() / subdivision;
final fracY = (y - ancestorY * subdivision).toDouble() / subdivision;
final srcW = ancestor.image.width / subdivision;
final srcH = ancestor.image.height / subdivision;
final src = Rect.fromLTWH(
fracX * ancestor.image.width,
fracY * ancestor.image.height,
srcW,
srcH,
);
_blitImage(canvas, ancestor.image, src, dst);
break;
}
}
// Queue native image if not already cached/in-flight.
if ((native == null || native.zoomBucket != zoomBucket) &&
!TilePyramidCache.isInFlight(z, x, y) &&
!interactionActive) {
_queueTile(z, x, y, zoomBucket);
}
}
void _blitImage(Canvas canvas, ui.Image image, Rect src, Rect dst) {
final paint = Paint()
..filterQuality = FilterQuality.high
..colorFilter = _tileColorFilter;
canvas.drawImageRect(image, src, dst, paint);
}
void _queueTile(int z, int x, int y, int zoomBucket) {
final url = _tileUrl(z, x, y);
final rasterPx = (tileSize * devicePixelRatio * _kTileResolutionScale)
.round();
final currentZoom = zoom;
TilePyramidCache.enqueue(
z,
x,
y,
zoomBucket,
() async {
final tile = await MapboxVectorTileCache.getOrFetch(url);
if (tile == null || tile.layers.isEmpty) return null;
return _rasterize(tile, zoom: currentZoom, rasterPx: rasterPx);
},
() {
// Image ready — ask Flutter to redraw.
SchedulerBinding.instance.scheduleFrame();
notifyListeners();
},
);
}
void _queueVisibleTiles(
int z,
int minTX,
int maxTX,
int minTY,
int maxTY,
int maxIndex, {
bool skipIfAlreadyQueued = false,
int visMinTX = 0,
int visMaxTX = 0,
int visMinTY = 0,
int visMaxTY = 0,
}) {
for (var tx = minTX; tx <= maxTX; tx++) {
for (var ty = minTY; ty <= maxTY; ty++) {
if (ty < 0 || ty >= maxIndex) continue;
// When pre-warming the guard ring, skip tiles in the core visible area
// (they're already handled by the first call).
if (skipIfAlreadyQueued &&
tx >= visMinTX &&
tx <= visMaxTX &&
ty >= visMinTY &&
ty <= visMaxTY) {
continue;
}
final wx = ((tx % maxIndex) + maxIndex) % maxIndex;
final cached = TilePyramidCache.peek(z, wx, ty);
if (cached != null && cached.zoomBucket == z) continue;
if (TilePyramidCache.isInFlight(z, wx, ty)) continue;
_queueTile(z, wx, ty, z);
}
}
}
// -------------------------------------------------------------------------
// Rasterisation for the cached tile images.
// -------------------------------------------------------------------------
void _drawTileContents(
Canvas canvas,
Size sz,
MvtTile tile, {
required double zoom,
}) {
final paint = Paint()..isAntiAlias = true;
paint.color = const Color(0xFFE6EAF1);
canvas.drawRect(Offset.zero & sz, paint);
_drawPolygons(
canvas,
sz,
tile,
const <String>{'landuse'},
const Color(0xFFCCDCBF),
zoom: zoom,
);
_drawPolygons(
canvas,
sz,
tile,
const <String>{'park'},
const Color(0xFF97D585),
zoom: zoom,
);
_drawPolygons(
canvas,
sz,
tile,
const <String>{'water'},
const Color(0xFF68B5EC),
zoom: zoom,
);
_drawPolygons(
canvas,
sz,
tile,
const <String>{'building'},
const Color(0xFFCCD3DF),
zoom: zoom,
strokeColor: const Color(0xFF8E9CB4),
strokeBaseWidth: 0.56,
);
_drawRoads(canvas, sz, tile, zoom: zoom);
_drawLabels(canvas, sz, tile, zoom: zoom);
}
Future<ui.Image?> _rasterize(
MvtTile tile, {
required double zoom,
required int rasterPx,
}) async {
if (rasterPx <= 0) return null;
final recorder = ui.PictureRecorder();
final sz = Size(rasterPx.toDouble(), rasterPx.toDouble());
_drawTileContents(Canvas(recorder), sz, tile, zoom: zoom);
final pic = recorder.endRecording();
try {
return await pic.toImage(rasterPx, rasterPx);
} finally {
pic.dispose();
}
}
void _drawPolygons(
Canvas canvas,
Size size,
MvtTile tile,
Set<String> layerNames,
Color color, {
required double zoom,
Color? strokeColor,
double strokeBaseWidth = 0.0,
}) {
final fill = Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
final stroke = strokeColor == null
? null
: (Paint()
..color = strokeColor
..style = PaintingStyle.stroke
..strokeWidth =
(strokeBaseWidth + (zoom - 15.0).clamp(0.0, 8.0) * 0.06).clamp(
0.44,
1.18,
)
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round
..isAntiAlias = true);
for (final entry in tile.layers.entries) {
if (!layerNames.contains(entry.key)) continue;
final layer = entry.value;
final scale = size.width / layer.extent;
for (final feature in layer.features) {
if (feature.type != MvtGeometryType.polygon) continue;
for (final ring in feature.geometry) {
if (ring.length < 3) continue;
final path = Path()
..moveTo(ring.first.dx * scale, ring.first.dy * scale);
for (var i = 1; i < ring.length; i++) {
path.lineTo(ring[i].dx * scale, ring[i].dy * scale);
}
path.close();
canvas.drawPath(path, fill);
if (stroke != null) canvas.drawPath(path, stroke);
}
}
}
}
void _drawRoads(
Canvas canvas,
Size size,
MvtTile tile, {
required double zoom,
}) {
final roadLayer = tile.layers['road'];
if (roadLayer == null) return;
final scale = size.width / roadLayer.extent;
for (final feature in roadLayer.features) {
if (feature.type != MvtGeometryType.lineString) continue;
final roadClass =
(feature.properties['class'] ?? feature.properties['type'] ?? '')
.toString()
.toLowerCase();
final isPathLike =
roadClass.contains('path') ||
roadClass.contains('footway') ||
roadClass.contains('steps') ||
roadClass.contains('cycleway') ||
roadClass.contains('track') ||
roadClass.contains('bridleway');
if (isPathLike && zoom < 15) continue;
final isMotorway = roadClass.contains('motorway');
final isTrunkPrimary =
roadClass.contains('trunk') || roadClass.contains('primary');
final isSecondary =
roadClass.contains('secondary') || roadClass.contains('tertiary');
final isLink = roadClass.contains('link');
var baseWidth = 0.92;
var growth = 0.24;
var casingColor = const Color(0xFF9BA7BE);
var fillColor = const Color(0xFFD8DFEC);
if (isMotorway) {
baseWidth = 1.60;
growth = 0.42;
casingColor = const Color(0xFF5D6F92);
fillColor = const Color(0xFF98AFCF);
} else if (isTrunkPrimary) {
baseWidth = 1.35;
growth = 0.34;
casingColor = const Color(0xFF7080A0);
fillColor = const Color(0xFFAFC0D8);
} else if (isSecondary) {
baseWidth = 1.06;
growth = 0.28;
casingColor = const Color(0xFF8694B0);
fillColor = const Color(0xFFC5D1E3);
}
if (isLink) baseWidth *= 0.82;
final zoomBoost = (zoom - 11).clamp(0.0, 10.0);
final width = (baseWidth + zoomBoost * growth).clamp(0.60, 7.20);
final casing = Paint()
..color = casingColor
..style = PaintingStyle.stroke
..strokeWidth = width + (isMotorway ? 1.15 : 0.95)
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
final road = Paint()
..color = fillColor
..style = PaintingStyle.stroke
..strokeWidth = width
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
for (final line in feature.geometry) {
if (line.length < 2) continue;
final path = Path()
..moveTo(line.first.dx * scale, line.first.dy * scale);
for (var i = 1; i < line.length; i++) {
path.lineTo(line[i].dx * scale, line[i].dy * scale);
}
canvas.drawPath(path, casing);
canvas.drawPath(path, road);
}
}
}
void _drawLabels(
Canvas canvas,
Size size,
MvtTile tile, {
required double zoom,
}) {
if (zoom < 12) return;
final occupiedRects = <Rect>[];
final seenTexts = <String>{};
void drawFromLayer(
String layerName,
Color color,
double fontSize, {
bool pointsOnly = true,
FontWeight fontWeight = FontWeight.w500,
required double minZoom,
int maxLabels = 9999,
double minSpacing = 10,
Set<String>? allowedClasses,
}) {
if (zoom < minZoom) return;
final layer = tile.layers[layerName];
if (layer == null) return;
final scale = size.width / layer.extent;
final candidates = <_LabelCandidate>[];
for (final feature in layer.features) {
if (pointsOnly && feature.type != MvtGeometryType.point) continue;
final featureClass =
(feature.properties['class'] ?? feature.properties['type'] ?? '')
.toString()
.toLowerCase();
if (allowedClasses != null &&
allowedClasses.isNotEmpty &&
!allowedClasses.contains(featureClass)) {
continue;
}
final text =
(feature.properties['name_en'] ?? feature.properties['name'] ?? '')
.toString()
.trim();
if (text.isEmpty || text.length > 26) continue;
final anchor =
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
? feature.geometry.first.first
: null;
if (anchor == null) continue;
final scalerank = _toRank(feature.properties['scalerank']) ?? 99;
final localrank = _toRank(feature.properties['localrank']) ?? 99;
candidates.add(
_LabelCandidate(
text: text,
anchor: anchor,
rank: scalerank * 100 + localrank,
),
);
}
candidates.sort((a, b) {
final r = a.rank.compareTo(b.rank);
return r != 0 ? r : a.text.length.compareTo(b.text.length);
});
var drawn = 0;
for (final c in candidates) {
if (drawn >= maxLabels) break;
if (seenTexts.contains(c.text.toLowerCase())) continue;
final tp = TextPainter(
text: TextSpan(
text: c.text,
style: TextStyle(
color: color,
fontSize: fontSize,
fontWeight: fontWeight,
shadows: const <Shadow>[
Shadow(color: Color(0xF7FFFFFF), blurRadius: 1.4),
],
height: 1.0,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '',
)..layout();
final x = c.anchor.dx * scale - tp.width / 2;
final y = c.anchor.dy * scale - tp.height / 2;
if (x < -tp.width ||
y < -tp.height ||
x > size.width ||
y > size.height) {
continue;
}
final labelRect = Rect.fromLTWH(
x,
y,
tp.width,
tp.height,
).inflate(minSpacing);
if (occupiedRects.any((r) => r.overlaps(labelRect))) continue;
tp.paint(canvas, Offset(x, y));
occupiedRects.add(labelRect);
seenTexts.add(c.text.toLowerCase());
drawn++;
}
}
drawFromLayer(
'place_label',
const Color(0xFF1F2636),
11.2,
fontWeight: FontWeight.w600,
minZoom: 12.5,
maxLabels: zoom >= 15 ? 5 : 2,
minSpacing: zoom >= 15 ? 24 : 30,
allowedClasses: const <String>{
'city',
'town',
'suburb',
'neighbourhood',
'borough',
},
);
}
int? _toRank(Object? value) {
if (value is int) return value;
if (value is double) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
// -------------------------------------------------------------------------
// ColorFilter matrices
// -------------------------------------------------------------------------
static List<double> _contrastMatrix(double contrast) {
final c = contrast.clamp(0.0, 3.0);
final t = 128.0 * (1.0 - c);
return <double>[c, 0, 0, 0, t, 0, c, 0, 0, t, 0, 0, c, 0, t, 0, 0, 0, 1, 0];
}
static List<double> _saturationMatrix(double saturation) {
final s = saturation.clamp(0.0, 2.0);
const rw = 0.213;
const gw = 0.715;
const bw = 0.072;
final a = 1 - s;
return <double>[
a * rw + s,
a * gw,
a * bw,
0,
0,
a * rw,
a * gw + s,
a * bw,
0,
0,
a * rw,
a * gw,
a * bw + s,
0,
0,
0,
0,
0,
1,
0,
];
}
// 4x5 colour matrix multiply: result = a * b (both are row-major 4x5).
static List<double> _multiplyMatrices(List<double> a, List<double> b) {
final out = List<double>.filled(20, 0);
for (int row = 0; row < 4; row++) {
for (int col = 0; col < 5; col++) {
double v = 0;
for (int k = 0; k < 4; k++) {
v += a[row * 5 + k] * b[k * 5 + col];
}
// translation column — just add when col == 4
if (col == 4) v += a[row * 5 + 4];
out[row * 5 + col] = v;
}
}
return out;
}
// -------------------------------------------------------------------------
// CustomPainter boilerplate
// -------------------------------------------------------------------------
@override
bool shouldRepaint(covariant TilePyramidPainter oldDelegate) => true;
@override
bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false;
@override
SemanticsBuilderCallback? get semanticsBuilder => null;
@override
bool hitTest(Offset position) => false;
}
class _LabelCandidate {
const _LabelCandidate({
required this.text,
required this.anchor,
required this.rank,
});
final String text;
final Offset anchor;
final int rank;
}