diff --git a/lib/main.dart b/lib/main.dart index 949e8a2..1cede9f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:rra_app/pages/map/page.dart'; +import 'package:rra_app/pages/map/tfl/tfl_bus_stop_cache.dart'; import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; import 'package:hive_flutter/hive_flutter.dart'; @@ -9,6 +10,7 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); await Hive.initFlutter(); await HiveTileCache.init(); + await TflBusStopCache.init(); runApp(const MyApp()); } diff --git a/lib/pages/map/constants.dart b/lib/pages/map/constants.dart index 1a285ad..c9e986d 100644 --- a/lib/pages/map/constants.dart +++ b/lib/pages/map/constants.dart @@ -8,13 +8,19 @@ final Color kMapWaterColor = Color(0xFF74B8ED); final Color kMapBuildingFillColor = Color(0xFFFED000); final Color kMapBuildingOutlineColor = Color(0xFFE7B600); +final Color kMapCommercialAreaColor = Color(0xFFF5C87A); +final Color kMapCommercialAreaOutlineColor = Color(0xFFE0A84A); + +final Color kMapParkingColor = Color(0xFFDDE3EE); +final Color kMapParkingOutlineColor = Color(0xFFC2CBDB); + 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 kMapPlaceLabelHaloColor = Color(0xFFA7BFDB); final Color kMapRoadLabelColor = Color(0xFF56657A); final Color kMapRoadLabelHaloColor = Color(0xF2FFFFFF); @@ -24,3 +30,5 @@ final Color kMapRailUnderlayColor = Color(0xFF8A97A8); final Color kMapPoiLabelColor = Color(0xFF032D51); final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF); + +const bool kMapDebugShowTileBoundaries = false; \ No newline at end of file diff --git a/lib/pages/map/page.dart b/lib/pages/map/page.dart index d5a05f1..a66e569 100644 --- a/lib/pages/map/page.dart +++ b/lib/pages/map/page.dart @@ -12,9 +12,13 @@ import 'package:flutter/gestures.dart' import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.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/tfl/tfl_bus_stop_cache.dart'; +import 'package:rra_app/pages/map/tfl/tfl_bus_stop_service.dart'; import 'package:rra_app/pages/map/vector/mapbox_vector_tile_cache.dart'; +import 'package:rra_app/pages/map/vector/mvt_parser.dart' show MvtGeometryType; 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'; @@ -80,6 +84,12 @@ class _MapPageState extends State with TickerProviderStateMixin { Timer? _interactionIdleTimer; MapDebugHit? _debugFeatureHit; + final TflBusStopService _busStopService = TflBusStopService(); + List _busStops = const []; + Timer? _busStopFetchTimer; + // zoom threshold below which we dont bother fetching + static const _busStopMinZoom = 18.0; + late final TilePyramidPainter _pyramidPainter; final List _frameMsWindow = []; @@ -110,6 +120,7 @@ class _MapPageState extends State with TickerProviderStateMixin { TilePyramidCache.clear(); SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings); _interactionIdleTimer?.cancel(); + _busStopFetchTimer?.cancel(); super.dispose(); } @@ -383,6 +394,7 @@ class _MapPageState extends State with TickerProviderStateMixin { ..devicePixelRatio = _devicePixelRatio ..interactionActive = _interactionIdleTimer?.isActive ?? false; _pyramidPainter.markDirty(); + _scheduleBusStopFetch(); } LatLng _segmentMidpoint(LatLng a, LatLng b) { @@ -449,6 +461,139 @@ class _MapPageState extends State with TickerProviderStateMixin { }); } + void _inspectBusStop(BusStop stop) { + final props = { + 'name': stop.name, + if (stop.stopLetter != null) 'stop_letter': stop.stopLetter, + if (stop.towards != null) 'towards': stop.towards, + 'naptan_id': stop.id, + 'lat': stop.position.latitude.toStringAsFixed(6), + 'lon': stop.position.longitude.toStringAsFixed(6), + }; + + setState(() { + _debugFeatureHit = MapDebugHit( + layerName: 'bus_stop', + geometryType: MvtGeometryType.point, + properties: props, + tileZ: 0, + tileX: 0, + tileY: 0, + distance: 0, + ); + _isInspectingFeature = false; + }); + } + + void _scheduleBusStopFetch() { + _busStopFetchTimer?.cancel(); + if (_zoom < _busStopMinZoom) { + if (_busStops.isNotEmpty) setState(() => _busStops = const []); + return; + } + + _busStopFetchTimer = Timer(const Duration(milliseconds: 600), () { + _fetchBusStops(); + }); + } + + Future _fetchBusStops() async { + if (_zoom < _busStopMinZoom || !mounted) return; + + final cacheKey = TflBusStopCache.keyFor(_mapCenter); + + // phase 1: show cached stops immediately + final cached = TflBusStopCache.peek(cacheKey); + if (cached != null && mounted) { + setState(() => _busStops = cached); + } + + // phase 2: fetch from TfL and refresh + final mpp = TflBusStopService.metersPerPixel(_mapCenter.latitude, _zoom); + final halfDiag = (_mapSize.longestSide / 2) * mpp; + final radius = halfDiag.clamp(200, 800).toInt(); + + try { + final stops = await _busStopService.fetchNearby( + _mapCenter, + radius: radius, + ); + if (!mounted) return; + setState(() => _busStops = stops); + await TflBusStopCache.store(cacheKey, stops); + } catch (_) { + // silently swallow + } + } + + List _buildBusStopWidgets() { + if (_zoom < _busStopMinZoom || _busStops.isEmpty) return const []; + + // first pass: collect visible stops + their raw screen positions + final visible = []; + final positions = []; + + for (final stop in _busStops) { + final screen = _latLngToScreen(stop.position); + if (screen.dx < -20 || screen.dx > _mapSize.width + 20) continue; + if (screen.dy < -20 || screen.dy > _mapSize.height + 20) continue; + visible.add(stop); + positions.add(screen); + } + + // repulsion pass — push overlapping markers apart in screen space + // only affects rendering positions, not the underlying LatLng data + const minDist = 28.0; + const iterations = 6; + + for (var iter = 0; iter < iterations; iter++) { + for (var i = 0; i < positions.length; i++) { + for (var j = i + 1; j < positions.length; j++) { + final dx = positions[j].dx - positions[i].dx; + final dy = positions[j].dy - positions[i].dy; + final dist = math.sqrt(dx * dx + dy * dy); + + if (dist >= minDist || dist < 0.001) continue; + + final overlap = (minDist - dist) / 2; + final nx = dx / dist; + final ny = dy / dist; + + positions[i] = Offset( + positions[i].dx - nx * overlap, + positions[i].dy - ny * overlap, + ); + positions[j] = Offset( + positions[j].dx + nx * overlap, + positions[j].dy + ny * overlap, + ); + } + } + } + + final widgets = []; + for (var i = 0; i < visible.length; i++) { + final stop = visible[i]; + final screen = positions[i]; + widgets.add( + Positioned( + left: screen.dx - 13, + top: screen.dy - 13, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _inspectBusStop(stop), + child: Tooltip( + message: stop.name, + child: _BusStopMarker(stop: stop), + ), + ), + ), + ); + } + + return widgets; + } + List _buildPointWidgets() { final widgets = []; for (var i = 0; i < _points.length; i++) { @@ -632,6 +777,7 @@ class _MapPageState extends State with TickerProviderStateMixin { ), ), ), + ..._buildBusStopWidgets(), ..._buildPointWidgets(), ..._buildInsertHandles(), ], @@ -701,6 +847,25 @@ class _MapPageState extends State with TickerProviderStateMixin { frameMs: _displayFrameMs, ), ), + const SizedBox(height: 6), + GestureDetector( + onTap: () { + TilePyramidCache.clear(); + MapboxVectorTileCache.clear(); + _pyramidPainter.markDirty(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'Clear Cache', + style: TextStyle(color: Colors.white, fontSize: 11), + ), + ), + ), const SizedBox(height: 8), _DebugInspectPanel( hit: _debugFeatureHit, @@ -711,13 +876,13 @@ class _MapPageState extends State with TickerProviderStateMixin { ), ), ), - SafeArea( - child: Align( - alignment: Alignment.bottomLeft, - child: Padding( - padding: const EdgeInsets.all(10), - child: const IgnorePointer(child: _HudCornerNotice()), - ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: _MapFooter( + mapCenter: _mapCenter, + zoom: _zoom, ), ), ], @@ -765,30 +930,85 @@ class _RoutePainter extends CustomPainter { } } -class _HudCornerNotice extends StatelessWidget { - const _HudCornerNotice(); +class _MapFooter extends StatelessWidget { + const _MapFooter({required this.mapCenter, required this.zoom}); + + final LatLng mapCenter; + final double zoom; @override Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(4), + final borderColor = const Color(0xFFE5E7EB); + final bg = Colors.white; + final muted = const Color(0xFF6B7280); + + TextStyle footerStyle() => GoogleFonts.ibmPlexMono( + fontSize: 11, + height: 1, + fontWeight: FontWeight.w600, + color: muted, + ); + + Widget txt(String s) => Transform.translate( + offset: const Offset(0, -1), + child: Text(s, style: footerStyle()), + ); + + Widget divider() => const Padding( + padding: EdgeInsets.symmetric(horizontal: 5), + child: SizedBox( + height: 12, + child: VerticalDivider(width: 1, color: Color(0xFFD1D5DB)), ), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Text( - '© 2026 IMBENJI.NET LTD - Roadbound Map Maker\n' - 'Alpha v2602-7a - This is a public alpha release;\n' - 'Expect bugs, missing features, and breaking changes.', - style: TextStyle( - fontSize: 12, - height: 1.35, - fontWeight: FontWeight.w700, - color: Color(0xFF525252), - fontFamily: 'monospace', + ); + + final latStr = mapCenter.latitude.toStringAsFixed(4); + final lngStr = mapCenter.longitude.toStringAsFixed(4); + + Widget viewportBlock() { + return Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + txt("$latStr, $lngStr"), + divider(), + txt("Zoom: ${zoom.toStringAsFixed(1)}"), + ], + ); + } + + + Widget copyrightBlock() { + return txt("© 2026 IMBENJI.NET LTD - Roadbound"); + } + + Widget versionBlock() { + return txt("Alpha v2602-7a"); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + width: double.infinity, + decoration: BoxDecoration( + color: bg, + border: Border(top: BorderSide(color: borderColor, width: 1)), + ), + child: Row( + children: [ + Expanded(child: Row(children: [copyrightBlock()])), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [viewportBlock()], + ), ), - ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [versionBlock()], + ), + ), + ], ), ); } @@ -991,3 +1211,42 @@ class _StatusPanel extends StatelessWidget { ); } } + +class _BusStopMarker extends StatelessWidget { + const _BusStopMarker({required this.stop}); + + final BusStop stop; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 26, + height: 26, + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFD50000), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + boxShadow: const [ + BoxShadow( + color: Color(0x44000000), + blurRadius: 3, + offset: Offset(0, 1), + ), + ], + ), + child: Center( + child: Text( + stop.stopLetter ?? 'B', + style: TextStyle( + fontSize: stop.stopLetter != null && stop.stopLetter!.length > 1 ? 9 : 11, + fontWeight: FontWeight.w900, + color: Colors.white, + height: 1, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/map/tfl/tfl_bus_stop_cache.dart b/lib/pages/map/tfl/tfl_bus_stop_cache.dart new file mode 100644 index 0000000..d316ab5 --- /dev/null +++ b/lib/pages/map/tfl/tfl_bus_stop_cache.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; + +import 'package:hive/hive.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:rra_app/pages/map/tfl/tfl_bus_stop_service.dart'; + +class TflBusStopCache { + TflBusStopCache._(); + + static const _boxName = 'tfl_bus_stop_cache_v1'; + static const _staleAfter = Duration(hours: 12); + static const _maxEntries = 512; + + static Future init() async { + if (!Hive.isBoxOpen(_boxName)) { + await Hive.openBox(_boxName); + } + } + + static Box? get _box { + try { + if (!Hive.isBoxOpen(_boxName)) return null; + return Hive.box(_boxName); + } catch (_) { + return null; + } + } + + // rounds to ~1km grid cells so nearby pans reuse the same cache entry + static String keyFor(LatLng center) { + final lat = (center.latitude * 100).round() / 100; + final lon = (center.longitude * 100).round() / 100; + return '$lat,$lon'; + } + + static List? peek(String key) { + final box = _box; + if (box == null) return null; + + final entry = box.get(key); + if (entry is! Map) return null; + + final ts = entry['ts']; + if (ts is! int) return null; + + final age = DateTime.now().millisecondsSinceEpoch - ts; + if (age > _staleAfter.inMilliseconds) return null; + + final raw = entry['stops']; + if (raw is! List) return null; + + try { + return _decodeStops(raw); + } catch (_) { + return null; + } + } + + static Future store(String key, List stops) async { + final box = _box; + if (box == null) return; + + await box.put(key, { + 'ts': DateTime.now().millisecondsSinceEpoch, + 'stops': _encodeStops(stops), + }); + + await _pruneIfNeeded(box); + } + + + static List> _encodeStops(List stops) { + return stops.map((s) => { + 'id': s.id, + 'name': s.name, + 'lat': s.position.latitude, + 'lon': s.position.longitude, + if (s.stopLetter != null) 'stopLetter': s.stopLetter, + if (s.towards != null) 'towards': s.towards, + }).toList(); + } + + static List _decodeStops(List raw) { + final result = []; + for (final item in raw) { + if (item is! Map) continue; + final lat = (item['lat'] as num?)?.toDouble(); + final lon = (item['lon'] as num?)?.toDouble(); + if (lat == null || lon == null) continue; + result.add(BusStop( + id: item['id'] as String? ?? '', + name: item['name'] as String? ?? '', + position: LatLng(lat, lon), + stopLetter: item['stopLetter'] as String?, + towards: item['towards'] as String?, + )); + } + return result; + } + + static Future _pruneIfNeeded(Box box) async { + final overflow = box.length - _maxEntries; + if (overflow <= 0) return; + + final entries = box.keys.map((k) { + final v = box.get(k); + final ts = (v is Map && v['ts'] is int) ? v['ts'] as int : 0; + return MapEntry(k, ts); + }).toList() + ..sort((a, b) => a.value.compareTo(b.value)); + + for (var i = 0; i < overflow; i++) { + await box.delete(entries[i].key); + } + } +} diff --git a/lib/pages/map/tfl/tfl_bus_stop_service.dart b/lib/pages/map/tfl/tfl_bus_stop_service.dart new file mode 100644 index 0000000..6e2fd02 --- /dev/null +++ b/lib/pages/map/tfl/tfl_bus_stop_service.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; + +class BusStop { + const BusStop({ + required this.id, + required this.name, + required this.position, + this.stopLetter, + this.towards, + }); + + final String id; + final String name; + final LatLng position; + + // the letter on the physical bus stop flag e.g. "A", "ZH" + final String? stopLetter; + + // may be null for parent stops + final String? towards; +} + +class TflBusStopService { + TflBusStopService(); + + static const _baseUrl = 'https://api.tfl.gov.uk'; + + // max radius the TfL API accepts is 1600m but in practice + // we clamp to 800 so we dont get swamped at low zoom + static const _maxRadius = 800; + + Future> fetchNearby(LatLng center, {int radius = 600}) async { + final r = radius.clamp(0, _maxRadius); + + final uri = Uri.parse( + '$_baseUrl/StopPoint' + '?stopTypes=NaptanPublicBusCoachTram' + '&lat=${center.latitude}' + '&lon=${center.longitude}' + '&radius=$r' + '&modes=bus', + ); + + final response = await http.get(uri, headers: { + 'Accept': 'application/json', + }); + + if (response.statusCode != 200) { + throw Exception('TfL StopPoint request failed (${response.statusCode})'); + } + + final body = jsonDecode(response.body) as Map; + final stopPoints = body['stopPoints']; + if (stopPoints is! List) return const []; + + final stops = []; + for (final raw in stopPoints) { + if (raw is! Map) continue; + + final lat = (raw['lat'] as num?)?.toDouble(); + final lon = (raw['lon'] as num?)?.toDouble(); + if (lat == null || lon == null) continue; + + final id = raw['id'] as String? ?? ''; + final name = raw['commonName'] as String? ?? ''; + final stopLetter = raw['stopLetter'] as String?; + + String? towards; + final addInfo = raw['additionalProperties']; + if (addInfo is List) { + for (final prop in addInfo) { + if (prop is Map && prop['key'] == 'Towards') { + towards = prop['value'] as String?; + break; + } + } + } + + stops.add(BusStop( + id: id, + name: name, + position: LatLng(lat, lon), + stopLetter: stopLetter, + towards: towards, + )); + } + + return stops; + } + + // rough haversine distance in metres, good enough for radius estimation + static double metersPerPixel(double lat, double zoom) { + const earthCircumference = 40075016.686; + final latRad = lat * math.pi / 180.0; + return (earthCircumference * math.cos(latRad)) / + (256 * math.pow(2, zoom)); + } +} diff --git a/lib/pages/map/vector/mvt_parser.dart b/lib/pages/map/vector/mvt_parser.dart index a81473c..2afbba3 100644 --- a/lib/pages/map/vector/mvt_parser.dart +++ b/lib/pages/map/vector/mvt_parser.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'dart:ui'; @@ -316,7 +317,7 @@ class _PbfReader { String readString() { final bytes = readBytes(); - return String.fromCharCodes(bytes); + return utf8.decode(bytes, allowMalformed: true); } double readFloat32() { diff --git a/lib/pages/map/vector/tile_pyramid_cache.dart b/lib/pages/map/vector/tile_pyramid_cache.dart index f637d5b..0a4703c 100644 --- a/lib/pages/map/vector/tile_pyramid_cache.dart +++ b/lib/pages/map/vector/tile_pyramid_cache.dart @@ -28,7 +28,7 @@ class TilePyramidCache { TilePyramidCache._(); static const int _maxEntries = 384; - static const int _maxApproxBytes = 256 * 1024 * 1024; + static const int _maxApproxBytes = 768 * 1024 * 1024; // Cap concurrent renders to avoid saturating the main isolate with // synchronous canvas recording. I/O (network/Hive) is still concurrent diff --git a/lib/pages/map/vector/tile_pyramid_painter.dart b/lib/pages/map/vector/tile_pyramid_painter.dart index f911d66..af8d51c 100644 --- a/lib/pages/map/vector/tile_pyramid_painter.dart +++ b/lib/pages/map/vector/tile_pyramid_painter.dart @@ -411,71 +411,500 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { ..isAntiAlias = true, ); - _drawPolygonLayer( - canvas, + final commands = <_DrawCommand>[]; + + // landuse retail + _collectPolygonCommands( + commands, size, tile, layerNames: const {'landuse'}, fillColor: kMapLanduseColor, allowedTypes: const {'retail'}, tileZoom: tileZoom, + baseZ: 20.0, ); - _drawPolygonLayer( - canvas, + + // landuse park/meadow/grass + _collectPolygonCommands( + commands, + size, + tile, + layerNames: const {'landuse'}, + fillColor: kMapGrassColor, + allowedTypes: const {'park', 'meadow', 'garden', 'wood', 'sports_centre', 'recreation_ground'}, + allowedClasses: const {'grass', 'cemetery', 'agriculture'}, + tileZoom: tileZoom, + baseZ: 25.0, + ); + + // park layer + _collectPolygonCommands( + commands, size, tile, layerNames: const {'park'}, fillColor: kMapGrassColor, tileZoom: tileZoom, + baseZ: 25.0, ); - _drawPolygonLayer( - canvas, + + // water — above grass so rivers/lakes cut through parks + _collectPolygonCommands( + commands, size, tile, layerNames: const {'water'}, fillColor: kMapWaterColor, tileZoom: tileZoom, + baseZ: 30.0, ); - _drawPolygonLayer( - canvas, + + // airport/aerodrome + _collectPolygonCommands( + commands, size, tile, layerNames: const {'landuse'}, - fillColor: kMapGrassColor, - allowedTypes: const {'park', 'meadow'}, - allowedClasses: const {'grass'}, + fillColor: kMapParkingColor, + strokeColor: kMapParkingOutlineColor, + strokeWorldUnits: 2.0, tileZoom: tileZoom, + allowedClasses: const {'airport'}, + baseZ: 10.0, ); - _drawPolygonLayer( - canvas, + + // school grounds + _collectPolygonCommands( + commands, size, tile, - layerNames: const {'park'}, - fillColor: kMapGrassColor, + layerNames: const {'landuse'}, + fillColor: kMapPathFillColour, tileZoom: tileZoom, + allowedClasses: const {'school'}, + baseZ: 35.0, ); - _drawRoadLayer(canvas, size, tile, tileZoom: tileZoom); - _drawPolygonLayer( - canvas, + + // parking areas + _collectPolygonCommands( + commands, + size, + tile, + layerNames: const {'landuse'}, + fillColor: kMapParkingColor, + strokeColor: kMapParkingOutlineColor, + strokeWorldUnits: 2.0, + tileZoom: tileZoom, + allowedTypes: const {'parking'}, + allowedClasses: const {'parking'}, + baseZ: 35.0, + ); + + // commercial areas (landuse) + _collectPolygonCommands( + commands, + size, + tile, + layerNames: const {'landuse'}, + fillColor: kMapCommercialAreaColor, + strokeColor: kMapCommercialAreaOutlineColor, + strokeWorldUnits: 2.0, + tileZoom: tileZoom, + allowedTypes: const {'commercial_area'}, + allowedClasses: const {'commercial_area'}, + baseZ: 35.0, + ); + + // buildings + _collectPolygonCommands( + commands, size, tile, layerNames: const {'building'}, fillColor: kMapBuildingFillColor, strokeColor: kMapBuildingOutlineColor, strokeWorldUnits: 3.5, - strokeCap: StrokeCap.butt, + strokeCap: StrokeCap.round, strokeJoin: StrokeJoin.miter, strokeAntiAlias: false, - allowedTypes: const {'retail', 'shelter', 'stadium'}, + allowedTypes: const {'retail', 'shelter', 'stadium', 'school', 'university', 'college', 'hospital', 'church', 'civic', 'train_station'}, excludeExtruded: true, - allowExtrudedTypes: const {'stadium'}, - minHeight: 4.1, + allowExtrudedTypes: const {'stadium', 'school', 'university', 'college', 'hospital', 'church', 'civic', 'train_station'}, + minHeight: 2.5, tileZoom: tileZoom, + baseZ: 200.0, ); - _drawRoadLabels(canvas, size, tile, tileZoom: tileZoom); - _drawPoiLabels(canvas, size, tile, tileZoom: tileZoom); - _drawPlaceLabels(canvas, size, tile, tileZoom: tileZoom); - _drawRasterTileBoundary(canvas, size); + + _collectRoadCommands(commands, size, tile, tileZoom: tileZoom); + + commands.sort((a, b) => a.z.compareTo(b.z)); + for (final cmd in commands) { + cmd.draw(canvas); + } + + if (kMapDebugShowTileBoundaries) _drawRasterTileBoundary(canvas, size); + } + + static String _epKey(Offset p) => '${p.dx.round()},${p.dy.round()}'; + + double _featureLayerProp(MvtFeature feature) { + return (_toDouble(feature.properties['layer']) ?? 0.0).clamp(-5.0, 5.0); + } + + void _collectPolygonCommands( + List<_DrawCommand> commands, + Size size, + MvtTile tile, { + required Set layerNames, + required Color fillColor, + Color? strokeColor, + double strokeWorldUnits = 0.0, + StrokeCap strokeCap = StrokeCap.round, + StrokeJoin strokeJoin = StrokeJoin.miter, + bool strokeAntiAlias = true, + Set? allowedTypes, + Set? allowedClasses, + bool excludeExtruded = false, + Set? allowExtrudedTypes, + double? minHeight, + required double tileZoom, + required double baseZ, + }) { + for (final entry in tile.layers.entries) { + if (!layerNames.contains(entry.key)) continue; + final layer = entry.value; + final scale = size.width / layer.extent; + + Paint? stroke; + + for (final feature in layer.features) { + if (feature.type != MvtGeometryType.polygon) continue; + + final featureType = (feature.properties['type'] ?? '').toString().toLowerCase(); + final featureClass = (feature.properties['class'] ?? '').toString().toLowerCase(); + final extrude = _isTruthy(feature.properties['extrude']); + final height = _toDouble(feature.properties['height']); + final minFeatureHeight = feature.properties['min_height']; + + if (allowedTypes != null && + allowedTypes.isNotEmpty && + (allowedClasses == null || allowedClasses.isEmpty) && + !allowedTypes.contains(featureType)) { + continue; + } + if (allowedClasses != null && + allowedClasses.isNotEmpty && + (allowedTypes == null || allowedTypes.isEmpty) && + !allowedClasses.contains(featureClass)) { + continue; + } + if (allowedTypes != null && + allowedTypes.isNotEmpty && + allowedClasses != null && + allowedClasses.isNotEmpty && + !allowedTypes.contains(featureType) && + !allowedClasses.contains(featureClass)) { + continue; + } + if (excludeExtruded && + extrude && + (allowExtrudedTypes == null || !allowExtrudedTypes.contains(featureType))) { + continue; + } + if (minHeight != null && height != null && height < minHeight && minFeatureHeight == null) { + continue; + } + + final z = baseZ + _featureLayerProp(feature) * 0.1; + + stroke ??= strokeColor == null + ? null + : (Paint() + ..color = strokeColor + ..style = PaintingStyle.stroke + ..strokeWidth = _worldStrokePx( + strokeWorldUnits, + extent: layer.extent, + rasterSize: size.width, + tileZoom: tileZoom, + ) + ..strokeCap = strokeCap + ..strokeJoin = strokeJoin + ..isAntiAlias = strokeAntiAlias); + + final fill = Paint() + ..color = fillColor + ..style = PaintingStyle.fill + ..isAntiAlias = true; + + final strokeSnap = stroke; + final rings = feature.geometry; + + commands.add(_DrawCommand( + z: z, + draw: (c) { + for (final ring in rings) { + if (ring.length < 3) continue; + final path = _scaledPath(ring, scale, close: true); + c.drawPath(path, fill); + if (strokeSnap != null) c.drawPath(path, strokeSnap); + } + }, + )); + } + } + } + + + void _collectRoadCommands( + List<_DrawCommand> commands, + Size size, + MvtTile tile, { + required double tileZoom, + }) { + final roadLayer = tile.layers['road']; + if (roadLayer == null) return; + + final scale = size.width / roadLayer.extent; + + // collect all styled line features upfront for the underlay punch-out pass + final styledLines = <({MvtFeature feature, _RoadStyle style, double layerProp, double sortKey, String roadClass})>[]; + for (final feature in roadLayer.features) { + if (feature.type != MvtGeometryType.lineString) continue; + final style = _roadStyleFor(feature, tileZoom: tileZoom); + if (style == null) continue; + styledLines.add(( + feature: feature, + style: style, + layerProp: _featureLayerProp(feature), + sortKey: _toDouble(feature.properties['sort_key']) ?? 0.0, + roadClass: (feature.properties['class'] ?? '').toString(), + )); + } + + // build junction set: endpoints shared by 2+ polylines across the whole tile + // only junction endpoints get round caps; terminal (dead-end) endpoints stay flat + final epCount = {}; + for (final f in styledLines) { + for (final line in f.feature.geometry) { + if (line.length < 2) continue; + final sk = _epKey(line.first); + final ek = _epKey(line.last); + epCount[sk] = (epCount[sk] ?? 0) + 1; + epCount[ek] = (epCount[ek] ?? 0) + 1; + } + } + final junctions = { + for (final e in epCount.entries) + if (e.value > 1) e.key, + }; + + // group by (layerProp, class) for correct z placement, but the punch-out erase + // uses all features of the same class across all layerProps + final byGroup = <(double, String), List<({MvtFeature feature, _RoadStyle style, double layerProp, double sortKey})>>{}; + for (final f in styledLines) { + (byGroup[(f.layerProp, f.roadClass)] ??= []).add((feature: f.feature, style: f.style, layerProp: f.layerProp, sortKey: f.sortKey)); + } + + // pre-build erase geometry per class — butt caps, circles at junctions only + final eraseByClass = > lines, Paint paint, List junctionPts, double radius, double terminalInset})>>{}; + for (final f in styledLines) { + if (f.style.outlineOnly || f.style.fillColor == kMapPathFillColour) continue; + final uR = _worldStrokePx(f.style.underlayWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom); + final cR = _worldStrokePx(f.style.coreWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom); + final erasePaint = Paint() + ..color = const Color(0xFFFFFFFF) + ..style = PaintingStyle.stroke + ..strokeWidth = cR + ..strokeCap = StrokeCap.butt + ..strokeJoin = StrokeJoin.round + ..blendMode = BlendMode.dstOut + ..isAntiAlias = true; + + final junctionPts = []; + for (final line in f.feature.geometry) { + if (line.length < 2) continue; + if (junctions.contains(_epKey(line.first))) junctionPts.add(line.first); + if (junctions.contains(_epKey(line.last))) junctionPts.add(line.last); + } + + final terminalInset = (uR - cR) / 2 / scale; + (eraseByClass[f.roadClass] ??= []).add((lines: f.feature.geometry, paint: erasePaint, junctionPts: junctionPts, radius: cR / 2, terminalInset: terminalInset)); + } + + for (final entry in byGroup.entries) { + final lp = entry.key.$1; + final features = entry.value; + final roadClass = entry.key.$2; + + final underlayDrawData = <({List> lines, Paint paint, List junctionPts, double radius})>[]; + final coreDrawData = <({List> lines, Paint paint, List junctionPts, double radius, double z, double terminalInset})>[]; + + for (final f in features) { + final junctionPts = []; + for (final line in f.feature.geometry) { + if (line.length < 2) continue; + if (junctions.contains(_epKey(line.first))) junctionPts.add(line.first); + if (junctions.contains(_epKey(line.last))) junctionPts.add(line.last); + } + + final uR = _worldStrokePx(f.style.underlayWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom); + final underlayPaint = Paint() + ..color = f.style.outlineOnly ? f.style.fillColor : f.style.underlayColor + ..style = PaintingStyle.stroke + ..strokeWidth = uR + ..strokeCap = StrokeCap.butt + ..strokeJoin = StrokeJoin.round + ..isAntiAlias = true; + + underlayDrawData.add((lines: f.feature.geometry, paint: underlayPaint, junctionPts: junctionPts, radius: uR / 2)); + + final skipOverlay = f.style.outlineOnly || f.style.fillColor == kMapPathFillColour; + if (!skipOverlay) { + final railBias = roadClass.contains('rail') ? -10.0 : 0.0; + final coreZ = 50.0 + f.layerProp * 30.0 + f.sortKey * 0.001 + 1.0 + railBias; + + final cR = _worldStrokePx(f.style.coreWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom); + final corePaint = Paint() + ..color = f.style.fillColor + ..style = PaintingStyle.stroke + ..strokeWidth = cR + ..strokeCap = StrokeCap.butt + ..strokeJoin = StrokeJoin.round + ..isAntiAlias = true; + + // inset amount in extent coords so the underlay shows as a flat border at terminal ends + final terminalInset = (uR - cR) / 2 / scale; + + coreDrawData.add((lines: f.feature.geometry, paint: corePaint, junctionPts: junctionPts, radius: cR / 2, z: coreZ, terminalInset: terminalInset)); + } + } + + // erase with all same-class cores (across all layerProps) + final classErase = eraseByClass[roadClass] ?? []; + + final railBias = roadClass.contains('rail') ? -10.0 : 0.0; + final underlayZ = 50.0 + lp * 30.0 + railBias; + commands.add(_DrawCommand( + z: underlayZ, + draw: (c) { + 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); + } + } + + for (final d in classErase) { + for (final line in d.lines) { + if (line.length < 2) continue; + final startIsTerminal = !junctions.contains(_epKey(line.first)); + final endIsTerminal = !junctions.contains(_epKey(line.last)); + final trimmed = _trimPolyline( + line, + startIsTerminal ? d.terminalInset : 0.0, + endIsTerminal ? d.terminalInset : 0.0, + ); + if (trimmed == null || trimmed.length < 2) continue; + c.drawPath(_scaledPath(trimmed, scale), d.paint); + } + final circlePaint = Paint() + ..color = d.paint.color + ..style = PaintingStyle.fill + ..blendMode = BlendMode.dstOut + ..isAntiAlias = true; + for (final pt in d.junctionPts) { + c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint); + } + } + + c.restore(); + }, + )); + + for (final d in coreDrawData) { + final lines = d.lines; + final paint = d.paint; + final jPts = d.junctionPts; + final r = d.radius; + final inset = d.terminalInset; + commands.add(_DrawCommand( + z: d.z, + draw: (c) { + for (final line in lines) { + if (line.length < 2) continue; + final startIsTerminal = !junctions.contains(_epKey(line.first)); + final endIsTerminal = !junctions.contains(_epKey(line.last)); + final trimmed = _trimPolyline( + line, + startIsTerminal ? inset : 0.0, + endIsTerminal ? inset : 0.0, + ); + if (trimmed == null || trimmed.length < 2) continue; + c.drawPath(_scaledPath(trimmed, scale), paint); + } + final circlePaint = Paint() + ..color = paint.color + ..style = PaintingStyle.fill + ..isAntiAlias = true; + for (final pt in jPts) { + c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), r, circlePaint); + } + }, + )); + } + } + + for (final feature in roadLayer.features) { + final style = _roadStyleFor(feature, tileZoom: tileZoom); + if (style == null) continue; + + final layerProp = _featureLayerProp(feature); + final sortKey = _toDouble(feature.properties['sort_key']) ?? 0.0; + + if (feature.type != MvtGeometryType.polygon) continue; + + final z = 22.0 + layerProp * 30.0; + final fill = Paint() + ..color = style.fillColor + ..style = PaintingStyle.fill + ..isAntiAlias = true; + final outline = Paint() + ..color = style.underlayColor + ..style = PaintingStyle.stroke + ..strokeWidth = _worldStrokePx( + math.max(style.coreWorldUnits * 0.22, 3.0), + extent: roadLayer.extent, + rasterSize: size.width, + tileZoom: tileZoom, + ) + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..isAntiAlias = true; + + final rings = feature.geometry; + commands.add(_DrawCommand( + z: z, + draw: (c) { + for (final ring in rings) { + if (ring.length < 3) continue; + final path = _scaledPath(ring, scale, close: true); + c.drawPath(path, fill); + c.drawPath(path, outline); + } + }, + )); + } } void _drawRasterTileBoundary(Canvas canvas, Size size) { @@ -498,7 +927,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { Color? strokeColor, double strokeWorldUnits = 0.0, StrokeCap strokeCap = StrokeCap.round, - StrokeJoin strokeJoin = StrokeJoin.round, + StrokeJoin strokeJoin = StrokeJoin.miter, bool strokeAntiAlias = true, Set? allowedTypes, Set? allowedClasses, @@ -629,7 +1058,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { tileZoom: tileZoom, ) ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round + ..strokeJoin = StrokeJoin.miter ..isAntiAlias = true; for (final ring in road.feature.geometry) { @@ -652,7 +1081,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { tileZoom: tileZoom, ) ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round + ..strokeJoin = StrokeJoin.miter ..isAntiAlias = true; for (final line in road.feature.geometry) { @@ -674,7 +1103,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { tileZoom: tileZoom, ) ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round + ..strokeJoin = StrokeJoin.miter ..isAntiAlias = true; for (final line in road.feature.geometry) { @@ -710,14 +1139,60 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { roadType.contains('train'); final isPlatform = roadClass.contains('platform') || roadType.contains('platform'); - final isService = - roadClass.contains('service') || roadType.contains('service'); + final isService = roadClass == 'service' && roadType == 'service'; + final isServiceSubtype = + roadClass == 'service' && roadType.startsWith('service:'); final isUnderground = structure.contains('tunnel') || structure.contains('underground'); + if (isPlatform && feature.type == MvtGeometryType.lineString) return null; + if (isServiceSubtype) { + return _RoadStyle( + sortOrder: 0, + coreWorldUnits: 7.4, + underlayWorldUnits: 11.6, + fillColor: kMapPathFillColour, + underlayColor: kMapBackgroundColor, + outlineOnly: true, + ); + } + if (isService) { + return _RoadStyle( + sortOrder: 0, + coreWorldUnits: 7.4, + underlayWorldUnits: 11.6, + fillColor: kMapPathFillColour, + underlayColor: kMapBackgroundColor, + outlineOnly: true, + ); + } + if (roadClass == 'aerialway' || roadClass.startsWith('aerialway')) { + const allowedAerialwayTypes = {'aerialway:gondola', 'aerialway:cable_car', 'aerialway:funicular'}; + if (!allowedAerialwayTypes.contains(roadType)) return null; + return _RoadStyle( + sortOrder: 0, + coreWorldUnits: 3.5, + underlayWorldUnits: 5.5, + fillColor: kMapRailFillColor, + underlayColor: kMapRailUnderlayColor, + outlineOnly: false, + ); + } + if (roadClass == 'ferry' || roadType == 'ferry') return null; + if (roadClass == 'golf' || roadType == 'hole') return null; + if (roadType == 'minor_rail') return null; + final layerPropVal = (_toDouble(feature.properties['layer']) ?? 0.0); if (isRailLike && isUnderground) return null; - if (isPlatform) return null; - if (isService) return null; if (isRailLike) { + if (isUnderground) { + return _RoadStyle( + sortOrder: 0, + coreWorldUnits: 3.5, + underlayWorldUnits: 5.5, + fillColor: kMapRailUnderlayColor, + underlayColor: kMapBackgroundColor, + outlineOnly: false, + ); + } return _RoadStyle( sortOrder: 0, coreWorldUnits: 3.5, @@ -744,7 +1219,8 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { roadClass.contains('track') || roadType.contains('track') || roadClass.contains('bridleway') || - roadType.contains('bridleway'); + roadType.contains('bridleway') || + roadType.contains('trail'); final isFootwayPath = isFootway && isPathClass; final isPavedPathLike = surface.contains('paved') && isPathLike; if (isFootwayPath) return null; @@ -828,6 +1304,18 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { ); } + final isOneway = _isTruthy(feature.properties['oneway']); + if (isTrunkPrimary && isOneway && !isMotorway) { + style = _RoadStyle( + sortOrder: style.sortOrder, + coreWorldUnits: style.coreWorldUnits * 0.75, + underlayWorldUnits: style.underlayWorldUnits * 0.75, + fillColor: style.fillColor, + underlayColor: style.underlayColor, + outlineOnly: style.outlineOnly, + ); + } + if (!isLink) return style; return _RoadStyle( @@ -851,6 +1339,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 'stadium', 'pitch', 'allotments', + 'supermarket', }; void _drawAllPlaceLabels(Canvas canvas) { @@ -899,11 +1388,23 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { .toLowerCase(); if (!_allowedPlaceClasses.contains(featureClass)) continue; - final text = _sanitizeLabel( + final capital = (_toDouble(feature.properties['capital']) ?? 0).toInt(); + final isCapital = capital >= 2; + + final symbolrank = (_toDouble(feature.properties['symbolrank']) ?? 99).toInt(); + if (symbolrank > 15 && zoom <= 14.0) continue; + + if (!isCapital) { + if (zoom < 12.5 && featureClass == 'settlement_subdivision') continue; + if (zoom < 12.5 && featureClass == 'settlement') continue; + } + + final rawText = _sanitizeLabel( (feature.properties['name_en'] ?? feature.properties['name'] ?? '') .toString() .trim()); - if (text.isEmpty || text.length > 30) continue; + if (rawText.isEmpty || rawText.length > 30) continue; + final text = rawText; final anchor = feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty @@ -916,7 +1417,6 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { tileTop + anchor.dy * scale, ); - final symbolrank = _toRank(feature.properties['symbolrank']) ?? 99; candidates.add(( text: text, featureClass: featureClass, @@ -945,7 +1445,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final isSettlement = c.featureClass == 'settlement'; final fontSize = isSettlement ? 22.0 : 15.0; - final fontWeight = isSettlement ? FontWeight.w700 : FontWeight.w600; + final fontWeight = isSettlement ? FontWeight.w800 : FontWeight.w800; final painter = TextPainter( text: TextSpan( @@ -954,8 +1454,11 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { color: kMapPlaceLabelColor, fontSize: fontSize, fontWeight: fontWeight, - letterSpacing: isSettlement ? 2.4 : 1.4, + letterSpacing: isSettlement ? 3.2 : 2.0, height: 1.0, + shadows: [ + Shadow(color: kMapPlaceLabelHaloColor, blurRadius: 6), + ], ), ), textDirection: TextDirection.ltr, @@ -1004,12 +1507,16 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { if (!_areaPoiClasses.contains(featureClass)) continue; - final text = _sanitizeLabel( + final rawText = _sanitizeLabel( (feature.properties['name_en'] ?? feature.properties['name'] ?? '') .toString() .trim()); - if (text.isEmpty || text.length > 30) continue; - if (!seenTexts.add(text.toLowerCase())) continue; + if (rawText.isEmpty || rawText.length > 30) continue; + if (!seenTexts.add(rawText.toLowerCase())) continue; + + final symbolrank = feature.properties['symbolrank']; + final sizerank = feature.properties['sizerank']; + final text = '$rawText [sr:$symbolrank sz:$sizerank]'; final anchor = feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty @@ -1025,10 +1532,10 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { fontSize: 10.0, fontWeight: FontWeight.w500, fontStyle: FontStyle.italic, - shadows: [ - Shadow(color: kMapPoiLabelHaloColor, blurRadius: 1.8), - ], height: 1.0, + shadows: [ + Shadow(color: kMapPoiLabelHaloColor, blurRadius: 4), + ], ), ), textDirection: TextDirection.ltr, @@ -1046,12 +1553,7 @@ 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)); @@ -1126,7 +1628,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final isSettlement = candidate.featureClass == 'settlement'; final fontSize = isSettlement ? 22.0 : 15.0; - final fontWeight = isSettlement ? FontWeight.w700 : FontWeight.w600; + final fontWeight = isSettlement ? FontWeight.w800 : FontWeight.w800; final painter = TextPainter( text: TextSpan( @@ -1135,10 +1637,9 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { color: kMapPlaceLabelColor, fontSize: fontSize, fontWeight: fontWeight, - letterSpacing: isSettlement ? 2.4 : 1.4, + letterSpacing: isSettlement ? 3.2 : 2.0, shadows: [ - Shadow(color: kMapPlaceLabelHaloColor, blurRadius: 2.5), - Shadow(color: kMapPlaceLabelHaloColor, blurRadius: 2.5), + Shadow(color: kMapPlaceLabelHaloColor, blurRadius: 6), ], height: 1.0, ), @@ -1177,7 +1678,8 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { // dont get clipped at tile boundaries void _drawAllRoadLabels(Canvas canvas) { final z = activeTileZoom; - if (zoom < 16.5) return; + if (zoom < 15.0) return; + final majorRoadsOnly = zoom < 16.5; final factor = math.pow(2, zoom - z).toDouble(); final maxIndex = 1 << z; @@ -1191,14 +1693,13 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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; + // collect all candidates first so we can sort by path length and always + // pick the longest visible segment per name — stable across panning + final candidates = <_RoadLabelCandidate>[]; + for (var tx = minTX; tx <= maxTX; tx++) { for (var ty = minTY; ty <= maxTY; ty++) { if (ty < 0 || ty >= maxIndex) continue; @@ -1211,11 +1712,8 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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) { @@ -1226,6 +1724,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { .toString() .toLowerCase(); if (_shouldSkipRoadLabelClass(roadClass)) continue; + if (majorRoadsOnly && !_isMajorRoadClass(roadClass)) continue; final style = _roadStyleFor(feature, tileZoom: z.toDouble()); if (style == null) continue; @@ -1237,10 +1736,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { .trim()); if (text.isEmpty || text.length > 32) continue; + final minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom; + final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble(); final roadCorePx = _worldStrokePx( style.coreWorldUnits, extent: layer.extent, - rasterSize: tileSize * factor, + rasterSize: tileSize * fontFactor, tileZoom: z.toDouble(), ); final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0); @@ -1263,61 +1764,77 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { 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( + final placement = _bestRoadLabelPlacement( feature.geometry, scale: 1.0, labelWidth: painter.width / scale, ); + // only cache successful placements — null means segment too short, + // which can change at different zoom/scale so we shouldn't lock it in + if (placement != null) { + _roadLabelPlacementCache[cacheKey] = placement; + } } 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); + candidates.add(_RoadLabelCandidate( + text: text, + screenCenter: screenCenter, + angle: localPlacement.angle, + pathLength: localPlacement.pathLength, + painter: painter, + )); } } } + + // longest segment wins — so the winner is always the same regardless of + // which order tiles were iterated + candidates.sort((a, b) => b.pathLength.compareTo(a.pathLength)); + + final occupiedRects = []; + final placedByName = >{}; + + for (final c in candidates) { + final bounds = Rect.fromCenter( + center: c.screenCenter, + width: c.painter.width + 12, + height: c.painter.height + 10, + ); + + if (occupiedRects.any((r) => r.overlaps(bounds))) continue; + + final key = c.text.toLowerCase(); + final existing = placedByName[key]; + if (existing != null) { + final tooClose = existing.any((p) { + final dx = p.dx - c.screenCenter.dx; + final dy = p.dy - c.screenCenter.dy; + return math.sqrt(dx * dx + dy * dy) < minSameNameSpacing; + }); + if (tooClose) continue; + } + + canvas.save(); + canvas.translate(c.screenCenter.dx, c.screenCenter.dy); + canvas.rotate(c.angle); + c.painter.paint(canvas, Offset(-c.painter.width / 2, -c.painter.height / 2)); + canvas.restore(); + + occupiedRects.add(bounds); + (placedByName[key] ??= []).add(c.screenCenter); + } } void _drawRoadLabels( @@ -1361,10 +1878,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { if (text.isEmpty || text.length > 32) continue; if (!seenTexts.add(text.toLowerCase())) continue; + final minFontZoom2 = _isMajorRoadClass(roadClass) ? 19.0 : zoom; + final fontRasterSize = size.width * math.pow(2, math.max(zoom, minFontZoom2) - tileZoom); final roadCorePx = _worldStrokePx( style.coreWorldUnits, extent: layer.extent, - rasterSize: size.width, + rasterSize: fontRasterSize, tileZoom: tileZoom, ); final fontSize = (roadCorePx * 1.05).clamp(8.5, 24.0); @@ -1552,6 +2071,37 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { return null; } + // Trim a polyline by [startDist] from the start and [endDist] from the end. + // Returns null if the remaining length is zero or negative. + List? _trimPolyline(List pts, double startDist, double endDist) { + if (startDist <= 0 && endDist <= 0) return pts; + + List trimStart(List p, double dist) { + var rem = dist; + for (var i = 1; i < p.length; i++) { + final dx = p[i].dx - p[i - 1].dx; + final dy = p[i].dy - p[i - 1].dy; + final len = math.sqrt(dx * dx + dy * dy); + if (rem <= len) { + final t = rem / len; + final newFirst = Offset(p[i - 1].dx + dx * t, p[i - 1].dy + dy * t); + return [newFirst, ...p.sublist(i)]; + } + rem -= len; + } + return []; + } + + var result = startDist > 0 ? trimStart(pts, startDist) : pts; + if (result.length < 2) return null; + + if (endDist > 0) { + result = trimStart(result.reversed.toList(), endDist).reversed.toList(); + } + + return result.length >= 2 ? result : null; + } + double _polylineLength(List points) { var length = 0.0; for (var i = 1; i < points.length; i++) { @@ -1562,6 +2112,13 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { return length; } + bool _isMajorRoadClass(String roadClass) { + return roadClass.contains('motorway') || + roadClass.contains('trunk') || + roadClass.contains('primary') || + roadClass.contains('secondary'); + } + bool _shouldSkipRoadLabelClass(String roadClass) { return roadClass.contains('path') || roadClass.contains('footway') || @@ -1590,7 +2147,7 @@ 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]'), '').trim(); + text.replaceAll(RegExp(r'[^\x20-\x7E\u00C0-\u024F\u2018-\u201D]'), '').trim(); bool _isTruthy(Object? value) { if (value is bool) return value; @@ -1689,6 +2246,22 @@ class _RoadLabelPlacement { final double pathLength; } +class _RoadLabelCandidate { + const _RoadLabelCandidate({ + required this.text, + required this.screenCenter, + required this.angle, + required this.pathLength, + required this.painter, + }); + + final String text; + final Offset screenCenter; + final double angle; + final double pathLength; + final TextPainter painter; +} + class _LabelCandidate { const _LabelCandidate({ required this.text, @@ -1702,3 +2275,9 @@ class _LabelCandidate { final int rank; final String featureClass; } + +class _DrawCommand { + const _DrawCommand({required this.z, required this.draw}); + final double z; + final void Function(Canvas) draw; +}