add tile caching and rendering for Mapbox vector tiles with custom painter
This commit is contained in:
parent
85c595f99c
commit
dc22ce2f46
8 changed files with 1543 additions and 397 deletions
|
|
@ -14,8 +14,8 @@ import 'package:flutter/scheduler.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:latlong2/latlong.dart' show LatLng;
|
import 'package:latlong2/latlong.dart' show LatLng;
|
||||||
import 'package:rra_app/pages/map/routing/osrm_routing_service.dart';
|
import 'package:rra_app/pages/map/routing/osrm_routing_service.dart';
|
||||||
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
|
import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart';
|
||||||
import 'package:rra_app/pages/map/tiles/hive_tile_image.dart';
|
import 'package:rra_app/pages/map/vector/tile_pyramid_painter.dart';
|
||||||
import 'package:rra_app/pages/map/widgets/toolbar.dart';
|
import 'package:rra_app/pages/map/widgets/toolbar.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn;
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn;
|
||||||
|
|
||||||
|
|
@ -35,23 +35,16 @@ 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 _minZoom = 2.0;
|
||||||
static const _maxZoom = 24.0;
|
static const _maxZoom = 24.0;
|
||||||
static const _minTileZoom = 2;
|
static const _minTileZoom = 2;
|
||||||
static const _maxTileZoom = 22;
|
static const _maxTileZoom = 22;
|
||||||
static const _tileOverscan = 1;
|
static const _tileOverscan = 1;
|
||||||
static const _tileFadeReadyRatio = 0.72;
|
|
||||||
static const _tileFadeDuration = Duration(milliseconds: 220);
|
|
||||||
static const _interactionIdleDelay = Duration(milliseconds: 280);
|
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 _fpsWindowSize = 90;
|
||||||
static const _fpsUpdateInterval = Duration(milliseconds: 240);
|
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/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token=pk.eyJ1IjoiaW1iZW5qaW5ldCIsImEiOiJjbW01azQ1bTcwODJ5MnBzOG9tMTUxdGRoIn0.z22taz_5I_8D5GuDlser_Q';
|
||||||
|
|
||||||
final GlobalKey _mapKey = GlobalKey();
|
final GlobalKey _mapKey = GlobalKey();
|
||||||
final OsrmRoutingService _routingService = const OsrmRoutingService();
|
final OsrmRoutingService _routingService = const OsrmRoutingService();
|
||||||
|
|
@ -63,106 +56,76 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
double _zoom = 12;
|
double _zoom = 12;
|
||||||
double _targetZoom = 12;
|
double _targetZoom = 12;
|
||||||
int _activeTileZoom = 12;
|
int _activeTileZoom = 12;
|
||||||
double _devicePixelRatio = 2.0;
|
int? _fallbackTileZoom;
|
||||||
Offset _zoomFocal = Offset.zero;
|
Offset _zoomFocal = Offset.zero;
|
||||||
Size _mapSize = Size.zero;
|
Size _mapSize = Size.zero;
|
||||||
|
double _devicePixelRatio = 2.0;
|
||||||
|
|
||||||
late final Ticker _zoomTicker;
|
late final Ticker _zoomTicker;
|
||||||
double _lastPinchScale = 1.0;
|
double _lastPinchScale = 1.0;
|
||||||
|
|
||||||
bool _isAddPointArmed = false;
|
|
||||||
bool _isResolvingRoute = false;
|
|
||||||
String? _routeError;
|
|
||||||
int _routeRequestId = 0;
|
|
||||||
int? _draggingInsertPointIndex;
|
|
||||||
int _lastPrefetchHash = 0;
|
|
||||||
int _lastPrefetchCount = -1;
|
|
||||||
List<String> _pendingPrefetchUrls = const <String>[];
|
|
||||||
Timer? _prefetchDebounceTimer;
|
|
||||||
bool _isInteractionActive = false;
|
|
||||||
Timer? _interactionIdleTimer;
|
|
||||||
Offset _pendingPanDelta = Offset.zero;
|
Offset _pendingPanDelta = Offset.zero;
|
||||||
bool _panFrameScheduled = false;
|
bool _panFrameScheduled = false;
|
||||||
Offset _pendingPanZoomDelta = Offset.zero;
|
Offset _pendingPanZoomDelta = Offset.zero;
|
||||||
double _pendingPanZoomZoomDelta = 0.0;
|
double _pendingPanZoomZoomDelta = 0.0;
|
||||||
Offset _pendingPanZoomFocal = Offset.zero;
|
Offset _pendingPanZoomFocal = Offset.zero;
|
||||||
bool _panZoomFrameScheduled = false;
|
bool _panZoomFrameScheduled = false;
|
||||||
|
|
||||||
|
bool _isAddPointArmed = false;
|
||||||
|
bool _isResolvingRoute = false;
|
||||||
|
String? _routeError;
|
||||||
|
int _routeRequestId = 0;
|
||||||
|
int? _draggingInsertPointIndex;
|
||||||
|
Timer? _interactionIdleTimer;
|
||||||
|
|
||||||
|
late final TilePyramidPainter _pyramidPainter;
|
||||||
|
|
||||||
final List<double> _frameMsWindow = <double>[];
|
final List<double> _frameMsWindow = <double>[];
|
||||||
late final Stopwatch _fpsUpdateStopwatch;
|
late final Stopwatch _fpsUpdateStopwatch;
|
||||||
double _displayFps = 0;
|
double _displayFps = 0;
|
||||||
double _displayFrameMs = 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 =
|
_pyramidPainter = TilePyramidPainter(
|
||||||
AnimationController(
|
tileUrlTemplate: _tileUrlTemplate,
|
||||||
vsync: this,
|
tileSize: _tileSize,
|
||||||
duration: _tileFadeDuration,
|
minTileZoom: _minTileZoom,
|
||||||
lowerBound: 0,
|
maxTileZoom: _maxTileZoom,
|
||||||
upperBound: 1,
|
tileOverscan: _tileOverscan,
|
||||||
value: 0,
|
);
|
||||||
)..addStatusListener((status) {
|
|
||||||
if (status != AnimationStatus.dismissed) return;
|
|
||||||
if (!mounted || _fallbackTileZoom == null) return;
|
|
||||||
setState(() {
|
|
||||||
_fallbackTileZoom = null;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
_fpsUpdateStopwatch = Stopwatch()..start();
|
_fpsUpdateStopwatch = Stopwatch()..start();
|
||||||
SchedulerBinding.instance.addTimingsCallback(_onFrameTimings);
|
SchedulerBinding.instance.addTimingsCallback(_onFrameTimings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cancelPrefetchTimer();
|
|
||||||
_cancelInteractionTimer();
|
|
||||||
_cancelTilePromotionTimer();
|
|
||||||
SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings);
|
|
||||||
_fallbackFadeController.dispose();
|
|
||||||
_zoomTicker.dispose();
|
_zoomTicker.dispose();
|
||||||
|
_pyramidPainter.dispose();
|
||||||
|
TilePyramidCache.clear();
|
||||||
|
SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings);
|
||||||
|
_interactionIdleTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFrameTimings(List<FrameTiming> timings) {
|
void _onFrameTimings(List<FrameTiming> timings) {
|
||||||
for (final timing in timings) {
|
for (final timing in timings) {
|
||||||
final frameMs = timing.totalSpan.inMicroseconds / 1000.0;
|
_frameMsWindow.add(timing.totalSpan.inMicroseconds / 1000.0);
|
||||||
_frameMsWindow.add(frameMs);
|
|
||||||
}
|
}
|
||||||
if (_frameMsWindow.length > _fpsWindowSize) {
|
if (_frameMsWindow.length > _fpsWindowSize) {
|
||||||
_frameMsWindow.removeRange(0, _frameMsWindow.length - _fpsWindowSize);
|
_frameMsWindow.removeRange(0, _frameMsWindow.length - _fpsWindowSize);
|
||||||
}
|
}
|
||||||
if (_fpsUpdateStopwatch.elapsed < _fpsUpdateInterval) return;
|
if (_fpsUpdateStopwatch.elapsed < _fpsUpdateInterval || !mounted) return;
|
||||||
_fpsUpdateStopwatch.reset();
|
_fpsUpdateStopwatch.reset();
|
||||||
if (_frameMsWindow.isEmpty || !mounted) return;
|
if (_frameMsWindow.isEmpty) return;
|
||||||
|
|
||||||
var totalMs = 0.0;
|
var total = 0.0;
|
||||||
for (final ms in _frameMsWindow) {
|
for (final ms in _frameMsWindow) {
|
||||||
totalMs += ms;
|
total += ms;
|
||||||
}
|
}
|
||||||
final avgMs = totalMs / _frameMsWindow.length;
|
final avgMs = total / _frameMsWindow.length;
|
||||||
final fps = avgMs <= 0 ? 0.0 : (1000.0 / avgMs);
|
final fps = avgMs <= 0 ? 0.0 : 1000.0 / avgMs;
|
||||||
if ((fps - _displayFps).abs() < 0.2 &&
|
if ((fps - _displayFps).abs() < 0.2 &&
|
||||||
(avgMs - _displayFrameMs).abs() < 0.2) {
|
(avgMs - _displayFrameMs).abs() < 0.2) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -233,13 +196,6 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _pointColor(int index) {
|
|
||||||
if (_points.length == 1) return const Color(0xFF0F9D58);
|
|
||||||
if (index == 0) return const Color(0xFF0F9D58);
|
|
||||||
if (index == _points.length - 1) return const Color(0xFFE11D48);
|
|
||||||
return const Color(0xFF2563EB);
|
|
||||||
}
|
|
||||||
|
|
||||||
math.Point<double> _latLngToWorld(LatLng latLng, double zoom) {
|
math.Point<double> _latLngToWorld(LatLng latLng, double zoom) {
|
||||||
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;
|
||||||
|
|
@ -257,25 +213,6 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
return LatLng(lat, lon);
|
return LatLng(lat, lon);
|
||||||
}
|
}
|
||||||
|
|
||||||
int get _tileZoom => _activeTileZoom;
|
|
||||||
|
|
||||||
int _idealTileZoom(double zoomValue, {required double devicePixelRatio}) {
|
|
||||||
final clampedDeviceRatio = devicePixelRatio.clamp(1.0, 4.0).toDouble();
|
|
||||||
final maxTileZoomForScreenResolution =
|
|
||||||
zoomValue +
|
|
||||||
(math.log(clampedDeviceRatio / _tileSourcePixelRatio) / math.ln2);
|
|
||||||
return maxTileZoomForScreenResolution.floor().clamp(
|
|
||||||
_minTileZoom,
|
|
||||||
_maxTileZoom,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
math.Point<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);
|
||||||
final pointWorld = _latLngToWorld(latLng, _zoom);
|
final pointWorld = _latLngToWorld(latLng, _zoom);
|
||||||
|
|
@ -294,12 +231,10 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
return _worldToLatLng(world, _zoom);
|
return _worldToLatLng(world, _zoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
Offset? _globalToMapLocal(Offset globalPosition) {
|
math.Point<double> _clampWorldY(math.Point<double> world, double zoom) {
|
||||||
final context = _mapKey.currentContext;
|
final scale = _tileSize * math.pow(2, zoom).toDouble();
|
||||||
if (context == null) return null;
|
final maxY = math.max(0.0, scale - 1);
|
||||||
final box = context.findRenderObject();
|
return math.Point<double>(world.x, world.y.clamp(0.0, maxY).toDouble());
|
||||||
if (box is! RenderBox) return null;
|
|
||||||
return box.globalToLocal(globalPosition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _panMap(Offset delta, {bool notify = true}) {
|
void _panMap(Offset delta, {bool notify = true}) {
|
||||||
|
|
@ -309,9 +244,8 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
math.Point<double>(centerWorld.x - delta.dx, centerWorld.y - delta.dy),
|
math.Point<double>(centerWorld.x - delta.dx, centerWorld.y - delta.dy),
|
||||||
_zoom,
|
_zoom,
|
||||||
);
|
);
|
||||||
void apply() {
|
|
||||||
_mapCenter = _worldToLatLng(nextCenter, _zoom);
|
void apply() => _mapCenter = _worldToLatLng(nextCenter, _zoom);
|
||||||
}
|
|
||||||
|
|
||||||
if (notify) {
|
if (notify) {
|
||||||
setState(apply);
|
setState(apply);
|
||||||
|
|
@ -320,6 +254,50 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _idealTileZoom(double zoomValue, {required double devicePixelRatio}) {
|
||||||
|
final ratio = devicePixelRatio.clamp(1.0, 4.0).toDouble();
|
||||||
|
final adjusted = zoomValue + (math.log(ratio / 2.0) / math.ln2);
|
||||||
|
return adjusted.floor().clamp(_minTileZoom, _maxTileZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setZoomAroundFocal(double requestedZoom, Offset focal) {
|
||||||
|
if (_mapSize == Size.zero) return;
|
||||||
|
final nextZoom = requestedZoom.clamp(_minZoom, _maxZoom);
|
||||||
|
if ((nextZoom - _zoom).abs() < 0.0001) return;
|
||||||
|
|
||||||
|
final previousTileZoom = _activeTileZoom;
|
||||||
|
final focalLatLng = _screenToLatLng(focal);
|
||||||
|
final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom);
|
||||||
|
final centerWorldNext = _clampWorldY(
|
||||||
|
math.Point<double>(
|
||||||
|
focalWorldNext.x - (focal.dx - _mapSize.width / 2),
|
||||||
|
focalWorldNext.y - (focal.dy - _mapSize.height / 2),
|
||||||
|
),
|
||||||
|
nextZoom,
|
||||||
|
);
|
||||||
|
|
||||||
|
_zoom = nextZoom;
|
||||||
|
_mapCenter = _worldToLatLng(centerWorldNext, nextZoom);
|
||||||
|
_activeTileZoom = _idealTileZoom(
|
||||||
|
_zoom,
|
||||||
|
devicePixelRatio: _devicePixelRatio,
|
||||||
|
);
|
||||||
|
if (_activeTileZoom != previousTileZoom) {
|
||||||
|
_fallbackTileZoom = previousTileZoom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onZoomTick(Duration _) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
const t = 0.2;
|
||||||
|
final newZoom = _zoom + (_targetZoom - _zoom) * t;
|
||||||
|
final done = (_targetZoom - newZoom).abs() < 0.001;
|
||||||
|
_setZoomAroundFocal(done ? _targetZoom : newZoom, _zoomFocal);
|
||||||
|
if (done) _zoomTicker.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _queuePan(Offset delta) {
|
void _queuePan(Offset delta) {
|
||||||
if (delta == Offset.zero) return;
|
if (delta == Offset.zero) return;
|
||||||
_noteInteraction();
|
_noteInteraction();
|
||||||
|
|
@ -332,9 +310,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
final pending = _pendingPanDelta;
|
final pending = _pendingPanDelta;
|
||||||
_pendingPanDelta = Offset.zero;
|
_pendingPanDelta = Offset.zero;
|
||||||
if (pending == Offset.zero) return;
|
if (pending == Offset.zero) return;
|
||||||
setState(() {
|
setState(() => _panMap(pending, notify: false));
|
||||||
_panMap(pending, notify: false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,55 +348,23 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _noteInteraction() {
|
void _noteInteraction() {
|
||||||
_isInteractionActive = true;
|
|
||||||
_interactionIdleTimer?.cancel();
|
_interactionIdleTimer?.cancel();
|
||||||
_interactionIdleTimer = Timer(_interactionIdleDelay, () {
|
_interactionIdleTimer = Timer(_interactionIdleDelay, () {});
|
||||||
_isInteractionActive = false;
|
|
||||||
_flushPrefetchNow();
|
|
||||||
_flushTilePromotions(forceAll: true);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setZoomAroundFocal(double requestedZoom, Offset focal) {
|
void _updatePainterViewport() {
|
||||||
if (_mapSize == Size.zero) return;
|
final cw = _latLngToWorld(_mapCenter, _activeTileZoom.toDouble());
|
||||||
final nextZoom = requestedZoom.clamp(_minZoom, _maxZoom);
|
_pyramidPainter
|
||||||
if ((nextZoom - _zoom).abs() < 0.0001) return;
|
..mapWidth = _mapSize.width
|
||||||
|
..mapHeight = _mapSize.height
|
||||||
final focalLatLng = _screenToLatLng(focal);
|
..zoom = _zoom
|
||||||
final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom);
|
..centerWorldX = cw.x
|
||||||
final centerWorldNext = _clampWorldY(
|
..centerWorldY = cw.y
|
||||||
math.Point<double>(
|
..activeTileZoom = _activeTileZoom
|
||||||
focalWorldNext.x - (focal.dx - _mapSize.width / 2),
|
..fallbackTileZoom = _fallbackTileZoom
|
||||||
focalWorldNext.y - (focal.dy - _mapSize.height / 2),
|
..devicePixelRatio = _devicePixelRatio
|
||||||
),
|
..interactionActive = _interactionIdleTimer?.isActive ?? false;
|
||||||
nextZoom,
|
_pyramidPainter.markDirty();
|
||||||
);
|
|
||||||
|
|
||||||
final previousTileZoom = _activeTileZoom;
|
|
||||||
_zoom = nextZoom;
|
|
||||||
_mapCenter = _worldToLatLng(centerWorldNext, nextZoom);
|
|
||||||
_activeTileZoom = _idealTileZoom(
|
|
||||||
_zoom,
|
|
||||||
devicePixelRatio: _devicePixelRatio,
|
|
||||||
);
|
|
||||||
final nextTileZoom = _activeTileZoom;
|
|
||||||
if (previousTileZoom != nextTileZoom) {
|
|
||||||
_fallbackTileZoom = previousTileZoom;
|
|
||||||
_fallbackFadeController.stop();
|
|
||||||
_fallbackFadeController.value = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onZoomTick(Duration _) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
const t = 0.2;
|
|
||||||
final newZoom = _zoom + (_targetZoom - _zoom) * t;
|
|
||||||
final done = (_targetZoom - newZoom).abs() < 0.001;
|
|
||||||
final actual = done ? _targetZoom : newZoom;
|
|
||||||
_setZoomAroundFocal(actual, _zoomFocal);
|
|
||||||
if (done) _zoomTicker.stop();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LatLng _segmentMidpoint(LatLng a, LatLng b) {
|
LatLng _segmentMidpoint(LatLng a, LatLng b) {
|
||||||
|
|
@ -448,198 +392,31 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
_updateInsertPointDrag(globalPosition);
|
_updateInsertPointDrag(globalPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Offset? _globalToMapLocal(Offset globalPosition) {
|
||||||
|
final context = _mapKey.currentContext;
|
||||||
|
if (context == null) return null;
|
||||||
|
final box = context.findRenderObject();
|
||||||
|
if (box is! RenderBox) return null;
|
||||||
|
return box.globalToLocal(globalPosition);
|
||||||
|
}
|
||||||
|
|
||||||
void _updateInsertPointDrag(Offset globalPosition) {
|
void _updateInsertPointDrag(Offset globalPosition) {
|
||||||
final index = _draggingInsertPointIndex;
|
final index = _draggingInsertPointIndex;
|
||||||
if (index == null || index < 0 || index >= _points.length) return;
|
if (index == null || index < 0 || index >= _points.length) return;
|
||||||
final local = _globalToMapLocal(globalPosition);
|
final local = _globalToMapLocal(globalPosition);
|
||||||
if (local == null) return;
|
if (local == null) return;
|
||||||
final point = _screenToLatLng(local);
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_points[index] = point;
|
_points[index] = _screenToLatLng(local);
|
||||||
_resolvedRoute = _points.toList();
|
_resolvedRoute = _points.toList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _finishInsertPointDrag() {
|
void _finishInsertPointDrag() {
|
||||||
if (_draggingInsertPointIndex == null) return;
|
if (_draggingInsertPointIndex == null) return;
|
||||||
setState(() {
|
setState(() => _draggingInsertPointIndex = null);
|
||||||
_draggingInsertPointIndex = null;
|
|
||||||
});
|
|
||||||
_resolveRoute();
|
_resolveRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
({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 factor = math.pow(2, _zoom - tileZoom).toDouble();
|
|
||||||
final centerWorld = _latLngToWorld(_mapCenter, tileZoom.toDouble());
|
|
||||||
final minWorldX = centerWorld.x - (_mapSize.width / 2) / factor;
|
|
||||||
final maxWorldX = centerWorld.x + (_mapSize.width / 2) / factor;
|
|
||||||
final minWorldY = centerWorld.y - (_mapSize.height / 2) / factor;
|
|
||||||
final maxWorldY = centerWorld.y + (_mapSize.height / 2) / factor;
|
|
||||||
|
|
||||||
final minTileX = (minWorldX / _tileSize).floor() - _tileOverscan;
|
|
||||||
final maxTileX = (maxWorldX / _tileSize).floor() + _tileOverscan;
|
|
||||||
final minTileY = (minWorldY / _tileSize).floor() - _tileOverscan;
|
|
||||||
final maxTileY = (maxWorldY / _tileSize).floor() + _tileOverscan;
|
|
||||||
|
|
||||||
final maxIndex = math.pow(2, tileZoom).toInt();
|
|
||||||
final tiles = <Widget>[];
|
|
||||||
final urls = <String>{};
|
|
||||||
final prefetchUrls = <String>{};
|
|
||||||
|
|
||||||
for (var tileX = minTileX; tileX <= maxTileX; tileX++) {
|
|
||||||
final wrappedX = ((tileX % maxIndex) + maxIndex) % maxIndex;
|
|
||||||
for (var tileY = minTileY; tileY <= maxTileY; tileY++) {
|
|
||||||
if (tileY < 0 || tileY >= maxIndex) continue;
|
|
||||||
|
|
||||||
final left = (tileX * _tileSize - minWorldX) * factor;
|
|
||||||
final top = (tileY * _tileSize - minWorldY) * factor;
|
|
||||||
final url = _tileUrlTemplate
|
|
||||||
.replaceAll('{z}', '$tileZoom')
|
|
||||||
.replaceAll('{x}', '$wrappedX')
|
|
||||||
.replaceAll('{y}', '$tileY');
|
|
||||||
urls.add(url);
|
|
||||||
|
|
||||||
tiles.add(
|
|
||||||
Positioned(
|
|
||||||
key: ValueKey<String>(url),
|
|
||||||
left: left,
|
|
||||||
top: top,
|
|
||||||
width: _tileSize * factor,
|
|
||||||
height: _tileSize * factor,
|
|
||||||
child: HiveTileImage(url: url, onLoaded: _markTileReady),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (prefetchChildren && tileZoom < _maxTileZoom) {
|
|
||||||
final childZoom = tileZoom + 1;
|
|
||||||
final childMaxIndex = math.pow(2, childZoom).toInt();
|
|
||||||
final childXBase = wrappedX * 2;
|
|
||||||
final childYBase = tileY * 2;
|
|
||||||
for (var dx = 0; dx < 2; dx++) {
|
|
||||||
for (var dy = 0; dy < 2; dy++) {
|
|
||||||
final childX =
|
|
||||||
((childXBase + dx) % childMaxIndex + childMaxIndex) %
|
|
||||||
childMaxIndex;
|
|
||||||
final childY = childYBase + dy;
|
|
||||||
if (childY < 0 || childY >= childMaxIndex) continue;
|
|
||||||
prefetchUrls.add(
|
|
||||||
_tileUrlTemplate
|
|
||||||
.replaceAll('{z}', '$childZoom')
|
|
||||||
.replaceAll('{x}', '$childX')
|
|
||||||
.replaceAll('{y}', '$childY'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (widgets: tiles, urls: urls, prefetchUrls: prefetchUrls);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _markTileReady(String url) {
|
|
||||||
if (_readyTileUrls.contains(url) || _pendingReadyTileUrls.contains(url)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_pendingReadyTileUrls.add(url);
|
|
||||||
_scheduleTilePromotionTick();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleTilePromotionTick() {
|
|
||||||
if (_tilePromotionTimer != null || _pendingReadyTileUrls.isEmpty) return;
|
|
||||||
final delay = _isInteractionActive
|
|
||||||
? _tilePromotionActiveInterval
|
|
||||||
: _tilePromotionIdleInterval;
|
|
||||||
_tilePromotionTimer = Timer(delay, () {
|
|
||||||
_tilePromotionTimer = null;
|
|
||||||
_flushTilePromotions(forceAll: true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _flushTilePromotions({bool forceAll = false}) {
|
|
||||||
if (_pendingReadyTileUrls.isEmpty) return;
|
|
||||||
final maxPromotions = forceAll || !_isInteractionActive
|
|
||||||
? _pendingReadyTileUrls.length
|
|
||||||
: _tilePromotionBatchSize;
|
|
||||||
final promoted = <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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
||||||
final widgets = <Widget>[];
|
final widgets = <Widget>[];
|
||||||
for (var i = 0; i < _points.length; i++) {
|
for (var i = 0; i < _points.length; i++) {
|
||||||
|
|
@ -653,7 +430,11 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
width: 34,
|
width: 34,
|
||||||
height: 34,
|
height: 34,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _pointColor(i),
|
color: i == 0
|
||||||
|
? const Color(0xFF0F9D58)
|
||||||
|
: (i == _points.length - 1
|
||||||
|
? const Color(0xFFE11D48)
|
||||||
|
: const Color(0xFF2563EB)),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
),
|
),
|
||||||
|
|
@ -668,7 +449,6 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
|
|
||||||
List<Widget> _buildInsertHandles() {
|
List<Widget> _buildInsertHandles() {
|
||||||
if (_points.length < 2) return const <Widget>[];
|
if (_points.length < 2) return const <Widget>[];
|
||||||
|
|
||||||
final handles = <Widget>[];
|
final handles = <Widget>[];
|
||||||
for (var i = 0; i < _points.length - 1; i++) {
|
for (var i = 0; i < _points.length - 1; i++) {
|
||||||
final midpoint = _segmentMidpoint(_points[i], _points[i + 1]);
|
final midpoint = _segmentMidpoint(_points[i], _points[i + 1]);
|
||||||
|
|
@ -717,34 +497,10 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final nextDevicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||||
if ((nextDevicePixelRatio - _devicePixelRatio).abs() > 0.01) {
|
if ((dpr - _devicePixelRatio).abs() > 0.01) {
|
||||||
_devicePixelRatio = nextDevicePixelRatio;
|
_devicePixelRatio = dpr;
|
||||||
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();
|
||||||
|
|
@ -756,6 +512,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_mapSize = constraints.biggest;
|
_mapSize = constraints.biggest;
|
||||||
|
_updatePainterViewport();
|
||||||
|
|
||||||
return Listener(
|
return Listener(
|
||||||
onPointerSignal: (event) {
|
onPointerSignal: (event) {
|
||||||
|
|
@ -827,12 +584,11 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
const Positioned.fill(
|
const Positioned.fill(
|
||||||
child: ColoredBox(color: Color(0xFFECECEC)),
|
child: ColoredBox(color: Color(0xFFECECEC)),
|
||||||
),
|
),
|
||||||
if (fallbackLayer != null)
|
Positioned.fill(
|
||||||
FadeTransition(
|
child: RepaintBoundary(
|
||||||
opacity: _fallbackFadeController,
|
child: CustomPaint(painter: _pyramidPainter),
|
||||||
child: Stack(children: fallbackLayer.widgets),
|
),
|
||||||
),
|
),
|
||||||
...currentLayer.widgets,
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
|
|
@ -911,12 +667,12 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SafeArea(
|
SafeArea(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomLeft,
|
alignment: Alignment.bottomLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
child: IgnorePointer(child: _HudCornerNotice()),
|
child: const IgnorePointer(child: _HudCornerNotice()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
71
lib/pages/map/vector/mapbox_vector_tile_cache.dart
Normal file
71
lib/pages/map/vector/mapbox_vector_tile_cache.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
|
||||||
|
import 'package:rra_app/pages/map/vector/mvt_parser.dart';
|
||||||
|
|
||||||
|
class MapboxVectorTileCache {
|
||||||
|
MapboxVectorTileCache._();
|
||||||
|
|
||||||
|
static final MvtParser _parser = const MvtParser();
|
||||||
|
static const Set<String> _styleLayerWhitelist = <String>{
|
||||||
|
'water',
|
||||||
|
'landuse',
|
||||||
|
'park',
|
||||||
|
'building',
|
||||||
|
'road',
|
||||||
|
'road_label',
|
||||||
|
'place_label',
|
||||||
|
'poi_label',
|
||||||
|
};
|
||||||
|
static const int _maxEntries = 220;
|
||||||
|
static final LinkedHashMap<String, MvtTile> _cache =
|
||||||
|
LinkedHashMap<String, MvtTile>();
|
||||||
|
static final Map<String, Future<MvtTile?>> _inFlight =
|
||||||
|
<String, Future<MvtTile?>>{};
|
||||||
|
|
||||||
|
static MvtTile? peek(String url) {
|
||||||
|
final cached = _cache.remove(url);
|
||||||
|
if (cached != null) {
|
||||||
|
_cache[url] = cached;
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<MvtTile?> getOrFetch(String url) {
|
||||||
|
final cached = peek(url);
|
||||||
|
if (cached != null) return Future<MvtTile?>.value(cached);
|
||||||
|
|
||||||
|
final inFlight = _inFlight[url];
|
||||||
|
if (inFlight != null) return inFlight;
|
||||||
|
|
||||||
|
final future = _fetch(url);
|
||||||
|
_inFlight[url] = future;
|
||||||
|
future.whenComplete(() => _inFlight.remove(url));
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<MvtTile?> _fetch(String url) async {
|
||||||
|
final bytes = await HiveTileCache.getOrFetch(url);
|
||||||
|
if (bytes == null || bytes.isEmpty) return null;
|
||||||
|
final tile = _parse(bytes);
|
||||||
|
if (tile == null) return null;
|
||||||
|
_cache[url] = tile;
|
||||||
|
_trim();
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
static MvtTile? _parse(Uint8List bytes) {
|
||||||
|
try {
|
||||||
|
return _parser.parse(bytes, layerNames: _styleLayerWhitelist);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _trim() {
|
||||||
|
while (_cache.length > _maxEntries) {
|
||||||
|
_cache.remove(_cache.keys.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
362
lib/pages/map/vector/mvt_parser.dart
Normal file
362
lib/pages/map/vector/mvt_parser.dart
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
enum MvtGeometryType { unknown, point, lineString, polygon }
|
||||||
|
|
||||||
|
class MvtTile {
|
||||||
|
const MvtTile({required this.layers});
|
||||||
|
|
||||||
|
final Map<String, MvtLayer> layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MvtLayer {
|
||||||
|
const MvtLayer({
|
||||||
|
required this.name,
|
||||||
|
required this.extent,
|
||||||
|
required this.features,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final int extent;
|
||||||
|
final List<MvtFeature> features;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MvtFeature {
|
||||||
|
const MvtFeature({
|
||||||
|
required this.type,
|
||||||
|
required this.properties,
|
||||||
|
required this.geometry,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MvtGeometryType type;
|
||||||
|
final Map<String, Object?> properties;
|
||||||
|
final List<List<Offset>> geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MvtParser {
|
||||||
|
const MvtParser();
|
||||||
|
|
||||||
|
MvtTile parse(Uint8List bytes, {Set<String>? layerNames}) {
|
||||||
|
final reader = _PbfReader(bytes);
|
||||||
|
final layers = <String, MvtLayer>{};
|
||||||
|
|
||||||
|
while (!reader.isAtEnd) {
|
||||||
|
final tag = reader.readVarint();
|
||||||
|
final field = tag >> 3;
|
||||||
|
final wire = tag & 0x07;
|
||||||
|
if (field == 3 && wire == 2) {
|
||||||
|
final layerBytes = reader.readBytes();
|
||||||
|
final layer = _parseLayer(layerBytes);
|
||||||
|
if (layer == null) continue;
|
||||||
|
if (layerNames != null && !layerNames.contains(layer.name)) continue;
|
||||||
|
layers[layer.name] = layer;
|
||||||
|
} else {
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MvtTile(layers: layers);
|
||||||
|
}
|
||||||
|
|
||||||
|
MvtLayer? _parseLayer(Uint8List bytes) {
|
||||||
|
final reader = _PbfReader(bytes);
|
||||||
|
String name = '';
|
||||||
|
var extent = 4096;
|
||||||
|
final rawFeatures = <_RawFeature>[];
|
||||||
|
final keys = <String>[];
|
||||||
|
final values = <Object?>[];
|
||||||
|
|
||||||
|
while (!reader.isAtEnd) {
|
||||||
|
final tag = reader.readVarint();
|
||||||
|
final field = tag >> 3;
|
||||||
|
final wire = tag & 0x07;
|
||||||
|
switch (field) {
|
||||||
|
case 1:
|
||||||
|
if (wire == 2) {
|
||||||
|
name = reader.readString();
|
||||||
|
} else {
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
if (wire == 2) {
|
||||||
|
rawFeatures.add(_parseRawFeature(reader.readBytes()));
|
||||||
|
} else {
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
if (wire == 2) {
|
||||||
|
keys.add(reader.readString());
|
||||||
|
} else {
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
if (wire == 2) {
|
||||||
|
values.add(_parseValue(reader.readBytes()));
|
||||||
|
} else {
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
case 5:
|
||||||
|
if (wire == 0) {
|
||||||
|
extent = reader.readVarint();
|
||||||
|
} else {
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.isEmpty) return null;
|
||||||
|
|
||||||
|
final features = <MvtFeature>[];
|
||||||
|
for (final raw in rawFeatures) {
|
||||||
|
final properties = <String, Object?>{};
|
||||||
|
for (var i = 0; i + 1 < raw.tags.length; i += 2) {
|
||||||
|
final keyIndex = raw.tags[i];
|
||||||
|
final valueIndex = raw.tags[i + 1];
|
||||||
|
if (keyIndex < 0 || keyIndex >= keys.length) continue;
|
||||||
|
if (valueIndex < 0 || valueIndex >= values.length) continue;
|
||||||
|
properties[keys[keyIndex]] = values[valueIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
features.add(
|
||||||
|
MvtFeature(
|
||||||
|
type: _decodeFeatureType(raw.type),
|
||||||
|
properties: properties,
|
||||||
|
geometry: _decodeGeometry(raw.geometry, _decodeFeatureType(raw.type)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MvtLayer(name: name, extent: extent, features: features);
|
||||||
|
}
|
||||||
|
|
||||||
|
_RawFeature _parseRawFeature(Uint8List bytes) {
|
||||||
|
final reader = _PbfReader(bytes);
|
||||||
|
var type = 0;
|
||||||
|
var geometry = <int>[];
|
||||||
|
final tags = <int>[];
|
||||||
|
|
||||||
|
while (!reader.isAtEnd) {
|
||||||
|
final tag = reader.readVarint();
|
||||||
|
final field = tag >> 3;
|
||||||
|
final wire = tag & 0x07;
|
||||||
|
switch (field) {
|
||||||
|
case 2:
|
||||||
|
if (wire == 2) {
|
||||||
|
tags.addAll(reader.readPackedVarints());
|
||||||
|
} else {
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
if (wire == 0) {
|
||||||
|
type = reader.readVarint();
|
||||||
|
} else {
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
if (wire == 2) {
|
||||||
|
geometry = reader.readPackedVarints();
|
||||||
|
} else {
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _RawFeature(type: type, tags: tags, geometry: geometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object? _parseValue(Uint8List bytes) {
|
||||||
|
final reader = _PbfReader(bytes);
|
||||||
|
while (!reader.isAtEnd) {
|
||||||
|
final tag = reader.readVarint();
|
||||||
|
final field = tag >> 3;
|
||||||
|
final wire = tag & 0x07;
|
||||||
|
switch (field) {
|
||||||
|
case 1:
|
||||||
|
if (wire == 2) return reader.readString();
|
||||||
|
case 2:
|
||||||
|
if (wire == 5) return reader.readFloat32();
|
||||||
|
case 3:
|
||||||
|
if (wire == 1) return reader.readFloat64();
|
||||||
|
case 4:
|
||||||
|
if (wire == 0) return reader.readVarint();
|
||||||
|
case 5:
|
||||||
|
if (wire == 0) return reader.readVarint();
|
||||||
|
case 6:
|
||||||
|
if (wire == 0) return _decodeZigZag(reader.readVarint());
|
||||||
|
case 7:
|
||||||
|
if (wire == 0) return reader.readVarint() != 0;
|
||||||
|
}
|
||||||
|
reader.skipWireType(wire);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
MvtGeometryType _decodeFeatureType(int rawType) {
|
||||||
|
switch (rawType) {
|
||||||
|
case 1:
|
||||||
|
return MvtGeometryType.point;
|
||||||
|
case 2:
|
||||||
|
return MvtGeometryType.lineString;
|
||||||
|
case 3:
|
||||||
|
return MvtGeometryType.polygon;
|
||||||
|
default:
|
||||||
|
return MvtGeometryType.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<List<Offset>> _decodeGeometry(List<int> commands, MvtGeometryType type) {
|
||||||
|
final geometries = <List<Offset>>[];
|
||||||
|
var cursorX = 0;
|
||||||
|
var cursorY = 0;
|
||||||
|
var i = 0;
|
||||||
|
List<Offset>? current;
|
||||||
|
|
||||||
|
while (i < commands.length) {
|
||||||
|
final commandInteger = commands[i++];
|
||||||
|
final commandId = commandInteger & 0x7;
|
||||||
|
final count = commandInteger >> 3;
|
||||||
|
|
||||||
|
if (commandId == 1) {
|
||||||
|
for (var c = 0; c < count; c++) {
|
||||||
|
if (i + 1 >= commands.length) break;
|
||||||
|
cursorX += _decodeZigZag(commands[i++]);
|
||||||
|
cursorY += _decodeZigZag(commands[i++]);
|
||||||
|
final point = Offset(cursorX.toDouble(), cursorY.toDouble());
|
||||||
|
if (type == MvtGeometryType.point) {
|
||||||
|
geometries.add(<Offset>[point]);
|
||||||
|
current = null;
|
||||||
|
} else {
|
||||||
|
current = <Offset>[point];
|
||||||
|
geometries.add(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (commandId == 2) {
|
||||||
|
if (current == null) {
|
||||||
|
current = <Offset>[];
|
||||||
|
geometries.add(current);
|
||||||
|
}
|
||||||
|
for (var c = 0; c < count; c++) {
|
||||||
|
if (i + 1 >= commands.length) break;
|
||||||
|
cursorX += _decodeZigZag(commands[i++]);
|
||||||
|
cursorY += _decodeZigZag(commands[i++]);
|
||||||
|
current.add(Offset(cursorX.toDouble(), cursorY.toDouble()));
|
||||||
|
}
|
||||||
|
} else if (commandId == 7) {
|
||||||
|
if (type == MvtGeometryType.polygon &&
|
||||||
|
current != null &&
|
||||||
|
current.isNotEmpty &&
|
||||||
|
current.first != current.last) {
|
||||||
|
current.add(current.first);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return geometries.where((path) => path.isNotEmpty).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _decodeZigZag(int n) => (n >> 1) ^ (-(n & 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RawFeature {
|
||||||
|
const _RawFeature({
|
||||||
|
required this.type,
|
||||||
|
required this.tags,
|
||||||
|
required this.geometry,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int type;
|
||||||
|
final List<int> tags;
|
||||||
|
final List<int> geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PbfReader {
|
||||||
|
_PbfReader(Uint8List bytes)
|
||||||
|
: _bytes = bytes,
|
||||||
|
_data = ByteData.sublistView(bytes);
|
||||||
|
|
||||||
|
final Uint8List _bytes;
|
||||||
|
final ByteData _data;
|
||||||
|
int _offset = 0;
|
||||||
|
|
||||||
|
bool get isAtEnd => _offset >= _bytes.length;
|
||||||
|
|
||||||
|
int readVarint() {
|
||||||
|
var shift = 0;
|
||||||
|
var result = 0;
|
||||||
|
while (true) {
|
||||||
|
if (_offset >= _bytes.length) {
|
||||||
|
throw const FormatException('Unexpected EOF while reading varint');
|
||||||
|
}
|
||||||
|
final byte = _bytes[_offset++];
|
||||||
|
result |= (byte & 0x7f) << shift;
|
||||||
|
if ((byte & 0x80) == 0) break;
|
||||||
|
shift += 7;
|
||||||
|
if (shift > 63) {
|
||||||
|
throw const FormatException('Varint too long');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List readBytes() {
|
||||||
|
final length = readVarint();
|
||||||
|
if (length < 0 || _offset + length > _bytes.length) {
|
||||||
|
throw const FormatException('Invalid length-delimited field');
|
||||||
|
}
|
||||||
|
final bytes = Uint8List.sublistView(_bytes, _offset, _offset + length);
|
||||||
|
_offset += length;
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
String readString() {
|
||||||
|
final bytes = readBytes();
|
||||||
|
return String.fromCharCodes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
double readFloat32() {
|
||||||
|
final value = _data.getFloat32(_offset, Endian.little);
|
||||||
|
_offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
double readFloat64() {
|
||||||
|
final value = _data.getFloat64(_offset, Endian.little);
|
||||||
|
_offset += 8;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> readPackedVarints() {
|
||||||
|
final bytes = readBytes();
|
||||||
|
final inner = _PbfReader(bytes);
|
||||||
|
final values = <int>[];
|
||||||
|
while (!inner.isAtEnd) {
|
||||||
|
values.add(inner.readVarint());
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
void skipWireType(int wireType) {
|
||||||
|
switch (wireType) {
|
||||||
|
case 0:
|
||||||
|
readVarint();
|
||||||
|
case 1:
|
||||||
|
_offset += 8;
|
||||||
|
case 2:
|
||||||
|
final length = readVarint();
|
||||||
|
_offset += length;
|
||||||
|
case 5:
|
||||||
|
_offset += 4;
|
||||||
|
default:
|
||||||
|
throw FormatException('Unsupported wire type: $wireType');
|
||||||
|
}
|
||||||
|
if (_offset > _bytes.length) {
|
||||||
|
throw const FormatException('Unexpected EOF while skipping field');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
188
lib/pages/map/vector/tile_pyramid_cache.dart
Normal file
188
lib/pages/map/vector/tile_pyramid_cache.dart
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
class _TileCoord {
|
||||||
|
const _TileCoord(this.z, this.x, this.y);
|
||||||
|
|
||||||
|
final int z;
|
||||||
|
final int x;
|
||||||
|
final int y;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is _TileCoord && other.z == z && other.x == x && other.y == y;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(z, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachedTile {
|
||||||
|
CachedTile(this.image, this.zoomBucket);
|
||||||
|
|
||||||
|
final ui.Image image;
|
||||||
|
final int zoomBucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static tile pyramid image cache. Stores rasterised ui.Image per (z, x, y).
|
||||||
|
// Each entry also records the zoom bucket it was rendered at — callers can
|
||||||
|
// check whether a re-render is warranted, but the old image stays as a
|
||||||
|
// fallback until the new one arrives.
|
||||||
|
//
|
||||||
|
// Ancestor lookup (for fallback blitting) just calls peek() at a coarser z.
|
||||||
|
class TilePyramidCache {
|
||||||
|
TilePyramidCache._();
|
||||||
|
|
||||||
|
static const int _maxEntries = 384;
|
||||||
|
static const int _maxApproxBytes = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Cap concurrent renders to avoid saturating the main isolate with
|
||||||
|
// synchronous canvas recording. I/O (network/Hive) is still concurrent
|
||||||
|
// up to this limit.
|
||||||
|
static const int _maxConcurrent = 4;
|
||||||
|
|
||||||
|
static final LinkedHashMap<_TileCoord, CachedTile> _cache =
|
||||||
|
LinkedHashMap<_TileCoord, CachedTile>();
|
||||||
|
|
||||||
|
static final Set<_TileCoord> _inFlight = <_TileCoord>{};
|
||||||
|
static final Queue<_Queued> _queue = Queue<_Queued>();
|
||||||
|
static int _active = 0;
|
||||||
|
static bool Function(int z, int x, int y)? _queueFilter;
|
||||||
|
|
||||||
|
static int _approxBytes = 0;
|
||||||
|
|
||||||
|
static CachedTile? peek(int z, int x, int y) {
|
||||||
|
final coord = _TileCoord(z, x, y);
|
||||||
|
final tile = _cache.remove(coord);
|
||||||
|
if (tile == null) return null;
|
||||||
|
_cache[coord] = tile; // promote to MRU
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isInFlight(int z, int x, int y) =>
|
||||||
|
_inFlight.contains(_TileCoord(z, x, y));
|
||||||
|
|
||||||
|
// Queue a render for (z, x, y). No-op if already in flight.
|
||||||
|
// render() produces the image; onReady() is called after it's stored.
|
||||||
|
static void enqueue(
|
||||||
|
int z,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
int zoomBucket,
|
||||||
|
Future<ui.Image?> Function() render,
|
||||||
|
void Function() onReady,
|
||||||
|
) {
|
||||||
|
final coord = _TileCoord(z, x, y);
|
||||||
|
if (_inFlight.contains(coord)) return;
|
||||||
|
final filter = _queueFilter;
|
||||||
|
if (filter != null && !filter(z, x, y)) return;
|
||||||
|
_inFlight.add(coord);
|
||||||
|
|
||||||
|
_queue.add(
|
||||||
|
_Queued(
|
||||||
|
coord: coord,
|
||||||
|
zoomBucket: zoomBucket,
|
||||||
|
render: render,
|
||||||
|
onReady: onReady,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void setQueueFilter(bool Function(int z, int x, int y)? filter) {
|
||||||
|
_queueFilter = filter;
|
||||||
|
_pruneQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _drain() {
|
||||||
|
while (_active < _maxConcurrent && _queue.isNotEmpty) {
|
||||||
|
_active++;
|
||||||
|
_run(_queue.removeFirst());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _run(_Queued item) async {
|
||||||
|
try {
|
||||||
|
final image = await item.render();
|
||||||
|
_inFlight.remove(item.coord);
|
||||||
|
if (image == null) return;
|
||||||
|
|
||||||
|
final filter = _queueFilter;
|
||||||
|
if (filter != null && !filter(item.coord.z, item.coord.x, item.coord.y)) {
|
||||||
|
image.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existing = _cache.remove(item.coord);
|
||||||
|
if (existing != null) {
|
||||||
|
_approxBytes -= _estimateBytes(existing.image);
|
||||||
|
existing.image.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache[item.coord] = CachedTile(image, item.zoomBucket);
|
||||||
|
_approxBytes += _estimateBytes(image);
|
||||||
|
_trim();
|
||||||
|
|
||||||
|
item.onReady();
|
||||||
|
} catch (_) {
|
||||||
|
_inFlight.remove(item.coord);
|
||||||
|
} finally {
|
||||||
|
_active--;
|
||||||
|
_drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _estimateBytes(ui.Image img) => img.width * img.height * 4;
|
||||||
|
|
||||||
|
static void _trim() {
|
||||||
|
while (_cache.length > _maxEntries || _approxBytes > _maxApproxBytes) {
|
||||||
|
final key = _cache.keys.first;
|
||||||
|
final tile = _cache.remove(key);
|
||||||
|
if (tile == null) continue;
|
||||||
|
_approxBytes -= _estimateBytes(tile.image);
|
||||||
|
tile.image.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _pruneQueue() {
|
||||||
|
final filter = _queueFilter;
|
||||||
|
if (filter == null || _queue.isEmpty) return;
|
||||||
|
|
||||||
|
final kept = Queue<_Queued>();
|
||||||
|
while (_queue.isNotEmpty) {
|
||||||
|
final item = _queue.removeFirst();
|
||||||
|
if (filter(item.coord.z, item.coord.x, item.coord.y)) {
|
||||||
|
kept.add(item);
|
||||||
|
} else {
|
||||||
|
_inFlight.remove(item.coord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_queue.addAll(kept);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void clear() {
|
||||||
|
for (final tile in _cache.values) {
|
||||||
|
tile.image.dispose();
|
||||||
|
}
|
||||||
|
_cache.clear();
|
||||||
|
_inFlight.clear();
|
||||||
|
_queue.clear();
|
||||||
|
_active = 0;
|
||||||
|
_approxBytes = 0;
|
||||||
|
_queueFilter = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Queued {
|
||||||
|
const _Queued({
|
||||||
|
required this.coord,
|
||||||
|
required this.zoomBucket,
|
||||||
|
required this.render,
|
||||||
|
required this.onReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _TileCoord coord;
|
||||||
|
final int zoomBucket;
|
||||||
|
final Future<ui.Image?> Function() render;
|
||||||
|
final void Function() onReady;
|
||||||
|
}
|
||||||
745
lib/pages/map/vector/tile_pyramid_painter.dart
Normal file
745
lib/pages/map/vector/tile_pyramid_painter.dart
Normal file
|
|
@ -0,0 +1,745 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:rra_app/pages/map/vector/mapbox_vector_tile_cache.dart';
|
||||||
|
import 'package:rra_app/pages/map/vector/mvt_parser.dart';
|
||||||
|
import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart';
|
||||||
|
|
||||||
|
// How many ancestor levels to walk when looking for a fallback image.
|
||||||
|
const int _kMaxAncestorLookup = 4;
|
||||||
|
const double _kTileResolutionScale = 2.0;
|
||||||
|
|
||||||
|
class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
|
TilePyramidPainter({
|
||||||
|
required this.tileUrlTemplate,
|
||||||
|
required this.tileSize,
|
||||||
|
required this.minTileZoom,
|
||||||
|
required this.maxTileZoom,
|
||||||
|
required this.tileOverscan,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String tileUrlTemplate;
|
||||||
|
final double tileSize;
|
||||||
|
final int minTileZoom;
|
||||||
|
final int maxTileZoom;
|
||||||
|
final int tileOverscan;
|
||||||
|
|
||||||
|
// Viewport state — updated by the map page each frame.
|
||||||
|
double mapWidth = 0;
|
||||||
|
double mapHeight = 0;
|
||||||
|
double zoom = 0;
|
||||||
|
double centerWorldX = 0;
|
||||||
|
double centerWorldY = 0;
|
||||||
|
int activeTileZoom = 0;
|
||||||
|
int? fallbackTileZoom;
|
||||||
|
double devicePixelRatio = 1;
|
||||||
|
bool interactionActive = false;
|
||||||
|
|
||||||
|
// Colour filters applied when drawing each tile (contrast + saturation combined).
|
||||||
|
static const double _contrast = 1.20;
|
||||||
|
static const double _saturation = 1.12;
|
||||||
|
late final ui.ColorFilter _tileColorFilter = ui.ColorFilter.matrix(
|
||||||
|
_multiplyMatrices(
|
||||||
|
_contrastMatrix(_contrast),
|
||||||
|
_saturationMatrix(_saturation),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void markDirty() => notifyListeners();
|
||||||
|
|
||||||
|
String _tileUrl(int z, int x, int y) => tileUrlTemplate
|
||||||
|
.replaceAll('{z}', '$z')
|
||||||
|
.replaceAll('{x}', '$x')
|
||||||
|
.replaceAll('{y}', '$y');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (mapWidth == 0 || mapHeight == 0) return;
|
||||||
|
|
||||||
|
final fallbackZ = fallbackTileZoom;
|
||||||
|
if (fallbackZ != null && fallbackZ != activeTileZoom) {
|
||||||
|
_drawTileLayer(canvas, fallbackZ, queueTiles: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawTileLayer(canvas, activeTileZoom, queueTiles: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawTileLayer(Canvas canvas, int z, {required bool queueTiles}) {
|
||||||
|
final zDelta = z - activeTileZoom;
|
||||||
|
final factor = math.pow(2, zoom - z).toDouble();
|
||||||
|
final worldScale = math.pow(2, zDelta).toDouble();
|
||||||
|
final centerX = centerWorldX * worldScale;
|
||||||
|
final centerY = centerWorldY * worldScale;
|
||||||
|
final maxIndex = 1 << z;
|
||||||
|
|
||||||
|
final minWX = centerX - (mapWidth / 2) / factor;
|
||||||
|
final minWY = centerY - (mapHeight / 2) / factor;
|
||||||
|
final maxWX = centerX + (mapWidth / 2) / factor;
|
||||||
|
final maxWY = centerY + (mapHeight / 2) / factor;
|
||||||
|
|
||||||
|
final minTX = (minWX / tileSize).floor() - tileOverscan;
|
||||||
|
final maxTX = (maxWX / tileSize).floor() + tileOverscan;
|
||||||
|
final minTY = (minWY / tileSize).floor() - tileOverscan;
|
||||||
|
final maxTY = (maxWY / tileSize).floor() + tileOverscan;
|
||||||
|
|
||||||
|
if (queueTiles) {
|
||||||
|
final allowed = _allowedQueuedTileHashes(
|
||||||
|
z,
|
||||||
|
minTX,
|
||||||
|
maxTX,
|
||||||
|
minTY,
|
||||||
|
maxTY,
|
||||||
|
maxIndex,
|
||||||
|
);
|
||||||
|
TilePyramidCache.setQueueFilter(
|
||||||
|
(qz, qx, qy) => allowed.contains(Object.hash(qz, qx, qy)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var tx = minTX; tx <= maxTX; tx++) {
|
||||||
|
for (var ty = minTY; ty <= maxTY; ty++) {
|
||||||
|
if (ty < 0 || ty >= maxIndex) continue;
|
||||||
|
|
||||||
|
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
|
||||||
|
|
||||||
|
final left = (tx * tileSize - minWX) * factor;
|
||||||
|
final top = (ty * tileSize - minWY) * factor;
|
||||||
|
final displaySize = tileSize * factor;
|
||||||
|
|
||||||
|
final dst = Rect.fromLTWH(left, top, displaySize, displaySize);
|
||||||
|
|
||||||
|
_drawTile(canvas, z, wrappedX, ty, dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue rasterisation for visible tiles when permitted.
|
||||||
|
if (queueTiles) {
|
||||||
|
_queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex);
|
||||||
|
|
||||||
|
// Pre-warm tiles just outside the viewport so panning feels instant.
|
||||||
|
if (!interactionActive) {
|
||||||
|
const guard = 2;
|
||||||
|
_queueVisibleTiles(
|
||||||
|
z,
|
||||||
|
minTX - guard,
|
||||||
|
maxTX + guard,
|
||||||
|
minTY - guard,
|
||||||
|
maxTY + guard,
|
||||||
|
maxIndex,
|
||||||
|
skipIfAlreadyQueued: true,
|
||||||
|
visMinTX: minTX,
|
||||||
|
visMaxTX: maxTX,
|
||||||
|
visMinTY: minTY,
|
||||||
|
visMaxTY: maxTY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<int> _allowedQueuedTileHashes(
|
||||||
|
int z,
|
||||||
|
int minTX,
|
||||||
|
int maxTX,
|
||||||
|
int minTY,
|
||||||
|
int maxTY,
|
||||||
|
int maxIndex,
|
||||||
|
) {
|
||||||
|
final hashes = <int>{};
|
||||||
|
|
||||||
|
for (var tx = minTX; tx <= maxTX; tx++) {
|
||||||
|
for (var ty = minTY; ty <= maxTY; ty++) {
|
||||||
|
if (ty < 0 || ty >= maxIndex) continue;
|
||||||
|
|
||||||
|
final wx = ((tx % maxIndex) + maxIndex) % maxIndex;
|
||||||
|
hashes.add(Object.hash(z, wx, ty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!interactionActive) {
|
||||||
|
const guard = 2;
|
||||||
|
final guardMinTX = minTX - guard;
|
||||||
|
final guardMaxTX = maxTX + guard;
|
||||||
|
final guardMinTY = minTY - guard;
|
||||||
|
final guardMaxTY = maxTY + guard;
|
||||||
|
|
||||||
|
for (var tx = guardMinTX; tx <= guardMaxTX; tx++) {
|
||||||
|
for (var ty = guardMinTY; ty <= guardMaxTY; ty++) {
|
||||||
|
if (ty < 0 || ty >= maxIndex) continue;
|
||||||
|
if (tx >= minTX && tx <= maxTX && ty >= minTY && ty <= maxTY) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final wx = ((tx % maxIndex) + maxIndex) % maxIndex;
|
||||||
|
hashes.add(Object.hash(z, wx, ty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) {
|
||||||
|
final zoomBucket = z;
|
||||||
|
|
||||||
|
// Try native level first.
|
||||||
|
final native = TilePyramidCache.peek(z, x, y);
|
||||||
|
if (native != null && native.zoomBucket == zoomBucket) {
|
||||||
|
_blitImage(
|
||||||
|
canvas,
|
||||||
|
native.image,
|
||||||
|
Rect.fromLTWH(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
native.image.width.toDouble(),
|
||||||
|
native.image.height.toDouble(),
|
||||||
|
),
|
||||||
|
dst,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk up the pyramid for an ancestor fallback.
|
||||||
|
for (int dz = 1; dz <= _kMaxAncestorLookup; dz++) {
|
||||||
|
final az = z - dz;
|
||||||
|
if (az < minTileZoom) break;
|
||||||
|
|
||||||
|
final subdivision = 1 << dz;
|
||||||
|
final ancestorX = x >> dz;
|
||||||
|
final ancestorY = y >> dz;
|
||||||
|
final ancestor = TilePyramidCache.peek(az, ancestorX, ancestorY);
|
||||||
|
if (ancestor != null) {
|
||||||
|
// Compute which sub-region of the ancestor image corresponds to this tile.
|
||||||
|
final fracX = (x - ancestorX * subdivision).toDouble() / subdivision;
|
||||||
|
final fracY = (y - ancestorY * subdivision).toDouble() / subdivision;
|
||||||
|
final srcW = ancestor.image.width / subdivision;
|
||||||
|
final srcH = ancestor.image.height / subdivision;
|
||||||
|
final src = Rect.fromLTWH(
|
||||||
|
fracX * ancestor.image.width,
|
||||||
|
fracY * ancestor.image.height,
|
||||||
|
srcW,
|
||||||
|
srcH,
|
||||||
|
);
|
||||||
|
|
||||||
|
_blitImage(canvas, ancestor.image, src, dst);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue native image if not already cached/in-flight.
|
||||||
|
if ((native == null || native.zoomBucket != zoomBucket) &&
|
||||||
|
!TilePyramidCache.isInFlight(z, x, y) &&
|
||||||
|
!interactionActive) {
|
||||||
|
_queueTile(z, x, y, zoomBucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _blitImage(Canvas canvas, ui.Image image, Rect src, Rect dst) {
|
||||||
|
final paint = Paint()
|
||||||
|
..filterQuality = FilterQuality.high
|
||||||
|
..colorFilter = _tileColorFilter;
|
||||||
|
|
||||||
|
canvas.drawImageRect(image, src, dst, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _queueTile(int z, int x, int y, int zoomBucket) {
|
||||||
|
final url = _tileUrl(z, x, y);
|
||||||
|
final rasterPx = (tileSize * devicePixelRatio * _kTileResolutionScale)
|
||||||
|
.round();
|
||||||
|
final currentZoom = zoom;
|
||||||
|
|
||||||
|
TilePyramidCache.enqueue(
|
||||||
|
z,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
zoomBucket,
|
||||||
|
() async {
|
||||||
|
final tile = await MapboxVectorTileCache.getOrFetch(url);
|
||||||
|
if (tile == null || tile.layers.isEmpty) return null;
|
||||||
|
|
||||||
|
return _rasterize(tile, zoom: currentZoom, rasterPx: rasterPx);
|
||||||
|
},
|
||||||
|
() {
|
||||||
|
// Image ready — ask Flutter to redraw.
|
||||||
|
SchedulerBinding.instance.scheduleFrame();
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _queueVisibleTiles(
|
||||||
|
int z,
|
||||||
|
int minTX,
|
||||||
|
int maxTX,
|
||||||
|
int minTY,
|
||||||
|
int maxTY,
|
||||||
|
int maxIndex, {
|
||||||
|
bool skipIfAlreadyQueued = false,
|
||||||
|
int visMinTX = 0,
|
||||||
|
int visMaxTX = 0,
|
||||||
|
int visMinTY = 0,
|
||||||
|
int visMaxTY = 0,
|
||||||
|
}) {
|
||||||
|
for (var tx = minTX; tx <= maxTX; tx++) {
|
||||||
|
for (var ty = minTY; ty <= maxTY; ty++) {
|
||||||
|
if (ty < 0 || ty >= maxIndex) continue;
|
||||||
|
|
||||||
|
// When pre-warming the guard ring, skip tiles in the core visible area
|
||||||
|
// (they're already handled by the first call).
|
||||||
|
if (skipIfAlreadyQueued &&
|
||||||
|
tx >= visMinTX &&
|
||||||
|
tx <= visMaxTX &&
|
||||||
|
ty >= visMinTY &&
|
||||||
|
ty <= visMaxTY) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final wx = ((tx % maxIndex) + maxIndex) % maxIndex;
|
||||||
|
final cached = TilePyramidCache.peek(z, wx, ty);
|
||||||
|
if (cached != null && cached.zoomBucket == z) continue;
|
||||||
|
if (TilePyramidCache.isInFlight(z, wx, ty)) continue;
|
||||||
|
_queueTile(z, wx, ty, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Rasterisation for the cached tile images.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void _drawTileContents(
|
||||||
|
Canvas canvas,
|
||||||
|
Size sz,
|
||||||
|
MvtTile tile, {
|
||||||
|
required double zoom,
|
||||||
|
}) {
|
||||||
|
final paint = Paint()..isAntiAlias = true;
|
||||||
|
paint.color = const Color(0xFFE6EAF1);
|
||||||
|
canvas.drawRect(Offset.zero & sz, paint);
|
||||||
|
|
||||||
|
_drawPolygons(
|
||||||
|
canvas,
|
||||||
|
sz,
|
||||||
|
tile,
|
||||||
|
const <String>{'landuse'},
|
||||||
|
const Color(0xFFCCDCBF),
|
||||||
|
zoom: zoom,
|
||||||
|
);
|
||||||
|
_drawPolygons(
|
||||||
|
canvas,
|
||||||
|
sz,
|
||||||
|
tile,
|
||||||
|
const <String>{'park'},
|
||||||
|
const Color(0xFF97D585),
|
||||||
|
zoom: zoom,
|
||||||
|
);
|
||||||
|
_drawPolygons(
|
||||||
|
canvas,
|
||||||
|
sz,
|
||||||
|
tile,
|
||||||
|
const <String>{'water'},
|
||||||
|
const Color(0xFF68B5EC),
|
||||||
|
zoom: zoom,
|
||||||
|
);
|
||||||
|
_drawPolygons(
|
||||||
|
canvas,
|
||||||
|
sz,
|
||||||
|
tile,
|
||||||
|
const <String>{'building'},
|
||||||
|
const Color(0xFFCCD3DF),
|
||||||
|
zoom: zoom,
|
||||||
|
strokeColor: const Color(0xFF8E9CB4),
|
||||||
|
strokeBaseWidth: 0.56,
|
||||||
|
);
|
||||||
|
|
||||||
|
_drawRoads(canvas, sz, tile, zoom: zoom);
|
||||||
|
_drawLabels(canvas, sz, tile, zoom: zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ui.Image?> _rasterize(
|
||||||
|
MvtTile tile, {
|
||||||
|
required double zoom,
|
||||||
|
required int rasterPx,
|
||||||
|
}) async {
|
||||||
|
if (rasterPx <= 0) return null;
|
||||||
|
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
final sz = Size(rasterPx.toDouble(), rasterPx.toDouble());
|
||||||
|
_drawTileContents(Canvas(recorder), sz, tile, zoom: zoom);
|
||||||
|
|
||||||
|
final pic = recorder.endRecording();
|
||||||
|
try {
|
||||||
|
return await pic.toImage(rasterPx, rasterPx);
|
||||||
|
} finally {
|
||||||
|
pic.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawPolygons(
|
||||||
|
Canvas canvas,
|
||||||
|
Size size,
|
||||||
|
MvtTile tile,
|
||||||
|
Set<String> layerNames,
|
||||||
|
Color color, {
|
||||||
|
required double zoom,
|
||||||
|
Color? strokeColor,
|
||||||
|
double strokeBaseWidth = 0.0,
|
||||||
|
}) {
|
||||||
|
final fill = Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.fill
|
||||||
|
..isAntiAlias = true;
|
||||||
|
|
||||||
|
final stroke = strokeColor == null
|
||||||
|
? null
|
||||||
|
: (Paint()
|
||||||
|
..color = strokeColor
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth =
|
||||||
|
(strokeBaseWidth + (zoom - 15.0).clamp(0.0, 8.0) * 0.06).clamp(
|
||||||
|
0.44,
|
||||||
|
1.18,
|
||||||
|
)
|
||||||
|
..strokeJoin = StrokeJoin.round
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..isAntiAlias = true);
|
||||||
|
|
||||||
|
for (final entry in tile.layers.entries) {
|
||||||
|
if (!layerNames.contains(entry.key)) continue;
|
||||||
|
final layer = entry.value;
|
||||||
|
final scale = size.width / layer.extent;
|
||||||
|
|
||||||
|
for (final feature in layer.features) {
|
||||||
|
if (feature.type != MvtGeometryType.polygon) continue;
|
||||||
|
for (final ring in feature.geometry) {
|
||||||
|
if (ring.length < 3) continue;
|
||||||
|
final path = Path()
|
||||||
|
..moveTo(ring.first.dx * scale, ring.first.dy * scale);
|
||||||
|
for (var i = 1; i < ring.length; i++) {
|
||||||
|
path.lineTo(ring[i].dx * scale, ring[i].dy * scale);
|
||||||
|
}
|
||||||
|
path.close();
|
||||||
|
canvas.drawPath(path, fill);
|
||||||
|
if (stroke != null) canvas.drawPath(path, stroke);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawRoads(
|
||||||
|
Canvas canvas,
|
||||||
|
Size size,
|
||||||
|
MvtTile tile, {
|
||||||
|
required double zoom,
|
||||||
|
}) {
|
||||||
|
final roadLayer = tile.layers['road'];
|
||||||
|
if (roadLayer == null) return;
|
||||||
|
|
||||||
|
final scale = size.width / roadLayer.extent;
|
||||||
|
|
||||||
|
for (final feature in roadLayer.features) {
|
||||||
|
if (feature.type != MvtGeometryType.lineString) continue;
|
||||||
|
|
||||||
|
final roadClass =
|
||||||
|
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
final isPathLike =
|
||||||
|
roadClass.contains('path') ||
|
||||||
|
roadClass.contains('footway') ||
|
||||||
|
roadClass.contains('steps') ||
|
||||||
|
roadClass.contains('cycleway') ||
|
||||||
|
roadClass.contains('track') ||
|
||||||
|
roadClass.contains('bridleway');
|
||||||
|
if (isPathLike && zoom < 15) continue;
|
||||||
|
|
||||||
|
final isMotorway = roadClass.contains('motorway');
|
||||||
|
final isTrunkPrimary =
|
||||||
|
roadClass.contains('trunk') || roadClass.contains('primary');
|
||||||
|
final isSecondary =
|
||||||
|
roadClass.contains('secondary') || roadClass.contains('tertiary');
|
||||||
|
final isLink = roadClass.contains('link');
|
||||||
|
|
||||||
|
var baseWidth = 0.92;
|
||||||
|
var growth = 0.24;
|
||||||
|
var casingColor = const Color(0xFF9BA7BE);
|
||||||
|
var fillColor = const Color(0xFFD8DFEC);
|
||||||
|
|
||||||
|
if (isMotorway) {
|
||||||
|
baseWidth = 1.60;
|
||||||
|
growth = 0.42;
|
||||||
|
casingColor = const Color(0xFF5D6F92);
|
||||||
|
fillColor = const Color(0xFF98AFCF);
|
||||||
|
} else if (isTrunkPrimary) {
|
||||||
|
baseWidth = 1.35;
|
||||||
|
growth = 0.34;
|
||||||
|
casingColor = const Color(0xFF7080A0);
|
||||||
|
fillColor = const Color(0xFFAFC0D8);
|
||||||
|
} else if (isSecondary) {
|
||||||
|
baseWidth = 1.06;
|
||||||
|
growth = 0.28;
|
||||||
|
casingColor = const Color(0xFF8694B0);
|
||||||
|
fillColor = const Color(0xFFC5D1E3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLink) baseWidth *= 0.82;
|
||||||
|
|
||||||
|
final zoomBoost = (zoom - 11).clamp(0.0, 10.0);
|
||||||
|
final width = (baseWidth + zoomBoost * growth).clamp(0.60, 7.20);
|
||||||
|
|
||||||
|
final casing = Paint()
|
||||||
|
..color = casingColor
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = width + (isMotorway ? 1.15 : 0.95)
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeJoin = StrokeJoin.round
|
||||||
|
..isAntiAlias = true;
|
||||||
|
|
||||||
|
final road = Paint()
|
||||||
|
..color = fillColor
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = width
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeJoin = StrokeJoin.round
|
||||||
|
..isAntiAlias = true;
|
||||||
|
|
||||||
|
for (final line in feature.geometry) {
|
||||||
|
if (line.length < 2) continue;
|
||||||
|
final path = Path()
|
||||||
|
..moveTo(line.first.dx * scale, line.first.dy * scale);
|
||||||
|
for (var i = 1; i < line.length; i++) {
|
||||||
|
path.lineTo(line[i].dx * scale, line[i].dy * scale);
|
||||||
|
}
|
||||||
|
canvas.drawPath(path, casing);
|
||||||
|
canvas.drawPath(path, road);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawLabels(
|
||||||
|
Canvas canvas,
|
||||||
|
Size size,
|
||||||
|
MvtTile tile, {
|
||||||
|
required double zoom,
|
||||||
|
}) {
|
||||||
|
if (zoom < 12) return;
|
||||||
|
|
||||||
|
final occupiedRects = <Rect>[];
|
||||||
|
final seenTexts = <String>{};
|
||||||
|
|
||||||
|
void drawFromLayer(
|
||||||
|
String layerName,
|
||||||
|
Color color,
|
||||||
|
double fontSize, {
|
||||||
|
bool pointsOnly = true,
|
||||||
|
FontWeight fontWeight = FontWeight.w500,
|
||||||
|
required double minZoom,
|
||||||
|
int maxLabels = 9999,
|
||||||
|
double minSpacing = 10,
|
||||||
|
Set<String>? allowedClasses,
|
||||||
|
}) {
|
||||||
|
if (zoom < minZoom) return;
|
||||||
|
final layer = tile.layers[layerName];
|
||||||
|
if (layer == null) return;
|
||||||
|
final scale = size.width / layer.extent;
|
||||||
|
final candidates = <_LabelCandidate>[];
|
||||||
|
|
||||||
|
for (final feature in layer.features) {
|
||||||
|
if (pointsOnly && feature.type != MvtGeometryType.point) continue;
|
||||||
|
final featureClass =
|
||||||
|
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.toLowerCase();
|
||||||
|
if (allowedClasses != null &&
|
||||||
|
allowedClasses.isNotEmpty &&
|
||||||
|
!allowedClasses.contains(featureClass)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final text =
|
||||||
|
(feature.properties['name_en'] ?? feature.properties['name'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
if (text.isEmpty || text.length > 26) continue;
|
||||||
|
|
||||||
|
final anchor =
|
||||||
|
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
|
||||||
|
? feature.geometry.first.first
|
||||||
|
: null;
|
||||||
|
if (anchor == null) continue;
|
||||||
|
|
||||||
|
final scalerank = _toRank(feature.properties['scalerank']) ?? 99;
|
||||||
|
final localrank = _toRank(feature.properties['localrank']) ?? 99;
|
||||||
|
candidates.add(
|
||||||
|
_LabelCandidate(
|
||||||
|
text: text,
|
||||||
|
anchor: anchor,
|
||||||
|
rank: scalerank * 100 + localrank,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) {
|
||||||
|
final r = a.rank.compareTo(b.rank);
|
||||||
|
return r != 0 ? r : a.text.length.compareTo(b.text.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
var drawn = 0;
|
||||||
|
for (final c in candidates) {
|
||||||
|
if (drawn >= maxLabels) break;
|
||||||
|
if (seenTexts.contains(c.text.toLowerCase())) continue;
|
||||||
|
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: c.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
shadows: const <Shadow>[
|
||||||
|
Shadow(color: Color(0xF7FFFFFF), blurRadius: 1.4),
|
||||||
|
],
|
||||||
|
height: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
maxLines: 1,
|
||||||
|
ellipsis: '',
|
||||||
|
)..layout();
|
||||||
|
|
||||||
|
final x = c.anchor.dx * scale - tp.width / 2;
|
||||||
|
final y = c.anchor.dy * scale - tp.height / 2;
|
||||||
|
|
||||||
|
if (x < -tp.width ||
|
||||||
|
y < -tp.height ||
|
||||||
|
x > size.width ||
|
||||||
|
y > size.height) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final labelRect = Rect.fromLTWH(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
tp.width,
|
||||||
|
tp.height,
|
||||||
|
).inflate(minSpacing);
|
||||||
|
if (occupiedRects.any((r) => r.overlaps(labelRect))) continue;
|
||||||
|
|
||||||
|
tp.paint(canvas, Offset(x, y));
|
||||||
|
occupiedRects.add(labelRect);
|
||||||
|
seenTexts.add(c.text.toLowerCase());
|
||||||
|
drawn++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFromLayer(
|
||||||
|
'place_label',
|
||||||
|
const Color(0xFF1F2636),
|
||||||
|
11.2,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
minZoom: 12.5,
|
||||||
|
maxLabels: zoom >= 15 ? 5 : 2,
|
||||||
|
minSpacing: zoom >= 15 ? 24 : 30,
|
||||||
|
allowedClasses: const <String>{
|
||||||
|
'city',
|
||||||
|
'town',
|
||||||
|
'suburb',
|
||||||
|
'neighbourhood',
|
||||||
|
'borough',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _toRank(Object? value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is double) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// ColorFilter matrices
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static List<double> _contrastMatrix(double contrast) {
|
||||||
|
final c = contrast.clamp(0.0, 3.0);
|
||||||
|
final t = 128.0 * (1.0 - c);
|
||||||
|
return <double>[c, 0, 0, 0, t, 0, c, 0, 0, t, 0, 0, c, 0, t, 0, 0, 0, 1, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<double> _saturationMatrix(double saturation) {
|
||||||
|
final s = saturation.clamp(0.0, 2.0);
|
||||||
|
const rw = 0.213;
|
||||||
|
const gw = 0.715;
|
||||||
|
const bw = 0.072;
|
||||||
|
final a = 1 - s;
|
||||||
|
return <double>[
|
||||||
|
a * rw + s,
|
||||||
|
a * gw,
|
||||||
|
a * bw,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
a * rw,
|
||||||
|
a * gw + s,
|
||||||
|
a * bw,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
a * rw,
|
||||||
|
a * gw,
|
||||||
|
a * bw + s,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4x5 colour matrix multiply: result = a * b (both are row-major 4x5).
|
||||||
|
static List<double> _multiplyMatrices(List<double> a, List<double> b) {
|
||||||
|
final out = List<double>.filled(20, 0);
|
||||||
|
for (int row = 0; row < 4; row++) {
|
||||||
|
for (int col = 0; col < 5; col++) {
|
||||||
|
double v = 0;
|
||||||
|
for (int k = 0; k < 4; k++) {
|
||||||
|
v += a[row * 5 + k] * b[k * 5 + col];
|
||||||
|
}
|
||||||
|
// translation column — just add when col == 4
|
||||||
|
if (col == 4) v += a[row * 5 + 4];
|
||||||
|
out[row * 5 + col] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// CustomPainter boilerplate
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant TilePyramidPainter oldDelegate) => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
SemanticsBuilderCallback? get semanticsBuilder => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool hitTest(Offset position) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LabelCandidate {
|
||||||
|
const _LabelCandidate({
|
||||||
|
required this.text,
|
||||||
|
required this.anchor,
|
||||||
|
required this.rank,
|
||||||
|
});
|
||||||
|
final String text;
|
||||||
|
final Offset anchor;
|
||||||
|
final int rank;
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||||
51E4CB61A33804794AE9D9C9 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D016D5F689457D86516420F2 /* Pods_RunnerTests.framework */; };
|
51E4CB61A33804794AE9D9C9 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D016D5F689457D86516420F2 /* Pods_RunnerTests.framework */; };
|
||||||
5798702EB376750FF163D730 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1F61950EF377D9EA76B9AA4 /* Pods_Runner.framework */; };
|
5798702EB376750FF163D730 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1F61950EF377D9EA76B9AA4 /* Pods_Runner.framework */; };
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
|
@ -82,6 +83,7 @@
|
||||||
33F78D48070AFAC7BED2D21D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
33F78D48070AFAC7BED2D21D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
347D28852ABFB31D8B8D33E0 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
347D28852ABFB31D8B8D33E0 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
5044E125F1BF00ABDAD0569C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
5044E125F1BF00ABDAD0569C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
C1F61950EF377D9EA76B9AA4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
C1F61950EF377D9EA76B9AA4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
|
@ -103,6 +105,7 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||||
5798702EB376750FF163D730 /* Pods_Runner.framework in Frameworks */,
|
5798702EB376750FF163D730 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
@ -164,6 +167,7 @@
|
||||||
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
33CEB47122A05771004F2AC0 /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
|
||||||
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
|
||||||
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
|
||||||
|
|
@ -240,7 +244,6 @@
|
||||||
33CC10EB2044A3C60003C045 /* Resources */,
|
33CC10EB2044A3C60003C045 /* Resources */,
|
||||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||||
8365B569AFE0AA469B1697ED /* [CP] Embed Pods Frameworks */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
|
|
@ -248,6 +251,9 @@
|
||||||
33CC11202044C79F0003C045 /* PBXTargetDependency */,
|
33CC11202044C79F0003C045 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 33CC10ED2044A3C60003C045 /* rra_app.app */;
|
productReference = 33CC10ED2044A3C60003C045 /* rra_app.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
|
|
@ -292,6 +298,9 @@
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 33CC10E42044A3C60003C045;
|
mainGroup = 33CC10E42044A3C60003C045;
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
|
|
@ -405,23 +414,6 @@
|
||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
8365B569AFE0AA469B1697ED /* [CP] Embed Pods Frameworks */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
|
||||||
outputFileListPaths = (
|
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
|
@ -796,6 +788,20 @@
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = FlutterGeneratedPluginSwiftPackage;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,24 @@
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Prepare Flutter Framework Script"
|
||||||
|
scriptText = ""$FLUTTER_ROOT"/packages/flutter_tools/bin/macos_assemble.sh prepare ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||||
|
BuildableName = "rra_app.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
|
|
|
||||||
20
pubspec.lock
20
pubspec.lock
|
|
@ -37,10 +37,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -321,26 +321,26 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mgrs_dart:
|
mgrs_dart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -534,10 +534,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.10"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue