add in-memory tile cache, tile fade transitions, and performance HUD to map page

This commit is contained in:
ImBenji
2026-02-27 00:18:43 +00:00
parent d460f0369e
commit 85c595f99c
3 changed files with 587 additions and 82 deletions

View File

@@ -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<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
class _MapPageState extends State<MapPage> 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<MapPage> 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<MapPage> with SingleTickerProviderStateMixin {
String? _routeError;
int _routeRequestId = 0;
int? _draggingInsertPointIndex;
String _lastPrefetchSignature = '';
int _lastPrefetchHash = 0;
int _lastPrefetchCount = -1;
List<String> _pendingPrefetchUrls = const <String>[];
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<double> _frameMsWindow = <double>[];
late final Stopwatch _fpsUpdateStopwatch;
double _displayFps = 0;
double _displayFrameMs = 0;
final Set<String> _readyTileUrls = <String>{};
final Set<String> _pendingReadyTileUrls = <String>{};
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<FrameTiming> 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<MapPage> 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<double>(x, y);
}
@@ -153,9 +257,24 @@ class _MapPageState extends State<MapPage> 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<double> _clampWorldY(math.Point<double> world, double zoom) {
final scale = _tileSize * math.pow(2, zoom).toDouble();
final maxY = math.max(0.0, scale - 1);
return math.Point<double>(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<MapPage> with SingleTickerProviderStateMixin {
void _panMap(Offset delta, {bool notify = true}) {
if (_mapSize == Size.zero) return;
final centerWorld = _latLngToWorld(_mapCenter, _zoom);
final nextCenter = math.Point<double>(centerWorld.x - delta.dx, centerWorld.y - delta.dy);
final nextCenter = _clampWorldY(
math.Point<double>(centerWorld.x - delta.dx, centerWorld.y - delta.dy),
_zoom,
);
void apply() {
_mapCenter = _worldToLatLng(nextCenter, _zoom);
}
@@ -198,20 +320,95 @@ class _MapPageState extends State<MapPage> 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<double>(
focalWorldNext.x - (focal.dx - _mapSize.width / 2),
focalWorldNext.y - (focal.dy - _mapSize.height / 2),
final centerWorldNext = _clampWorldY(
math.Point<double>(
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<MapPage> 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<MapPage> with SingleTickerProviderStateMixin {
_resolveRoute();
}
List<Widget> _buildTileWidgets() {
if (_mapSize == Size.zero) return const <Widget>[];
({List<Widget> widgets, Set<String> urls, Set<String> prefetchUrls})
_buildTileLayer({required int tileZoom, bool prefetchChildren = false}) {
if (_mapSize == Size.zero) {
return (widgets: <Widget>[], urls: <String>{}, prefetchUrls: <String>{});
}
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 = <Widget>[];
final urls = <String>{};
final prefetchUrls = <String>{};
for (var tileX = minTileX; tileX <= maxTileX; tileX++) {
@@ -296,6 +502,7 @@ class _MapPageState extends State<MapPage> 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<MapPage> 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<MapPage> 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 = <String>[];
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<String> 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<String> 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 <String>[];
if (pending.isEmpty) return;
HiveTileCache.prefetchUrls(pending);
}
List<Widget> _buildPointWidgets() {
@@ -380,8 +679,10 @@ class _MapPageState extends State<MapPage> 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<MapPage> 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<MapPage> 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<MapPage> 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<MapPage> 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<MapPage> 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),

View File

@@ -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<String, Future<Uint8List?>> _inFlight =
<String, Future<Uint8List?>>{};
static final LinkedHashMap<String, Uint8List> _memoryCache =
LinkedHashMap<String, Uint8List>();
static final Map<String, int> _recentPrefetches = <String, int>{};
static Future<void> init() async {
if (!Hive.isBoxOpen(_boxName)) {
@@ -27,30 +33,68 @@ class HiveTileCache {
}
}
static Future<Uint8List?> 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<String> urls, {
int maxCount = 96,
}) {
static Future<Uint8List?> 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<String> 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, <String, dynamic>{'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);
}
}
}

View File

@@ -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<String>? onLoaded;
@override
State<HiveTileImage> createState() => _HiveTileImageState();
@@ -15,18 +16,30 @@ class HiveTileImage extends StatefulWidget {
class _HiveTileImageState extends State<HiveTileImage> {
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<HiveTileImage> {
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,
);
}
}