Roadbound-Map-Utility/lib/pages/map/vector/tile_pyramid_painter.dart

1704 lines
50 KiB
Dart

import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:rra_app/pages/map/constants.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';
const int _kMaxAncestorLookup = 4;
const double _kTileResolutionScale = 2.0;
const double _kLineReferenceZoom = 14.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;
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;
// cache of tile-local road label placements keyed by "$url:$featureIndex"
// placement is stored in tile extent coordinates so it never changes on pan
final Map<String, _RoadLabelPlacement?> _roadLabelPlacementCache = {};
void markDirty() => notifyListeners();
String _tileUrl(int z, int x, int y) => tileUrlTemplate
.replaceAll('{z}', '$z')
.replaceAll('{x}', '$x')
.replaceAll('{y}', '$y');
Future<MapDebugHit?> inspectFeatureAt(Offset localPosition) async {
if (mapWidth <= 0 || mapHeight <= 0) return null;
final z = activeTileZoom;
final factor = math.pow(2, zoom - z).toDouble();
final maxIndex = 1 << z;
final minWX = centerWorldX - (mapWidth / 2) / factor;
final minWY = centerWorldY - (mapHeight / 2) / factor;
final worldX = minWX + localPosition.dx / factor;
final worldY = minWY + localPosition.dy / factor;
final tileX = (worldX / tileSize).floor();
final tileY = (worldY / tileSize).floor();
if (tileY < 0 || tileY >= maxIndex) return null;
final wrappedX = ((tileX % maxIndex) + maxIndex) % maxIndex;
final tile = await MapboxVectorTileCache.getOrFetch(
_tileUrl(z, wrappedX, tileY),
);
if (tile == null) return null;
final displayTileSize = tileSize * factor;
for (final layerName in _inspectLayerOrder) {
final layer = tile.layers[layerName];
if (layer == null) continue;
final localExtentPoint = Offset(
(worldX - tileX * tileSize) / tileSize * layer.extent,
(worldY - tileY * tileSize) / tileSize * layer.extent,
);
final hit = _inspectLayerHit(
layer,
layerName: layerName,
localPoint: localExtentPoint,
pixelTolerance: 10.0,
displayTileSize: displayTileSize,
tileZ: z,
tileX: wrappedX,
tileY: tileY,
);
if (hit != null) return hit;
}
return null;
}
@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);
_drawAllRoadLabels(canvas);
_drawAllPlaceLabels(canvas);
}
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 dst = Rect.fromLTWH(
left,
top,
tileSize * factor,
tileSize * factor,
);
_drawTile(canvas, z, wrappedX, ty, dst);
}
}
if (queueTiles) {
_queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex);
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>{};
void addWindow(int fromTX, int toTX, int fromTY, int toTY) {
for (var tx = fromTX; tx <= toTX; tx++) {
for (var ty = fromTY; ty <= toTY; ty++) {
if (ty < 0 || ty >= maxIndex) continue;
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
hashes.add(Object.hash(z, wrappedX, ty));
}
}
}
addWindow(minTX, maxTX, minTY, maxTY);
if (!interactionActive) {
const guard = 2;
addWindow(minTX - guard, maxTX + guard, minTY - guard, maxTY + guard);
}
return hashes;
}
void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) {
final native = TilePyramidCache.peek(z, x, y);
if (native != null) {
_blitImage(
canvas,
native.image,
Rect.fromLTWH(
0,
0,
native.image.width.toDouble(),
native.image.height.toDouble(),
),
dst,
);
return;
}
for (var 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) continue;
final fracX = (x - ancestorX * subdivision).toDouble() / subdivision;
final fracY = (y - ancestorY * subdivision).toDouble() / subdivision;
final src = Rect.fromLTWH(
fracX * ancestor.image.width,
fracY * ancestor.image.height,
ancestor.image.width / subdivision,
ancestor.image.height / subdivision,
);
_blitImage(canvas, ancestor.image, src, dst);
break;
}
if (!TilePyramidCache.isInFlight(z, x, y)) {
_queueTile(z, x, y);
}
}
void _blitImage(Canvas canvas, ui.Image image, Rect src, Rect dst) {
final paint = Paint()..filterQuality = FilterQuality.high;
canvas.drawImageRect(image, src, dst, paint);
}
void _queueTile(int z, int x, int y) {
final url = _tileUrl(z, x, y);
final rasterPx = (tileSize * devicePixelRatio * _kTileResolutionScale)
.round();
TilePyramidCache.enqueue(
z,
x,
y,
() async {
final tile = await MapboxVectorTileCache.getOrFetch(url);
if (tile == null || tile.layers.isEmpty) return null;
return _rasterize(tile, tileZoom: z.toDouble(), rasterPx: rasterPx);
},
() {
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;
if (skipIfAlreadyQueued &&
tx >= visMinTX &&
tx <= visMaxTX &&
ty >= visMinTY &&
ty <= visMaxTY) {
continue;
}
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
if (TilePyramidCache.peek(z, wrappedX, ty) != null) continue;
if (TilePyramidCache.isInFlight(z, wrappedX, ty)) continue;
_queueTile(z, wrappedX, ty);
}
}
}
MapDebugHit? _inspectLayerHit(
MvtLayer layer, {
required String layerName,
required Offset localPoint,
required double pixelTolerance,
required double displayTileSize,
required int tileZ,
required int tileX,
required int tileY,
}) {
final tolerance = layer.extent / displayTileSize * pixelTolerance;
for (final feature in layer.features) {
if (feature.type != MvtGeometryType.lineString) continue;
final distance = _minDistanceToGeometry(localPoint, feature.geometry);
if (distance <= tolerance) {
return MapDebugHit(
layerName: layerName,
geometryType: feature.type,
properties: feature.properties,
tileZ: tileZ,
tileX: tileX,
tileY: tileY,
distance: distance,
);
}
}
for (final feature in layer.features) {
if (feature.type != MvtGeometryType.polygon) continue;
if (_polygonContains(localPoint, feature.geometry)) {
return MapDebugHit(
layerName: layerName,
geometryType: feature.type,
properties: feature.properties,
tileZ: tileZ,
tileX: tileX,
tileY: tileY,
distance: 0,
);
}
}
for (final feature in layer.features) {
if (feature.type != MvtGeometryType.point) continue;
final distance = _minDistanceToPoints(localPoint, feature.geometry);
if (distance <= tolerance) {
return MapDebugHit(
layerName: layerName,
geometryType: feature.type,
properties: feature.properties,
tileZ: tileZ,
tileX: tileX,
tileY: tileY,
distance: distance,
);
}
}
return null;
}
Future<ui.Image?> _rasterize(
MvtTile tile, {
required double tileZoom,
required int rasterPx,
}) async {
if (rasterPx <= 0) return null;
final recorder = ui.PictureRecorder();
final size = Size.square(rasterPx.toDouble());
final canvas = Canvas(recorder);
_paintTile(canvas, size, tile, tileZoom: tileZoom);
final picture = recorder.endRecording();
try {
return await picture.toImage(rasterPx, rasterPx);
} finally {
picture.dispose();
}
}
void _paintTile(
Canvas canvas,
Size size,
MvtTile tile, {
required double tileZoom,
}) {
canvas.drawRect(
Offset.zero & size,
Paint()
..color = kMapBackgroundColor
..isAntiAlias = true,
);
_drawPolygonLayer(
canvas,
size,
tile,
layerNames: const <String>{'landuse'},
fillColor: kMapLanduseColor,
allowedTypes: const <String>{'retail'},
tileZoom: tileZoom,
);
_drawPolygonLayer(
canvas,
size,
tile,
layerNames: const <String>{'park'},
fillColor: kMapGrassColor,
tileZoom: tileZoom,
);
_drawPolygonLayer(
canvas,
size,
tile,
layerNames: const <String>{'water'},
fillColor: kMapWaterColor,
tileZoom: tileZoom,
);
_drawPolygonLayer(
canvas,
size,
tile,
layerNames: const <String>{'landuse'},
fillColor: kMapGrassColor,
allowedTypes: const <String>{'park', 'meadow'},
allowedClasses: const <String>{'grass'},
tileZoom: tileZoom,
);
_drawPolygonLayer(
canvas,
size,
tile,
layerNames: const <String>{'park'},
fillColor: kMapGrassColor,
tileZoom: tileZoom,
);
_drawRoadLayer(canvas, size, tile, tileZoom: tileZoom);
_drawPolygonLayer(
canvas,
size,
tile,
layerNames: const <String>{'building'},
fillColor: kMapBuildingFillColor,
strokeColor: kMapBuildingOutlineColor,
strokeWorldUnits: 3.5,
strokeCap: StrokeCap.butt,
strokeJoin: StrokeJoin.miter,
strokeAntiAlias: false,
allowedTypes: const <String>{'retail', 'shelter', 'stadium'},
excludeExtruded: true,
allowExtrudedTypes: const <String>{'stadium'},
minHeight: 4.1,
tileZoom: tileZoom,
);
_drawRoadLabels(canvas, size, tile, tileZoom: tileZoom);
_drawPoiLabels(canvas, size, tile, tileZoom: tileZoom);
_drawPlaceLabels(canvas, size, tile, tileZoom: tileZoom);
_drawRasterTileBoundary(canvas, size);
}
void _drawRasterTileBoundary(Canvas canvas, Size size) {
canvas.drawRect(
Offset.zero & size,
Paint()
..color = kMapDebugTileBoundaryColor
..style = PaintingStyle.stroke
..strokeWidth = 2.0
..isAntiAlias = false,
);
}
void _drawPolygonLayer(
Canvas canvas,
Size size,
MvtTile tile, {
required Set<String> layerNames,
required Color fillColor,
Color? strokeColor,
double strokeWorldUnits = 0.0,
StrokeCap strokeCap = StrokeCap.round,
StrokeJoin strokeJoin = StrokeJoin.round,
bool strokeAntiAlias = true,
Set<String>? allowedTypes,
Set<String>? allowedClasses,
bool excludeExtruded = false,
Set<String>? allowExtrudedTypes,
double? minHeight,
required double tileZoom,
}) {
final fill = Paint()
..color = fillColor
..style = PaintingStyle.fill
..isAntiAlias = true;
Paint? stroke;
for (final entry in tile.layers.entries) {
if (!layerNames.contains(entry.key)) continue;
final layer = entry.value;
final scale = size.width / layer.extent;
stroke ??= strokeColor == null
? null
: (Paint()
..color = strokeColor
..style = PaintingStyle.stroke
..strokeWidth = _worldStrokePx(
strokeWorldUnits,
extent: layer.extent,
rasterSize: size.width,
tileZoom: tileZoom,
)
..strokeCap = strokeCap
..strokeJoin = strokeJoin
..isAntiAlias = strokeAntiAlias);
for (final feature in layer.features) {
if (feature.type != MvtGeometryType.polygon) continue;
final featureType = (feature.properties['type'] ?? '')
.toString()
.toLowerCase();
final featureClass = (feature.properties['class'] ?? '')
.toString()
.toLowerCase();
final extrude = _isTruthy(feature.properties['extrude']);
final height = _toDouble(feature.properties['height']);
final minFeatureHeight = feature.properties['min_height'];
if (allowedTypes != null &&
allowedTypes.isNotEmpty &&
(allowedClasses == null || allowedClasses.isEmpty) &&
!allowedTypes.contains(featureType)) {
continue;
}
if (allowedClasses != null &&
allowedClasses.isNotEmpty &&
(allowedTypes == null || allowedTypes.isEmpty) &&
!allowedClasses.contains(featureClass)) {
continue;
}
if (allowedTypes != null &&
allowedTypes.isNotEmpty &&
allowedClasses != null &&
allowedClasses.isNotEmpty &&
!allowedTypes.contains(featureType) &&
!allowedClasses.contains(featureClass)) {
continue;
}
if (excludeExtruded &&
extrude &&
(allowExtrudedTypes == null ||
!allowExtrudedTypes.contains(featureType))) {
continue;
}
if (minHeight != null &&
height != null &&
height < minHeight &&
minFeatureHeight == null) {
continue;
}
for (final ring in feature.geometry) {
if (ring.length < 3) continue;
final path = _scaledPath(ring, scale, close: true);
canvas.drawPath(path, fill);
if (stroke != null) canvas.drawPath(path, stroke);
}
}
}
}
void _drawRoadLayer(
Canvas canvas,
Size size,
MvtTile tile, {
required double tileZoom,
}) {
final roadLayer = tile.layers['road'];
if (roadLayer == null) return;
final scale = size.width / roadLayer.extent;
final styledLines = <_StyledRoad>[];
final styledPolygons = <_StyledRoad>[];
for (final feature in roadLayer.features) {
final style = _roadStyleFor(feature, tileZoom: tileZoom);
if (style == null) continue;
if (feature.type == MvtGeometryType.lineString) {
styledLines.add(_StyledRoad(feature: feature, style: style));
} else if (feature.type == MvtGeometryType.polygon) {
styledPolygons.add(_StyledRoad(feature: feature, style: style));
}
}
styledPolygons.sort(
(a, b) => a.style.sortOrder.compareTo(b.style.sortOrder),
);
styledLines.sort((a, b) => a.style.sortOrder.compareTo(b.style.sortOrder));
for (final road in styledPolygons) {
final fill = Paint()
..color = road.style.fillColor
..style = PaintingStyle.fill
..isAntiAlias = true;
final outline = Paint()
..color = road.style.underlayColor
..style = PaintingStyle.stroke
..strokeWidth = _worldStrokePx(
math.max(road.style.coreWorldUnits * 0.22, 3.0),
extent: roadLayer.extent,
rasterSize: size.width,
tileZoom: tileZoom,
)
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
for (final ring in road.feature.geometry) {
if (ring.length < 3) continue;
final path = _scaledPath(ring, scale, close: true);
canvas.drawPath(path, fill);
canvas.drawPath(path, outline);
}
}
for (final road in styledLines) {
final outlineOnly = road.style.outlineOnly;
final underlay = Paint()
..color = outlineOnly ? road.style.fillColor : road.style.underlayColor
..style = PaintingStyle.stroke
..strokeWidth = _worldStrokePx(
road.style.underlayWorldUnits,
extent: roadLayer.extent,
rasterSize: size.width,
tileZoom: tileZoom,
)
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
for (final line in road.feature.geometry) {
if (line.length < 2) continue;
final path = _scaledPath(line, scale);
canvas.drawPath(path, underlay);
}
}
for (final road in styledLines) {
if (road.style.outlineOnly) continue;
final core = Paint()
..color = road.style.fillColor
..style = PaintingStyle.stroke
..strokeWidth = _worldStrokePx(
road.style.coreWorldUnits,
extent: roadLayer.extent,
rasterSize: size.width,
tileZoom: tileZoom,
)
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
for (final line in road.feature.geometry) {
if (line.length < 2) continue;
final path = _scaledPath(line, scale);
canvas.drawPath(path, core);
}
}
}
_RoadStyle? _roadStyleFor(MvtFeature feature, {required double tileZoom}) {
final roadClass = (feature.properties['class'] ?? '')
.toString()
.toLowerCase();
final rawRoadType = feature.properties['type'];
final roadType = (rawRoadType ?? '').toString().toLowerCase();
final surface = (feature.properties['surface'] ?? '')
.toString()
.toLowerCase();
final structure =
(feature.properties['structure'] ?? feature.properties['brunnel'] ?? '')
.toString()
.toLowerCase();
final isRailLike =
roadClass.contains('rail') ||
roadType.contains('rail') ||
roadClass.contains('subway') ||
roadType.contains('subway') ||
roadClass.contains('metro') ||
roadType.contains('metro') ||
roadClass.contains('train') ||
roadType.contains('train');
final isPlatform =
roadClass.contains('platform') || roadType.contains('platform');
final isService =
roadClass.contains('service') || roadType.contains('service');
final isUnderground =
structure.contains('tunnel') || structure.contains('underground');
if (isRailLike && isUnderground) return null;
if (isPlatform) return null;
if (isService) return null;
if (isRailLike) {
return _RoadStyle(
sortOrder: 0,
coreWorldUnits: 3.5,
underlayWorldUnits: 5.5,
fillColor: kMapRailFillColor,
underlayColor: kMapRailUnderlayColor,
outlineOnly: false,
);
}
final isPedestrian =
roadClass.contains('pedestrian') || roadType.contains('pedestrian');
final isFootway =
roadClass.contains('footway') || roadType.contains('footway');
final isPathClass = roadClass.contains('path');
final isPathLike =
isPathClass ||
roadType.contains('path') ||
isFootway ||
roadClass.contains('steps') ||
roadType.contains('steps') ||
roadClass.contains('cycleway') ||
roadType.contains('cycleway') ||
roadClass.contains('track') ||
roadType.contains('track') ||
roadClass.contains('bridleway') ||
roadType.contains('bridleway');
final isFootwayPath = isFootway && isPathClass;
final isPavedPathLike = surface.contains('paved') && isPathLike;
if (isFootwayPath) return null;
if (isPavedPathLike) return null;
if (isFootway) {
return _RoadStyle(
sortOrder: 0,
coreWorldUnits: 7.4,
underlayWorldUnits: 11.6,
fillColor: kMapPathFillColour,
underlayColor: kMapBackgroundColor,
outlineOnly: true,
);
}
if (isPathLike) return null;
final isMotorway =
roadClass.contains('motorway') || roadType.contains('motorway');
final isTrunkPrimary =
roadClass.contains('trunk') ||
roadType.contains('trunk') ||
roadClass.contains('primary') ||
roadType.contains('primary');
final isSecondary =
roadClass.contains('secondary') ||
roadType.contains('secondary') ||
roadClass.contains('tertiary') ||
roadType.contains('tertiary');
final isLink = roadClass.contains('link') || roadType.contains('link');
var style = _RoadStyle(
sortOrder: 1,
coreWorldUnits: 13.4,
underlayWorldUnits: 20.4,
fillColor: kMapRoadFillColor,
underlayColor: kMapPathFillColour,
outlineOnly: false,
);
if (isSecondary) {
style = _RoadStyle(
sortOrder: 2,
coreWorldUnits: 16.4,
underlayWorldUnits: 24.8,
fillColor: kMapRoadFillColor,
underlayColor: kMapPathFillColour,
outlineOnly: false,
);
}
if (isTrunkPrimary) {
style = _RoadStyle(
sortOrder: 3,
coreWorldUnits: 20.9,
underlayWorldUnits: 31.0,
fillColor: kMapRoadFillColor,
underlayColor: kMapPathFillColour,
outlineOnly: false,
);
}
if (isMotorway) {
style = _RoadStyle(
sortOrder: 4,
coreWorldUnits: 25.3,
underlayWorldUnits: 37.0,
fillColor: kMapRoadFillColor,
underlayColor: kMapPathFillColour,
outlineOnly: false,
);
}
if (isPedestrian) {
style = _RoadStyle(
sortOrder: style.sortOrder,
coreWorldUnits: style.coreWorldUnits,
underlayWorldUnits: style.underlayWorldUnits,
fillColor: kMapPathFillColour,
underlayColor: style.underlayColor,
outlineOnly: false,
);
}
if (!isLink) return style;
return _RoadStyle(
sortOrder: style.sortOrder,
coreWorldUnits: style.coreWorldUnits * 0.82,
underlayWorldUnits: style.underlayWorldUnits * 0.82,
fillColor: style.fillColor,
underlayColor: style.underlayColor,
outlineOnly: style.outlineOnly,
);
}
static const Set<String> _areaPoiClasses = <String>{
'park',
'nature',
'cemetery',
'wood',
'forest',
'golf',
'recreation_ground',
'stadium',
'pitch',
'allotments',
};
void _drawAllPlaceLabels(Canvas canvas) {
final z = activeTileZoom;
final factor = math.pow(2, zoom - z).toDouble();
final maxIndex = 1 << z;
final minWX = centerWorldX - (mapWidth / 2) / factor;
final minWY = centerWorldY - (mapHeight / 2) / factor;
final maxWX = centerWorldX + (mapWidth / 2) / factor;
final maxWY = centerWorldY + (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;
final occupiedRects = <Rect>[];
final placedByName = <String, List<Offset>>{};
final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight);
const minSameNameSpacing = 300.0;
final candidates = <({String text, String featureClass, Offset screenPt, int rank})>[];
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 url = _tileUrl(z, wrappedX, ty);
final tile = MapboxVectorTileCache.peek(url);
if (tile == null) continue;
final layer = tile.layers['place_label'];
if (layer == null) continue;
final tileLeft = (tx * tileSize - minWX) * factor;
final tileTop = (ty * tileSize - minWY) * factor;
final scale = (tileSize * factor) / layer.extent;
for (final feature in layer.features) {
if (feature.type != MvtGeometryType.point) continue;
final featureClass =
(feature.properties['class'] ?? feature.properties['type'] ?? '')
.toString()
.toLowerCase();
if (!_allowedPlaceClasses.contains(featureClass)) continue;
final text = _sanitizeLabel(
(feature.properties['name_en'] ?? feature.properties['name'] ?? '')
.toString()
.trim());
if (text.isEmpty || text.length > 30) continue;
final anchor =
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
? feature.geometry.first.first
: null;
if (anchor == null) continue;
final screenPt = Offset(
tileLeft + anchor.dx * scale,
tileTop + anchor.dy * scale,
);
final symbolrank = _toRank(feature.properties['symbolrank']) ?? 99;
candidates.add((
text: text,
featureClass: featureClass,
screenPt: screenPt,
rank: symbolrank,
));
}
}
}
candidates.sort((a, b) => a.rank.compareTo(b.rank));
for (final c in candidates) {
if (!screenBounds.contains(c.screenPt)) continue;
final key = c.text.toLowerCase();
final existing = placedByName[key];
if (existing != null) {
final tooClose = existing.any((p) {
final dx = p.dx - c.screenPt.dx;
final dy = p.dy - c.screenPt.dy;
return math.sqrt(dx * dx + dy * dy) < minSameNameSpacing;
});
if (tooClose) continue;
}
final isSettlement = c.featureClass == 'settlement';
final fontSize = isSettlement ? 22.0 : 15.0;
final fontWeight = isSettlement ? FontWeight.w700 : FontWeight.w600;
final painter = TextPainter(
text: TextSpan(
text: c.text.toUpperCase(),
style: GoogleFonts.inter(
color: kMapPlaceLabelColor,
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: isSettlement ? 2.4 : 1.4,
height: 1.0,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '',
)..layout();
final bounds = Rect.fromCenter(
center: c.screenPt,
width: painter.width + 16,
height: painter.height + 12,
);
if (occupiedRects.any((r) => r.overlaps(bounds))) continue;
painter.paint(canvas, Offset(c.screenPt.dx - painter.width / 2, c.screenPt.dy - painter.height / 2));
occupiedRects.add(bounds);
(placedByName[key] ??= <Offset>[]).add(c.screenPt);
}
}
void _drawPoiLabels(
Canvas canvas,
Size size,
MvtTile tile, {
required double tileZoom,
}) {
if (tileZoom < 14) return;
final layer = tile.layers['poi_label'];
if (layer == null) return;
final scale = size.width / layer.extent;
final occupiedRects = <Rect>[];
final seenTexts = <String>{};
for (final feature in layer.features) {
if (feature.type != MvtGeometryType.point) continue;
final featureClass =
(feature.properties['class'] ??
feature.properties['type'] ??
feature.properties['maki'] ??
'')
.toString()
.toLowerCase();
if (!_areaPoiClasses.contains(featureClass)) continue;
final text = _sanitizeLabel(
(feature.properties['name_en'] ?? feature.properties['name'] ?? '')
.toString()
.trim());
if (text.isEmpty || text.length > 30) continue;
if (!seenTexts.add(text.toLowerCase())) continue;
final anchor =
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
? feature.geometry.first.first
: null;
if (anchor == null) continue;
final painter = TextPainter(
text: TextSpan(
text: text,
style: GoogleFonts.inter(
color: kMapPoiLabelColor,
fontSize: 10.0,
fontWeight: FontWeight.w500,
fontStyle: FontStyle.italic,
shadows: <Shadow>[
Shadow(color: kMapPoiLabelHaloColor, blurRadius: 1.8),
],
height: 1.0,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '',
)..layout();
final x = anchor.dx * scale - painter.width / 2;
final y = anchor.dy * scale - painter.height / 2;
if (x < -painter.width ||
y < -painter.height ||
x > size.width ||
y > size.height) {
continue;
}
final bounds = Rect.fromLTWH(
x,
y,
painter.width,
painter.height,
).inflate(20);
if (occupiedRects.any((r) => r.overlaps(bounds))) continue;
painter.paint(canvas, Offset(x, y));
occupiedRects.add(bounds);
}
}
void _drawPlaceLabels(
Canvas canvas,
Size size,
MvtTile tile, {
required double tileZoom,
}) {
if (tileZoom < 12) return;
final layer = tile.layers['place_label'];
if (layer == null) return;
final candidates = <_LabelCandidate>[];
final occupiedRects = <Rect>[];
final seenTexts = <String>{};
final scale = size.width / layer.extent;
for (final feature in layer.features) {
if (feature.type != MvtGeometryType.point) continue;
final featureClass =
(feature.properties['class'] ?? feature.properties['type'] ?? '')
.toString()
.toLowerCase();
// temp: log all place_label classes to figure out whats in the data
if (!_allowedPlaceClasses.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,
featureClass: featureClass,
),
);
}
candidates.sort((a, b) {
final rankCompare = a.rank.compareTo(b.rank);
return rankCompare != 0
? rankCompare
: a.text.length.compareTo(b.text.length);
});
final maxLabels = tileZoom >= 15 ? 5 : 2;
final minSpacing = tileZoom >= 15 ? 24.0 : 30.0;
var drawn = 0;
for (final candidate in candidates) {
if (drawn >= maxLabels) break;
if (seenTexts.contains(candidate.text.toLowerCase())) continue;
final isSettlement = candidate.featureClass == 'settlement';
final fontSize = isSettlement ? 22.0 : 15.0;
final fontWeight = isSettlement ? FontWeight.w700 : FontWeight.w600;
final painter = TextPainter(
text: TextSpan(
text: candidate.text.toUpperCase(),
style: GoogleFonts.inter(
color: kMapPlaceLabelColor,
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: isSettlement ? 2.4 : 1.4,
shadows: <Shadow>[
Shadow(color: kMapPlaceLabelHaloColor, blurRadius: 2.5),
Shadow(color: kMapPlaceLabelHaloColor, blurRadius: 2.5),
],
height: 1.0,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '',
)..layout();
final x = candidate.anchor.dx * scale - painter.width / 2;
final y = candidate.anchor.dy * scale - painter.height / 2;
if (x < -painter.width ||
y < -painter.height ||
x > size.width ||
y > size.height) {
continue;
}
final bounds = Rect.fromLTWH(
x,
y,
painter.width,
painter.height,
).inflate(minSpacing);
if (occupiedRects.any((rect) => rect.overlaps(bounds))) continue;
painter.paint(canvas, Offset(x, y));
occupiedRects.add(bounds);
seenTexts.add(candidate.text.toLowerCase());
drawn++;
}
}
// draws road labels in screen space across all visible tiles so they
// dont get clipped at tile boundaries
void _drawAllRoadLabels(Canvas canvas) {
final z = activeTileZoom;
if (zoom < 16.5) return;
final factor = math.pow(2, zoom - z).toDouble();
final maxIndex = 1 << z;
final minWX = centerWorldX - (mapWidth / 2) / factor;
final minWY = centerWorldY - (mapHeight / 2) / factor;
final maxWX = centerWorldX + (mapWidth / 2) / factor;
final maxWY = centerWorldY + (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;
final occupiedRects = <Rect>[];
// track placed label centers per road name for proximity dedup
final placedByName = <String, List<Offset>>{};
final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight);
// min distance between two labels with the same name (screen px)
const minSameNameSpacing = 500.0;
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 url = _tileUrl(z, wrappedX, ty);
final tile = MapboxVectorTileCache.peek(url);
if (tile == null) continue;
final layer = tile.layers['road'];
if (layer == null) continue;
// pixel offset of this tile's top-left corner in screen space
final tileLeft = (tx * tileSize - minWX) * factor;
final tileTop = (ty * tileSize - minWY) * factor;
// scale from tile extent coords to screen pixels
final scale = (tileSize * factor) / layer.extent;
for (final feature in layer.features) {
if (feature.type != MvtGeometryType.lineString) continue;
final roadClass =
(feature.properties['class'] ?? feature.properties['type'] ?? '')
.toString()
.toLowerCase();
if (_shouldSkipRoadLabelClass(roadClass)) continue;
final style = _roadStyleFor(feature, tileZoom: z.toDouble());
if (style == null) continue;
final text = _sanitizeLabel(
(feature.properties['name_en'] ??
feature.properties['name'] ??
'')
.toString()
.trim());
if (text.isEmpty || text.length > 32) continue;
final roadCorePx = _worldStrokePx(
style.coreWorldUnits,
extent: layer.extent,
rasterSize: tileSize * factor,
tileZoom: z.toDouble(),
);
final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0);
final painter = TextPainter(
text: TextSpan(
text: text,
style: GoogleFonts.inter(
color: kMapRoadLabelColor,
fontSize: fontSize,
fontWeight: FontWeight.w600,
shadows: <Shadow>[
Shadow(color: kMapRoadLabelHaloColor, blurRadius: 2.2),
],
height: 1.0,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '',
)..layout();
// look up or compute placement in tile-local extent coords
final featureIndex = layer.features.indexOf(feature);
final cacheKey = '$url:$featureIndex';
if (!_roadLabelPlacementCache.containsKey(cacheKey)) {
_roadLabelPlacementCache[cacheKey] = _bestRoadLabelPlacement(
feature.geometry,
scale: 1.0,
labelWidth: painter.width / scale,
);
}
final localPlacement = _roadLabelPlacementCache[cacheKey];
if (localPlacement == null) continue;
// convert tile-local extent coords to screen space
final screenCenter = Offset(
tileLeft + localPlacement.center.dx * scale,
tileTop + localPlacement.center.dy * scale,
);
final bounds = Rect.fromCenter(
center: screenCenter,
width: painter.width + 12,
height: painter.height + 10,
);
// only skip if center is fully outside the visible screen
if (!screenBounds.contains(screenCenter)) continue;
if (occupiedRects.any((r) => r.overlaps(bounds))) continue;
// proximity dedup: skip if same name was already placed nearby
final key = text.toLowerCase();
final existing = placedByName[key];
if (existing != null) {
final tooClose = existing.any((p) {
final dx = p.dx - screenCenter.dx;
final dy = p.dy - screenCenter.dy;
return math.sqrt(dx * dx + dy * dy) < minSameNameSpacing;
});
if (tooClose) continue;
}
canvas.save();
canvas.translate(screenCenter.dx, screenCenter.dy);
canvas.rotate(localPlacement.angle);
painter.paint(
canvas,
Offset(-painter.width / 2, -painter.height / 2),
);
canvas.restore();
occupiedRects.add(bounds);
(placedByName[key] ??= <Offset>[]).add(screenCenter);
}
}
}
}
void _drawRoadLabels(
Canvas canvas,
Size size,
MvtTile tile, {
required double tileZoom,
}) {
if (tileZoom < 14) return;
final layer = tile.layers['road'];
if (layer == null) return;
final scale = size.width / layer.extent;
final seenTexts = <String>{};
final occupiedRects = <Rect>[];
final tileBounds = Offset.zero & size;
const tileEdgeMargin = 12.0;
final labelSafeBounds = Rect.fromLTWH(
tileEdgeMargin,
tileEdgeMargin,
math.max(0, size.width - tileEdgeMargin * 2),
math.max(0, size.height - tileEdgeMargin * 2),
);
for (final feature in layer.features) {
if (feature.type != MvtGeometryType.lineString) continue;
final roadClass =
(feature.properties['class'] ?? feature.properties['type'] ?? '')
.toString()
.toLowerCase();
if (_shouldSkipRoadLabelClass(roadClass)) continue;
final style = _roadStyleFor(feature, tileZoom: tileZoom);
if (style == null) continue;
final text = _sanitizeLabel(
(feature.properties['name_en'] ?? feature.properties['name'] ?? '')
.toString()
.trim());
if (text.isEmpty || text.length > 32) continue;
if (!seenTexts.add(text.toLowerCase())) continue;
final roadCorePx = _worldStrokePx(
style.coreWorldUnits,
extent: layer.extent,
rasterSize: size.width,
tileZoom: tileZoom,
);
final fontSize = (roadCorePx * 1.05).clamp(8.5, 24.0);
final painter = TextPainter(
text: TextSpan(
text: text,
style: GoogleFonts.inter(
color: kMapRoadLabelColor,
fontSize: fontSize,
fontWeight: FontWeight.w600,
shadows: <Shadow>[
Shadow(color: kMapRoadLabelHaloColor, blurRadius: 2.2),
],
height: 1.0,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: '',
)..layout();
final placement = _bestRoadLabelPlacement(
feature.geometry,
scale: scale,
labelWidth: painter.width,
);
if (placement == null) continue;
final bounds = Rect.fromCenter(
center: placement.center,
width: painter.width + 12,
height: painter.height + 10,
);
if (!tileBounds.contains(bounds.topLeft) ||
!tileBounds.contains(bounds.bottomRight)) {
continue;
}
if (!labelSafeBounds.contains(placement.center)) continue;
if (occupiedRects.any((rect) => rect.overlaps(bounds))) continue;
canvas.save();
canvas.translate(placement.center.dx, placement.center.dy);
canvas.rotate(placement.angle);
painter.paint(canvas, Offset(-painter.width / 2, -painter.height / 2));
canvas.restore();
occupiedRects.add(bounds);
}
}
double _worldStrokePx(
double worldUnits, {
required int extent,
required double rasterSize,
required double tileZoom,
}) {
final zoomScale = math.pow(2.0, tileZoom - _kLineReferenceZoom).toDouble();
return worldUnits * zoomScale * (rasterSize / extent);
}
bool _polygonContains(Offset point, List<List<Offset>> geometry) {
for (final ring in geometry) {
if (ring.length < 3) continue;
final path = Path()..moveTo(ring.first.dx, ring.first.dy);
for (var i = 1; i < ring.length; i++) {
path.lineTo(ring[i].dx, ring[i].dy);
}
path.close();
if (path.contains(point)) return true;
}
return false;
}
double _minDistanceToGeometry(Offset point, List<List<Offset>> geometry) {
var best = double.infinity;
for (final line in geometry) {
for (var i = 1; i < line.length; i++) {
final distance = _distanceToSegment(point, line[i - 1], line[i]);
if (distance < best) best = distance;
}
}
return best;
}
double _minDistanceToPoints(Offset point, List<List<Offset>> geometry) {
var best = double.infinity;
for (final group in geometry) {
for (final candidate in group) {
final dx = candidate.dx - point.dx;
final dy = candidate.dy - point.dy;
final distance = math.sqrt(dx * dx + dy * dy);
if (distance < best) best = distance;
}
}
return best;
}
double _distanceToSegment(Offset point, Offset a, Offset b) {
final dx = b.dx - a.dx;
final dy = b.dy - a.dy;
final lengthSquared = dx * dx + dy * dy;
if (lengthSquared <= 0.0001) {
final px = point.dx - a.dx;
final py = point.dy - a.dy;
return math.sqrt(px * px + py * py);
}
final t =
(((point.dx - a.dx) * dx) + ((point.dy - a.dy) * dy)) / lengthSquared;
final clamped = t.clamp(0.0, 1.0);
final projX = a.dx + dx * clamped;
final projY = a.dy + dy * clamped;
final distX = point.dx - projX;
final distY = point.dy - projY;
return math.sqrt(distX * distX + distY * distY);
}
Path _scaledPath(List<Offset> points, double scale, {bool close = false}) {
final path = Path()
..moveTo(points.first.dx * scale, points.first.dy * scale);
for (var i = 1; i < points.length; i++) {
path.lineTo(points[i].dx * scale, points[i].dy * scale);
}
if (close) path.close();
return path;
}
_RoadLabelPlacement? _bestRoadLabelPlacement(
List<List<Offset>> geometry, {
required double scale,
required double labelWidth,
}) {
_RoadLabelPlacement? best;
for (final line in geometry) {
if (line.length < 2) continue;
final scaled = <Offset>[
for (final point in line) Offset(point.dx * scale, point.dy * scale),
];
final length = _polylineLength(scaled);
if (length < math.max(labelWidth * 1.35, 48.0)) continue;
final placement = _placementAlongPolyline(scaled, distance: length / 2);
if (placement == null) continue;
if (best == null || length > best.pathLength) {
best = _RoadLabelPlacement(
center: placement.center,
angle: placement.angle,
pathLength: length,
);
}
}
return best;
}
_RoadLabelPlacement? _placementAlongPolyline(
List<Offset> points, {
required double distance,
}) {
var traversed = 0.0;
for (var i = 1; i < points.length; i++) {
final a = points[i - 1];
final b = points[i];
final dx = b.dx - a.dx;
final dy = b.dy - a.dy;
final segmentLength = math.sqrt(dx * dx + dy * dy);
if (segmentLength <= 0.001) continue;
if (traversed + segmentLength >= distance) {
final t = (distance - traversed) / segmentLength;
final center = Offset(a.dx + dx * t, a.dy + dy * t);
var angle = math.atan2(dy, dx);
if (angle > math.pi / 2 || angle < -math.pi / 2) {
angle += math.pi;
}
return _RoadLabelPlacement(center: center, angle: angle, pathLength: 0);
}
traversed += segmentLength;
}
return null;
}
double _polylineLength(List<Offset> points) {
var length = 0.0;
for (var i = 1; i < points.length; i++) {
final dx = points[i].dx - points[i - 1].dx;
final dy = points[i].dy - points[i - 1].dy;
length += math.sqrt(dx * dx + dy * dy);
}
return length;
}
bool _shouldSkipRoadLabelClass(String roadClass) {
return roadClass.contains('path') ||
roadClass.contains('footway') ||
roadClass.contains('steps') ||
roadClass.contains('cycleway') ||
roadClass.contains('track') ||
roadClass.contains('bridleway') ||
roadClass.contains('rail') ||
roadClass.contains('subway') ||
roadClass.contains('metro') ||
roadClass.contains('train');
}
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;
}
double? _toDouble(Object? value) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value.trim());
return null;
}
// strips non-printable and non-latin characters from label text
String _sanitizeLabel(String text) =>
text.replaceAll(RegExp(r'[^\x20-\x7E\u00C0-\u024F]'), '').trim();
bool _isTruthy(Object? value) {
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final normalized = value.trim().toLowerCase();
return normalized == 'true' || normalized == '1' || normalized == 'yes';
}
return false;
}
static const Set<String> _allowedPlaceClasses = <String>{
'city',
'town',
'settlement',
'settlement_subdivision',
};
static const List<String> _inspectLayerOrder = <String>[
'road',
'building',
'water',
'park',
'landuse',
'road_label',
'place_label',
];
@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 _RoadStyle {
const _RoadStyle({
required this.sortOrder,
required this.coreWorldUnits,
required this.underlayWorldUnits,
required this.fillColor,
required this.underlayColor,
required this.outlineOnly,
});
final int sortOrder;
final double coreWorldUnits;
final double underlayWorldUnits;
final Color fillColor;
final Color underlayColor;
final bool outlineOnly;
}
class _StyledRoad {
const _StyledRoad({required this.feature, required this.style});
final MvtFeature feature;
final _RoadStyle style;
}
class MapDebugHit {
const MapDebugHit({
required this.layerName,
required this.geometryType,
required this.properties,
required this.tileZ,
required this.tileX,
required this.tileY,
required this.distance,
});
final String layerName;
final MvtGeometryType geometryType;
final Map<String, Object?> properties;
final int tileZ;
final int tileX;
final int tileY;
final double distance;
}
class _RoadLabelPlacement {
const _RoadLabelPlacement({
required this.center,
required this.angle,
required this.pathLength,
});
final Offset center;
final double angle;
final double pathLength;
}
class _LabelCandidate {
const _LabelCandidate({
required this.text,
required this.anchor,
required this.rank,
required this.featureClass,
});
final String text;
final Offset anchor;
final int rank;
final String featureClass;
}