diff --git a/lib/pages/map/page.dart b/lib/pages/map/page.dart index 8c60292..7fb2acf 100644 --- a/lib/pages/map/page.dart +++ b/lib/pages/map/page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/gestures.dart' @@ -30,10 +31,25 @@ class MapPage extends StatefulWidget { State createState() => _MapPageState(); } -class _MapPageState extends State with SingleTickerProviderStateMixin { +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'; @@ -46,6 +62,8 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { LatLng _mapCenter = _london; double _zoom = 12; double _targetZoom = 12; + int _activeTileZoom = 12; + double _devicePixelRatio = 2.0; Offset _zoomFocal = Offset.zero; Size _mapSize = Size.zero; late final Ticker _zoomTicker; @@ -56,20 +74,105 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { String? _routeError; int _routeRequestId = 0; int? _draggingInsertPointIndex; - String _lastPrefetchSignature = ''; + 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; + 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; + }); + }); + _fpsUpdateStopwatch = Stopwatch()..start(); + SchedulerBinding.instance.addTimingsCallback(_onFrameTimings); } @override void dispose() { + _cancelPrefetchTimer(); + _cancelInteractionTimer(); + _cancelTilePromotionTimer(); + SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings); + _fallbackFadeController.dispose(); _zoomTicker.dispose(); super.dispose(); } + void _onFrameTimings(List timings) { + for (final timing in timings) { + final frameMs = timing.totalSpan.inMicroseconds / 1000.0; + _frameMsWindow.add(frameMs); + } + if (_frameMsWindow.length > _fpsWindowSize) { + _frameMsWindow.removeRange(0, _frameMsWindow.length - _fpsWindowSize); + } + if (_fpsUpdateStopwatch.elapsed < _fpsUpdateInterval) return; + _fpsUpdateStopwatch.reset(); + if (_frameMsWindow.isEmpty || !mounted) return; + + var totalMs = 0.0; + for (final ms in _frameMsWindow) { + totalMs += ms; + } + final avgMs = totalMs / _frameMsWindow.length; + final fps = avgMs <= 0 ? 0.0 : (1000.0 / avgMs); + if ((fps - _displayFps).abs() < 0.2 && + (avgMs - _displayFrameMs).abs() < 0.2) { + return; + } + setState(() { + _displayFps = fps; + _displayFrameMs = avgMs; + }); + } + void _addPoint(LatLng point) { setState(() { _points.add(point); @@ -141,7 +244,8 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { final scale = _tileSize * math.pow(2, zoom).toDouble(); final x = (latLng.longitude + 180.0) / 360.0 * scale; final sinLat = math.sin(latLng.latitude * math.pi / 180.0); - final y = (0.5 - math.log((1 + sinLat) / (1 - sinLat)) / (4 * math.pi)) * scale; + final y = + (0.5 - math.log((1 + sinLat) / (1 - sinLat)) / (4 * math.pi)) * scale; return math.Point(x, y); } @@ -153,9 +257,24 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { return LatLng(lat, lon); } - int get _tileZoom => _zoom.floor().clamp(2, 18); + int get _tileZoom => _activeTileZoom; - double get _tileZoomFactor => math.pow(2, _zoom - _tileZoom).toDouble(); + 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); @@ -186,7 +305,10 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { void _panMap(Offset delta, {bool notify = true}) { if (_mapSize == Size.zero) return; final centerWorld = _latLngToWorld(_mapCenter, _zoom); - final nextCenter = math.Point(centerWorld.x - delta.dx, centerWorld.y - delta.dy); + final nextCenter = _clampWorldY( + math.Point(centerWorld.x - delta.dx, centerWorld.y - delta.dy), + _zoom, + ); void apply() { _mapCenter = _worldToLatLng(nextCenter, _zoom); } @@ -198,20 +320,95 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { } } + void _queuePan(Offset delta) { + if (delta == Offset.zero) return; + _noteInteraction(); + _pendingPanDelta += delta; + if (_panFrameScheduled) return; + _panFrameScheduled = true; + SchedulerBinding.instance.scheduleFrameCallback((_) { + _panFrameScheduled = false; + if (!mounted) return; + final pending = _pendingPanDelta; + _pendingPanDelta = Offset.zero; + if (pending == Offset.zero) return; + setState(() { + _panMap(pending, notify: false); + }); + }); + } + + void _queuePanZoomUpdate({ + required Offset panDelta, + required double zoomDelta, + required Offset focal, + }) { + if (panDelta == Offset.zero && zoomDelta.abs() < 0.0001) return; + _noteInteraction(); + _pendingPanZoomDelta += panDelta; + _pendingPanZoomZoomDelta += zoomDelta; + _pendingPanZoomFocal = focal; + if (_panZoomFrameScheduled) return; + _panZoomFrameScheduled = true; + SchedulerBinding.instance.scheduleFrameCallback((_) { + _panZoomFrameScheduled = false; + if (!mounted) return; + final pendingPan = _pendingPanZoomDelta; + final pendingZoom = _pendingPanZoomZoomDelta; + final pendingFocal = _pendingPanZoomFocal; + _pendingPanZoomDelta = Offset.zero; + _pendingPanZoomZoomDelta = 0.0; + if (pendingPan == Offset.zero && pendingZoom.abs() < 0.0001) return; + setState(() { + if (pendingZoom.abs() >= 0.0001) { + _setZoomAroundFocal(_zoom + pendingZoom, pendingFocal); + _targetZoom = _zoom; + } + if (pendingPan != Offset.zero) { + _panMap(pendingPan, notify: false); + } + }); + }); + } + + void _noteInteraction() { + _isInteractionActive = true; + _interactionIdleTimer?.cancel(); + _interactionIdleTimer = Timer(_interactionIdleDelay, () { + _isInteractionActive = false; + _flushPrefetchNow(); + _flushTilePromotions(forceAll: true); + }); + } + void _setZoomAroundFocal(double requestedZoom, Offset focal) { if (_mapSize == Size.zero) return; - final nextZoom = requestedZoom.clamp(2.0, 18.0); + final nextZoom = requestedZoom.clamp(_minZoom, _maxZoom); if ((nextZoom - _zoom).abs() < 0.0001) return; final focalLatLng = _screenToLatLng(focal); final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom); - final centerWorldNext = math.Point( - focalWorldNext.x - (focal.dx - _mapSize.width / 2), - focalWorldNext.y - (focal.dy - _mapSize.height / 2), + 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 _) { @@ -227,12 +424,18 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { } LatLng _segmentMidpoint(LatLng a, LatLng b) { - return LatLng((a.latitude + b.latitude) / 2, (a.longitude + b.longitude) / 2); + return LatLng( + (a.latitude + b.latitude) / 2, + (a.longitude + b.longitude) / 2, + ); } void _startInsertPointDrag(int segmentIndex, Offset globalPosition) { if (segmentIndex < 0 || segmentIndex >= _points.length - 1) return; - final midpoint = _segmentMidpoint(_points[segmentIndex], _points[segmentIndex + 1]); + final midpoint = _segmentMidpoint( + _points[segmentIndex], + _points[segmentIndex + 1], + ); final insertIndex = segmentIndex + 1; setState(() { _points.insert(insertIndex, midpoint); @@ -265,24 +468,27 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { _resolveRoute(); } - List _buildTileWidgets() { - if (_mapSize == Size.zero) return const []; + ({List widgets, Set urls, Set prefetchUrls}) + _buildTileLayer({required int tileZoom, bool prefetchChildren = false}) { + if (_mapSize == Size.zero) { + return (widgets: [], urls: {}, prefetchUrls: {}); + } - final tileZoom = _tileZoom; - final factor = _tileZoomFactor; + 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(); - final maxTileX = (maxWorldX / _tileSize).floor(); - final minTileY = (minWorldY / _tileSize).floor(); - final maxTileY = (maxWorldY / _tileSize).floor(); + 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++) { @@ -296,6 +502,7 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { .replaceAll('{z}', '$tileZoom') .replaceAll('{x}', '$wrappedX') .replaceAll('{y}', '$tileY'); + urls.add(url); tiles.add( Positioned( @@ -304,18 +511,19 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { top: top, width: _tileSize * factor, height: _tileSize * factor, - child: HiveTileImage(url: url), + child: HiveTileImage(url: url, onLoaded: _markTileReady), ), ); - if (tileZoom < 18) { + 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) % + final childX = + ((childXBase + dx) % childMaxIndex + childMaxIndex) % childMaxIndex; final childY = childYBase + dy; if (childY < 0 || childY >= childMaxIndex) continue; @@ -331,14 +539,105 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { } } - final prefetchList = prefetchUrls.toList()..sort(); - final signature = prefetchList.join('|'); - if (signature != _lastPrefetchSignature) { - _lastPrefetchSignature = signature; - HiveTileCache.prefetchUrls(prefetchList); + 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; } - return tiles; + 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() { @@ -380,8 +679,10 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { top: screen.dy - 22, child: GestureDetector( behavior: HitTestBehavior.opaque, - onPanStart: (details) => _startInsertPointDrag(i, details.globalPosition), - onPanUpdate: (details) => _updateInsertPointDrag(details.globalPosition), + onPanStart: (details) => + _startInsertPointDrag(i, details.globalPosition), + onPanUpdate: (details) => + _updateInsertPointDrag(details.globalPosition), onPanEnd: (_) => _finishInsertPointDrag(), onPanCancel: _finishInsertPointDrag, child: SizedBox( @@ -394,9 +695,16 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.95), shape: BoxShape.circle, - border: Border.all(color: const Color(0xFF111827), width: 1.5), + border: Border.all( + color: const Color(0xFF111827), + width: 1.5, + ), + ), + child: const Icon( + Icons.open_with, + size: 14, + color: Color(0xFF111827), ), - child: const Icon(Icons.open_with, size: 14, color: Color(0xFF111827)), ), ), ), @@ -409,6 +717,35 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { @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 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(); @@ -423,61 +760,86 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { return Listener( onPointerSignal: (event) { if (event is PointerScaleEvent) { + _noteInteraction(); setState(() { - _setZoomAroundFocal(_zoom + math.log(event.scale) / math.ln2, event.localPosition); + _setZoomAroundFocal( + _zoom + math.log(event.scale) / math.ln2, + event.localPosition, + ); _targetZoom = _zoom; }); } else if (event is PointerScrollEvent) { if (event.kind == PointerDeviceKind.trackpad) { - _panMap(event.scrollDelta); + _queuePan(event.scrollDelta); } else { + _noteInteraction(); final zoomStep = math.log(1.1) / math.ln2; _zoomFocal = event.localPosition; - _targetZoom = (_targetZoom + - (event.scrollDelta.dy < 0 ? zoomStep : -zoomStep)) - .clamp(2.0, 18.0); + _targetZoom = + (_targetZoom + + (event.scrollDelta.dy < 0 + ? zoomStep + : -zoomStep)) + .clamp(_minZoom, _maxZoom); if (!_zoomTicker.isTicking) _zoomTicker.start(); } } }, onPointerPanZoomStart: (PointerPanZoomStartEvent event) { _lastPinchScale = 1.0; + _pendingPanZoomDelta = Offset.zero; + _pendingPanZoomZoomDelta = 0.0; + _pendingPanZoomFocal = event.localPosition; + _noteInteraction(); }, onPointerPanZoomUpdate: (PointerPanZoomUpdateEvent event) { - setState(() { - if ((_lastPinchScale - event.scale).abs() > 0.25) { - _lastPinchScale = event.scale; - } - final scaleChange = event.scale / _lastPinchScale; - _lastPinchScale = event.scale; - final dz = math.log(scaleChange) / math.ln2; - _setZoomAroundFocal(_zoom + dz, event.localPosition); - _targetZoom = _zoom; - if (event.panDelta != Offset.zero) { - _panMap(event.panDelta, notify: false); - } - }); + final scaleChange = event.scale / _lastPinchScale; + _lastPinchScale = event.scale; + final dz = (scaleChange - 1.0).abs() > 0.001 + ? math.log(scaleChange) / math.ln2 + : 0.0; + _queuePanZoomUpdate( + panDelta: event.panDelta, + zoomDelta: dz, + focal: event.localPosition, + ); + }, + onPointerPanZoomEnd: (PointerPanZoomEndEvent event) { + _noteInteraction(); }, - onPointerPanZoomEnd: (PointerPanZoomEndEvent event) {}, child: GestureDetector( key: _mapKey, behavior: HitTestBehavior.opaque, onPanUpdate: (details) { if (_draggingInsertPointIndex != null) return; - _panMap(details.delta); + _queuePan(details.delta); }, onTapUp: (details) { - if (!_isAddPointArmed || _draggingInsertPointIndex != null) return; + if (!_isAddPointArmed || + _draggingInsertPointIndex != null) { + return; + } _addPoint(_screenToLatLng(details.localPosition)); }, child: ClipRect( child: Stack( children: [ - ..._buildTileWidgets(), + const Positioned.fill( + child: ColoredBox(color: Color(0xFFECECEC)), + ), + if (fallbackLayer != null) + FadeTransition( + opacity: _fallbackFadeController, + child: Stack(children: fallbackLayer.widgets), + ), + ...currentLayer.widgets, Positioned.fill( child: IgnorePointer( child: CustomPaint( - painter: _RoutePainter(points: routeScreenPoints, routeColor: _routeColor), + painter: _RoutePainter( + points: routeScreenPoints, + routeColor: _routeColor, + ), ), ), ), @@ -519,7 +881,10 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { spreadRadius: 2, ), ], - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), child: _StatusPanel( isAddPointArmed: _isAddPointArmed, isResolvingRoute: _isResolvingRoute, @@ -532,6 +897,20 @@ class _MapPageState extends State with SingleTickerProviderStateMixin { ), ), ), + SafeArea( + child: Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(10), + child: IgnorePointer( + child: _PerformanceHud( + fps: _displayFps, + frameMs: _displayFrameMs, + ), + ), + ), + ), + ), const SafeArea( child: Align( alignment: Alignment.bottomLeft, @@ -615,6 +994,51 @@ class _HudCornerNotice extends StatelessWidget { } } +class _PerformanceHud extends StatelessWidget { + const _PerformanceHud({required this.fps, required this.frameMs}); + + final double fps; + final double frameMs; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow(color: Color(0x26000000), blurRadius: 4, spreadRadius: 2), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${fps.toStringAsFixed(1)} FPS', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Color(0xFF111827), + ), + ), + Text( + '${frameMs.toStringAsFixed(1)} ms', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF4B5563), + ), + ), + ], + ), + ), + ); + } +} + class _StatusPanel extends StatelessWidget { const _StatusPanel({ required this.isAddPointArmed, @@ -675,10 +1099,7 @@ class _StatusPanel extends StatelessWidget { const SizedBox(height: 4), Text( routeError!, - style: const TextStyle( - fontSize: 12, - color: Color(0xFFB91C1C), - ), + style: const TextStyle(fontSize: 12, color: Color(0xFFB91C1C)), ), ], const SizedBox(height: 4), diff --git a/lib/pages/map/tiles/hive_tile_cache.dart b/lib/pages/map/tiles/hive_tile_cache.dart index e22e921..c87315a 100644 --- a/lib/pages/map/tiles/hive_tile_cache.dart +++ b/lib/pages/map/tiles/hive_tile_cache.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'dart:typed_data'; import 'package:hive/hive.dart'; @@ -8,9 +9,14 @@ class HiveTileCache { static const String _boxName = 'map_tile_cache_v1'; static const int _maxEntries = 6000; + static const int _maxMemoryEntries = 1024; static const Duration _staleAfter = Duration(days: 14); + static const Duration _prefetchCooldown = Duration(seconds: 25); static final Map> _inFlight = >{}; + static final LinkedHashMap _memoryCache = + LinkedHashMap(); + static final Map _recentPrefetches = {}; static Future init() async { if (!Hive.isBoxOpen(_boxName)) { @@ -27,30 +33,68 @@ class HiveTileCache { } } - static Future getOrFetch(String url) async { - final now = DateTime.now().millisecondsSinceEpoch; - final box = _boxOrNull; - if (box == null) return _fetchWithoutCache(url); - final cached = _freshCachedBytes(box, url, now); - if (cached != null) return cached; + static Uint8List? peek(String url) { + final inMemory = _memoryCache.remove(url); + if (inMemory != null) { + _memoryCache[url] = inMemory; + return inMemory; + } - return _fetchAndStore(box, url, now); + final box = _boxOrNull; + if (box == null) return null; + final now = DateTime.now().millisecondsSinceEpoch; + final cached = _freshCachedBytes(box, url, now); + if (cached == null || cached.isEmpty) return null; + _rememberInMemory(url, cached); + return cached; } - static void prefetchUrls( - Iterable urls, { - int maxCount = 96, - }) { + static Future getOrFetch(String url) async { + final immediate = peek(url); + if (immediate != null) return immediate; + + final now = DateTime.now().millisecondsSinceEpoch; + final box = _boxOrNull; + if (box == null) { + final bytes = await _fetchWithoutCache(url); + if (bytes != null && bytes.isNotEmpty) { + _rememberInMemory(url, bytes); + } + return bytes; + } + + final bytes = await _fetchAndStore(box, url, now); + if (bytes != null && bytes.isNotEmpty) { + _rememberInMemory(url, bytes); + } + return bytes; + } + + static void prefetchUrls(Iterable urls, {int maxCount = 48}) { final box = _boxOrNull; if (box == null) return; final now = DateTime.now().millisecondsSinceEpoch; + _recentPrefetches.removeWhere( + (_, ts) => now - ts > _prefetchCooldown.inMilliseconds, + ); var queued = 0; for (final url in urls) { if (queued >= maxCount) break; - if (_freshCachedBytes(box, url, now) != null) continue; + if (_memoryCache.containsKey(url)) continue; + if (box.containsKey(url)) continue; + final lastPrefetchTs = _recentPrefetches[url]; + if (lastPrefetchTs != null && + now - lastPrefetchTs < _prefetchCooldown.inMilliseconds) { + continue; + } if (_inFlight.containsKey(url)) continue; + _recentPrefetches[url] = now; queued += 1; - _fetchAndStore(box, url, now); + _fetchAndStore(box, url, now).then((bytes) { + if (bytes != null && bytes.isNotEmpty) { + _rememberInMemory(url, bytes); + } + }); } } @@ -89,6 +133,7 @@ class HiveTileCache { if (response.statusCode != 200) return null; final bytes = response.bodyBytes; await box.put(url, {'ts': now, 'bytes': bytes}); + _rememberInMemory(url, bytes); await _pruneIfNeeded(box); return bytes; } catch (_) { @@ -116,13 +161,30 @@ class HiveTileCache { } entries.sort((a, b) { - final ats = (a.value is Map && a.value['ts'] is int) ? a.value['ts'] as int : 0; - final bts = (b.value is Map && b.value['ts'] is int) ? b.value['ts'] as int : 0; + final ats = (a.value is Map && a.value['ts'] is int) + ? a.value['ts'] as int + : 0; + final bts = (b.value is Map && b.value['ts'] is int) + ? b.value['ts'] as int + : 0; return ats.compareTo(bts); }); for (var i = 0; i < overflow; i++) { - await box.delete(entries[i].key); + final key = entries[i].key; + await box.delete(key); + if (key is String) { + _memoryCache.remove(key); + } + } + } + + static void _rememberInMemory(String url, Uint8List bytes) { + if (bytes.isEmpty) return; + _memoryCache.remove(url); + _memoryCache[url] = bytes; + while (_memoryCache.length > _maxMemoryEntries) { + _memoryCache.remove(_memoryCache.keys.first); } } } diff --git a/lib/pages/map/tiles/hive_tile_image.dart b/lib/pages/map/tiles/hive_tile_image.dart index 4ddfbd1..62d1bce 100644 --- a/lib/pages/map/tiles/hive_tile_image.dart +++ b/lib/pages/map/tiles/hive_tile_image.dart @@ -4,9 +4,10 @@ import 'package:flutter/material.dart'; import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart'; class HiveTileImage extends StatefulWidget { - const HiveTileImage({required this.url, super.key}); + const HiveTileImage({required this.url, this.onLoaded, super.key}); final String url; + final ValueChanged? onLoaded; @override State createState() => _HiveTileImageState(); @@ -15,18 +16,30 @@ class HiveTileImage extends StatefulWidget { class _HiveTileImageState extends State { Uint8List? _bytes; String? _loadingUrl; + bool _reportedLoaded = false; @override void initState() { super.initState(); - _load(widget.url); + _bytes = HiveTileCache.peek(widget.url); + if (_bytes != null && _bytes!.isNotEmpty) { + _reportLoaded(); + } else { + _load(widget.url); + } } @override void didUpdateWidget(covariant HiveTileImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.url != widget.url) { - _load(widget.url); + _reportedLoaded = false; + _bytes = HiveTileCache.peek(widget.url); + if (_bytes != null && _bytes!.isNotEmpty) { + _reportLoaded(); + } else { + _load(widget.url); + } } } @@ -38,20 +51,29 @@ class _HiveTileImageState extends State { setState(() { _bytes = bytes; }); + _reportLoaded(); } } + void _reportLoaded() { + if (_reportedLoaded) return; + _reportedLoaded = true; + widget.onLoaded?.call(widget.url); + } + @override Widget build(BuildContext context) { final bytes = _bytes; if (bytes == null || bytes.isEmpty) { - return const ColoredBox(color: Color(0xFFE0E0E0)); + return const ColoredBox(color: Colors.transparent); } + _reportLoaded(); return Image.memory( bytes, + scale: widget.url.contains('@2x') ? 2.0 : 1.0, fit: BoxFit.cover, gaplessPlayback: true, - filterQuality: FilterQuality.low, + filterQuality: FilterQuality.medium, ); } }