import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; 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; // web WASM heap is limited — use 1x resolution to keep memory under control final double _kTileResolutionScale = kIsWeb ? 1.0 : 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; List routeScreenPoints = const []; // 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 _roadLabelPlacementCache = {}; // laid-out TextPainters keyed by "text|fontSize" — layout is expensive final Map _textPainterCache = {}; static const int _maxTextPainterCacheSize = 600; // world-space road label cache — rebuilt only when tiles change or zoom changes List<_WorldRoadLabel>? _roadLabelWorld; int _tileVersion = 0; int _roadLabelCacheVersion = -1; double _roadLabelCacheZoom = -1.0; void markDirty() { _tileVersion++; notifyListeners(); } String _tileUrl(int z, int x, int y) => tileUrlTemplate .replaceAll('{z}', '$z') .replaceAll('{x}', '$x') .replaceAll('{y}', '$y'); Future 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); } void paintLabels(Canvas canvas) { if (mapWidth == 0 || mapHeight == 0) return; _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 && !kIsWeb) { 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 && !kIsWeb) { _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 _allowedQueuedTileHashes( int z, int minTX, int maxTX, int minTY, int maxTY, int maxIndex, ) { final hashes = {}; 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) { // on web, skip the rasterization cache entirely and paint directly if (kIsWeb) { _drawTileWeb(canvas, z, x, y, dst); return; } 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; } } void _drawTileWeb(Canvas canvas, int z, int x, int y, Rect dst) { final url = _tileUrl(z, x, y); final tile = MapboxVectorTileCache.peek(url); if (tile != null) { canvas.save(); canvas.clipRect(dst); canvas.translate(dst.left, dst.top); canvas.scale(dst.width / tileSize, dst.height / tileSize); _paintTile(canvas, Size.square(tileSize), tile, tileZoom: z.toDouble()); canvas.restore(); return; } // fetch if not cached yet if (!MapboxVectorTileCache.isFetching(url)) { MapboxVectorTileCache.getOrFetch(url).then((_) => markDirty()); } // show ancestor tile scaled up while loading final maxIndex = 1 << z; 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 ancestorUrl = _tileUrl(az, ((ancestorX % (maxIndex >> dz)) + (maxIndex >> dz)) % (maxIndex >> dz), ancestorY); final ancestor = MapboxVectorTileCache.peek(ancestorUrl); if (ancestor == null) continue; final fracX = (x - ancestorX * subdivision).toDouble() / subdivision; final fracY = (y - ancestorY * subdivision).toDouble() / subdivision; final srcLeft = fracX * tileSize; final srcTop = fracY * tileSize; final srcSize = tileSize / subdivision; canvas.save(); canvas.clipRect(dst); canvas.translate(dst.left, dst.top); canvas.scale(dst.width / srcSize, dst.height / srcSize); canvas.translate(-srcLeft, -srcTop); _paintTile(canvas, Size.square(tileSize), ancestor, tileZoom: az.toDouble()); canvas.restore(); break; } } 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); // cap render resolution so high-DPI mobile doesnt blow the image cache // (256 * 3dpr * 2x scale = 1536px = 9MB per tile, way too big) // 512px = 1MB per tile, fits ~80 tiles in the mobile budget final dprCapped = devicePixelRatio.clamp(1.0, 2.0); final rasterPx = (tileSize * dprCapped * _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, }) { // centerWorldX/Y are already in pixel coords at activeTileZoom scale final centerTX = centerWorldX / tileSize; final centerTY = centerWorldY / tileSize; final pending = <(int tx, int ty, int wrappedX, double dist)>[]; 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; final dx = tx + 0.5 - centerTX; final dy = ty + 0.5 - centerTY; pending.add((tx, ty, wrappedX, dx * dx + dy * dy)); } } pending.sort((a, b) => a.$4.compareTo(b.$4)); for (final t in pending) { _queueTile(z, t.$3, t.$2); } } 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 _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, Offset.zero & size); _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, ); final commands = <_DrawCommand>[]; // landuse retail _collectPolygonCommands( commands, size, tile, layerNames: const {'landuse'}, fillColor: kMapLanduseColor, allowedTypes: const {'retail'}, tileZoom: tileZoom, baseZ: 20.0, ); // landuse park/meadow/grass _collectPolygonCommands( commands, size, tile, layerNames: const {'landuse'}, fillColor: kMapGrassColor, allowedTypes: const {'park', 'meadow', 'garden', 'wood', 'sports_centre', 'recreation_ground'}, allowedClasses: const {'grass', 'cemetery', 'agriculture'}, tileZoom: tileZoom, baseZ: 25.0, ); // park layer _collectPolygonCommands( commands, size, tile, layerNames: const {'park'}, fillColor: kMapGrassColor, tileZoom: tileZoom, baseZ: 25.0, ); // water _collectPolygonCommands( commands, size, tile, layerNames: const {'water'}, fillColor: kMapWaterColor, tileZoom: tileZoom, baseZ: 30.0, ); // airport/aerodrome _collectPolygonCommands( commands, size, tile, layerNames: const {'landuse'}, fillColor: kMapParkingColor, strokeColor: kMapParkingOutlineColor, strokeWorldUnits: 2.0, tileZoom: tileZoom, allowedClasses: const {'airport'}, baseZ: 10.0, ); // school grounds _collectPolygonCommands( commands, size, tile, layerNames: const {'landuse'}, fillColor: kMapPathFillColour, tileZoom: tileZoom, allowedClasses: const {'school'}, allowedTypes: const {'school', 'college', 'university'}, baseZ: 35.0, ); // parking areas _collectPolygonCommands( commands, size, tile, layerNames: const {'landuse'}, fillColor: kMapParkingColor, strokeColor: kMapParkingOutlineColor, strokeWorldUnits: 2.0, tileZoom: tileZoom, allowedTypes: const {'parking'}, allowedClasses: const {'parking'}, baseZ: 35.0, ); // commercial areas (landuse) _collectPolygonCommands( commands, size, tile, layerNames: const {'landuse'}, fillColor: kMapCommercialAreaColor, strokeColor: kMapCommercialAreaOutlineColor, strokeWorldUnits: 2.0, tileZoom: tileZoom, allowedTypes: const {'commercial_area'}, allowedClasses: const {'commercial_area'}, baseZ: 35.0, ); // buildings _collectPolygonCommands( commands, size, tile, layerNames: const {'building'}, fillColor: kMapBuildingFillColor, strokeColor: kMapBuildingOutlineColor, strokeWorldUnits: 3.5, strokeCap: StrokeCap.round, strokeJoin: StrokeJoin.miter, strokeAntiAlias: false, allowedTypes: const {'retail', 'shelter', 'stadium', 'school', 'university', 'college', 'hospital', 'church', 'civic', 'train_station'}, excludeExtruded: true, allowExtrudedTypes: const {'stadium', 'school', 'university', 'college', 'hospital', 'church', 'civic', 'train_station'}, minHeight: 2.5, tileZoom: tileZoom, baseZ: 200.0, ); _collectRoadCommands(commands, size, tile, tileZoom: tileZoom); commands.sort((a, b) => a.z.compareTo(b.z)); for (final cmd in commands) { cmd.draw(canvas); } if (kMapDebugShowTileBoundaries) _drawRasterTileBoundary(canvas, size); } static String _epKey(Offset p) => '${p.dx.round()},${p.dy.round()}'; double _featureLayerProp(MvtFeature feature) { return (_toDouble(feature.properties['layer']) ?? 0.0).clamp(-5.0, 5.0); } void _collectPolygonCommands( List<_DrawCommand> commands, Size size, MvtTile tile, { required Set layerNames, required Color fillColor, Color? strokeColor, double strokeWorldUnits = 0.0, StrokeCap strokeCap = StrokeCap.round, StrokeJoin strokeJoin = StrokeJoin.miter, bool strokeAntiAlias = true, Set? allowedTypes, Set? allowedClasses, bool excludeExtruded = false, Set? allowExtrudedTypes, double? minHeight, required double tileZoom, required double baseZ, }) { for (final entry in tile.layers.entries) { if (!layerNames.contains(entry.key)) continue; final layer = entry.value; final scale = size.width / layer.extent; Paint? stroke; 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; } final z = baseZ + _featureLayerProp(feature) * 0.1; 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); final fill = Paint() ..color = fillColor ..style = PaintingStyle.fill ..isAntiAlias = true; final strokeSnap = stroke; final rings = feature.geometry; commands.add(_DrawCommand( z: z, draw: (c) { for (final ring in rings) { if (ring.length < 3) continue; final path = _scaledPath(ring, scale, close: true); c.drawPath(path, fill); if (strokeSnap != null) c.drawPath(path, strokeSnap); } }, )); } } } void _collectRoadCommands( List<_DrawCommand> commands, Size size, MvtTile tile, { required double tileZoom, }) { final roadLayer = tile.layers['road']; if (roadLayer == null) return; final scale = size.width / roadLayer.extent; // collect all styled line features upfront for the underlay punch-out pass final styledLines = <({MvtFeature feature, _RoadStyle style, double layerProp, double sortKey, String roadClass})>[]; for (final feature in roadLayer.features) { if (feature.type != MvtGeometryType.lineString) continue; final roadClass = (feature.properties['class'] ?? '').toString().toLowerCase(); if (tileZoom < 13 && !_isMajorRoadClass(roadClass) && !roadClass.contains('street') && !roadClass.contains('rail') && roadClass != 'aerialway') continue; final style = _roadStyleFor(feature, tileZoom: tileZoom); if (style == null) continue; styledLines.add(( feature: feature, style: style, layerProp: _featureLayerProp(feature), sortKey: _toDouble(feature.properties['sort_key']) ?? 0.0, roadClass: roadClass, )); } // build junction set: endpoints shared by 2+ polylines across the whole tile // only junction endpoints get round caps; terminal (dead-end) endpoints stay flat final epCount = {}; for (final f in styledLines) { for (final line in f.feature.geometry) { if (line.length < 2) continue; final sk = _epKey(line.first); final ek = _epKey(line.last); epCount[sk] = (epCount[sk] ?? 0) + 1; epCount[ek] = (epCount[ek] ?? 0) + 1; } } final junctions = { for (final e in epCount.entries) if (e.value > 1) e.key, }; // group by (layerProp, class) for correct z placement, but the punch-out erase // uses all features of the same class across all layerProps final byGroup = <(double, String), List<({MvtFeature feature, _RoadStyle style, double layerProp, double sortKey})>>{}; for (final f in styledLines) { (byGroup[(f.layerProp, f.roadClass)] ??= []).add((feature: f.feature, style: f.style, layerProp: f.layerProp, sortKey: f.sortKey)); } // pre-build erase geometry per class — butt caps, circles at junctions only final eraseByClass = > lines, Paint paint, List junctionPts, double radius, double terminalInset})>>{}; for (final f in styledLines) { if (f.style.outlineOnly || f.style.fillColor == kMapPathFillColour) continue; final uR = _worldStrokePx(f.style.underlayWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom); final cR = _worldStrokePx(f.style.coreWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom); final erasePaint = Paint() ..color = const Color(0xFFFFFFFF) ..style = PaintingStyle.stroke ..strokeWidth = cR ..strokeCap = StrokeCap.butt ..strokeJoin = StrokeJoin.round ..blendMode = BlendMode.dstOut ..isAntiAlias = true; final junctionPts = []; for (final line in f.feature.geometry) { if (line.length < 2) continue; if (junctions.contains(_epKey(line.first))) junctionPts.add(line.first); if (junctions.contains(_epKey(line.last))) junctionPts.add(line.last); } final terminalInset = (uR - cR) / 2 / scale; (eraseByClass[f.roadClass] ??= []).add((lines: f.feature.geometry, paint: erasePaint, junctionPts: junctionPts, radius: cR / 2, terminalInset: terminalInset)); } for (final entry in byGroup.entries) { final lp = entry.key.$1; final features = entry.value; final roadClass = entry.key.$2; final underlayDrawData = <({List> lines, Paint paint, List junctionPts, double radius})>[]; final coreDrawData = <({List> lines, Paint paint, List junctionPts, double radius, double z, double terminalInset})>[]; for (final f in features) { final junctionPts = []; for (final line in f.feature.geometry) { if (line.length < 2) continue; if (junctions.contains(_epKey(line.first))) junctionPts.add(line.first); if (junctions.contains(_epKey(line.last))) junctionPts.add(line.last); } final uR = _worldStrokePx(f.style.underlayWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom); final underlayPaint = Paint() ..color = f.style.outlineOnly ? f.style.fillColor : f.style.underlayColor ..style = PaintingStyle.stroke ..strokeWidth = uR ..strokeCap = StrokeCap.butt ..strokeJoin = StrokeJoin.round ..isAntiAlias = true; underlayDrawData.add((lines: f.feature.geometry, paint: underlayPaint, junctionPts: junctionPts, radius: uR / 2)); final skipOverlay = f.style.outlineOnly || f.style.fillColor == kMapPathFillColour; if (!skipOverlay) { final railBias = roadClass.contains('rail') ? -10.0 : 0.0; final coreZ = 50.0 + f.layerProp * 30.0 + f.sortKey * 0.001 + 1.0 + railBias; final cR = _worldStrokePx(f.style.coreWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom); final corePaint = Paint() ..color = f.style.fillColor ..style = PaintingStyle.stroke ..strokeWidth = cR ..strokeCap = StrokeCap.butt ..strokeJoin = StrokeJoin.round ..isAntiAlias = true; // inset amount in extent coords so the underlay shows as a flat border at terminal ends final terminalInset = (uR - cR) / 2 / scale; coreDrawData.add((lines: f.feature.geometry, paint: corePaint, junctionPts: junctionPts, radius: cR / 2, z: coreZ, terminalInset: terminalInset)); } } // erase with all same-class cores (across all layerProps) final classErase = eraseByClass[roadClass] ?? []; final needsErase = classErase.isNotEmpty; final railBias = roadClass.contains('rail') ? -10.0 : 0.0; final underlayZ = 50.0 + lp * 30.0 + railBias; commands.add(_DrawCommand( z: underlayZ, draw: (c) { if (needsErase) c.saveLayer(Offset.zero & size, Paint()); for (final d in underlayDrawData) { for (final line in d.lines) { if (line.length < 2) continue; c.drawPath(_scaledPath(line, scale), d.paint); } final circlePaint = Paint() ..color = d.paint.color ..style = PaintingStyle.fill ..isAntiAlias = true; for (final pt in d.junctionPts) { c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint); } } if (needsErase) { for (final d in classErase) { for (final line in d.lines) { if (line.length < 2) continue; final startIsTerminal = !junctions.contains(_epKey(line.first)); final endIsTerminal = !junctions.contains(_epKey(line.last)); final trimmed = _trimPolyline( line, startIsTerminal ? d.terminalInset : 0.0, endIsTerminal ? d.terminalInset : 0.0, ); if (trimmed == null || trimmed.length < 2) continue; c.drawPath(_scaledPath(trimmed, scale), d.paint); } final circlePaint = Paint() ..color = d.paint.color ..style = PaintingStyle.fill ..blendMode = BlendMode.dstOut ..isAntiAlias = true; for (final pt in d.junctionPts) { c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint); } } c.restore(); } }, )); for (final d in coreDrawData) { final lines = d.lines; final paint = d.paint; final jPts = d.junctionPts; final r = d.radius; final inset = d.terminalInset; commands.add(_DrawCommand( z: d.z, draw: (c) { for (final line in lines) { if (line.length < 2) continue; final startIsTerminal = !junctions.contains(_epKey(line.first)); final endIsTerminal = !junctions.contains(_epKey(line.last)); final trimmed = _trimPolyline( line, startIsTerminal ? inset : 0.0, endIsTerminal ? inset : 0.0, ); if (trimmed == null || trimmed.length < 2) continue; c.drawPath(_scaledPath(trimmed, scale), paint); } final circlePaint = Paint() ..color = paint.color ..style = PaintingStyle.fill ..isAntiAlias = true; for (final pt in jPts) { c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), r, circlePaint); } }, )); } } for (final feature in roadLayer.features) { final style = _roadStyleFor(feature, tileZoom: tileZoom); if (style == null) continue; final layerProp = _featureLayerProp(feature); final sortKey = _toDouble(feature.properties['sort_key']) ?? 0.0; if (feature.type != MvtGeometryType.polygon) continue; final z = 22.0 + layerProp * 30.0; final fill = Paint() ..color = style.fillColor ..style = PaintingStyle.fill ..isAntiAlias = true; final outline = Paint() ..color = style.underlayColor ..style = PaintingStyle.stroke ..strokeWidth = _worldStrokePx( math.max(style.coreWorldUnits * 0.22, 3.0), extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom, ) ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..isAntiAlias = true; final rings = feature.geometry; commands.add(_DrawCommand( z: z, draw: (c) { for (final ring in rings) { if (ring.length < 3) continue; final path = _scaledPath(ring, scale, close: true); c.drawPath(path, fill); c.drawPath(path, outline); } }, )); } } 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 layerNames, required Color fillColor, Color? strokeColor, double strokeWorldUnits = 0.0, StrokeCap strokeCap = StrokeCap.round, StrokeJoin strokeJoin = StrokeJoin.miter, bool strokeAntiAlias = true, Set? allowedTypes, Set? allowedClasses, bool excludeExtruded = false, Set? 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.miter ..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.miter ..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.miter ..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 == 'service' && roadType == 'service'; final isServiceSubtype = roadClass == 'service' && roadType.startsWith('service:'); final isUnderground = structure.contains('tunnel') || structure.contains('underground'); if (isPlatform && feature.type == MvtGeometryType.lineString) return null; if (isServiceSubtype) { return _RoadStyle( sortOrder: 0, coreWorldUnits: 7.4, underlayWorldUnits: 11.6, fillColor: kMapPathFillColour, underlayColor: kMapBackgroundColor, outlineOnly: true, ); } if (isService) { return _RoadStyle( sortOrder: 0, coreWorldUnits: 7.4, underlayWorldUnits: 11.6, fillColor: kMapPathFillColour, underlayColor: kMapBackgroundColor, outlineOnly: true, ); } if (roadClass == 'aerialway' || roadClass.startsWith('aerialway')) { const allowedAerialwayTypes = {'aerialway:gondola', 'aerialway:cable_car', 'aerialway:funicular'}; if (!allowedAerialwayTypes.contains(roadType)) return null; return _RoadStyle( sortOrder: 0, coreWorldUnits: 3.5, underlayWorldUnits: 5.5, fillColor: kMapRailFillColor, underlayColor: kMapRailUnderlayColor, outlineOnly: false, ); } if (roadClass == 'ferry' || roadType == 'ferry') return null; if (roadClass == 'golf' || roadType == 'hole') return null; if (roadType == 'minor_rail') return null; final layerPropVal = (_toDouble(feature.properties['layer']) ?? 0.0); if (isRailLike && isUnderground) return null; if (isRailLike) { if (isUnderground) { return _RoadStyle( sortOrder: 0, coreWorldUnits: 3.5, underlayWorldUnits: 5.5, fillColor: kMapRailUnderlayColor, underlayColor: kMapBackgroundColor, outlineOnly: false, ); } 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') || roadType.contains('trail'); 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, ); } final isOneway = _isTruthy(feature.properties['oneway']); if (isTrunkPrimary && isOneway && !isMotorway) { style = _RoadStyle( sortOrder: style.sortOrder, coreWorldUnits: style.coreWorldUnits * 0.75, underlayWorldUnits: style.underlayWorldUnits * 0.75, fillColor: style.fillColor, underlayColor: style.underlayColor, outlineOnly: style.outlineOnly, ); } 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 _areaPoiClasses = { 'park', 'nature', 'cemetery', 'wood', 'forest', 'golf', 'recreation_ground', 'stadium', 'pitch', 'allotments', 'supermarket', }; 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 = []; final placedByName = >{}; 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 capital = (_toDouble(feature.properties['capital']) ?? 0).toInt(); final isCapital = capital >= 2; final symbolrank = (_toDouble(feature.properties['symbolrank']) ?? 99).toInt(); if (kMapUseSymbolRankAsZoomGate && zoom < symbolrank) continue; final isMajorCity = symbolrank < 13; if (!isCapital && !isMajorCity) { if (zoom < 12.5 && featureClass == 'settlement_subdivision') continue; if (zoom < 12.5 && featureClass == 'settlement') continue; } final rawText = _sanitizeLabel( (feature.properties['name_en'] ?? feature.properties['name'] ?? '') .toString() .trim()); if (rawText.isEmpty || rawText.length > 30) continue; final text = kMapDebugShowPlaceSymbolRank ? '$rawText [$symbolrank]' : rawText; 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, ); 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.w800 : FontWeight.w800; final painter = TextPainter( text: TextSpan( text: c.text.toUpperCase(), style: GoogleFonts.inter( color: kMapPlaceLabelColor, fontSize: fontSize, fontWeight: fontWeight, letterSpacing: isSettlement ? 3.2 : 2.0, height: 1.0, shadows: [ Shadow(color: kMapPlaceLabelHaloColor, blurRadius: 6), ], ), ), 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] ??= []).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 = []; final seenTexts = {}; 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 rawText = _sanitizeLabel( (feature.properties['name_en'] ?? feature.properties['name'] ?? '') .toString() .trim()); if (rawText.isEmpty || rawText.length > 30) continue; if (!seenTexts.add(rawText.toLowerCase())) continue; final symbolrank = feature.properties['symbolrank']; final sizerank = feature.properties['sizerank']; final text = '$rawText [sr:$symbolrank sz:$sizerank]'; 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, height: 1.0, shadows: [ Shadow(color: kMapPoiLabelHaloColor, blurRadius: 4), ], ), ), 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 = []; final seenTexts = {}; 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.w800 : FontWeight.w800; final painter = TextPainter( text: TextSpan( text: candidate.text.toUpperCase(), style: GoogleFonts.inter( color: kMapPlaceLabelColor, fontSize: fontSize, fontWeight: fontWeight, letterSpacing: isSettlement ? 3.2 : 2.0, shadows: [ Shadow(color: kMapPlaceLabelHaloColor, blurRadius: 6), ], 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 < 15.0) return; final majorRoadsOnly = zoom < 16.5; 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; const minSameNameSpacing = 500.0; // rebuild world-space label list only when tiles or zoom changed // pan only changes screen positions, which we recompute cheaply from worldX/Y if (_tileVersion != _roadLabelCacheVersion || zoom != _roadLabelCacheZoom) { final worldLabels = <_WorldRoadLabel>[]; 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; final scale = (tileSize * factor) / layer.extent; for (var fi = 0; fi < layer.features.length; fi++) { final feature = layer.features[fi]; if (feature.type != MvtGeometryType.lineString) continue; final roadClass = (feature.properties['class'] ?? feature.properties['type'] ?? '') .toString() .toLowerCase(); if (_shouldSkipRoadLabelClass(roadClass)) continue; if (majorRoadsOnly && !_isMajorRoadClass(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 minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom; final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble(); final roadCorePx = _worldStrokePx( style.coreWorldUnits, extent: layer.extent, rasterSize: tileSize * fontFactor, tileZoom: z.toDouble(), ); final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0); final painterKey = '$text|${fontSize.toStringAsFixed(1)}'; final painter = _textPainterCache[painterKey] ?? () { final p = TextPainter( text: TextSpan( text: text, style: GoogleFonts.inter( color: kMapRoadLabelColor, fontSize: fontSize, fontWeight: FontWeight.w600, shadows: [ Shadow(color: kMapRoadLabelHaloColor, blurRadius: 2.2), ], height: 1.0, ), ), textDirection: TextDirection.ltr, maxLines: 1, ellipsis: '', )..layout(); if (_textPainterCache.length >= _maxTextPainterCacheSize) { _textPainterCache.remove(_textPainterCache.keys.first); } _textPainterCache[painterKey] = p; return p; }(); final cacheKey = '$url:$fi'; if (!_roadLabelPlacementCache.containsKey(cacheKey)) { final placement = _bestRoadLabelPlacement( feature.geometry, scale: 1.0, labelWidth: painter.width / scale, ); if (placement != null) { _roadLabelPlacementCache[cacheKey] = placement; } } final localPlacement = _roadLabelPlacementCache[cacheKey]; if (localPlacement == null) continue; // store in world space so pan is free final worldX = tx * tileSize + localPlacement.center.dx * tileSize / layer.extent; final worldY = ty * tileSize + localPlacement.center.dy * tileSize / layer.extent; worldLabels.add(_WorldRoadLabel( worldX: worldX, worldY: worldY, angle: localPlacement.angle, pathLength: localPlacement.pathLength, text: text, painter: painter, )); } } } // longest segment always wins regardless of tile iteration order worldLabels.sort((a, b) => b.pathLength.compareTo(a.pathLength)); _roadLabelWorld = worldLabels; _roadLabelCacheVersion = _tileVersion; _roadLabelCacheZoom = zoom; } final worldLabels = _roadLabelWorld; if (worldLabels == null || worldLabels.isEmpty) return; // reproject world → screen using current pan/zoom (cheap) final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight); final occupiedRects = []; final placedByName = >{}; for (final w in worldLabels) { final screenCenter = Offset( (w.worldX - minWX) * factor, (w.worldY - minWY) * factor, ); if (!screenBounds.contains(screenCenter)) continue; final bounds = Rect.fromCenter( center: screenCenter, width: w.painter.width + 12, height: w.painter.height + 10, ); if (occupiedRects.any((r) => r.overlaps(bounds))) continue; final key = w.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; } final overRoute = _labelOverRoute(bounds); canvas.save(); canvas.translate(screenCenter.dx, screenCenter.dy); canvas.rotate(w.angle); if (overRoute) { final strokeKey = '${w.text}|route_stroke'; final strokePainter = _textPainterCache[strokeKey] ?? () { final baseStyle = w.painter.text!.style!; final p = TextPainter( text: TextSpan( text: w.text, style: baseStyle.copyWith( color: null, foreground: Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3.5 ..strokeJoin = StrokeJoin.round ..color = kMapRoadLabelHaloColor, shadows: null, ), ), textDirection: TextDirection.ltr, maxLines: 1, ellipsis: '', )..layout(); if (_textPainterCache.length >= _maxTextPainterCacheSize) { _textPainterCache.remove(_textPainterCache.keys.first); } _textPainterCache[strokeKey] = p; return p; }(); strokePainter.paint(canvas, Offset(-strokePainter.width / 2, -strokePainter.height / 2)); } w.painter.paint(canvas, Offset(-w.painter.width / 2, -w.painter.height / 2)); canvas.restore(); occupiedRects.add(bounds); (placedByName[key] ??= []).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 = {}; final occupiedRects = []; 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 minFontZoom2 = _isMajorRoadClass(roadClass) ? 19.0 : zoom; final fontRasterSize = size.width * math.pow(2, math.max(zoom, minFontZoom2) - tileZoom); final roadCorePx = _worldStrokePx( style.coreWorldUnits, extent: layer.extent, rasterSize: fontRasterSize, 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(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> 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> 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> 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 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> geometry, { required double scale, required double labelWidth, }) { _RoadLabelPlacement? best; for (final line in geometry) { if (line.length < 2) continue; final scaled = [ 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 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; } // Trim a polyline by [startDist] from the start and [endDist] from the end. // Returns null if the remaining length is zero or negative. List? _trimPolyline(List pts, double startDist, double endDist) { if (startDist <= 0 && endDist <= 0) return pts; List trimStart(List p, double dist) { var rem = dist; for (var i = 1; i < p.length; i++) { final dx = p[i].dx - p[i - 1].dx; final dy = p[i].dy - p[i - 1].dy; final len = math.sqrt(dx * dx + dy * dy); if (rem <= len) { final t = rem / len; final newFirst = Offset(p[i - 1].dx + dx * t, p[i - 1].dy + dy * t); return [newFirst, ...p.sublist(i)]; } rem -= len; } return []; } var result = startDist > 0 ? trimStart(pts, startDist) : pts; if (result.length < 2) return null; if (endDist > 0) { result = trimStart(result.reversed.toList(), endDist).reversed.toList(); } return result.length >= 2 ? result : null; } double _polylineLength(List 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 _labelOverRoute(Rect labelBounds) { final pts = routeScreenPoints; if (pts.length < 2) return false; final inflated = labelBounds.inflate(4); for (var i = 0; i < pts.length - 1; i++) { if (_segmentIntersectsRect(pts[i], pts[i + 1], inflated)) return true; } return false; } bool _segmentIntersectsRect(Offset a, Offset b, Rect r) { // quick AABB check first final minX = math.min(a.dx, b.dx); final maxX = math.max(a.dx, b.dx); final minY = math.min(a.dy, b.dy); final maxY = math.max(a.dy, b.dy); if (maxX < r.left || minX > r.right || maxY < r.top || minY > r.bottom) return false; // check if any rect corner is on opposite sides of the segment final dx = b.dx - a.dx; final dy = b.dy - a.dy; double sign(Offset p) => dx * (p.dy - a.dy) - dy * (p.dx - a.dx); final s1 = sign(r.topLeft); final s2 = sign(r.topRight); final s3 = sign(r.bottomLeft); final s4 = sign(r.bottomRight); final mn = math.min(math.min(s1, s2), math.min(s3, s4)); final mx = math.max(math.max(s1, s2), math.max(s3, s4)); return mn <= 0 && mx >= 0; } bool _isMajorRoadClass(String roadClass) { return roadClass.contains('motorway') || roadClass.contains('trunk') || roadClass.contains('primary') || roadClass.contains('secondary'); } 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\u2018-\u201D]'), '').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 _allowedPlaceClasses = { 'city', 'town', 'settlement', 'settlement_subdivision', }; static const List _inspectLayerOrder = [ '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 TileLabelPainter implements CustomPainter { TileLabelPainter(this._painter); final TilePyramidPainter _painter; @override void paint(Canvas canvas, Size size) => _painter.paintLabels(canvas); @override bool shouldRepaint(covariant TileLabelPainter old) => true; @override bool shouldRebuildSemantics(covariant CustomPainter old) => false; @override SemanticsBuilderCallback? get semanticsBuilder => null; @override bool hitTest(Offset position) => false; @override void addListener(ui.VoidCallback listener) {} @override void removeListener(ui.VoidCallback listener) {} } 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 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 _RoadLabelCandidate { const _RoadLabelCandidate({ required this.text, required this.screenCenter, required this.angle, required this.pathLength, required this.painter, }); final String text; final Offset screenCenter; final double angle; final double pathLength; final TextPainter painter; } 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; } // road label stored in world-space so screen position can be recomputed on pan // without re-iterating tile features class _WorldRoadLabel { const _WorldRoadLabel({ required this.worldX, required this.worldY, required this.angle, required this.pathLength, required this.text, required this.painter, }); final double worldX; final double worldY; final double angle; final double pathLength; final String text; final TextPainter painter; } class _DrawCommand { const _DrawCommand({required this.z, required this.draw}); final double z; final void Function(Canvas) draw; }