diff --git a/.gitignore b/.gitignore index 7bafe97..1a5aa5c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,7 +67,6 @@ **/ios/Runner/GeneratedPluginRegistrant.* # Web related -**/web/**/lib/generated_plugin_registrant.dart # Service account files svc-keyfile.json @@ -87,4 +86,4 @@ yarn.lock .claude/ logs/ -CHANGELOG.md \ No newline at end of file +CHANGELOG.md diff --git a/lib/pages/map/page.dart b/lib/pages/map/page.dart index 3b03d02..eb1adec 100644 --- a/lib/pages/map/page.dart +++ b/lib/pages/map/page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/gestures.dart' show PointerDeviceKind, @@ -288,12 +289,14 @@ class _MapPageState extends State with TickerProviderStateMixin { } } - int _idealTileZoom(double zoomValue, {required double devicePixelRatio}) { - final ratio = devicePixelRatio.clamp(1.0, 4.0).toDouble(); - // Bias toward higher-detail tiles on high-DPI screens so the visible - // raster layer matches the viewport density more closely. - final adjusted = zoomValue + (math.log(ratio) / math.ln2); - return adjusted.round().clamp(_minTileZoom, _maxTileZoom); + int _idealTileZoom(double zoomValue) { + // Raster tiles are already rendered at device-pixel-ratio-aware + // resolutions, so boosting source tile zoom by DPR double-counts detail + // and explodes the number of tiles mobile has to keep visible at once. + // We still snap by logical zoom so the next tile level starts loading as + // you cross the midpoint, instead of waiting for the next whole integer. + final snapped = zoomValue.round(); + return snapped.clamp(_minTileZoom, _maxTileZoom); } void _setZoomAroundFocal(double requestedZoom, Offset focal) { @@ -314,10 +317,7 @@ class _MapPageState extends State with TickerProviderStateMixin { _zoom = nextZoom; _mapCenter = _worldToLatLng(centerWorldNext, nextZoom); - _activeTileZoom = _idealTileZoom( - _zoom, - devicePixelRatio: _devicePixelRatio, - ); + _activeTileZoom = _idealTileZoom(_zoom); if (_activeTileZoom != previousTileZoom) { _fallbackTileZoom = previousTileZoom; } @@ -390,6 +390,17 @@ class _MapPageState extends State with TickerProviderStateMixin { void _updatePainterViewport() { final cw = _latLngToWorld(_mapCenter, _activeTileZoom.toDouble()); + final interactionActive = _interactionIdleTimer?.isActive ?? false; + final viewportChanged = + _pyramidPainter.mapWidth != _mapSize.width || + _pyramidPainter.mapHeight != _mapSize.height || + _pyramidPainter.zoom != _zoom || + _pyramidPainter.centerWorldX != cw.x || + _pyramidPainter.centerWorldY != cw.y || + _pyramidPainter.activeTileZoom != _activeTileZoom || + _pyramidPainter.fallbackTileZoom != _fallbackTileZoom || + _pyramidPainter.devicePixelRatio != _devicePixelRatio || + _pyramidPainter.interactionActive != interactionActive; _pyramidPainter ..mapWidth = _mapSize.width ..mapHeight = _mapSize.height @@ -399,8 +410,10 @@ class _MapPageState extends State with TickerProviderStateMixin { ..activeTileZoom = _activeTileZoom ..fallbackTileZoom = _fallbackTileZoom ..devicePixelRatio = _devicePixelRatio - ..interactionActive = _interactionIdleTimer?.isActive ?? false; - _pyramidPainter.markDirty(); + ..interactionActive = interactionActive; + if (viewportChanged) { + _pyramidPainter.markDirty(); + } _maybeScheduleBusStopFetch(); } @@ -911,6 +924,14 @@ class _MapPageState extends State with TickerProviderStateMixin { child: _PerformanceHud( fps: _displayFps, frameMs: _displayFrameMs, + tileDebugSummary: kDebugMode + ? 'z$_activeTileZoom' + '${_fallbackTileZoom != null ? ' f$_fallbackTileZoom' : ''} ' + 'c${TilePyramidCache.debugCacheCount} ' + 'q${TilePyramidCache.debugQueueCount} ' + 'i${TilePyramidCache.debugInFlightCount} ' + '${TilePyramidCache.debugApproxMegabytes.toStringAsFixed(0)}MB' + : null, ), ), const SizedBox(height: 6), @@ -1166,10 +1187,15 @@ class _DebugInspectPanel extends StatelessWidget { } class _PerformanceHud extends StatelessWidget { - const _PerformanceHud({required this.fps, required this.frameMs}); + const _PerformanceHud({ + required this.fps, + required this.frameMs, + this.tileDebugSummary, + }); final double fps; final double frameMs; + final String? tileDebugSummary; @override Widget build(BuildContext context) { @@ -1203,6 +1229,15 @@ class _PerformanceHud extends StatelessWidget { color: Color(0xFF4B5563), ), ), + if (tileDebugSummary != null) + Text( + tileDebugSummary!, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Color(0xFF6B7280), + ), + ), ], ), ), diff --git a/lib/pages/map/vector/tile_pyramid_cache.dart b/lib/pages/map/vector/tile_pyramid_cache.dart index f23b312..0c25bb8 100644 --- a/lib/pages/map/vector/tile_pyramid_cache.dart +++ b/lib/pages/map/vector/tile_pyramid_cache.dart @@ -64,6 +64,17 @@ class TilePyramidCache { static bool isInFlight(int z, int x, int y) => _inFlight.contains(_TileCoord(z, x, y)); + static bool contains(int z, int x, int y) => + _cache.containsKey(_TileCoord(z, x, y)); + + static int get debugCacheCount => _cache.length; + + static int get debugInFlightCount => _inFlight.length; + + static int get debugQueueCount => _queue.length; + + static double get debugApproxMegabytes => _approxBytes / (1024 * 1024); + // Queue a render for (z, x, y). No-op if already in flight. // render() produces the image; onReady() is called after it's stored. static void enqueue( diff --git a/lib/pages/map/vector/tile_pyramid_painter.dart b/lib/pages/map/vector/tile_pyramid_painter.dart index f823500..f6fbc31 100644 --- a/lib/pages/map/vector/tile_pyramid_painter.dart +++ b/lib/pages/map/vector/tile_pyramid_painter.dart @@ -14,6 +14,9 @@ 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; +const double _kWebMaxRasterTilePx = 256.0; +const double _kMobileMaxRasterTilePx = 512.0; +const double _kDesktopMaxRasterTilePx = 1024.0; class TilePyramidPainter extends ChangeNotifier implements CustomPainter { TilePyramidPainter({ @@ -41,6 +44,13 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { bool interactionActive = false; List routeScreenPoints = const []; + bool get _isMobilePlatform => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); + + int get _idlePrefetchGuard => (kIsWeb || _isMobilePlatform) ? 0 : 2; + // 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 = {}; @@ -115,40 +125,53 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { @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; + if (kIsWeb && interactionActive) return; _drawAllRoadLabels(canvas); _drawAllPlaceLabels(canvas); } - void _drawTileLayer(Canvas canvas, int z, {required bool queueTiles}) { + _TileBounds _tileBoundsForZoom(int z, {required int overscan}) { 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; + return _TileBounds( + minTX: (minWX / tileSize).floor() - overscan, + maxTX: (maxWX / tileSize).floor() + overscan, + minTY: (minWY / tileSize).floor() - overscan, + maxTY: (maxWY / tileSize).floor() + overscan, + ); + } - if (queueTiles && !kIsWeb) { + void _drawTileLayer(Canvas canvas, int z, {required bool queueTiles}) { + final maxIndex = 1 << z; + final factor = math.pow(2, zoom - z).toDouble(); + final zDelta = z - activeTileZoom; + final worldScale = math.pow(2, zDelta).toDouble(); + final centerX = centerWorldX * worldScale; + final centerY = centerWorldY * worldScale; + final bounds = _tileBoundsForZoom(z, overscan: tileOverscan); + + final minWX = centerX - (mapWidth / 2) / factor; + final minWY = centerY - (mapHeight / 2) / factor; + final minTX = bounds.minTX; + final maxTX = bounds.maxTX; + final minTY = bounds.minTY; + final maxTY = bounds.maxTY; + + if (queueTiles) { final allowed = _allowedQueuedTileHashes( z, minTX, @@ -180,11 +203,11 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } } - if (queueTiles && !kIsWeb) { + if (queueTiles) { _queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex); - if (!interactionActive) { - const guard = 2; + final guard = _idlePrefetchGuard; + if (!interactionActive && guard > 0) { _queueVisibleTiles( z, minTX - guard, @@ -224,8 +247,8 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { addWindow(minTX, maxTX, minTY, maxTY); - if (!interactionActive) { - const guard = 2; + final guard = _idlePrefetchGuard; + if (!interactionActive && guard > 0) { addWindow(minTX - guard, maxTX + guard, minTY - guard, maxTY + guard); } @@ -233,7 +256,6 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } 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; @@ -280,26 +302,47 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } void _drawTileWeb(Canvas canvas, int z, int x, int y, Rect dst) { - final url = _tileUrl(z, x, y); - final tile = MapboxVectorTileCache.peek(url); + final imageTile = TilePyramidCache.peek(z, x, y); + if (imageTile != null) { + _blitImage( + canvas, + imageTile.image, + Rect.fromLTWH( + 0, + 0, + imageTile.image.width.toDouble(), + imageTile.image.height.toDouble(), + ), + dst, + ); + return; + } - if (tile != null) { + final url = _tileUrl(z, x, y); + final vectorTile = MapboxVectorTileCache.peek(url); + if (vectorTile != 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()); + _paintTileWebFast( + canvas, + Size.square(tileSize), + vectorTile, + tileZoom: z.toDouble(), + ); canvas.restore(); + + if (!TilePyramidCache.isInFlight(z, x, y)) { + _queueTile(z, x, y); + } return; } - // fetch if not cached yet - if (!MapboxVectorTileCache.isFetching(url)) { - MapboxVectorTileCache.getOrFetch(url).then((_) => markDirty()); + if (!TilePyramidCache.isInFlight(z, x, y)) { + _queueTile(z, x, y); } - // 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; @@ -307,23 +350,19 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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); + 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 srcLeft = fracX * tileSize; - final srcTop = fracY * tileSize; - final srcSize = tileSize / subdivision; + final src = Rect.fromLTWH( + fracX * ancestor.image.width, + fracY * ancestor.image.height, + ancestor.image.width / subdivision, + ancestor.image.height / 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(); + _blitImage(canvas, ancestor.image, src, dst); break; } } @@ -336,11 +375,20 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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 + // Keep mobile raster tiles small enough that the image cache can hold + // roughly a full viewport plus overscan without immediately evicting them. final dprCapped = devicePixelRatio.clamp(1.0, 2.0); - final rasterPx = (tileSize * dprCapped * _kTileResolutionScale).round(); + final targetRasterPx = tileSize * dprCapped * _kTileResolutionScale; + final isMobilePlatform = + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); + final maxRasterPx = kIsWeb + ? _kWebMaxRasterTilePx + : (isMobilePlatform + ? _kMobileMaxRasterTilePx + : _kDesktopMaxRasterTilePx); + final rasterPx = targetRasterPx.clamp(tileSize, maxRasterPx).round(); TilePyramidCache.enqueue( z, @@ -353,7 +401,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { }, () { SchedulerBinding.instance.scheduleFrame(); - notifyListeners(); + markDirty(); }, ); } @@ -500,6 +548,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { ..isAntiAlias = true, ); + if (kIsWeb) { + _paintTileWebFast(canvas, size, tile, tileZoom: tileZoom); + if (kMapDebugShowTileBoundaries) _drawRasterTileBoundary(canvas, size); + return; + } + final commands = <_DrawCommand>[]; // landuse retail @@ -521,7 +575,14 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { tile, layerNames: const {'landuse'}, fillColor: kMapGrassColor, - allowedTypes: const {'park', 'meadow', 'garden', 'wood', 'sports_centre', 'recreation_ground'}, + allowedTypes: const { + 'park', + 'meadow', + + 'wood', + 'sports_centre', + 'recreation_ground', + }, allowedClasses: const {'grass', 'cemetery', 'agriculture'}, tileZoom: tileZoom, baseZ: 25.0, @@ -618,9 +679,29 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { strokeCap: StrokeCap.round, strokeJoin: StrokeJoin.miter, strokeAntiAlias: false, - allowedTypes: const {'retail', 'shelter', 'stadium', 'school', 'university', 'college', 'hospital', 'church', 'civic', 'train_station'}, + 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'}, + allowExtrudedTypes: const { + 'stadium', + 'school', + 'university', + 'college', + 'hospital', + 'church', + 'civic', + 'train_station', + }, minHeight: 2.5, tileZoom: tileZoom, baseZ: 200.0, @@ -636,6 +717,148 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { if (kMapDebugShowTileBoundaries) _drawRasterTileBoundary(canvas, size); } + void _paintTileWebFast( + Canvas canvas, + Size size, + MvtTile tile, { + required double tileZoom, + }) { + _drawPolygonLayer( + canvas, + size, + tile, + layerNames: const {'landuse'}, + fillColor: kMapParkingColor, + strokeColor: kMapParkingOutlineColor, + strokeWorldUnits: 2.0, + tileZoom: tileZoom, + allowedClasses: const {'airport'}, + ); + + _drawPolygonLayer( + canvas, + size, + tile, + layerNames: const {'landuse'}, + fillColor: kMapLanduseColor, + tileZoom: tileZoom, + allowedTypes: const {'retail'}, + ); + + _drawPolygonLayer( + canvas, + size, + tile, + layerNames: const {'landuse'}, + fillColor: kMapGrassColor, + tileZoom: tileZoom, + allowedTypes: const { + 'park', + 'meadow', + + 'wood', + 'sports_centre', + 'recreation_ground', + }, + allowedClasses: const {'grass', 'cemetery', 'agriculture'}, + ); + + _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: kMapPathFillColour, + tileZoom: tileZoom, + allowedClasses: const {'school'}, + allowedTypes: const {'school', 'college', 'university'}, + ); + + _drawPolygonLayer( + canvas, + size, + tile, + layerNames: const {'landuse'}, + fillColor: kMapParkingColor, + strokeColor: kMapParkingOutlineColor, + strokeWorldUnits: 2.0, + tileZoom: tileZoom, + allowedTypes: const {'parking'}, + allowedClasses: const {'parking'}, + ); + + _drawPolygonLayer( + canvas, + size, + tile, + layerNames: const {'landuse'}, + fillColor: kMapCommercialAreaColor, + strokeColor: kMapCommercialAreaOutlineColor, + strokeWorldUnits: 2.0, + tileZoom: tileZoom, + allowedTypes: const {'commercial_area'}, + allowedClasses: const {'commercial_area'}, + ); + + _drawRoadLayer(canvas, size, tile, tileZoom: tileZoom); + + _drawPolygonLayer( + canvas, + 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, + ); + } + static String _epKey(Offset p) => '${p.dx.round()},${p.dy.round()}'; double _featureLayerProp(MvtFeature feature) { @@ -671,8 +894,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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 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']; @@ -699,10 +926,14 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } if (excludeExtruded && extrude && - (allowExtrudedTypes == null || !allowExtrudedTypes.contains(featureType))) { + (allowExtrudedTypes == null || + !allowExtrudedTypes.contains(featureType))) { continue; } - if (minHeight != null && height != null && height < minHeight && minFeatureHeight == null) { + if (minHeight != null && + height != null && + height < minHeight && + minFeatureHeight == null) { continue; } @@ -731,22 +962,23 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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); - } - }, - )); + 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, @@ -759,11 +991,27 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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})>[]; + 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 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(( @@ -794,17 +1042,56 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { // 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})>>{}; + 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)); + (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})>>{}; + final eraseByClass = + < + String, + List< + ({ + List> 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); + 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 @@ -822,7 +1109,13 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } final terminalInset = (uR - cR) / 2 / scale; - (eraseByClass[f.roadClass] ??= []).add((lines: f.feature.geometry, paint: erasePaint, junctionPts: junctionPts, radius: cR / 2, terminalInset: terminalInset)); + (eraseByClass[f.roadClass] ??= []).add(( + lines: f.feature.geometry, + paint: erasePaint, + junctionPts: junctionPts, + radius: cR / 2, + terminalInset: terminalInset, + )); } for (final entry in byGroup.entries) { @@ -830,34 +1123,72 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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})>[]; + 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.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 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 + ..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)); + underlayDrawData.add(( + lines: f.feature.geometry, + paint: underlayPaint, + junctionPts: junctionPts, + radius: uR / 2, + )); - final skipOverlay = f.style.outlineOnly || f.style.fillColor == kMapPathFillColour; + 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 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 cR = _worldStrokePx( + f.style.coreWorldUnits, + extent: roadLayer.extent, + rasterSize: size.width, + tileZoom: tileZoom, + ); final corePaint = Paint() ..color = f.style.fillColor ..style = PaintingStyle.stroke @@ -869,62 +1200,83 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { // 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)); + 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; + // Flutter web debug builds visibly checkerboard these offscreen layers, + // and the punch-out blend pass is where the artifact comes from. + final needsErase = !kIsWeb && 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()); + 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 d in underlayDrawData) { 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); + c.drawPath(_scaledPath(line, 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.drawCircle( + Offset(pt.dx * scale, pt.dy * scale), + d.radius, + circlePaint, + ); } } - c.restore(); - } - }, - )); + + 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; @@ -932,30 +1284,36 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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); - } - }, - )); + 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, + ); + } + }, + ), + ); } } @@ -987,17 +1345,19 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { ..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); - } - }, - )); + 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); + } + }, + ), + ); } } @@ -1123,6 +1483,21 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final styledPolygons = <_StyledRoad>[]; for (final feature in roadLayer.features) { + final roadClass = (feature.properties['class'] ?? '') + .toString() + .toLowerCase(); + if (feature.type == MvtGeometryType.lineString && + tileZoom < 13 && + !_isMajorRoadClass(roadClass) && + !roadClass.contains('street') && + !roadClass.contains('rail') && + roadClass != 'aerialway') { + continue; + } + if (feature.type == MvtGeometryType.polygon && tileZoom < 14) { + continue; + } + final style = _roadStyleFor(feature, tileZoom: tileZoom); if (style == null) continue; if (feature.type == MvtGeometryType.lineString) { @@ -1260,7 +1635,11 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { ); } if (roadClass == 'aerialway' || roadClass.startsWith('aerialway')) { - const allowedAerialwayTypes = {'aerialway:gondola', 'aerialway:cable_car', 'aerialway:funicular'}; + const allowedAerialwayTypes = { + 'aerialway:gondola', + 'aerialway:cable_car', + 'aerialway:funicular', + }; if (!allowedAerialwayTypes.contains(roadType)) return null; return _RoadStyle( sortOrder: 0, @@ -1273,7 +1652,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } if (roadClass == 'ferry' || roadType == 'ferry') return null; if (roadClass == 'golf' || roadType == 'hole') return null; - if (roadType == 'minor_rail') return null; + if (roadType == 'minor_rail' || roadType == 'miniature') return null; final layerPropVal = (_toDouble(feature.properties['layer']) ?? 0.0); if (isRailLike && isUnderground) return null; if (isRailLike) { @@ -1455,7 +1834,8 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight); const minSameNameSpacing = 300.0; - final candidates = <({String text, String featureClass, Offset screenPt, int rank})>[]; + final candidates = + <({String text, String featureClass, Offset screenPt, int rank})>[]; for (var tx = minTX; tx <= maxTX; tx++) { for (var ty = minTY; ty <= maxTY; ty++) { @@ -1482,24 +1862,30 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { .toLowerCase(); if (!_allowedPlaceClasses.contains(featureClass)) continue; - final capital = (_toDouble(feature.properties['capital']) ?? 0).toInt(); + final capital = (_toDouble(feature.properties['capital']) ?? 0) + .toInt(); final isCapital = capital >= 2; - final symbolrank = (_toDouble(feature.properties['symbolrank']) ?? 99).toInt(); + 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_subdivision') + continue; if (zoom < 12.5 && featureClass == 'settlement') continue; } final rawText = _sanitizeLabel( - (feature.properties['name_en'] ?? feature.properties['name'] ?? '') - .toString() - .trim()); + (feature.properties['name_en'] ?? feature.properties['name'] ?? '') + .toString() + .trim(), + ); if (rawText.isEmpty || rawText.length > 30) continue; - final text = kMapDebugShowPlaceSymbolRank ? '$rawText [$symbolrank]' : rawText; + final text = kMapDebugShowPlaceSymbolRank + ? '$rawText [$symbolrank]' + : rawText; final anchor = feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty @@ -1568,7 +1954,13 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { ); if (occupiedRects.any((r) => r.overlaps(bounds))) continue; - painter.paint(canvas, Offset(c.screenPt.dx - painter.width / 2, c.screenPt.dy - painter.height / 2)); + painter.paint( + canvas, + Offset( + c.screenPt.dx - painter.width / 2, + c.screenPt.dy - painter.height / 2, + ), + ); occupiedRects.add(bounds); (placedByName[key] ??= []).add(c.screenPt); } @@ -1603,9 +1995,10 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { if (!_areaPoiClasses.contains(featureClass)) continue; final rawText = _sanitizeLabel( - (feature.properties['name_en'] ?? feature.properties['name'] ?? '') - .toString() - .trim()); + (feature.properties['name_en'] ?? feature.properties['name'] ?? '') + .toString() + .trim(), + ); if (rawText.isEmpty || rawText.length > 30) continue; if (!seenTexts.add(rawText.toLowerCase())) continue; @@ -1648,7 +2041,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { continue; } - final bounds = Rect.fromLTWH(x, y, painter.width, painter.height).inflate(20); + 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)); @@ -1814,7 +2212,9 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { if (feature.type != MvtGeometryType.lineString) continue; final roadClass = - (feature.properties['class'] ?? feature.properties['type'] ?? '') + (feature.properties['class'] ?? + feature.properties['type'] ?? + '') .toString() .toLowerCase(); if (_shouldSkipRoadLabelClass(roadClass)) continue; @@ -1822,16 +2222,13 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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 text = _roadLabelText(feature); + if (text.isEmpty || text.length > 40) continue; final minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom; - final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble(); + final fontFactor = math + .pow(2, math.max(zoom, minFontZoom) - z) + .toDouble(); final roadCorePx = _worldStrokePx( style.coreWorldUnits, extent: layer.extent, @@ -1841,30 +2238,35 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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 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)) { @@ -1881,17 +2283,23 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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; + 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, - )); + worldLabels.add( + _WorldRoadLabel( + worldX: worldX, + worldY: worldY, + angle: localPlacement.angle, + pathLength: localPlacement.pathLength, + text: text, + painter: painter, + ), + ); } } } @@ -1943,34 +2351,42 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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)); + 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)); + w.painter.paint( + canvas, + Offset(-w.painter.width / 2, -w.painter.height / 2), + ); canvas.restore(); occupiedRects.add(bounds); @@ -2012,15 +2428,13 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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; + final text = _roadLabelText(feature); + if (text.isEmpty || text.length > 40) 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 fontRasterSize = + size.width * math.pow(2, math.max(zoom, minFontZoom2) - tileZoom); final roadCorePx = _worldStrokePx( style.coreWorldUnits, extent: layer.extent, @@ -2214,7 +2628,11 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { // 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) { + List? _trimPolyline( + List pts, + double startDist, + double endDist, + ) { if (startDist <= 0 && endDist <= 0) return pts; List trimStart(List p, double dist) { @@ -2269,7 +2687,8 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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; + 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; @@ -2317,8 +2736,21 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } // 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(); + String _roadLabelText(MvtFeature feature) { + final name = _sanitizeLabel( + (feature.properties['name_en'] ?? feature.properties['name'] ?? '') + .toString() + .trim(), + ); + final ref = (feature.properties['ref'] ?? '').toString().trim(); + if (name.isEmpty) return ref; + if (ref.isEmpty || (!ref.startsWith('M') && !ref.startsWith('A'))) return name; + return '$name ($ref)'; + } + + 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; @@ -2369,7 +2801,8 @@ class TileLabelPainter implements CustomPainter { void paint(Canvas canvas, Size size) => _painter.paintLabels(canvas); @override - bool shouldRepaint(covariant TileLabelPainter old) => true; + bool shouldRepaint(covariant TileLabelPainter old) => + old._painter != _painter; @override bool shouldRebuildSemantics(covariant CustomPainter old) => false; @@ -2381,10 +2814,11 @@ class TileLabelPainter implements CustomPainter { bool hitTest(Offset position) => false; @override - void addListener(ui.VoidCallback listener) {} + void addListener(ui.VoidCallback listener) => _painter.addListener(listener); @override - void removeListener(ui.VoidCallback listener) {} + void removeListener(ui.VoidCallback listener) => + _painter.removeListener(listener); } class _RoadStyle { @@ -2412,6 +2846,20 @@ class _StyledRoad { final _RoadStyle style; } +class _TileBounds { + const _TileBounds({ + required this.minTX, + required this.maxTX, + required this.minTY, + required this.maxTY, + }); + + final int minTX; + final int maxTX; + final int minTY; + final int maxTY; +} + class MapDebugHit { const MapDebugHit({ required this.layerName,