add in-memory tile cache, tile fade transitions, and performance HUD to map page
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user