diff --git a/lib/pages/map/constants.dart b/lib/pages/map/constants.dart new file mode 100644 index 0000000..1a285ad --- /dev/null +++ b/lib/pages/map/constants.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +final Color kMapBackgroundColor = Color(0xFFA7BFDB); + +final Color kMapLanduseColor = Color(0xFFE500FE); +final Color kMapGrassColor = Color(0xFFA2CE83); +final Color kMapWaterColor = Color(0xFF74B8ED); +final Color kMapBuildingFillColor = Color(0xFFFED000); +final Color kMapBuildingOutlineColor = Color(0xFFE7B600); + +final Color kMapRoadFillColor = Color(0xFFFEFEFF); +final Color kMapPathFillColour = Color(0xFFD9E0F0); + +final Color kMapDebugTileBoundaryColor = Color(0xCCFF2D55); + +final Color kMapPlaceLabelColor = Color(0xFF1F2636); +final Color kMapPlaceLabelHaloColor = Color(0xF7FFFFFF); + +final Color kMapRoadLabelColor = Color(0xFF56657A); +final Color kMapRoadLabelHaloColor = Color(0xF2FFFFFF); + +final Color kMapRailFillColor = Color(0xFF032D51); +final Color kMapRailUnderlayColor = Color(0xFF8A97A8); + +final Color kMapPoiLabelColor = Color(0xFF032D51); +final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF); diff --git a/lib/pages/map/page.dart b/lib/pages/map/page.dart index 93b28f1..d5a05f1 100644 --- a/lib/pages/map/page.dart +++ b/lib/pages/map/page.dart @@ -14,6 +14,7 @@ import 'package:flutter/scheduler.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart' show LatLng; import 'package:rra_app/pages/map/routing/osrm_routing_service.dart'; +import 'package:rra_app/pages/map/vector/mapbox_vector_tile_cache.dart'; import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart'; import 'package:rra_app/pages/map/vector/tile_pyramid_painter.dart'; import 'package:rra_app/pages/map/widgets/toolbar.dart'; @@ -72,10 +73,12 @@ class _MapPageState extends State with TickerProviderStateMixin { bool _isAddPointArmed = false; bool _isResolvingRoute = false; + bool _isInspectingFeature = false; String? _routeError; int _routeRequestId = 0; int? _draggingInsertPointIndex; Timer? _interactionIdleTimer; + MapDebugHit? _debugFeatureHit; late final TilePyramidPainter _pyramidPainter; @@ -103,12 +106,25 @@ class _MapPageState extends State with TickerProviderStateMixin { void dispose() { _zoomTicker.dispose(); _pyramidPainter.dispose(); + MapboxVectorTileCache.clear(); TilePyramidCache.clear(); SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings); _interactionIdleTimer?.cancel(); super.dispose(); } + @override + void reassemble() { + super.reassemble(); + MapboxVectorTileCache.clear(); + TilePyramidCache.clear(); + _pyramidPainter.markDirty(); + if (!mounted) return; + setState(() { + _fallbackTileZoom = null; + }); + } + void _onFrameTimings(List timings) { for (final timing in timings) { _frameMsWindow.add(timing.totalSpan.inMicroseconds / 1000.0); @@ -256,8 +272,10 @@ class _MapPageState extends State with TickerProviderStateMixin { int _idealTileZoom(double zoomValue, {required double devicePixelRatio}) { final ratio = devicePixelRatio.clamp(1.0, 4.0).toDouble(); - final adjusted = zoomValue + (math.log(ratio / 2.0) / math.ln2); - return adjusted.floor().clamp(_minTileZoom, _maxTileZoom); + // 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); } void _setZoomAroundFocal(double requestedZoom, Offset focal) { @@ -417,6 +435,20 @@ class _MapPageState extends State with TickerProviderStateMixin { _resolveRoute(); } + Future _inspectFeatureAt(Offset localPosition) async { + setState(() { + _isInspectingFeature = true; + }); + + final hit = await _pyramidPainter.inspectFeatureAt(localPosition); + if (!mounted) return; + + setState(() { + _debugFeatureHit = hit; + _isInspectingFeature = false; + }); + } + List _buildPointWidgets() { final widgets = []; for (var i = 0; i < _points.length; i++) { @@ -572,11 +604,12 @@ class _MapPageState extends State with TickerProviderStateMixin { _queuePan(details.delta); }, onTapUp: (details) { - if (!_isAddPointArmed || - _draggingInsertPointIndex != null) { + if (_draggingInsertPointIndex != null) return; + if (_isAddPointArmed) { + _addPoint(_screenToLatLng(details.localPosition)); return; } - _addPoint(_screenToLatLng(details.localPosition)); + _inspectFeatureAt(details.localPosition); }, child: ClipRect( child: Stack( @@ -658,11 +691,22 @@ class _MapPageState extends State with TickerProviderStateMixin { alignment: Alignment.topRight, child: Padding( padding: const EdgeInsets.all(10), - child: IgnorePointer( - child: _PerformanceHud( - fps: _displayFps, - frameMs: _displayFrameMs, - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + IgnorePointer( + child: _PerformanceHud( + fps: _displayFps, + frameMs: _displayFrameMs, + ), + ), + const SizedBox(height: 8), + _DebugInspectPanel( + hit: _debugFeatureHit, + isInspecting: _isInspectingFeature, + ), + ], ), ), ), @@ -750,6 +794,83 @@ class _HudCornerNotice extends StatelessWidget { } } +class _DebugInspectPanel extends StatelessWidget { + const _DebugInspectPanel({required this.hit, required this.isInspecting}); + + final MapDebugHit? hit; + final bool isInspecting; + + @override + Widget build(BuildContext context) { + if (!isInspecting && hit == null) return const SizedBox.shrink(); + + final rows = [ + if (hit != null) 'layer: ${hit!.layerName}', + if (hit != null) 'type: ${hit!.geometryType.name}', + if (hit != null) 'tile: z${hit!.tileZ}/${hit!.tileX}/${hit!.tileY}', + if (hit != null) 'distance: ${hit!.distance.toStringAsFixed(1)}', + if (hit != null) + ...hit!.properties.entries.map( + (entry) => '${entry.key}: ${entry.value}', + ), + ]; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.94), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0x22000000)), + boxShadow: const [ + BoxShadow( + color: Color(0x22000000), + blurRadius: 10, + spreadRadius: 1, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: DefaultTextStyle( + style: const TextStyle( + fontSize: 12, + height: 1.35, + color: Color(0xFF1F2937), + fontWeight: FontWeight.w600, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isInspecting ? 'Inspecting map...' : 'Map Inspect', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + color: Color(0xFF111827), + ), + ), + if (!isInspecting && hit == null) + const Padding( + padding: EdgeInsets.only(top: 6), + child: Text('Click the map to inspect a feature.'), + ), + if (rows.isNotEmpty) const SizedBox(height: 6), + for (final row in rows) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(row), + ), + ], + ), + ), + ), + ), + ); + } +} + class _PerformanceHud extends StatelessWidget { const _PerformanceHud({required this.fps, required this.frameMs}); diff --git a/lib/pages/map/vector/mapbox_vector_tile_cache.dart b/lib/pages/map/vector/mapbox_vector_tile_cache.dart index 679fa58..2dba67c 100644 --- a/lib/pages/map/vector/mapbox_vector_tile_cache.dart +++ b/lib/pages/map/vector/mapbox_vector_tile_cache.dart @@ -68,4 +68,9 @@ class MapboxVectorTileCache { _cache.remove(_cache.keys.first); } } + + static void clear() { + _cache.clear(); + _inFlight.clear(); + } } diff --git a/lib/pages/map/vector/tile_pyramid_cache.dart b/lib/pages/map/vector/tile_pyramid_cache.dart index c6b1ece..f637d5b 100644 --- a/lib/pages/map/vector/tile_pyramid_cache.dart +++ b/lib/pages/map/vector/tile_pyramid_cache.dart @@ -17,18 +17,13 @@ class _TileCoord { } class CachedTile { - CachedTile(this.image, this.zoomBucket); + CachedTile(this.image); final ui.Image image; - final int zoomBucket; } -// Static tile pyramid image cache. Stores rasterised ui.Image per (z, x, y). -// Each entry also records the zoom bucket it was rendered at — callers can -// check whether a re-render is warranted, but the old image stays as a -// fallback until the new one arrives. -// -// Ancestor lookup (for fallback blitting) just calls peek() at a coarser z. +// Static tile pyramid image cache. Stores one rasterised ui.Image per +// deterministic pyramid tile coordinate (z, x, y). class TilePyramidCache { TilePyramidCache._(); @@ -67,7 +62,6 @@ class TilePyramidCache { int z, int x, int y, - int zoomBucket, Future Function() render, void Function() onReady, ) { @@ -77,14 +71,7 @@ class TilePyramidCache { if (filter != null && !filter(z, x, y)) return; _inFlight.add(coord); - _queue.add( - _Queued( - coord: coord, - zoomBucket: zoomBucket, - render: render, - onReady: onReady, - ), - ); + _queue.add(_Queued(coord: coord, render: render, onReady: onReady)); _drain(); } @@ -118,7 +105,7 @@ class TilePyramidCache { existing.image.dispose(); } - _cache[item.coord] = CachedTile(image, item.zoomBucket); + _cache[item.coord] = CachedTile(image); _approxBytes += _estimateBytes(image); _trim(); @@ -176,13 +163,11 @@ class TilePyramidCache { class _Queued { const _Queued({ required this.coord, - required this.zoomBucket, required this.render, required this.onReady, }); final _TileCoord coord; - final int zoomBucket; final Future Function() render; final void Function() onReady; } diff --git a/lib/pages/map/vector/tile_pyramid_painter.dart b/lib/pages/map/vector/tile_pyramid_painter.dart index 7071c6e..f911d66 100644 --- a/lib/pages/map/vector/tile_pyramid_painter.dart +++ b/lib/pages/map/vector/tile_pyramid_painter.dart @@ -3,13 +3,15 @@ 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'; -// How many ancestor levels to walk when looking for a fallback image. const int _kMaxAncestorLookup = 4; const double _kTileResolutionScale = 2.0; +const double _kLineReferenceZoom = 14.0; class TilePyramidPainter extends ChangeNotifier implements CustomPainter { TilePyramidPainter({ @@ -26,7 +28,6 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final int maxTileZoom; final int tileOverscan; - // Viewport state — updated by the map page each frame. double mapWidth = 0; double mapHeight = 0; double zoom = 0; @@ -37,15 +38,9 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { double devicePixelRatio = 1; bool interactionActive = false; - // Colour filters applied when drawing each tile (contrast + saturation combined). - static const double _contrast = 1.20; - static const double _saturation = 1.12; - late final ui.ColorFilter _tileColorFilter = ui.ColorFilter.matrix( - _multiplyMatrices( - _contrastMatrix(_contrast), - _saturationMatrix(_saturation), - ), - ); + // 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(); @@ -54,6 +49,53 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { .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; @@ -64,6 +106,8 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } _drawTileLayer(canvas, activeTileZoom, queueTiles: true); + _drawAllRoadLabels(canvas); + _drawAllPlaceLabels(canvas); } void _drawTileLayer(Canvas canvas, int z, {required bool queueTiles}) { @@ -103,22 +147,22 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { if (ty < 0 || ty >= maxIndex) continue; final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex; - final left = (tx * tileSize - minWX) * factor; final top = (ty * tileSize - minWY) * factor; - final displaySize = tileSize * factor; - - final dst = Rect.fromLTWH(left, top, displaySize, displaySize); + final dst = Rect.fromLTWH( + left, + top, + tileSize * factor, + tileSize * factor, + ); _drawTile(canvas, z, wrappedX, ty, dst); } } - // Queue rasterisation for visible tiles when permitted. if (queueTiles) { _queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex); - // Pre-warm tiles just outside the viewport so panning feels instant. if (!interactionActive) { const guard = 2; _queueVisibleTiles( @@ -148,44 +192,29 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { ) { final hashes = {}; - for (var tx = minTX; tx <= maxTX; tx++) { - for (var ty = minTY; ty <= maxTY; ty++) { - if (ty < 0 || ty >= maxIndex) continue; - - final wx = ((tx % maxIndex) + maxIndex) % maxIndex; - hashes.add(Object.hash(z, wx, ty)); + 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; - final guardMinTX = minTX - guard; - final guardMaxTX = maxTX + guard; - final guardMinTY = minTY - guard; - final guardMaxTY = maxTY + guard; - - for (var tx = guardMinTX; tx <= guardMaxTX; tx++) { - for (var ty = guardMinTY; ty <= guardMaxTY; ty++) { - if (ty < 0 || ty >= maxIndex) continue; - if (tx >= minTX && tx <= maxTX && ty >= minTY && ty <= maxTY) { - continue; - } - - final wx = ((tx % maxIndex) + maxIndex) % maxIndex; - hashes.add(Object.hash(z, wx, ty)); - } - } + addWindow(minTX - guard, maxTX + guard, minTY - guard, maxTY + guard); } return hashes; } void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) { - final zoomBucket = z; - - // Try native level first. final native = TilePyramidCache.peek(z, x, y); - if (native != null && native.zoomBucket == zoomBucket) { + if (native != null) { _blitImage( canvas, native.image, @@ -200,8 +229,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { return; } - // Walk up the pyramid for an ancestor fallback. - for (int dz = 1; dz <= _kMaxAncestorLookup; dz++) { + for (var dz = 1; dz <= _kMaxAncestorLookup; dz++) { final az = z - dz; if (az < minTileZoom) break; @@ -209,59 +237,46 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final ancestorX = x >> dz; final ancestorY = y >> dz; final ancestor = TilePyramidCache.peek(az, ancestorX, ancestorY); - if (ancestor != null) { - // Compute which sub-region of the ancestor image corresponds to this tile. - final fracX = (x - ancestorX * subdivision).toDouble() / subdivision; - final fracY = (y - ancestorY * subdivision).toDouble() / subdivision; - final srcW = ancestor.image.width / subdivision; - final srcH = ancestor.image.height / subdivision; - final src = Rect.fromLTWH( - fracX * ancestor.image.width, - fracY * ancestor.image.height, - srcW, - srcH, - ); + if (ancestor == null) continue; - _blitImage(canvas, ancestor.image, src, dst); - break; - } + 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; } - // Queue native image if not already cached/in-flight. - if ((native == null || native.zoomBucket != zoomBucket) && - !TilePyramidCache.isInFlight(z, x, y) && - !interactionActive) { - _queueTile(z, x, y, zoomBucket); + 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 - ..colorFilter = _tileColorFilter; - + final paint = Paint()..filterQuality = FilterQuality.high; canvas.drawImageRect(image, src, dst, paint); } - void _queueTile(int z, int x, int y, int zoomBucket) { + void _queueTile(int z, int x, int y) { final url = _tileUrl(z, x, y); final rasterPx = (tileSize * devicePixelRatio * _kTileResolutionScale) .round(); - final currentZoom = zoom; TilePyramidCache.enqueue( z, x, y, - zoomBucket, () async { final tile = await MapboxVectorTileCache.getOrFetch(url); if (tile == null || tile.layers.isEmpty) return null; - - return _rasterize(tile, zoom: currentZoom, rasterPx: rasterPx); + return _rasterize(tile, tileZoom: z.toDouble(), rasterPx: rasterPx); }, () { - // Image ready — ask Flutter to redraw. SchedulerBinding.instance.scheduleFrame(); notifyListeners(); }, @@ -284,9 +299,6 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { for (var tx = minTX; tx <= maxTX; tx++) { for (var ty = minTY; ty <= maxTY; ty++) { if (ty < 0 || ty >= maxIndex) continue; - - // When pre-warming the guard ring, skip tiles in the core visible area - // (they're already handled by the first call). if (skipIfAlreadyQueued && tx >= visMinTX && tx <= visMaxTX && @@ -295,131 +307,278 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { continue; } - final wx = ((tx % maxIndex) + maxIndex) % maxIndex; - final cached = TilePyramidCache.peek(z, wx, ty); - if (cached != null && cached.zoomBucket == z) continue; - if (TilePyramidCache.isInFlight(z, wx, ty)) continue; - _queueTile(z, wx, ty, z); + 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); } } } - // ------------------------------------------------------------------------- - // Rasterisation for the cached tile images. - // ------------------------------------------------------------------------- - - void _drawTileContents( - Canvas canvas, - Size sz, - MvtTile tile, { - required double zoom, + 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 paint = Paint()..isAntiAlias = true; - paint.color = const Color(0xFFE6EAF1); - canvas.drawRect(Offset.zero & sz, paint); + final tolerance = layer.extent / displayTileSize * pixelTolerance; - _drawPolygons( - canvas, - sz, - tile, - const {'landuse'}, - const Color(0xFFCCDCBF), - zoom: zoom, - ); - _drawPolygons( - canvas, - sz, - tile, - const {'park'}, - const Color(0xFF97D585), - zoom: zoom, - ); - _drawPolygons( - canvas, - sz, - tile, - const {'water'}, - const Color(0xFF68B5EC), - zoom: zoom, - ); - _drawPolygons( - canvas, - sz, - tile, - const {'building'}, - const Color(0xFFCCD3DF), - zoom: zoom, - strokeColor: const Color(0xFF8E9CB4), - strokeBaseWidth: 0.56, - ); + 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, + ); + } + } - _drawRoads(canvas, sz, tile, zoom: zoom); - _drawLabels(canvas, sz, tile, zoom: zoom); + 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 zoom, + required double tileZoom, required int rasterPx, }) async { if (rasterPx <= 0) return null; final recorder = ui.PictureRecorder(); - final sz = Size(rasterPx.toDouble(), rasterPx.toDouble()); - _drawTileContents(Canvas(recorder), sz, tile, zoom: zoom); + final size = Size.square(rasterPx.toDouble()); + final canvas = Canvas(recorder); - final pic = recorder.endRecording(); + _paintTile(canvas, size, tile, tileZoom: tileZoom); + + final picture = recorder.endRecording(); try { - return await pic.toImage(rasterPx, rasterPx); + return await picture.toImage(rasterPx, rasterPx); } finally { - pic.dispose(); + picture.dispose(); } } - void _drawPolygons( + void _paintTile( Canvas canvas, Size size, - MvtTile tile, - Set layerNames, - Color color, { - required double zoom, + 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 strokeBaseWidth = 0.0, + 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 = color + ..color = fillColor ..style = PaintingStyle.fill ..isAntiAlias = true; - final stroke = strokeColor == null - ? null - : (Paint() - ..color = strokeColor - ..style = PaintingStyle.stroke - ..strokeWidth = - (strokeBaseWidth + (zoom - 15.0).clamp(0.0, 8.0) * 0.06).clamp( - 0.44, - 1.18, - ) - ..strokeJoin = StrokeJoin.round - ..strokeCap = StrokeCap.round - ..isAntiAlias = true); + 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 = Path() - ..moveTo(ring.first.dx * scale, ring.first.dy * scale); - for (var i = 1; i < ring.length; i++) { - path.lineTo(ring[i].dx * scale, ring[i].dy * scale); - } - path.close(); + final path = _scaledPath(ring, scale, close: true); canvas.drawPath(path, fill); if (stroke != null) canvas.drawPath(path, stroke); } @@ -427,229 +586,993 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } } - void _drawRoads( + void _drawRoadLayer( Canvas canvas, Size size, MvtTile tile, { - required double zoom, + 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 isPathLike = - roadClass.contains('path') || - roadClass.contains('footway') || - roadClass.contains('steps') || - roadClass.contains('cycleway') || - roadClass.contains('track') || - roadClass.contains('bridleway'); - if (isPathLike && zoom < 15) continue; + final 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 isMotorway = roadClass.contains('motorway'); - final isTrunkPrimary = - roadClass.contains('trunk') || roadClass.contains('primary'); - final isSecondary = - roadClass.contains('secondary') || roadClass.contains('tertiary'); - final isLink = roadClass.contains('link'); + final roadCorePx = _worldStrokePx( + style.coreWorldUnits, + extent: layer.extent, + rasterSize: size.width, + tileZoom: tileZoom, + ); + final fontSize = (roadCorePx * 1.05).clamp(8.5, 24.0); - var baseWidth = 0.92; - var growth = 0.24; - var casingColor = const Color(0xFF9BA7BE); - var fillColor = const Color(0xFFD8DFEC); + 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(); - if (isMotorway) { - baseWidth = 1.60; - growth = 0.42; - casingColor = const Color(0xFF5D6F92); - fillColor = const Color(0xFF98AFCF); - } else if (isTrunkPrimary) { - baseWidth = 1.35; - growth = 0.34; - casingColor = const Color(0xFF7080A0); - fillColor = const Color(0xFFAFC0D8); - } else if (isSecondary) { - baseWidth = 1.06; - growth = 0.28; - casingColor = const Color(0xFF8694B0); - fillColor = const Color(0xFFC5D1E3); + 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; - if (isLink) baseWidth *= 0.82; + 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(); - final zoomBoost = (zoom - 11).clamp(0.0, 10.0); - final width = (baseWidth + zoomBoost * growth).clamp(0.60, 7.20); - - final casing = Paint() - ..color = casingColor - ..style = PaintingStyle.stroke - ..strokeWidth = width + (isMotorway ? 1.15 : 0.95) - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round - ..isAntiAlias = true; - - final road = Paint() - ..color = fillColor - ..style = PaintingStyle.stroke - ..strokeWidth = width - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round - ..isAntiAlias = true; - - for (final line in feature.geometry) { - if (line.length < 2) continue; - final path = Path() - ..moveTo(line.first.dx * scale, line.first.dy * scale); - for (var i = 1; i < line.length; i++) { - path.lineTo(line[i].dx * scale, line[i].dy * scale); - } - canvas.drawPath(path, casing); - canvas.drawPath(path, road); - } + occupiedRects.add(bounds); } } - void _drawLabels( - Canvas canvas, - Size size, - MvtTile tile, { - required double zoom, + double _worldStrokePx( + double worldUnits, { + required int extent, + required double rasterSize, + required double tileZoom, }) { - if (zoom < 12) return; + final zoomScale = math.pow(2.0, tileZoom - _kLineReferenceZoom).toDouble(); + return worldUnits * zoomScale * (rasterSize / extent); + } - final occupiedRects = []; - final seenTexts = {}; - - void drawFromLayer( - String layerName, - Color color, - double fontSize, { - bool pointsOnly = true, - FontWeight fontWeight = FontWeight.w500, - required double minZoom, - int maxLabels = 9999, - double minSpacing = 10, - Set? allowedClasses, - }) { - if (zoom < minZoom) return; - final layer = tile.layers[layerName]; - if (layer == null) return; - final scale = size.width / layer.extent; - final candidates = <_LabelCandidate>[]; - - for (final feature in layer.features) { - if (pointsOnly && feature.type != MvtGeometryType.point) continue; - final featureClass = - (feature.properties['class'] ?? feature.properties['type'] ?? '') - .toString() - .toLowerCase(); - if (allowedClasses != null && - allowedClasses.isNotEmpty && - !allowedClasses.contains(featureClass)) { - continue; - } - - final text = - (feature.properties['name_en'] ?? feature.properties['name'] ?? '') - .toString() - .trim(); - if (text.isEmpty || text.length > 26) continue; - - final anchor = - feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty - ? feature.geometry.first.first - : null; - if (anchor == null) continue; - - final scalerank = _toRank(feature.properties['scalerank']) ?? 99; - final localrank = _toRank(feature.properties['localrank']) ?? 99; - candidates.add( - _LabelCandidate( - text: text, - anchor: anchor, - rank: scalerank * 100 + localrank, - ), - ); + 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; + } - candidates.sort((a, b) { - final r = a.rank.compareTo(b.rank); - return r != 0 ? r : a.text.length.compareTo(b.text.length); - }); + 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; + } - var drawn = 0; - for (final c in candidates) { - if (drawn >= maxLabels) break; - if (seenTexts.contains(c.text.toLowerCase())) continue; + 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; + } - final tp = TextPainter( - text: TextSpan( - text: c.text, - style: TextStyle( - color: color, - fontSize: fontSize, - fontWeight: fontWeight, - shadows: const [ - Shadow(color: Color(0xF7FFFFFF), blurRadius: 1.4), - ], - height: 1.0, - ), - ), - textDirection: TextDirection.ltr, - maxLines: 1, - ellipsis: '', - )..layout(); + 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 x = c.anchor.dx * scale - tp.width / 2; - final y = c.anchor.dy * scale - tp.height / 2; + 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); + } - if (x < -tp.width || - y < -tp.height || - x > size.width || - y > size.height) { - continue; - } + 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; + } - final labelRect = Rect.fromLTWH( - x, - y, - tp.width, - tp.height, - ).inflate(minSpacing); - if (occupiedRects.any((r) => r.overlaps(labelRect))) continue; + _RoadLabelPlacement? _bestRoadLabelPlacement( + List> geometry, { + required double scale, + required double labelWidth, + }) { + _RoadLabelPlacement? best; - tp.paint(canvas, Offset(x, y)); - occupiedRects.add(labelRect); - seenTexts.add(c.text.toLowerCase()); - drawn++; + 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, + ); } } - drawFromLayer( - 'place_label', - const Color(0xFF1F2636), - 11.2, - fontWeight: FontWeight.w600, - minZoom: 12.5, - maxLabels: zoom >= 15 ? 5 : 2, - minSpacing: zoom >= 15 ? 24 : 30, - allowedClasses: const { - 'city', - 'town', - 'suburb', - 'neighbourhood', - 'borough', - }, - ); + 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) { @@ -659,66 +1582,42 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { return null; } - // ------------------------------------------------------------------------- - // ColorFilter matrices - // ------------------------------------------------------------------------- - - static List _contrastMatrix(double contrast) { - final c = contrast.clamp(0.0, 3.0); - final t = 128.0 * (1.0 - c); - return [c, 0, 0, 0, t, 0, c, 0, 0, t, 0, 0, c, 0, t, 0, 0, 0, 1, 0]; + double? _toDouble(Object? value) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value.trim()); + return null; } - static List _saturationMatrix(double saturation) { - final s = saturation.clamp(0.0, 2.0); - const rw = 0.213; - const gw = 0.715; - const bw = 0.072; - final a = 1 - s; - return [ - a * rw + s, - a * gw, - a * bw, - 0, - 0, - a * rw, - a * gw + s, - a * bw, - 0, - 0, - a * rw, - a * gw, - a * bw + s, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - ]; - } + // strips non-printable and non-latin characters from label text + String _sanitizeLabel(String text) => + text.replaceAll(RegExp(r'[^\x20-\x7E\u00C0-\u024F]'), '').trim(); - // 4x5 colour matrix multiply: result = a * b (both are row-major 4x5). - static List _multiplyMatrices(List a, List b) { - final out = List.filled(20, 0); - for (int row = 0; row < 4; row++) { - for (int col = 0; col < 5; col++) { - double v = 0; - for (int k = 0; k < 4; k++) { - v += a[row * 5 + k] * b[k * 5 + col]; - } - // translation column — just add when col == 4 - if (col == 4) v += a[row * 5 + 4]; - out[row * 5 + col] = v; - } + 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 out; + return false; } - // ------------------------------------------------------------------------- - // CustomPainter boilerplate - // ------------------------------------------------------------------------- + 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; @@ -733,13 +1632,73 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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; } diff --git a/pubspec.lock b/pubspec.lock index 199523a..4610941 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -197,6 +197,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" hive: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b74d06e..5090597 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: go_router: ^14.8.1 http: ^1.6.0 shadcn_flutter: ^0.0.51 + google_fonts: ^6.3.2 dev_dependencies: flutter_test: