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 _roadLabelPlacementCache = {}; void markDirty() => 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); _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 _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) { 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 _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 {'landuse'}, fillColor: kMapLanduseColor, allowedTypes: const {'retail'}, tileZoom: tileZoom, ); _drawPolygonLayer( canvas, size, tile, layerNames: const {'park'}, fillColor: kMapGrassColor, tileZoom: tileZoom, ); _drawPolygonLayer( canvas, size, tile, layerNames: const {'water'}, fillColor: kMapWaterColor, tileZoom: tileZoom, ); _drawPolygonLayer( canvas, size, tile, layerNames: const {'landuse'}, fillColor: kMapGrassColor, allowedTypes: const {'park', 'meadow'}, allowedClasses: const {'grass'}, tileZoom: tileZoom, ); _drawPolygonLayer( canvas, size, tile, layerNames: const {'park'}, fillColor: kMapGrassColor, tileZoom: tileZoom, ); _drawRoadLayer(canvas, size, tile, tileZoom: tileZoom); _drawPolygonLayer( canvas, size, tile, layerNames: const {'building'}, fillColor: kMapBuildingFillColor, strokeColor: kMapBuildingOutlineColor, strokeWorldUnits: 3.5, strokeCap: StrokeCap.butt, strokeJoin: StrokeJoin.miter, strokeAntiAlias: false, allowedTypes: const {'retail', 'shelter', 'stadium'}, excludeExtruded: true, allowExtrudedTypes: const {'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 layerNames, required Color fillColor, Color? strokeColor, double strokeWorldUnits = 0.0, StrokeCap strokeCap = StrokeCap.round, StrokeJoin strokeJoin = StrokeJoin.round, 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.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 _areaPoiClasses = { '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 = []; 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 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] ??= []).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 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(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 = []; 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.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(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 = []; // track placed label centers per road name for proximity dedup final placedByName = >{}; 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(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] ??= []).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 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(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; } 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 _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 _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 _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 _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; }