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 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart'
|
import 'package:flutter/gestures.dart'
|
||||||
@@ -30,10 +31,25 @@ class MapPage extends StatefulWidget {
|
|||||||
State<MapPage> createState() => _MapPageState();
|
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 _london = LatLng(51.5074, -0.1278);
|
||||||
static const _routeColor = Color(0xFFE02425);
|
static const _routeColor = Color(0xFFE02425);
|
||||||
static const _tileSize = 256.0;
|
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 =
|
static const _tileUrlTemplate =
|
||||||
'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=pk.eyJ1IjoiaW1iZW5qaTAzIiwiYSI6ImNtN2Rqdmw5MDA0bzEyaXM3YjE1emkzOXAifQ.cYyCPQE7OvZx0hzKX2hEiQ';
|
'https://api.mapbox.com/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;
|
LatLng _mapCenter = _london;
|
||||||
double _zoom = 12;
|
double _zoom = 12;
|
||||||
double _targetZoom = 12;
|
double _targetZoom = 12;
|
||||||
|
int _activeTileZoom = 12;
|
||||||
|
double _devicePixelRatio = 2.0;
|
||||||
Offset _zoomFocal = Offset.zero;
|
Offset _zoomFocal = Offset.zero;
|
||||||
Size _mapSize = Size.zero;
|
Size _mapSize = Size.zero;
|
||||||
late final Ticker _zoomTicker;
|
late final Ticker _zoomTicker;
|
||||||
@@ -56,20 +74,105 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
String? _routeError;
|
String? _routeError;
|
||||||
int _routeRequestId = 0;
|
int _routeRequestId = 0;
|
||||||
int? _draggingInsertPointIndex;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_zoomTicker = createTicker(_onZoomTick);
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_cancelPrefetchTimer();
|
||||||
|
_cancelInteractionTimer();
|
||||||
|
_cancelTilePromotionTimer();
|
||||||
|
SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings);
|
||||||
|
_fallbackFadeController.dispose();
|
||||||
_zoomTicker.dispose();
|
_zoomTicker.dispose();
|
||||||
super.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) {
|
void _addPoint(LatLng point) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_points.add(point);
|
_points.add(point);
|
||||||
@@ -141,7 +244,8 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
final scale = _tileSize * math.pow(2, zoom).toDouble();
|
final scale = _tileSize * math.pow(2, zoom).toDouble();
|
||||||
final x = (latLng.longitude + 180.0) / 360.0 * scale;
|
final x = (latLng.longitude + 180.0) / 360.0 * scale;
|
||||||
final sinLat = math.sin(latLng.latitude * math.pi / 180.0);
|
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);
|
return math.Point<double>(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +257,24 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
return LatLng(lat, lon);
|
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) {
|
Offset _latLngToScreen(LatLng latLng) {
|
||||||
final centerWorld = _latLngToWorld(_mapCenter, _zoom);
|
final centerWorld = _latLngToWorld(_mapCenter, _zoom);
|
||||||
@@ -186,7 +305,10 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
void _panMap(Offset delta, {bool notify = true}) {
|
void _panMap(Offset delta, {bool notify = true}) {
|
||||||
if (_mapSize == Size.zero) return;
|
if (_mapSize == Size.zero) return;
|
||||||
final centerWorld = _latLngToWorld(_mapCenter, _zoom);
|
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() {
|
void apply() {
|
||||||
_mapCenter = _worldToLatLng(nextCenter, _zoom);
|
_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) {
|
void _setZoomAroundFocal(double requestedZoom, Offset focal) {
|
||||||
if (_mapSize == Size.zero) return;
|
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;
|
if ((nextZoom - _zoom).abs() < 0.0001) return;
|
||||||
|
|
||||||
final focalLatLng = _screenToLatLng(focal);
|
final focalLatLng = _screenToLatLng(focal);
|
||||||
final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom);
|
final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom);
|
||||||
final centerWorldNext = math.Point<double>(
|
final centerWorldNext = _clampWorldY(
|
||||||
focalWorldNext.x - (focal.dx - _mapSize.width / 2),
|
math.Point<double>(
|
||||||
focalWorldNext.y - (focal.dy - _mapSize.height / 2),
|
focalWorldNext.x - (focal.dx - _mapSize.width / 2),
|
||||||
|
focalWorldNext.y - (focal.dy - _mapSize.height / 2),
|
||||||
|
),
|
||||||
|
nextZoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final previousTileZoom = _activeTileZoom;
|
||||||
_zoom = nextZoom;
|
_zoom = nextZoom;
|
||||||
_mapCenter = _worldToLatLng(centerWorldNext, 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 _) {
|
void _onZoomTick(Duration _) {
|
||||||
@@ -227,12 +424,18 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LatLng _segmentMidpoint(LatLng a, LatLng b) {
|
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) {
|
void _startInsertPointDrag(int segmentIndex, Offset globalPosition) {
|
||||||
if (segmentIndex < 0 || segmentIndex >= _points.length - 1) return;
|
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;
|
final insertIndex = segmentIndex + 1;
|
||||||
setState(() {
|
setState(() {
|
||||||
_points.insert(insertIndex, midpoint);
|
_points.insert(insertIndex, midpoint);
|
||||||
@@ -265,24 +468,27 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
_resolveRoute();
|
_resolveRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildTileWidgets() {
|
({List<Widget> widgets, Set<String> urls, Set<String> prefetchUrls})
|
||||||
if (_mapSize == Size.zero) return const <Widget>[];
|
_buildTileLayer({required int tileZoom, bool prefetchChildren = false}) {
|
||||||
|
if (_mapSize == Size.zero) {
|
||||||
|
return (widgets: <Widget>[], urls: <String>{}, prefetchUrls: <String>{});
|
||||||
|
}
|
||||||
|
|
||||||
final tileZoom = _tileZoom;
|
final factor = math.pow(2, _zoom - tileZoom).toDouble();
|
||||||
final factor = _tileZoomFactor;
|
|
||||||
final centerWorld = _latLngToWorld(_mapCenter, tileZoom.toDouble());
|
final centerWorld = _latLngToWorld(_mapCenter, tileZoom.toDouble());
|
||||||
final minWorldX = centerWorld.x - (_mapSize.width / 2) / factor;
|
final minWorldX = centerWorld.x - (_mapSize.width / 2) / factor;
|
||||||
final maxWorldX = centerWorld.x + (_mapSize.width / 2) / factor;
|
final maxWorldX = centerWorld.x + (_mapSize.width / 2) / factor;
|
||||||
final minWorldY = centerWorld.y - (_mapSize.height / 2) / factor;
|
final minWorldY = centerWorld.y - (_mapSize.height / 2) / factor;
|
||||||
final maxWorldY = centerWorld.y + (_mapSize.height / 2) / factor;
|
final maxWorldY = centerWorld.y + (_mapSize.height / 2) / factor;
|
||||||
|
|
||||||
final minTileX = (minWorldX / _tileSize).floor();
|
final minTileX = (minWorldX / _tileSize).floor() - _tileOverscan;
|
||||||
final maxTileX = (maxWorldX / _tileSize).floor();
|
final maxTileX = (maxWorldX / _tileSize).floor() + _tileOverscan;
|
||||||
final minTileY = (minWorldY / _tileSize).floor();
|
final minTileY = (minWorldY / _tileSize).floor() - _tileOverscan;
|
||||||
final maxTileY = (maxWorldY / _tileSize).floor();
|
final maxTileY = (maxWorldY / _tileSize).floor() + _tileOverscan;
|
||||||
|
|
||||||
final maxIndex = math.pow(2, tileZoom).toInt();
|
final maxIndex = math.pow(2, tileZoom).toInt();
|
||||||
final tiles = <Widget>[];
|
final tiles = <Widget>[];
|
||||||
|
final urls = <String>{};
|
||||||
final prefetchUrls = <String>{};
|
final prefetchUrls = <String>{};
|
||||||
|
|
||||||
for (var tileX = minTileX; tileX <= maxTileX; tileX++) {
|
for (var tileX = minTileX; tileX <= maxTileX; tileX++) {
|
||||||
@@ -296,6 +502,7 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
.replaceAll('{z}', '$tileZoom')
|
.replaceAll('{z}', '$tileZoom')
|
||||||
.replaceAll('{x}', '$wrappedX')
|
.replaceAll('{x}', '$wrappedX')
|
||||||
.replaceAll('{y}', '$tileY');
|
.replaceAll('{y}', '$tileY');
|
||||||
|
urls.add(url);
|
||||||
|
|
||||||
tiles.add(
|
tiles.add(
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -304,18 +511,19 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
top: top,
|
top: top,
|
||||||
width: _tileSize * factor,
|
width: _tileSize * factor,
|
||||||
height: _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 childZoom = tileZoom + 1;
|
||||||
final childMaxIndex = math.pow(2, childZoom).toInt();
|
final childMaxIndex = math.pow(2, childZoom).toInt();
|
||||||
final childXBase = wrappedX * 2;
|
final childXBase = wrappedX * 2;
|
||||||
final childYBase = tileY * 2;
|
final childYBase = tileY * 2;
|
||||||
for (var dx = 0; dx < 2; dx++) {
|
for (var dx = 0; dx < 2; dx++) {
|
||||||
for (var dy = 0; dy < 2; dy++) {
|
for (var dy = 0; dy < 2; dy++) {
|
||||||
final childX = ((childXBase + dx) % childMaxIndex + childMaxIndex) %
|
final childX =
|
||||||
|
((childXBase + dx) % childMaxIndex + childMaxIndex) %
|
||||||
childMaxIndex;
|
childMaxIndex;
|
||||||
final childY = childYBase + dy;
|
final childY = childYBase + dy;
|
||||||
if (childY < 0 || childY >= childMaxIndex) continue;
|
if (childY < 0 || childY >= childMaxIndex) continue;
|
||||||
@@ -331,14 +539,105 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final prefetchList = prefetchUrls.toList()..sort();
|
return (widgets: tiles, urls: urls, prefetchUrls: prefetchUrls);
|
||||||
final signature = prefetchList.join('|');
|
}
|
||||||
if (signature != _lastPrefetchSignature) {
|
|
||||||
_lastPrefetchSignature = signature;
|
void _markTileReady(String url) {
|
||||||
HiveTileCache.prefetchUrls(prefetchList);
|
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() {
|
List<Widget> _buildPointWidgets() {
|
||||||
@@ -380,8 +679,10 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
top: screen.dy - 22,
|
top: screen.dy - 22,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onPanStart: (details) => _startInsertPointDrag(i, details.globalPosition),
|
onPanStart: (details) =>
|
||||||
onPanUpdate: (details) => _updateInsertPointDrag(details.globalPosition),
|
_startInsertPointDrag(i, details.globalPosition),
|
||||||
|
onPanUpdate: (details) =>
|
||||||
|
_updateInsertPointDrag(details.globalPosition),
|
||||||
onPanEnd: (_) => _finishInsertPointDrag(),
|
onPanEnd: (_) => _finishInsertPointDrag(),
|
||||||
onPanCancel: _finishInsertPointDrag,
|
onPanCancel: _finishInsertPointDrag,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@@ -394,9 +695,16 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.95),
|
color: Colors.white.withValues(alpha: 0.95),
|
||||||
shape: BoxShape.circle,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 routePoints = _resolvedRoute.isNotEmpty ? _resolvedRoute : _points;
|
||||||
final routeScreenPoints = routePoints.map(_latLngToScreen).toList();
|
final routeScreenPoints = routePoints.map(_latLngToScreen).toList();
|
||||||
|
|
||||||
@@ -423,61 +760,86 @@ class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
|
|||||||
return Listener(
|
return Listener(
|
||||||
onPointerSignal: (event) {
|
onPointerSignal: (event) {
|
||||||
if (event is PointerScaleEvent) {
|
if (event is PointerScaleEvent) {
|
||||||
|
_noteInteraction();
|
||||||
setState(() {
|
setState(() {
|
||||||
_setZoomAroundFocal(_zoom + math.log(event.scale) / math.ln2, event.localPosition);
|
_setZoomAroundFocal(
|
||||||
|
_zoom + math.log(event.scale) / math.ln2,
|
||||||
|
event.localPosition,
|
||||||
|
);
|
||||||
_targetZoom = _zoom;
|
_targetZoom = _zoom;
|
||||||
});
|
});
|
||||||
} else if (event is PointerScrollEvent) {
|
} else if (event is PointerScrollEvent) {
|
||||||
if (event.kind == PointerDeviceKind.trackpad) {
|
if (event.kind == PointerDeviceKind.trackpad) {
|
||||||
_panMap(event.scrollDelta);
|
_queuePan(event.scrollDelta);
|
||||||
} else {
|
} else {
|
||||||
|
_noteInteraction();
|
||||||
final zoomStep = math.log(1.1) / math.ln2;
|
final zoomStep = math.log(1.1) / math.ln2;
|
||||||
_zoomFocal = event.localPosition;
|
_zoomFocal = event.localPosition;
|
||||||
_targetZoom = (_targetZoom +
|
_targetZoom =
|
||||||
(event.scrollDelta.dy < 0 ? zoomStep : -zoomStep))
|
(_targetZoom +
|
||||||
.clamp(2.0, 18.0);
|
(event.scrollDelta.dy < 0
|
||||||
|
? zoomStep
|
||||||
|
: -zoomStep))
|
||||||
|
.clamp(_minZoom, _maxZoom);
|
||||||
if (!_zoomTicker.isTicking) _zoomTicker.start();
|
if (!_zoomTicker.isTicking) _zoomTicker.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPointerPanZoomStart: (PointerPanZoomStartEvent event) {
|
onPointerPanZoomStart: (PointerPanZoomStartEvent event) {
|
||||||
_lastPinchScale = 1.0;
|
_lastPinchScale = 1.0;
|
||||||
|
_pendingPanZoomDelta = Offset.zero;
|
||||||
|
_pendingPanZoomZoomDelta = 0.0;
|
||||||
|
_pendingPanZoomFocal = event.localPosition;
|
||||||
|
_noteInteraction();
|
||||||
},
|
},
|
||||||
onPointerPanZoomUpdate: (PointerPanZoomUpdateEvent event) {
|
onPointerPanZoomUpdate: (PointerPanZoomUpdateEvent event) {
|
||||||
setState(() {
|
final scaleChange = event.scale / _lastPinchScale;
|
||||||
if ((_lastPinchScale - event.scale).abs() > 0.25) {
|
_lastPinchScale = event.scale;
|
||||||
_lastPinchScale = event.scale;
|
final dz = (scaleChange - 1.0).abs() > 0.001
|
||||||
}
|
? math.log(scaleChange) / math.ln2
|
||||||
final scaleChange = event.scale / _lastPinchScale;
|
: 0.0;
|
||||||
_lastPinchScale = event.scale;
|
_queuePanZoomUpdate(
|
||||||
final dz = math.log(scaleChange) / math.ln2;
|
panDelta: event.panDelta,
|
||||||
_setZoomAroundFocal(_zoom + dz, event.localPosition);
|
zoomDelta: dz,
|
||||||
_targetZoom = _zoom;
|
focal: event.localPosition,
|
||||||
if (event.panDelta != Offset.zero) {
|
);
|
||||||
_panMap(event.panDelta, notify: false);
|
},
|
||||||
}
|
onPointerPanZoomEnd: (PointerPanZoomEndEvent event) {
|
||||||
});
|
_noteInteraction();
|
||||||
},
|
},
|
||||||
onPointerPanZoomEnd: (PointerPanZoomEndEvent event) {},
|
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
key: _mapKey,
|
key: _mapKey,
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onPanUpdate: (details) {
|
onPanUpdate: (details) {
|
||||||
if (_draggingInsertPointIndex != null) return;
|
if (_draggingInsertPointIndex != null) return;
|
||||||
_panMap(details.delta);
|
_queuePan(details.delta);
|
||||||
},
|
},
|
||||||
onTapUp: (details) {
|
onTapUp: (details) {
|
||||||
if (!_isAddPointArmed || _draggingInsertPointIndex != null) return;
|
if (!_isAddPointArmed ||
|
||||||
|
_draggingInsertPointIndex != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_addPoint(_screenToLatLng(details.localPosition));
|
_addPoint(_screenToLatLng(details.localPosition));
|
||||||
},
|
},
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
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(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: CustomPaint(
|
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,
|
spreadRadius: 2,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
child: _StatusPanel(
|
child: _StatusPanel(
|
||||||
isAddPointArmed: _isAddPointArmed,
|
isAddPointArmed: _isAddPointArmed,
|
||||||
isResolvingRoute: _isResolvingRoute,
|
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(
|
const SafeArea(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomLeft,
|
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 {
|
class _StatusPanel extends StatelessWidget {
|
||||||
const _StatusPanel({
|
const _StatusPanel({
|
||||||
required this.isAddPointArmed,
|
required this.isAddPointArmed,
|
||||||
@@ -675,10 +1099,7 @@ class _StatusPanel extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
routeError!,
|
routeError!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 12, color: Color(0xFFB91C1C)),
|
||||||
fontSize: 12,
|
|
||||||
color: Color(0xFFB91C1C),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:collection';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
@@ -8,9 +9,14 @@ class HiveTileCache {
|
|||||||
|
|
||||||
static const String _boxName = 'map_tile_cache_v1';
|
static const String _boxName = 'map_tile_cache_v1';
|
||||||
static const int _maxEntries = 6000;
|
static const int _maxEntries = 6000;
|
||||||
|
static const int _maxMemoryEntries = 1024;
|
||||||
static const Duration _staleAfter = Duration(days: 14);
|
static const Duration _staleAfter = Duration(days: 14);
|
||||||
|
static const Duration _prefetchCooldown = Duration(seconds: 25);
|
||||||
static final Map<String, Future<Uint8List?>> _inFlight =
|
static final Map<String, Future<Uint8List?>> _inFlight =
|
||||||
<String, Future<Uint8List?>>{};
|
<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 {
|
static Future<void> init() async {
|
||||||
if (!Hive.isBoxOpen(_boxName)) {
|
if (!Hive.isBoxOpen(_boxName)) {
|
||||||
@@ -27,30 +33,68 @@ class HiveTileCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Uint8List?> getOrFetch(String url) async {
|
static Uint8List? peek(String url) {
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
final inMemory = _memoryCache.remove(url);
|
||||||
final box = _boxOrNull;
|
if (inMemory != null) {
|
||||||
if (box == null) return _fetchWithoutCache(url);
|
_memoryCache[url] = inMemory;
|
||||||
final cached = _freshCachedBytes(box, url, now);
|
return inMemory;
|
||||||
if (cached != null) return cached;
|
}
|
||||||
|
|
||||||
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(
|
static Future<Uint8List?> getOrFetch(String url) async {
|
||||||
Iterable<String> urls, {
|
final immediate = peek(url);
|
||||||
int maxCount = 96,
|
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;
|
final box = _boxOrNull;
|
||||||
if (box == null) return;
|
if (box == null) return;
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
_recentPrefetches.removeWhere(
|
||||||
|
(_, ts) => now - ts > _prefetchCooldown.inMilliseconds,
|
||||||
|
);
|
||||||
var queued = 0;
|
var queued = 0;
|
||||||
for (final url in urls) {
|
for (final url in urls) {
|
||||||
if (queued >= maxCount) break;
|
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;
|
if (_inFlight.containsKey(url)) continue;
|
||||||
|
_recentPrefetches[url] = now;
|
||||||
queued += 1;
|
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;
|
if (response.statusCode != 200) return null;
|
||||||
final bytes = response.bodyBytes;
|
final bytes = response.bodyBytes;
|
||||||
await box.put(url, <String, dynamic>{'ts': now, 'bytes': bytes});
|
await box.put(url, <String, dynamic>{'ts': now, 'bytes': bytes});
|
||||||
|
_rememberInMemory(url, bytes);
|
||||||
await _pruneIfNeeded(box);
|
await _pruneIfNeeded(box);
|
||||||
return bytes;
|
return bytes;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -116,13 +161,30 @@ class HiveTileCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries.sort((a, b) {
|
entries.sort((a, b) {
|
||||||
final ats = (a.value is Map && a.value['ts'] is int) ? a.value['ts'] as int : 0;
|
final ats = (a.value is Map && a.value['ts'] is int)
|
||||||
final bts = (b.value is Map && b.value['ts'] is int) ? b.value['ts'] as int : 0;
|
? 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);
|
return ats.compareTo(bts);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (var i = 0; i < overflow; i++) {
|
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';
|
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
|
||||||
|
|
||||||
class HiveTileImage extends StatefulWidget {
|
class HiveTileImage extends StatefulWidget {
|
||||||
const HiveTileImage({required this.url, super.key});
|
const HiveTileImage({required this.url, this.onLoaded, super.key});
|
||||||
|
|
||||||
final String url;
|
final String url;
|
||||||
|
final ValueChanged<String>? onLoaded;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HiveTileImage> createState() => _HiveTileImageState();
|
State<HiveTileImage> createState() => _HiveTileImageState();
|
||||||
@@ -15,18 +16,30 @@ class HiveTileImage extends StatefulWidget {
|
|||||||
class _HiveTileImageState extends State<HiveTileImage> {
|
class _HiveTileImageState extends State<HiveTileImage> {
|
||||||
Uint8List? _bytes;
|
Uint8List? _bytes;
|
||||||
String? _loadingUrl;
|
String? _loadingUrl;
|
||||||
|
bool _reportedLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_load(widget.url);
|
_bytes = HiveTileCache.peek(widget.url);
|
||||||
|
if (_bytes != null && _bytes!.isNotEmpty) {
|
||||||
|
_reportLoaded();
|
||||||
|
} else {
|
||||||
|
_load(widget.url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant HiveTileImage oldWidget) {
|
void didUpdateWidget(covariant HiveTileImage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.url != widget.url) {
|
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(() {
|
setState(() {
|
||||||
_bytes = bytes;
|
_bytes = bytes;
|
||||||
});
|
});
|
||||||
|
_reportLoaded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _reportLoaded() {
|
||||||
|
if (_reportedLoaded) return;
|
||||||
|
_reportedLoaded = true;
|
||||||
|
widget.onLoaded?.call(widget.url);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bytes = _bytes;
|
final bytes = _bytes;
|
||||||
if (bytes == null || bytes.isEmpty) {
|
if (bytes == null || bytes.isEmpty) {
|
||||||
return const ColoredBox(color: Color(0xFFE0E0E0));
|
return const ColoredBox(color: Colors.transparent);
|
||||||
}
|
}
|
||||||
|
_reportLoaded();
|
||||||
return Image.memory(
|
return Image.memory(
|
||||||
bytes,
|
bytes,
|
||||||
|
scale: widget.url.contains('@2x') ? 2.0 : 1.0,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
filterQuality: FilterQuality.low,
|
filterQuality: FilterQuality.medium,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user