From dc22ce2f46e00bb7e22dda921dd9f978674aa0f3 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Mon, 30 Mar 2026 18:13:42 +0100 Subject: [PATCH] add tile caching and rendering for Mapbox vector tiles with custom painter --- lib/pages/map/page.dart | 494 +++--------- .../map/vector/mapbox_vector_tile_cache.dart | 71 ++ lib/pages/map/vector/mvt_parser.dart | 362 +++++++++ lib/pages/map/vector/tile_pyramid_cache.dart | 188 +++++ .../map/vector/tile_pyramid_painter.dart | 745 ++++++++++++++++++ macos/Runner.xcodeproj/project.pbxproj | 42 +- .../xcshareddata/xcschemes/Runner.xcscheme | 18 + pubspec.lock | 20 +- 8 files changed, 1543 insertions(+), 397 deletions(-) create mode 100644 lib/pages/map/vector/mapbox_vector_tile_cache.dart create mode 100644 lib/pages/map/vector/mvt_parser.dart create mode 100644 lib/pages/map/vector/tile_pyramid_cache.dart create mode 100644 lib/pages/map/vector/tile_pyramid_painter.dart diff --git a/lib/pages/map/page.dart b/lib/pages/map/page.dart index 7fb2acf..93b28f1 100644 --- a/lib/pages/map/page.dart +++ b/lib/pages/map/page.dart @@ -14,8 +14,8 @@ 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/tiles/hive_tile_cache.dart'; -import 'package:rra_app/pages/map/tiles/hive_tile_image.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'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -35,23 +35,16 @@ class _MapPageState extends State with TickerProviderStateMixin { static const _london = LatLng(51.5074, -0.1278); static const _routeColor = Color(0xFFE02425); static const _tileSize = 256.0; - static const _tileSourcePixelRatio = 2.0; static const _minZoom = 2.0; static const _maxZoom = 24.0; static const _minTileZoom = 2; static const _maxTileZoom = 22; static const _tileOverscan = 1; - static const _tileFadeReadyRatio = 0.72; - static const _tileFadeDuration = Duration(milliseconds: 220); static const _interactionIdleDelay = Duration(milliseconds: 280); - static const _prefetchDebounceDelay = Duration(milliseconds: 280); - static const _tilePromotionActiveInterval = Duration(seconds: 1); - static const _tilePromotionIdleInterval = Duration(seconds: 1); - static const _tilePromotionBatchSize = 28; static const _fpsWindowSize = 90; static const _fpsUpdateInterval = Duration(milliseconds: 240); static const _tileUrlTemplate = - 'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=pk.eyJ1IjoiaW1iZW5qaTAzIiwiYSI6ImNtN2Rqdmw5MDA0bzEyaXM3YjE1emkzOXAifQ.cYyCPQE7OvZx0hzKX2hEiQ'; + 'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token=pk.eyJ1IjoiaW1iZW5qaW5ldCIsImEiOiJjbW01azQ1bTcwODJ5MnBzOG9tMTUxdGRoIn0.z22taz_5I_8D5GuDlser_Q'; final GlobalKey _mapKey = GlobalKey(); final OsrmRoutingService _routingService = const OsrmRoutingService(); @@ -63,106 +56,76 @@ class _MapPageState extends State with TickerProviderStateMixin { double _zoom = 12; double _targetZoom = 12; int _activeTileZoom = 12; - double _devicePixelRatio = 2.0; + int? _fallbackTileZoom; Offset _zoomFocal = Offset.zero; Size _mapSize = Size.zero; + double _devicePixelRatio = 2.0; + late final Ticker _zoomTicker; double _lastPinchScale = 1.0; - - bool _isAddPointArmed = false; - bool _isResolvingRoute = false; - String? _routeError; - int _routeRequestId = 0; - int? _draggingInsertPointIndex; - int _lastPrefetchHash = 0; - int _lastPrefetchCount = -1; - List _pendingPrefetchUrls = const []; - Timer? _prefetchDebounceTimer; - bool _isInteractionActive = false; - Timer? _interactionIdleTimer; Offset _pendingPanDelta = Offset.zero; bool _panFrameScheduled = false; Offset _pendingPanZoomDelta = Offset.zero; double _pendingPanZoomZoomDelta = 0.0; Offset _pendingPanZoomFocal = Offset.zero; bool _panZoomFrameScheduled = false; + + bool _isAddPointArmed = false; + bool _isResolvingRoute = false; + String? _routeError; + int _routeRequestId = 0; + int? _draggingInsertPointIndex; + Timer? _interactionIdleTimer; + + late final TilePyramidPainter _pyramidPainter; + final List _frameMsWindow = []; late final Stopwatch _fpsUpdateStopwatch; double _displayFps = 0; double _displayFrameMs = 0; - final Set _readyTileUrls = {}; - final Set _pendingReadyTileUrls = {}; - Timer? _tilePromotionTimer; - int? _fallbackTileZoom; - bool _fallbackSyncScheduled = false; - late final AnimationController _fallbackFadeController; - - void _cancelPrefetchTimer() { - _prefetchDebounceTimer?.cancel(); - _prefetchDebounceTimer = null; - } - - void _cancelInteractionTimer() { - _interactionIdleTimer?.cancel(); - _interactionIdleTimer = null; - } - - void _cancelTilePromotionTimer() { - _tilePromotionTimer?.cancel(); - _tilePromotionTimer = null; - } @override void initState() { super.initState(); _zoomTicker = createTicker(_onZoomTick); - _fallbackFadeController = - AnimationController( - vsync: this, - duration: _tileFadeDuration, - lowerBound: 0, - upperBound: 1, - value: 0, - )..addStatusListener((status) { - if (status != AnimationStatus.dismissed) return; - if (!mounted || _fallbackTileZoom == null) return; - setState(() { - _fallbackTileZoom = null; - }); - }); + _pyramidPainter = TilePyramidPainter( + tileUrlTemplate: _tileUrlTemplate, + tileSize: _tileSize, + minTileZoom: _minTileZoom, + maxTileZoom: _maxTileZoom, + tileOverscan: _tileOverscan, + ); _fpsUpdateStopwatch = Stopwatch()..start(); SchedulerBinding.instance.addTimingsCallback(_onFrameTimings); } @override void dispose() { - _cancelPrefetchTimer(); - _cancelInteractionTimer(); - _cancelTilePromotionTimer(); - SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings); - _fallbackFadeController.dispose(); _zoomTicker.dispose(); + _pyramidPainter.dispose(); + TilePyramidCache.clear(); + SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings); + _interactionIdleTimer?.cancel(); super.dispose(); } void _onFrameTimings(List timings) { for (final timing in timings) { - final frameMs = timing.totalSpan.inMicroseconds / 1000.0; - _frameMsWindow.add(frameMs); + _frameMsWindow.add(timing.totalSpan.inMicroseconds / 1000.0); } if (_frameMsWindow.length > _fpsWindowSize) { _frameMsWindow.removeRange(0, _frameMsWindow.length - _fpsWindowSize); } - if (_fpsUpdateStopwatch.elapsed < _fpsUpdateInterval) return; + if (_fpsUpdateStopwatch.elapsed < _fpsUpdateInterval || !mounted) return; _fpsUpdateStopwatch.reset(); - if (_frameMsWindow.isEmpty || !mounted) return; + if (_frameMsWindow.isEmpty) return; - var totalMs = 0.0; + var total = 0.0; for (final ms in _frameMsWindow) { - totalMs += ms; + total += ms; } - final avgMs = totalMs / _frameMsWindow.length; - final fps = avgMs <= 0 ? 0.0 : (1000.0 / avgMs); + final avgMs = total / _frameMsWindow.length; + final fps = avgMs <= 0 ? 0.0 : 1000.0 / avgMs; if ((fps - _displayFps).abs() < 0.2 && (avgMs - _displayFrameMs).abs() < 0.2) { return; @@ -233,13 +196,6 @@ class _MapPageState extends State with TickerProviderStateMixin { } } - Color _pointColor(int index) { - if (_points.length == 1) return const Color(0xFF0F9D58); - if (index == 0) return const Color(0xFF0F9D58); - if (index == _points.length - 1) return const Color(0xFFE11D48); - return const Color(0xFF2563EB); - } - math.Point _latLngToWorld(LatLng latLng, double zoom) { final scale = _tileSize * math.pow(2, zoom).toDouble(); final x = (latLng.longitude + 180.0) / 360.0 * scale; @@ -257,25 +213,6 @@ class _MapPageState extends State with TickerProviderStateMixin { return LatLng(lat, lon); } - int get _tileZoom => _activeTileZoom; - - int _idealTileZoom(double zoomValue, {required double devicePixelRatio}) { - final clampedDeviceRatio = devicePixelRatio.clamp(1.0, 4.0).toDouble(); - final maxTileZoomForScreenResolution = - zoomValue + - (math.log(clampedDeviceRatio / _tileSourcePixelRatio) / math.ln2); - return maxTileZoomForScreenResolution.floor().clamp( - _minTileZoom, - _maxTileZoom, - ); - } - - math.Point _clampWorldY(math.Point world, double zoom) { - final scale = _tileSize * math.pow(2, zoom).toDouble(); - final maxY = math.max(0.0, scale - 1); - return math.Point(world.x, world.y.clamp(0.0, maxY).toDouble()); - } - Offset _latLngToScreen(LatLng latLng) { final centerWorld = _latLngToWorld(_mapCenter, _zoom); final pointWorld = _latLngToWorld(latLng, _zoom); @@ -294,12 +231,10 @@ class _MapPageState extends State with TickerProviderStateMixin { return _worldToLatLng(world, _zoom); } - Offset? _globalToMapLocal(Offset globalPosition) { - final context = _mapKey.currentContext; - if (context == null) return null; - final box = context.findRenderObject(); - if (box is! RenderBox) return null; - return box.globalToLocal(globalPosition); + math.Point _clampWorldY(math.Point world, double zoom) { + final scale = _tileSize * math.pow(2, zoom).toDouble(); + final maxY = math.max(0.0, scale - 1); + return math.Point(world.x, world.y.clamp(0.0, maxY).toDouble()); } void _panMap(Offset delta, {bool notify = true}) { @@ -309,9 +244,8 @@ class _MapPageState extends State with TickerProviderStateMixin { math.Point(centerWorld.x - delta.dx, centerWorld.y - delta.dy), _zoom, ); - void apply() { - _mapCenter = _worldToLatLng(nextCenter, _zoom); - } + + void apply() => _mapCenter = _worldToLatLng(nextCenter, _zoom); if (notify) { setState(apply); @@ -320,6 +254,50 @@ 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); + } + + void _setZoomAroundFocal(double requestedZoom, Offset focal) { + if (_mapSize == Size.zero) return; + final nextZoom = requestedZoom.clamp(_minZoom, _maxZoom); + if ((nextZoom - _zoom).abs() < 0.0001) return; + + final previousTileZoom = _activeTileZoom; + final focalLatLng = _screenToLatLng(focal); + final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom); + final centerWorldNext = _clampWorldY( + math.Point( + focalWorldNext.x - (focal.dx - _mapSize.width / 2), + focalWorldNext.y - (focal.dy - _mapSize.height / 2), + ), + nextZoom, + ); + + _zoom = nextZoom; + _mapCenter = _worldToLatLng(centerWorldNext, nextZoom); + _activeTileZoom = _idealTileZoom( + _zoom, + devicePixelRatio: _devicePixelRatio, + ); + if (_activeTileZoom != previousTileZoom) { + _fallbackTileZoom = previousTileZoom; + } + } + + void _onZoomTick(Duration _) { + if (!mounted) return; + setState(() { + const t = 0.2; + final newZoom = _zoom + (_targetZoom - _zoom) * t; + final done = (_targetZoom - newZoom).abs() < 0.001; + _setZoomAroundFocal(done ? _targetZoom : newZoom, _zoomFocal); + if (done) _zoomTicker.stop(); + }); + } + void _queuePan(Offset delta) { if (delta == Offset.zero) return; _noteInteraction(); @@ -332,9 +310,7 @@ class _MapPageState extends State with TickerProviderStateMixin { final pending = _pendingPanDelta; _pendingPanDelta = Offset.zero; if (pending == Offset.zero) return; - setState(() { - _panMap(pending, notify: false); - }); + setState(() => _panMap(pending, notify: false)); }); } @@ -372,55 +348,23 @@ class _MapPageState extends State with TickerProviderStateMixin { } void _noteInteraction() { - _isInteractionActive = true; _interactionIdleTimer?.cancel(); - _interactionIdleTimer = Timer(_interactionIdleDelay, () { - _isInteractionActive = false; - _flushPrefetchNow(); - _flushTilePromotions(forceAll: true); - }); + _interactionIdleTimer = Timer(_interactionIdleDelay, () {}); } - void _setZoomAroundFocal(double requestedZoom, Offset focal) { - if (_mapSize == Size.zero) return; - final nextZoom = requestedZoom.clamp(_minZoom, _maxZoom); - if ((nextZoom - _zoom).abs() < 0.0001) return; - - final focalLatLng = _screenToLatLng(focal); - final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom); - final centerWorldNext = _clampWorldY( - math.Point( - focalWorldNext.x - (focal.dx - _mapSize.width / 2), - focalWorldNext.y - (focal.dy - _mapSize.height / 2), - ), - nextZoom, - ); - - final previousTileZoom = _activeTileZoom; - _zoom = nextZoom; - _mapCenter = _worldToLatLng(centerWorldNext, nextZoom); - _activeTileZoom = _idealTileZoom( - _zoom, - devicePixelRatio: _devicePixelRatio, - ); - final nextTileZoom = _activeTileZoom; - if (previousTileZoom != nextTileZoom) { - _fallbackTileZoom = previousTileZoom; - _fallbackFadeController.stop(); - _fallbackFadeController.value = 1; - } - } - - void _onZoomTick(Duration _) { - if (!mounted) return; - setState(() { - const t = 0.2; - final newZoom = _zoom + (_targetZoom - _zoom) * t; - final done = (_targetZoom - newZoom).abs() < 0.001; - final actual = done ? _targetZoom : newZoom; - _setZoomAroundFocal(actual, _zoomFocal); - if (done) _zoomTicker.stop(); - }); + void _updatePainterViewport() { + final cw = _latLngToWorld(_mapCenter, _activeTileZoom.toDouble()); + _pyramidPainter + ..mapWidth = _mapSize.width + ..mapHeight = _mapSize.height + ..zoom = _zoom + ..centerWorldX = cw.x + ..centerWorldY = cw.y + ..activeTileZoom = _activeTileZoom + ..fallbackTileZoom = _fallbackTileZoom + ..devicePixelRatio = _devicePixelRatio + ..interactionActive = _interactionIdleTimer?.isActive ?? false; + _pyramidPainter.markDirty(); } LatLng _segmentMidpoint(LatLng a, LatLng b) { @@ -448,198 +392,31 @@ class _MapPageState extends State with TickerProviderStateMixin { _updateInsertPointDrag(globalPosition); } + Offset? _globalToMapLocal(Offset globalPosition) { + final context = _mapKey.currentContext; + if (context == null) return null; + final box = context.findRenderObject(); + if (box is! RenderBox) return null; + return box.globalToLocal(globalPosition); + } + void _updateInsertPointDrag(Offset globalPosition) { final index = _draggingInsertPointIndex; if (index == null || index < 0 || index >= _points.length) return; final local = _globalToMapLocal(globalPosition); if (local == null) return; - final point = _screenToLatLng(local); setState(() { - _points[index] = point; + _points[index] = _screenToLatLng(local); _resolvedRoute = _points.toList(); }); } void _finishInsertPointDrag() { if (_draggingInsertPointIndex == null) return; - setState(() { - _draggingInsertPointIndex = null; - }); + setState(() => _draggingInsertPointIndex = null); _resolveRoute(); } - ({List widgets, Set urls, Set prefetchUrls}) - _buildTileLayer({required int tileZoom, bool prefetchChildren = false}) { - if (_mapSize == Size.zero) { - return (widgets: [], urls: {}, prefetchUrls: {}); - } - - final factor = math.pow(2, _zoom - tileZoom).toDouble(); - final centerWorld = _latLngToWorld(_mapCenter, tileZoom.toDouble()); - final minWorldX = centerWorld.x - (_mapSize.width / 2) / factor; - final maxWorldX = centerWorld.x + (_mapSize.width / 2) / factor; - final minWorldY = centerWorld.y - (_mapSize.height / 2) / factor; - final maxWorldY = centerWorld.y + (_mapSize.height / 2) / factor; - - final minTileX = (minWorldX / _tileSize).floor() - _tileOverscan; - final maxTileX = (maxWorldX / _tileSize).floor() + _tileOverscan; - final minTileY = (minWorldY / _tileSize).floor() - _tileOverscan; - final maxTileY = (maxWorldY / _tileSize).floor() + _tileOverscan; - - final maxIndex = math.pow(2, tileZoom).toInt(); - final tiles = []; - final urls = {}; - final prefetchUrls = {}; - - for (var tileX = minTileX; tileX <= maxTileX; tileX++) { - final wrappedX = ((tileX % maxIndex) + maxIndex) % maxIndex; - for (var tileY = minTileY; tileY <= maxTileY; tileY++) { - if (tileY < 0 || tileY >= maxIndex) continue; - - final left = (tileX * _tileSize - minWorldX) * factor; - final top = (tileY * _tileSize - minWorldY) * factor; - final url = _tileUrlTemplate - .replaceAll('{z}', '$tileZoom') - .replaceAll('{x}', '$wrappedX') - .replaceAll('{y}', '$tileY'); - urls.add(url); - - tiles.add( - Positioned( - key: ValueKey(url), - left: left, - top: top, - width: _tileSize * factor, - height: _tileSize * factor, - child: HiveTileImage(url: url, onLoaded: _markTileReady), - ), - ); - - if (prefetchChildren && tileZoom < _maxTileZoom) { - final childZoom = tileZoom + 1; - final childMaxIndex = math.pow(2, childZoom).toInt(); - final childXBase = wrappedX * 2; - final childYBase = tileY * 2; - for (var dx = 0; dx < 2; dx++) { - for (var dy = 0; dy < 2; dy++) { - final childX = - ((childXBase + dx) % childMaxIndex + childMaxIndex) % - childMaxIndex; - final childY = childYBase + dy; - if (childY < 0 || childY >= childMaxIndex) continue; - prefetchUrls.add( - _tileUrlTemplate - .replaceAll('{z}', '$childZoom') - .replaceAll('{x}', '$childX') - .replaceAll('{y}', '$childY'), - ); - } - } - } - } - } - - return (widgets: tiles, urls: urls, prefetchUrls: prefetchUrls); - } - - void _markTileReady(String url) { - if (_readyTileUrls.contains(url) || _pendingReadyTileUrls.contains(url)) { - return; - } - _pendingReadyTileUrls.add(url); - _scheduleTilePromotionTick(); - } - - void _scheduleTilePromotionTick() { - if (_tilePromotionTimer != null || _pendingReadyTileUrls.isEmpty) return; - final delay = _isInteractionActive - ? _tilePromotionActiveInterval - : _tilePromotionIdleInterval; - _tilePromotionTimer = Timer(delay, () { - _tilePromotionTimer = null; - _flushTilePromotions(forceAll: true); - }); - } - - void _flushTilePromotions({bool forceAll = false}) { - if (_pendingReadyTileUrls.isEmpty) return; - final maxPromotions = forceAll || !_isInteractionActive - ? _pendingReadyTileUrls.length - : _tilePromotionBatchSize; - final promoted = []; - final iterator = _pendingReadyTileUrls.iterator; - while (promoted.length < maxPromotions && iterator.moveNext()) { - promoted.add(iterator.current); - } - if (promoted.isEmpty) return; - - var changed = false; - for (final url in promoted) { - _pendingReadyTileUrls.remove(url); - changed = _readyTileUrls.add(url) || changed; - } - - if (changed && mounted && _fallbackTileZoom != null) { - setState(() {}); - } - if (_pendingReadyTileUrls.isNotEmpty) { - _scheduleTilePromotionTick(); - } - } - - double _tileReadyRatio(Set urls) { - if (urls.isEmpty) return 1; - var ready = 0; - for (final url in urls) { - if (_readyTileUrls.contains(url)) { - ready += 1; - } - } - return ready / urls.length; - } - - void _syncFallbackFade(double readyRatio) { - if (_fallbackTileZoom == null || _fallbackSyncScheduled) return; - _fallbackSyncScheduled = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - _fallbackSyncScheduled = false; - if (!mounted || _fallbackTileZoom == null) return; - if (readyRatio >= _tileFadeReadyRatio) { - if (_fallbackFadeController.status == AnimationStatus.reverse || - _fallbackFadeController.value <= 0) { - return; - } - _fallbackFadeController.reverse(from: _fallbackFadeController.value); - return; - } - if (_fallbackFadeController.value != 1) { - _fallbackFadeController.stop(); - _fallbackFadeController.value = 1; - } - }); - } - - void _schedulePrefetch(Set urls) { - if (urls.isEmpty) return; - final hash = Object.hashAllUnordered(urls); - if (hash == _lastPrefetchHash && urls.length == _lastPrefetchCount) return; - _lastPrefetchHash = hash; - _lastPrefetchCount = urls.length; - _pendingPrefetchUrls = urls.toList(growable: false); - if (_isInteractionActive) return; - _prefetchDebounceTimer?.cancel(); - _prefetchDebounceTimer = Timer(_prefetchDebounceDelay, _flushPrefetchNow); - } - - void _flushPrefetchNow() { - _prefetchDebounceTimer?.cancel(); - _prefetchDebounceTimer = null; - final pending = _pendingPrefetchUrls; - _pendingPrefetchUrls = const []; - if (pending.isEmpty) return; - HiveTileCache.prefetchUrls(pending); - } - List _buildPointWidgets() { final widgets = []; for (var i = 0; i < _points.length; i++) { @@ -653,7 +430,11 @@ class _MapPageState extends State with TickerProviderStateMixin { width: 34, height: 34, decoration: BoxDecoration( - color: _pointColor(i), + color: i == 0 + ? const Color(0xFF0F9D58) + : (i == _points.length - 1 + ? const Color(0xFFE11D48) + : const Color(0xFF2563EB)), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), ), @@ -668,7 +449,6 @@ class _MapPageState extends State with TickerProviderStateMixin { List _buildInsertHandles() { if (_points.length < 2) return const []; - final handles = []; for (var i = 0; i < _points.length - 1; i++) { final midpoint = _segmentMidpoint(_points[i], _points[i + 1]); @@ -717,35 +497,11 @@ class _MapPageState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - final nextDevicePixelRatio = MediaQuery.devicePixelRatioOf(context); - if ((nextDevicePixelRatio - _devicePixelRatio).abs() > 0.01) { - _devicePixelRatio = nextDevicePixelRatio; - final nextTileZoom = _idealTileZoom( - _zoom, - devicePixelRatio: _devicePixelRatio, - ); - if (nextTileZoom != _activeTileZoom) { - _fallbackTileZoom = _activeTileZoom; - _fallbackFadeController.stop(); - _fallbackFadeController.value = 1; - _activeTileZoom = nextTileZoom; - } + final dpr = MediaQuery.devicePixelRatioOf(context); + if ((dpr - _devicePixelRatio).abs() > 0.01) { + _devicePixelRatio = dpr; } - final currentLayer = _buildTileLayer( - tileZoom: _tileZoom, - prefetchChildren: !_isInteractionActive, - ); - final currentReadyRatio = _tileReadyRatio(currentLayer.urls); - _syncFallbackFade(currentReadyRatio); - - _schedulePrefetch(currentLayer.prefetchUrls); - - final fallbackLayer = - _fallbackTileZoom == null || _fallbackTileZoom == _tileZoom - ? null - : _buildTileLayer(tileZoom: _fallbackTileZoom!); - final routePoints = _resolvedRoute.isNotEmpty ? _resolvedRoute : _points; final routeScreenPoints = routePoints.map(_latLngToScreen).toList(); @@ -756,6 +512,7 @@ class _MapPageState extends State with TickerProviderStateMixin { child: LayoutBuilder( builder: (context, constraints) { _mapSize = constraints.biggest; + _updatePainterViewport(); return Listener( onPointerSignal: (event) { @@ -827,12 +584,11 @@ class _MapPageState extends State with TickerProviderStateMixin { const Positioned.fill( child: ColoredBox(color: Color(0xFFECECEC)), ), - if (fallbackLayer != null) - FadeTransition( - opacity: _fallbackFadeController, - child: Stack(children: fallbackLayer.widgets), + Positioned.fill( + child: RepaintBoundary( + child: CustomPaint(painter: _pyramidPainter), ), - ...currentLayer.widgets, + ), Positioned.fill( child: IgnorePointer( child: CustomPaint( @@ -911,12 +667,12 @@ class _MapPageState extends State with TickerProviderStateMixin { ), ), ), - const SafeArea( + SafeArea( child: Align( alignment: Alignment.bottomLeft, child: Padding( - padding: EdgeInsets.all(10), - child: IgnorePointer(child: _HudCornerNotice()), + padding: const EdgeInsets.all(10), + child: const IgnorePointer(child: _HudCornerNotice()), ), ), ), diff --git a/lib/pages/map/vector/mapbox_vector_tile_cache.dart b/lib/pages/map/vector/mapbox_vector_tile_cache.dart new file mode 100644 index 0000000..679fa58 --- /dev/null +++ b/lib/pages/map/vector/mapbox_vector_tile_cache.dart @@ -0,0 +1,71 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart'; +import 'package:rra_app/pages/map/vector/mvt_parser.dart'; + +class MapboxVectorTileCache { + MapboxVectorTileCache._(); + + static final MvtParser _parser = const MvtParser(); + static const Set _styleLayerWhitelist = { + 'water', + 'landuse', + 'park', + 'building', + 'road', + 'road_label', + 'place_label', + 'poi_label', + }; + static const int _maxEntries = 220; + static final LinkedHashMap _cache = + LinkedHashMap(); + static final Map> _inFlight = + >{}; + + static MvtTile? peek(String url) { + final cached = _cache.remove(url); + if (cached != null) { + _cache[url] = cached; + } + return cached; + } + + static Future getOrFetch(String url) { + final cached = peek(url); + if (cached != null) return Future.value(cached); + + final inFlight = _inFlight[url]; + if (inFlight != null) return inFlight; + + final future = _fetch(url); + _inFlight[url] = future; + future.whenComplete(() => _inFlight.remove(url)); + return future; + } + + static Future _fetch(String url) async { + final bytes = await HiveTileCache.getOrFetch(url); + if (bytes == null || bytes.isEmpty) return null; + final tile = _parse(bytes); + if (tile == null) return null; + _cache[url] = tile; + _trim(); + return tile; + } + + static MvtTile? _parse(Uint8List bytes) { + try { + return _parser.parse(bytes, layerNames: _styleLayerWhitelist); + } catch (_) { + return null; + } + } + + static void _trim() { + while (_cache.length > _maxEntries) { + _cache.remove(_cache.keys.first); + } + } +} diff --git a/lib/pages/map/vector/mvt_parser.dart b/lib/pages/map/vector/mvt_parser.dart new file mode 100644 index 0000000..a81473c --- /dev/null +++ b/lib/pages/map/vector/mvt_parser.dart @@ -0,0 +1,362 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +enum MvtGeometryType { unknown, point, lineString, polygon } + +class MvtTile { + const MvtTile({required this.layers}); + + final Map layers; +} + +class MvtLayer { + const MvtLayer({ + required this.name, + required this.extent, + required this.features, + }); + + final String name; + final int extent; + final List features; +} + +class MvtFeature { + const MvtFeature({ + required this.type, + required this.properties, + required this.geometry, + }); + + final MvtGeometryType type; + final Map properties; + final List> geometry; +} + +class MvtParser { + const MvtParser(); + + MvtTile parse(Uint8List bytes, {Set? layerNames}) { + final reader = _PbfReader(bytes); + final layers = {}; + + while (!reader.isAtEnd) { + final tag = reader.readVarint(); + final field = tag >> 3; + final wire = tag & 0x07; + if (field == 3 && wire == 2) { + final layerBytes = reader.readBytes(); + final layer = _parseLayer(layerBytes); + if (layer == null) continue; + if (layerNames != null && !layerNames.contains(layer.name)) continue; + layers[layer.name] = layer; + } else { + reader.skipWireType(wire); + } + } + + return MvtTile(layers: layers); + } + + MvtLayer? _parseLayer(Uint8List bytes) { + final reader = _PbfReader(bytes); + String name = ''; + var extent = 4096; + final rawFeatures = <_RawFeature>[]; + final keys = []; + final values = []; + + while (!reader.isAtEnd) { + final tag = reader.readVarint(); + final field = tag >> 3; + final wire = tag & 0x07; + switch (field) { + case 1: + if (wire == 2) { + name = reader.readString(); + } else { + reader.skipWireType(wire); + } + case 2: + if (wire == 2) { + rawFeatures.add(_parseRawFeature(reader.readBytes())); + } else { + reader.skipWireType(wire); + } + case 3: + if (wire == 2) { + keys.add(reader.readString()); + } else { + reader.skipWireType(wire); + } + case 4: + if (wire == 2) { + values.add(_parseValue(reader.readBytes())); + } else { + reader.skipWireType(wire); + } + case 5: + if (wire == 0) { + extent = reader.readVarint(); + } else { + reader.skipWireType(wire); + } + default: + reader.skipWireType(wire); + } + } + + if (name.isEmpty) return null; + + final features = []; + for (final raw in rawFeatures) { + final properties = {}; + for (var i = 0; i + 1 < raw.tags.length; i += 2) { + final keyIndex = raw.tags[i]; + final valueIndex = raw.tags[i + 1]; + if (keyIndex < 0 || keyIndex >= keys.length) continue; + if (valueIndex < 0 || valueIndex >= values.length) continue; + properties[keys[keyIndex]] = values[valueIndex]; + } + + features.add( + MvtFeature( + type: _decodeFeatureType(raw.type), + properties: properties, + geometry: _decodeGeometry(raw.geometry, _decodeFeatureType(raw.type)), + ), + ); + } + + return MvtLayer(name: name, extent: extent, features: features); + } + + _RawFeature _parseRawFeature(Uint8List bytes) { + final reader = _PbfReader(bytes); + var type = 0; + var geometry = []; + final tags = []; + + while (!reader.isAtEnd) { + final tag = reader.readVarint(); + final field = tag >> 3; + final wire = tag & 0x07; + switch (field) { + case 2: + if (wire == 2) { + tags.addAll(reader.readPackedVarints()); + } else { + reader.skipWireType(wire); + } + case 3: + if (wire == 0) { + type = reader.readVarint(); + } else { + reader.skipWireType(wire); + } + case 4: + if (wire == 2) { + geometry = reader.readPackedVarints(); + } else { + reader.skipWireType(wire); + } + default: + reader.skipWireType(wire); + } + } + + return _RawFeature(type: type, tags: tags, geometry: geometry); + } + + Object? _parseValue(Uint8List bytes) { + final reader = _PbfReader(bytes); + while (!reader.isAtEnd) { + final tag = reader.readVarint(); + final field = tag >> 3; + final wire = tag & 0x07; + switch (field) { + case 1: + if (wire == 2) return reader.readString(); + case 2: + if (wire == 5) return reader.readFloat32(); + case 3: + if (wire == 1) return reader.readFloat64(); + case 4: + if (wire == 0) return reader.readVarint(); + case 5: + if (wire == 0) return reader.readVarint(); + case 6: + if (wire == 0) return _decodeZigZag(reader.readVarint()); + case 7: + if (wire == 0) return reader.readVarint() != 0; + } + reader.skipWireType(wire); + } + return null; + } + + MvtGeometryType _decodeFeatureType(int rawType) { + switch (rawType) { + case 1: + return MvtGeometryType.point; + case 2: + return MvtGeometryType.lineString; + case 3: + return MvtGeometryType.polygon; + default: + return MvtGeometryType.unknown; + } + } + + List> _decodeGeometry(List commands, MvtGeometryType type) { + final geometries = >[]; + var cursorX = 0; + var cursorY = 0; + var i = 0; + List? current; + + while (i < commands.length) { + final commandInteger = commands[i++]; + final commandId = commandInteger & 0x7; + final count = commandInteger >> 3; + + if (commandId == 1) { + for (var c = 0; c < count; c++) { + if (i + 1 >= commands.length) break; + cursorX += _decodeZigZag(commands[i++]); + cursorY += _decodeZigZag(commands[i++]); + final point = Offset(cursorX.toDouble(), cursorY.toDouble()); + if (type == MvtGeometryType.point) { + geometries.add([point]); + current = null; + } else { + current = [point]; + geometries.add(current); + } + } + } else if (commandId == 2) { + if (current == null) { + current = []; + geometries.add(current); + } + for (var c = 0; c < count; c++) { + if (i + 1 >= commands.length) break; + cursorX += _decodeZigZag(commands[i++]); + cursorY += _decodeZigZag(commands[i++]); + current.add(Offset(cursorX.toDouble(), cursorY.toDouble())); + } + } else if (commandId == 7) { + if (type == MvtGeometryType.polygon && + current != null && + current.isNotEmpty && + current.first != current.last) { + current.add(current.first); + } + } else { + break; + } + } + + return geometries.where((path) => path.isNotEmpty).toList(growable: false); + } + + int _decodeZigZag(int n) => (n >> 1) ^ (-(n & 1)); +} + +class _RawFeature { + const _RawFeature({ + required this.type, + required this.tags, + required this.geometry, + }); + + final int type; + final List tags; + final List geometry; +} + +class _PbfReader { + _PbfReader(Uint8List bytes) + : _bytes = bytes, + _data = ByteData.sublistView(bytes); + + final Uint8List _bytes; + final ByteData _data; + int _offset = 0; + + bool get isAtEnd => _offset >= _bytes.length; + + int readVarint() { + var shift = 0; + var result = 0; + while (true) { + if (_offset >= _bytes.length) { + throw const FormatException('Unexpected EOF while reading varint'); + } + final byte = _bytes[_offset++]; + result |= (byte & 0x7f) << shift; + if ((byte & 0x80) == 0) break; + shift += 7; + if (shift > 63) { + throw const FormatException('Varint too long'); + } + } + return result; + } + + Uint8List readBytes() { + final length = readVarint(); + if (length < 0 || _offset + length > _bytes.length) { + throw const FormatException('Invalid length-delimited field'); + } + final bytes = Uint8List.sublistView(_bytes, _offset, _offset + length); + _offset += length; + return bytes; + } + + String readString() { + final bytes = readBytes(); + return String.fromCharCodes(bytes); + } + + double readFloat32() { + final value = _data.getFloat32(_offset, Endian.little); + _offset += 4; + return value; + } + + double readFloat64() { + final value = _data.getFloat64(_offset, Endian.little); + _offset += 8; + return value; + } + + List readPackedVarints() { + final bytes = readBytes(); + final inner = _PbfReader(bytes); + final values = []; + while (!inner.isAtEnd) { + values.add(inner.readVarint()); + } + return values; + } + + void skipWireType(int wireType) { + switch (wireType) { + case 0: + readVarint(); + case 1: + _offset += 8; + case 2: + final length = readVarint(); + _offset += length; + case 5: + _offset += 4; + default: + throw FormatException('Unsupported wire type: $wireType'); + } + if (_offset > _bytes.length) { + throw const FormatException('Unexpected EOF while skipping field'); + } + } +} diff --git a/lib/pages/map/vector/tile_pyramid_cache.dart b/lib/pages/map/vector/tile_pyramid_cache.dart new file mode 100644 index 0000000..c6b1ece --- /dev/null +++ b/lib/pages/map/vector/tile_pyramid_cache.dart @@ -0,0 +1,188 @@ +import 'dart:collection'; +import 'dart:ui' as ui; + +class _TileCoord { + const _TileCoord(this.z, this.x, this.y); + + final int z; + final int x; + final int y; + + @override + bool operator ==(Object other) => + other is _TileCoord && other.z == z && other.x == x && other.y == y; + + @override + int get hashCode => Object.hash(z, x, y); +} + +class CachedTile { + CachedTile(this.image, this.zoomBucket); + + 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. +class TilePyramidCache { + TilePyramidCache._(); + + static const int _maxEntries = 384; + static const int _maxApproxBytes = 256 * 1024 * 1024; + + // Cap concurrent renders to avoid saturating the main isolate with + // synchronous canvas recording. I/O (network/Hive) is still concurrent + // up to this limit. + static const int _maxConcurrent = 4; + + static final LinkedHashMap<_TileCoord, CachedTile> _cache = + LinkedHashMap<_TileCoord, CachedTile>(); + + static final Set<_TileCoord> _inFlight = <_TileCoord>{}; + static final Queue<_Queued> _queue = Queue<_Queued>(); + static int _active = 0; + static bool Function(int z, int x, int y)? _queueFilter; + + static int _approxBytes = 0; + + static CachedTile? peek(int z, int x, int y) { + final coord = _TileCoord(z, x, y); + final tile = _cache.remove(coord); + if (tile == null) return null; + _cache[coord] = tile; // promote to MRU + return tile; + } + + static bool isInFlight(int z, int x, int y) => + _inFlight.contains(_TileCoord(z, x, y)); + + // Queue a render for (z, x, y). No-op if already in flight. + // render() produces the image; onReady() is called after it's stored. + static void enqueue( + int z, + int x, + int y, + int zoomBucket, + Future Function() render, + void Function() onReady, + ) { + final coord = _TileCoord(z, x, y); + if (_inFlight.contains(coord)) return; + final filter = _queueFilter; + if (filter != null && !filter(z, x, y)) return; + _inFlight.add(coord); + + _queue.add( + _Queued( + coord: coord, + zoomBucket: zoomBucket, + render: render, + onReady: onReady, + ), + ); + _drain(); + } + + static void setQueueFilter(bool Function(int z, int x, int y)? filter) { + _queueFilter = filter; + _pruneQueue(); + } + + static void _drain() { + while (_active < _maxConcurrent && _queue.isNotEmpty) { + _active++; + _run(_queue.removeFirst()); + } + } + + static Future _run(_Queued item) async { + try { + final image = await item.render(); + _inFlight.remove(item.coord); + if (image == null) return; + + final filter = _queueFilter; + if (filter != null && !filter(item.coord.z, item.coord.x, item.coord.y)) { + image.dispose(); + return; + } + + final existing = _cache.remove(item.coord); + if (existing != null) { + _approxBytes -= _estimateBytes(existing.image); + existing.image.dispose(); + } + + _cache[item.coord] = CachedTile(image, item.zoomBucket); + _approxBytes += _estimateBytes(image); + _trim(); + + item.onReady(); + } catch (_) { + _inFlight.remove(item.coord); + } finally { + _active--; + _drain(); + } + } + + static int _estimateBytes(ui.Image img) => img.width * img.height * 4; + + static void _trim() { + while (_cache.length > _maxEntries || _approxBytes > _maxApproxBytes) { + final key = _cache.keys.first; + final tile = _cache.remove(key); + if (tile == null) continue; + _approxBytes -= _estimateBytes(tile.image); + tile.image.dispose(); + } + } + + static void _pruneQueue() { + final filter = _queueFilter; + if (filter == null || _queue.isEmpty) return; + + final kept = Queue<_Queued>(); + while (_queue.isNotEmpty) { + final item = _queue.removeFirst(); + if (filter(item.coord.z, item.coord.x, item.coord.y)) { + kept.add(item); + } else { + _inFlight.remove(item.coord); + } + } + + _queue.addAll(kept); + } + + static void clear() { + for (final tile in _cache.values) { + tile.image.dispose(); + } + _cache.clear(); + _inFlight.clear(); + _queue.clear(); + _active = 0; + _approxBytes = 0; + _queueFilter = null; + } +} + +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 new file mode 100644 index 0000000..7071c6e --- /dev/null +++ b/lib/pages/map/vector/tile_pyramid_painter.dart @@ -0,0 +1,745 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.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; + +class TilePyramidPainter extends ChangeNotifier implements CustomPainter { + TilePyramidPainter({ + required this.tileUrlTemplate, + required this.tileSize, + required this.minTileZoom, + required this.maxTileZoom, + required this.tileOverscan, + }); + + final String tileUrlTemplate; + final double tileSize; + final int minTileZoom; + final int maxTileZoom; + final int tileOverscan; + + // Viewport state — updated by the map page each frame. + double mapWidth = 0; + double mapHeight = 0; + double zoom = 0; + double centerWorldX = 0; + double centerWorldY = 0; + int activeTileZoom = 0; + int? fallbackTileZoom; + double devicePixelRatio = 1; + bool interactionActive = false; + + // 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), + ), + ); + + void markDirty() => notifyListeners(); + + String _tileUrl(int z, int x, int y) => tileUrlTemplate + .replaceAll('{z}', '$z') + .replaceAll('{x}', '$x') + .replaceAll('{y}', '$y'); + + @override + void paint(Canvas canvas, Size size) { + if (mapWidth == 0 || mapHeight == 0) return; + + final fallbackZ = fallbackTileZoom; + if (fallbackZ != null && fallbackZ != activeTileZoom) { + _drawTileLayer(canvas, fallbackZ, queueTiles: false); + } + + _drawTileLayer(canvas, activeTileZoom, queueTiles: true); + } + + void _drawTileLayer(Canvas canvas, int z, {required bool queueTiles}) { + final zDelta = z - activeTileZoom; + final factor = math.pow(2, zoom - z).toDouble(); + final worldScale = math.pow(2, zDelta).toDouble(); + final centerX = centerWorldX * worldScale; + final centerY = centerWorldY * worldScale; + final maxIndex = 1 << z; + + final minWX = centerX - (mapWidth / 2) / factor; + final minWY = centerY - (mapHeight / 2) / factor; + final maxWX = centerX + (mapWidth / 2) / factor; + final maxWY = centerY + (mapHeight / 2) / factor; + + final minTX = (minWX / tileSize).floor() - tileOverscan; + final maxTX = (maxWX / tileSize).floor() + tileOverscan; + final minTY = (minWY / tileSize).floor() - tileOverscan; + final maxTY = (maxWY / tileSize).floor() + tileOverscan; + + if (queueTiles) { + final allowed = _allowedQueuedTileHashes( + z, + minTX, + maxTX, + minTY, + maxTY, + maxIndex, + ); + TilePyramidCache.setQueueFilter( + (qz, qx, qy) => allowed.contains(Object.hash(qz, qx, qy)), + ); + } + + for (var tx = minTX; tx <= maxTX; tx++) { + for (var ty = minTY; ty <= maxTY; ty++) { + if (ty < 0 || ty >= maxIndex) continue; + + final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex; + + final left = (tx * tileSize - minWX) * factor; + final top = (ty * tileSize - minWY) * factor; + final displaySize = tileSize * factor; + + final dst = Rect.fromLTWH(left, top, displaySize, displaySize); + + _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( + z, + minTX - guard, + maxTX + guard, + minTY - guard, + maxTY + guard, + maxIndex, + skipIfAlreadyQueued: true, + visMinTX: minTX, + visMaxTX: maxTX, + visMinTY: minTY, + visMaxTY: maxTY, + ); + } + } + } + + Set _allowedQueuedTileHashes( + int z, + int minTX, + int maxTX, + int minTY, + int maxTY, + int maxIndex, + ) { + final hashes = {}; + + 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)); + } + } + + 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)); + } + } + } + + 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) { + _blitImage( + canvas, + native.image, + Rect.fromLTWH( + 0, + 0, + native.image.width.toDouble(), + native.image.height.toDouble(), + ), + dst, + ); + return; + } + + // Walk up the pyramid for an ancestor fallback. + for (int dz = 1; dz <= _kMaxAncestorLookup; dz++) { + final az = z - dz; + if (az < minTileZoom) break; + + final subdivision = 1 << dz; + final ancestorX = x >> dz; + final ancestorY = y >> dz; + final ancestor = TilePyramidCache.peek(az, ancestorX, ancestorY); + if (ancestor != null) { + // 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, + ); + + _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); + } + } + + void _blitImage(Canvas canvas, ui.Image image, Rect src, Rect dst) { + final paint = Paint() + ..filterQuality = FilterQuality.high + ..colorFilter = _tileColorFilter; + + canvas.drawImageRect(image, src, dst, paint); + } + + void _queueTile(int z, int x, int y, int zoomBucket) { + 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); + }, + () { + // Image ready — ask Flutter to redraw. + SchedulerBinding.instance.scheduleFrame(); + notifyListeners(); + }, + ); + } + + void _queueVisibleTiles( + int z, + int minTX, + int maxTX, + int minTY, + int maxTY, + int maxIndex, { + bool skipIfAlreadyQueued = false, + int visMinTX = 0, + int visMaxTX = 0, + int visMinTY = 0, + int visMaxTY = 0, + }) { + for (var tx = minTX; tx <= maxTX; tx++) { + for (var ty = minTY; ty <= maxTY; ty++) { + if (ty < 0 || ty >= maxIndex) continue; + + // 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 && + ty >= visMinTY && + ty <= visMaxTY) { + 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); + } + } + } + + // ------------------------------------------------------------------------- + // Rasterisation for the cached tile images. + // ------------------------------------------------------------------------- + + void _drawTileContents( + Canvas canvas, + Size sz, + MvtTile tile, { + required double zoom, + }) { + final paint = Paint()..isAntiAlias = true; + paint.color = const Color(0xFFE6EAF1); + canvas.drawRect(Offset.zero & sz, paint); + + _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, + ); + + _drawRoads(canvas, sz, tile, zoom: zoom); + _drawLabels(canvas, sz, tile, zoom: zoom); + } + + Future _rasterize( + MvtTile tile, { + required double zoom, + 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 pic = recorder.endRecording(); + try { + return await pic.toImage(rasterPx, rasterPx); + } finally { + pic.dispose(); + } + } + + void _drawPolygons( + Canvas canvas, + Size size, + MvtTile tile, + Set layerNames, + Color color, { + required double zoom, + Color? strokeColor, + double strokeBaseWidth = 0.0, + }) { + final fill = Paint() + ..color = color + ..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); + + for (final entry in tile.layers.entries) { + if (!layerNames.contains(entry.key)) continue; + final layer = entry.value; + final scale = size.width / layer.extent; + + for (final feature in layer.features) { + if (feature.type != MvtGeometryType.polygon) 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(); + canvas.drawPath(path, fill); + if (stroke != null) canvas.drawPath(path, stroke); + } + } + } + } + + void _drawRoads( + Canvas canvas, + Size size, + MvtTile tile, { + required double zoom, + }) { + final roadLayer = tile.layers['road']; + if (roadLayer == null) return; + + final scale = size.width / roadLayer.extent; + + for (final feature in roadLayer.features) { + if (feature.type != MvtGeometryType.lineString) continue; + + final roadClass = + (feature.properties['class'] ?? feature.properties['type'] ?? '') + .toString() + .toLowerCase(); + + 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 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'); + + var baseWidth = 0.92; + var growth = 0.24; + var casingColor = const Color(0xFF9BA7BE); + var fillColor = const Color(0xFFD8DFEC); + + 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); + } + + if (isLink) baseWidth *= 0.82; + + 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); + } + } + } + + void _drawLabels( + Canvas canvas, + Size size, + MvtTile tile, { + required double zoom, + }) { + if (zoom < 12) return; + + 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, + ), + ); + } + + candidates.sort((a, b) { + final r = a.rank.compareTo(b.rank); + return r != 0 ? r : a.text.length.compareTo(b.text.length); + }); + + var drawn = 0; + for (final c in candidates) { + if (drawn >= maxLabels) break; + if (seenTexts.contains(c.text.toLowerCase())) continue; + + 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(); + + final x = c.anchor.dx * scale - tp.width / 2; + final y = c.anchor.dy * scale - tp.height / 2; + + if (x < -tp.width || + y < -tp.height || + x > size.width || + y > size.height) { + continue; + } + + final labelRect = Rect.fromLTWH( + x, + y, + tp.width, + tp.height, + ).inflate(minSpacing); + if (occupiedRects.any((r) => r.overlaps(labelRect))) continue; + + tp.paint(canvas, Offset(x, y)); + occupiedRects.add(labelRect); + seenTexts.add(c.text.toLowerCase()); + drawn++; + } + } + + 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', + }, + ); + } + + int? _toRank(Object? value) { + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } + + // ------------------------------------------------------------------------- + // 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]; + } + + 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, + ]; + } + + // 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; + } + } + return out; + } + + // ------------------------------------------------------------------------- + // CustomPainter boilerplate + // ------------------------------------------------------------------------- + + @override + bool shouldRepaint(covariant TilePyramidPainter oldDelegate) => true; + + @override + bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false; + + @override + SemanticsBuilderCallback? get semanticsBuilder => null; + + @override + bool hitTest(Offset position) => false; +} + +class _LabelCandidate { + const _LabelCandidate({ + required this.text, + required this.anchor, + required this.rank, + }); + final String text; + final Offset anchor; + final int rank; +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 738d189..9f321f4 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; 51E4CB61A33804794AE9D9C9 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D016D5F689457D86516420F2 /* Pods_RunnerTests.framework */; }; 5798702EB376750FF163D730 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1F61950EF377D9EA76B9AA4 /* Pods_Runner.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -82,6 +83,7 @@ 33F78D48070AFAC7BED2D21D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 347D28852ABFB31D8B8D33E0 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 5044E125F1BF00ABDAD0569C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; C1F61950EF377D9EA76B9AA4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -103,6 +105,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 5798702EB376750FF163D730 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -164,6 +167,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -240,7 +244,6 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 8365B569AFE0AA469B1697ED /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -248,6 +251,9 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* rra_app.app */; productType = "com.apple.product-type.application"; @@ -292,6 +298,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -405,23 +414,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 8365B569AFE0AA469B1697ED /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -796,6 +788,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index cbbc639..2c77741 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + +