aves_mio0.31/lib/widgets/common/map/mapbox/map.dart
2026-07-18 13:39:22 +02:00

1199 lines
31 KiB
Dart

// lib/widgets/common/map/mapbox/map.dart
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/basic/gestures/gesture_detector.dart';
import 'package:aves/widgets/common/map/mapbox/tracks_layer.dart';
import 'package:aves_map/aves_map.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart' as mbx;
import 'package:provider/provider.dart';
class EntryMapboxMap<T> extends StatefulWidget {
final AvesMapController controller;
final Listenable clusterListenable;
final ValueNotifier<ZoomedBounds> boundsNotifier;
final double minZoom, maxZoom;
final EntryMapStyle style;
final TransitionBuilder decoratorBuilder;
final WidgetBuilder buttonPanelBuilder;
final MarkerClusterBuilder<T> markerClusterBuilder;
final MarkerWidgetBuilder<T> markerWidgetBuilder;
final ValueNotifier<LatLng?>? dotLocationNotifier;
final Size markerSize, dotMarkerSize;
final ValueNotifier<double>? overlayOpacityNotifier;
final MapOverlay? overlayEntry;
final Set<List<LatLng>>? tracks;
final UserZoomChangeCallback? onUserZoomChange;
final MapTapCallback? onMapTap;
final MarkerTapCallback<T>? onMarkerTap;
final MarkerLongPressCallback<T>? onMarkerLongPress;
final ValueChanged<Set<MarkerKey<T>>>? onVisibleMarkerKeysChanged;
const EntryMapboxMap({
super.key,
required this.controller,
required this.clusterListenable,
required this.boundsNotifier,
this.minZoom = 0,
this.maxZoom = 22,
required this.style,
required this.decoratorBuilder,
required this.buttonPanelBuilder,
required this.markerClusterBuilder,
required this.markerWidgetBuilder,
required this.dotLocationNotifier,
required this.markerSize,
required this.dotMarkerSize,
this.overlayOpacityNotifier,
this.overlayEntry,
this.tracks,
this.onUserZoomChange,
this.onMapTap,
this.onMarkerTap,
this.onMarkerLongPress,
this.onVisibleMarkerKeysChanged,
});
@override
State<EntryMapboxMap<T>> createState() => _EntryMapboxMapState<T>();
}
class _EntryMapboxMapState<T> extends State<EntryMapboxMap<T>> {
static const bool _debugMapbox = true;
static const bool _debugCameraMove = false;
static const bool _debugOverlayBuild = true;
final Set<StreamSubscription> _subscriptions = {};
final Debouncer _debouncer = Debouncer(
delay: ADurations.mapIdleDebounceDelay,
);
mbx.MapboxMap? _mapboxMap;
Map<MarkerKey<T>, GeoEntry<T>> _geoEntryByMarkerKey = {};
Map<MarkerKey<T>, Offset> _screenOffsetByMarkerKey = {};
Map<MarkerKey<T>, Offset> _lastKnownScreenOffsetByMarkerKey = {};
Map<String, Offset> _lastKnownScreenOffsetByStableKey = {};
Set<MarkerKey<T>> _lastReportedVisibleMarkerKeys = {};
Offset? _dotScreenOffset;
Size _mapSize = Size.zero;
mbx.Point? _lastCameraCenter;
double? _lastCameraZoom;
double? _lastCameraBearing;
double? _lastCameraPitch;
double? _lastTerrainPitch;
bool _updatingScreenPositions = false;
bool _updatingBoundsFromMap = false;
int _lastScreenPositionUpdateMs = 0;
int _screenPositionGeneration = 0;
late final VoidCallback _clusterListenableListener = () {
_updateMarkers(reason: 'cluster-listener');
};
ZoomedBounds get bounds => widget.boundsNotifier.value;
static const _cameraAnimationDurationMs = 600;
static const _terrainSourceId = 'aves-mapbox-dem';
static const _terrainSourceUrl = 'mapbox://mapbox.mapbox-terrain-dem-v1';
static const _terrainExaggeration = 1.5;
void _log(String message) {
if (_debugMapbox) {
debugPrint('[mapbox] $message');
}
}
String _stableScreenOffsetKey(
MarkerKey<T> markerKey,
GeoEntry<T> geoEntry,
) {
final markerId = geoEntry.markerId;
if (markerId != null) {
return 'entry:$markerId';
}
final childMarkerId = geoEntry.childMarkerId;
if (childMarkerId != null) {
return 'cluster-child:$childMarkerId';
}
final lat = geoEntry.latitude?.toStringAsFixed(3) ?? 'null';
final lng = geoEntry.longitude?.toStringAsFixed(3) ?? 'null';
return 'location:$lat:$lng';
}
bool _shouldReportVisibleMarkerKeys({required String reason}) {
return reason == 'load-style' ||
reason.startsWith('idle-') ||
reason.startsWith('update-markers-idle-') ||
reason.startsWith('pitch-clamp-') ||
reason == 'pitch-change';
}
void _notifyVisibleMarkerKeys(Set<MarkerKey<T>> markerKeys) {
final oldKeys = _lastReportedVisibleMarkerKeys;
final unchanged =
oldKeys.length == markerKeys.length && oldKeys.containsAll(markerKeys);
if (unchanged) return;
_lastReportedVisibleMarkerKeys = Set.unmodifiable(markerKeys);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
widget.onVisibleMarkerKeysChanged?.call(_lastReportedVisibleMarkerKeys);
});
}
void _rememberCameraState(
mbx.CameraState camera, {
bool rememberTerrainPitch = true,
}) {
_lastCameraCenter = camera.center;
_lastCameraZoom = camera.zoom;
_lastCameraBearing = camera.bearing;
_lastCameraPitch = camera.pitch;
if (rememberTerrainPitch && widget.style.mapboxTerrain) {
_lastTerrainPitch = camera.pitch;
}
}
Future<void> _captureTerrainPitch({required String reason}) async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
final camera = await mapboxMap.getCameraState();
_lastTerrainPitch = camera.pitch;
_log(
'capture terrain pitch '
'reason=$reason '
'pitch=${camera.pitch}',
);
_rememberCameraState(
camera,
rememberTerrainPitch: false,
);
}
Future<void> _applyMapboxOrnamentSettings() async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
_log(
'apply ornament settings '
'style=${widget.style.key} '
'hideCompass=true',
);
await mapboxMap.compass.updateSettings(
mbx.CompassSettings(
enabled: false,
visibility: false,
),
);
}
@override
void initState() {
super.initState();
_setMapboxToken();
_registerWidget(widget);
_updateMarkers(reason: 'init');
}
@override
void didUpdateWidget(covariant EntryMapboxMap<T> oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
final styleChanged =
oldWidget.style.key != widget.style.key ||
oldWidget.style.mapboxStyleUri != widget.style.mapboxStyleUri ||
oldWidget.style.mapboxTerrain != widget.style.mapboxTerrain;
if (styleChanged) {
_log(
'style changed ${oldWidget.style.key} -> ${widget.style.key}',
);
unawaited(() async {
if (oldWidget.style.mapboxTerrain && !widget.style.mapboxTerrain) {
await _captureTerrainPitch(reason: 'before-leaving-terrain');
}
await _applyMapboxGestureSettings();
await _applyMapboxOrnamentSettings();
await _loadSelectedStyle();
}());
}
if (oldWidget.tracks != widget.tracks) {
unawaited(_updateTracksLayer(reason: 'widget-update'));
}
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _setMapboxToken() {
const token = String.fromEnvironment('MAPBOX_ACCESS_TOKEN');
_log(
'token present=${token.isNotEmpty} '
'length=${token.length}',
);
if (token.isNotEmpty) {
mbx.MapboxOptions.setAccessToken(token);
}
}
void _registerWidget(EntryMapboxMap<T> widget) {
final avesMapController = widget.controller;
_subscriptions.add(
avesMapController.moveCommands.listen(
(event) => _moveTo(event.latLng),
),
);
_subscriptions.add(
avesMapController.zoomCommands.listen(
(event) => _zoomBy(event.delta),
),
);
_subscriptions.add(
avesMapController.rotationResetCommands.listen(
(_) => _resetRotation(),
),
);
widget.clusterListenable.addListener(_clusterListenableListener);
widget.boundsNotifier.addListener(_onBoundsChanged);
widget.dotLocationNotifier?.addListener(_updateDotScreenPosition);
}
void _unregisterWidget(EntryMapboxMap<T> widget) {
widget.clusterListenable.removeListener(_clusterListenableListener);
widget.boundsNotifier.removeListener(_onBoundsChanged);
widget.dotLocationNotifier?.removeListener(_updateDotScreenPosition);
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
Future<void> _applyMapboxGestureSettings() async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
final pitchEnabled = widget.style.mapboxTerrain;
_log(
'apply gesture settings '
'style=${widget.style.key} '
'pitchEnabled=$pitchEnabled',
);
await mapboxMap.gestures.updateSettings(
mbx.GesturesSettings(
pitchEnabled: pitchEnabled,
rotateEnabled: true,
pinchToZoomEnabled: true,
scrollEnabled: true,
simultaneousRotateAndPinchToZoomEnabled: true,
),
);
// In 2D il pitch corrente deve essere 0, ma NON cancelliamo _lastTerrainPitch.
// Così, tornando in 3D, ripristiniamo l'ultima inclinazione usata.
if (!pitchEnabled) {
_lastCameraPitch = 0.0;
}
}
Future<void> _updateTracksLayer({required String reason}) async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
if (!mounted) return;
await MapboxTracksLayer.update(
mapboxMap: mapboxMap,
tracks: widget.tracks,
color: Theme.of(context).colorScheme.primary,
width: MapThemeData.trackWidth.toDouble(),
log: _log,
reason: reason,
);
}
Future<void> _loadSelectedStyle() async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
final styleUri = widget.style.mapboxStyleUri ??
'mapbox://styles/mapbox/satellite-streets-v12';
_log(
'loadStyleURI '
'key=${widget.style.key} '
'uri=$styleUri '
'terrain=${widget.style.mapboxTerrain}',
);
await mapboxMap.loadStyleURI(styleUri);
await _applyMapboxTerrainIfNeeded();
await _applyCameraForSelectedStyle();
await _updateTracksLayer(reason: 'load-style');
await _updateVisibleRegion(reason: 'load-style');
_updateMarkers(reason: 'load-style');
await _updateMarkerScreenPositions(
reason: 'load-style',
keepPreviousOnEmpty: true,
);
await _updateDotScreenPosition();
}
Future<void> _applyMapboxTerrainIfNeeded() async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
if (!widget.style.mapboxTerrain) {
_log('terrain disabled style=${widget.style.key}');
return;
}
_log('applying 3D terrain');
try {
await mapboxMap.style.addSource(
mbx.RasterDemSource(
id: _terrainSourceId,
url: _terrainSourceUrl,
tileSize: 512.0,
maxzoom: 14.0,
),
);
_log('terrain source added');
} catch (e) {
_log('terrain source add skipped/error: $e');
}
final terrainProperties = jsonEncode({
'source': _terrainSourceId,
'exaggeration': _terrainExaggeration,
});
final style = mapboxMap.style as dynamic;
try {
await style.setStyleTerrain(terrainProperties);
_log('terrain applied via setStyleTerrain');
return;
} catch (e) {
_log('setStyleTerrain failed: $e');
}
try {
await style.setTerrain(terrainProperties);
_log('terrain applied via setTerrain');
return;
} catch (e) {
_log('setTerrain failed: $e');
}
_log(
'terrain API not available in this plugin version. '
'Map will keep pitch but real DEM terrain may not be enabled.',
);
}
Future<void> _applyCameraForSelectedStyle() async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
final camera = await mapboxMap.getCameraState();
_rememberCameraState(
camera,
rememberTerrainPitch: false,
);
final hasRememberedCamera =
_lastCameraCenter != null && _lastCameraZoom != null;
final targetCenter = hasRememberedCamera
? _lastCameraCenter
: camera.center;
final targetZoom = hasRememberedCamera
? _lastCameraZoom
: camera.zoom;
final targetBearing = hasRememberedCamera
? (_lastCameraBearing ?? camera.bearing)
: bounds.rotation;
final targetPitch = widget.style.mapboxTerrain
? (_lastTerrainPitch ?? widget.style.initialPitch ?? camera.pitch)
: 0.0;
_log(
'apply camera '
'style=${widget.style.key} '
'terrain=${widget.style.mapboxTerrain} '
'hasRememberedCamera=$hasRememberedCamera '
'pitch=$targetPitch '
'bearing=$targetBearing',
);
await mapboxMap.flyTo(
mbx.CameraOptions(
center: targetCenter,
zoom: targetZoom,
pitch: targetPitch,
bearing: targetBearing,
),
mbx.MapAnimationOptions(
duration: _cameraAnimationDurationMs,
),
);
final updatedCamera = await mapboxMap.getCameraState();
_rememberCameraState(
updatedCamera,
rememberTerrainPitch: widget.style.mapboxTerrain,
);
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
widget.decoratorBuilder(context, _buildMap()),
_buildMarkerOverlay(),
widget.buttonPanelBuilder(context),
],
);
}
Widget _buildMap() {
final center = bounds.projectedCenter;
final styleUri = widget.style.mapboxStyleUri ??
'mapbox://styles/mapbox/satellite-streets-v12';
final hasRememberedCamera =
_lastCameraCenter != null && _lastCameraZoom != null;
final cameraOptions = mbx.CameraOptions(
center: hasRememberedCamera
? _lastCameraCenter
: mbx.Point(
coordinates: mbx.Position(
center.longitude,
center.latitude,
),
),
zoom: hasRememberedCamera ? _lastCameraZoom : bounds.zoom,
bearing: hasRememberedCamera
? (_lastCameraBearing ?? 0.0)
: bounds.rotation,
pitch: widget.style.mapboxTerrain
? (_lastTerrainPitch ??
widget.style.initialPitch ??
_lastCameraPitch ??
0.0)
: 0.0,
);
return LayoutBuilder(
builder: (context, constraints) {
final newMapSize = Size(
constraints.maxWidth,
constraints.maxHeight,
);
if (_mapSize != newMapSize) {
_mapSize = newMapSize;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_onIdle(reason: 'map-size-change'));
});
}
return mbx.MapWidget(
key: const ValueKey('mapbox_map_widget'),
styleUri: styleUri,
cameraOptions: cameraOptions,
onMapCreated: (mapboxMap) async {
_log('onMapCreated');
_mapboxMap = mapboxMap;
await _applyMapboxGestureSettings();
await _applyMapboxOrnamentSettings();
await _loadSelectedStyle();
_log('initial bounds=${widget.boundsNotifier.value}');
},
onTapListener: (context) {
final coordinate = context.point.coordinates;
widget.onMapTap?.call(
LatLng(
coordinate.lat.toDouble(),
coordinate.lng.toDouble(),
),
);
},
onCameraChangeListener: (_) {
if (_debugCameraMove) {
_log('camera change style=${widget.style.key}');
}
unawaited(_updateScreenPositionsDuringCameraMove());
_debouncer(() => _onIdle(reason: 'camera-debounced'));
},
onMapIdleListener: (_) {
_onIdle(reason: 'map-idle');
},
);
},
);
}
Widget _buildMarkerOverlay() {
final markerSize = widget.markerSize;
final dotMarkerSize = widget.dotMarkerSize;
var overlayTotal = 0;
var overlayWithDirectOffset = 0;
var overlayWithStableOffset = 0;
var overlayWithoutOffset = 0;
final interactive = context.select<MapThemeData, bool>(
(v) => v.interactive,
);
final markerWidgets = _geoEntryByMarkerKey.entries.map((kv) {
final markerKey = kv.key;
final geoEntry = kv.value;
overlayTotal++;
final latLng = LatLng(
geoEntry.latitude!,
geoEntry.longitude!,
);
final stableKey = _stableScreenOffsetKey(markerKey, geoEntry);
final directOffset = _screenOffsetByMarkerKey[markerKey];
final stableOffset = _lastKnownScreenOffsetByStableKey[stableKey];
if (directOffset != null) {
overlayWithDirectOffset++;
} else if (stableOffset != null) {
overlayWithStableOffset++;
} else {
overlayWithoutOffset++;
}
final offset = directOffset ?? stableOffset;
if (offset == null) return const SizedBox();
final onMarkerLongPress = widget.onMarkerLongPress;
final onLongPress = onMarkerLongPress != null
? Feedback.wrapForLongPress(
() => onMarkerLongPress.call(geoEntry, latLng),
context,
)
: null;
return Positioned(
left: offset.dx - markerSize.width / 2,
top: offset.dy - markerSize.height,
width: markerSize.width,
height: markerSize.height,
child: AGestureDetector(
onTap: () => widget.onMarkerTap?.call(geoEntry),
onDoubleTap: interactive
? () => _zoomBy(
1,
focalPoint: latLng,
)
: null,
onLongPress: onLongPress,
longPressTimeout: Duration(
milliseconds: min(
settings.longPressTimeout.inMilliseconds,
kLongPressTimeout.inMilliseconds,
),
),
child: widget.markerWidgetBuilder(markerKey),
),
);
}).toList();
if (_debugOverlayBuild) {
_log(
'overlay build '
'total=$overlayTotal '
'direct=$overlayWithDirectOffset '
'stable=$overlayWithStableOffset '
'missing=$overlayWithoutOffset '
'screenOffsets=${_screenOffsetByMarkerKey.length} '
'stableOffsets=${_lastKnownScreenOffsetByStableKey.length} '
'style=${widget.style.key} '
'terrain=${widget.style.mapboxTerrain}',
);
}
final dotLocationNotifier = widget.dotLocationNotifier;
return IgnorePointer(
ignoring: false,
child: Stack(
children: [
...markerWidgets,
if (dotLocationNotifier != null)
ValueListenableBuilder<LatLng?>(
valueListenable: dotLocationNotifier,
builder: (context, dotLocation, child) {
if (dotLocation == null) return const SizedBox();
final offset = _dotScreenOffset;
if (offset == null) return const SizedBox();
return Positioned(
left: offset.dx - dotMarkerSize.width / 2,
top: offset.dy - dotMarkerSize.height / 2,
width: dotMarkerSize.width,
height: dotMarkerSize.height,
child: const DotMarker(),
);
},
),
],
),
);
}
Future<Offset?> _screenOffsetForLatLng(LatLng location) async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return null;
if (_mapSize.width <= 0 || _mapSize.height <= 0) return null;
final screenCoordinate = await mapboxMap.pixelForCoordinate(
mbx.Point(
coordinates: mbx.Position(
location.longitude,
location.latitude,
),
),
);
final x = screenCoordinate.x;
final y = screenCoordinate.y;
final margin = max(
widget.markerSize.width,
widget.markerSize.height,
);
if (x < -margin ||
y < -margin ||
x > _mapSize.width + margin ||
y > _mapSize.height + margin) {
final camera = await mapboxMap.getCameraState();
_log(
'projection outside viewport '
'lat=${location.latitude} '
'lng=${location.longitude} '
'x=$x '
'y=$y '
'mapSize=$_mapSize '
'pitch=${camera.pitch} '
'zoom=${camera.zoom} '
'bearing=${camera.bearing} '
'style=${widget.style.key} '
'terrain=${widget.style.mapboxTerrain}',
);
return null;
}
return Offset(x, y);
}
Future<LatLng?> _latLngForScreenOffset(Offset offset) async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return null;
final point = await mapboxMap.coordinateForPixel(
mbx.ScreenCoordinate(
x: offset.dx,
y: offset.dy,
),
);
final coordinates = point.coordinates;
return LatLng(
coordinates.lat.toDouble(),
coordinates.lng.toDouble(),
);
}
void _onBoundsChanged() {
if (_updatingBoundsFromMap) return;
_debouncer(
() => _onIdle(reason: 'bounds-changed-external'),
);
}
Future<void> _onIdle({required String reason}) async {
if (!mounted) return;
_log(
'onIdle '
'reason=$reason '
'style=${widget.style.key} '
'terrain=${widget.style.mapboxTerrain}',
);
await _updateVisibleRegion(reason: reason);
widget.controller.notifyIdle(widget.boundsNotifier.value);
_updateMarkers(reason: 'idle-$reason');
await _updateMarkerScreenPositions(
reason: 'idle-$reason',
keepPreviousOnEmpty: true,
);
await _updateDotScreenPosition();
}
Future<void> _updateScreenPositionsDuringCameraMove() async {
if (_updatingScreenPositions) return;
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastScreenPositionUpdateMs < 50) return;
_lastScreenPositionUpdateMs = now;
_updatingScreenPositions = true;
try {
await _updateMarkerScreenPositions(
reason: 'camera-move',
keepPreviousOnEmpty: true,
);
await _updateDotScreenPosition();
} finally {
_updatingScreenPositions = false;
}
}
void _updateMarkers({required String reason}) {
if (!mounted) return;
final markers = widget.markerClusterBuilder();
_log(
'updateMarkers '
'reason=$reason '
'count=${markers.length} '
'style=${widget.style.key} '
'terrain=${widget.style.mapboxTerrain}',
);
setState(() {
_geoEntryByMarkerKey = markers;
});
unawaited(
_updateMarkerScreenPositions(
reason: 'update-markers-$reason',
keepPreviousOnEmpty: true,
),
);
unawaited(_updateDotScreenPosition());
}
Future<void> _updateMarkerScreenPositions({
required String reason,
required bool keepPreviousOnEmpty,
}) async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
final generation = ++_screenPositionGeneration;
final result = <MarkerKey<T>, Offset>{};
var total = 0;
var visible = 0;
var invalid = 0;
var retained = 0;
for (final kv in _geoEntryByMarkerKey.entries) {
total++;
final geoEntry = kv.value;
final stableKey = _stableScreenOffsetKey(kv.key, geoEntry);
final latLng = LatLng(
geoEntry.latitude!,
geoEntry.longitude!,
);
final offset = await _screenOffsetForLatLng(latLng);
if (offset != null) {
visible++;
result[kv.key] = offset;
_lastKnownScreenOffsetByMarkerKey[kv.key] = offset;
_lastKnownScreenOffsetByStableKey[stableKey] = offset;
} else {
invalid++;
final previousOffset =
_lastKnownScreenOffsetByMarkerKey[kv.key] ??
_lastKnownScreenOffsetByStableKey[stableKey] ??
_screenOffsetByMarkerKey[kv.key];
if (previousOffset != null) {
retained++;
result[kv.key] = previousOffset;
}
}
}
if (!mounted) return;
if (generation != _screenPositionGeneration) {
_log(
'marker screen positions skipped stale '
'reason=$reason '
'generation=$generation current=$_screenPositionGeneration',
);
return;
}
_log(
'marker screen positions '
'reason=$reason '
'total=$total '
'visible=$visible '
'invalid=$invalid '
'retained=$retained '
'keepPreviousOnEmpty=$keepPreviousOnEmpty '
'style=${widget.style.key} '
'terrain=${widget.style.mapboxTerrain} '
'zoom=${widget.boundsNotifier.value.zoom} '
'rotation=${widget.boundsNotifier.value.rotation}',
);
if (keepPreviousOnEmpty &&
total > 0 &&
visible == 0 &&
retained == 0 &&
_screenOffsetByMarkerKey.isNotEmpty) {
_log(
'keeping previous marker positions because current projection is empty '
'reason=$reason '
'previous=${_screenOffsetByMarkerKey.length}',
);
return;
}
final previousCount = _screenOffsetByMarkerKey.length;
final newCount = result.length;
if (previousCount > 0 && newCount < previousCount && invalid > 0) {
_log(
'merging previous marker positions '
'reason=$reason '
'previous=$previousCount '
'new=$newCount '
'invalid=$invalid',
);
for (final entry in _screenOffsetByMarkerKey.entries) {
result.putIfAbsent(entry.key, () => entry.value);
}
}
setState(() {
_screenOffsetByMarkerKey = result;
});
if (_shouldReportVisibleMarkerKeys(reason: reason)) {
final visibleMarkerKeys = result.keys
.where(_geoEntryByMarkerKey.containsKey)
.toSet();
_notifyVisibleMarkerKeys(visibleMarkerKeys);
} else {
_log(
'visible marker keys report skipped '
'reason=$reason',
);
}
}
Future<void> _updateDotScreenPosition() async {
final dotLocation = widget.dotLocationNotifier?.value;
if (dotLocation == null) {
if (!mounted) return;
setState(() {
_dotScreenOffset = null;
});
return;
}
final offset = await _screenOffsetForLatLng(dotLocation);
if (!mounted) return;
setState(() {
_dotScreenOffset = offset;
});
}
Future<ZoomedBounds?> _queryBoundsForCamera(mbx.CameraState camera) async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return null;
try {
final bounds = await (mapboxMap as dynamic).coordinateBoundsForCamera(
mbx.CameraOptions(
center: camera.center,
zoom: camera.zoom,
bearing: camera.bearing,
// Use a top-down query footprint, while keeping the real camera pitched.
pitch: 0.0,
),
);
final swCoordinates = bounds.southwest.coordinates;
final neCoordinates = bounds.northeast.coordinates;
return ZoomedBounds(
sw: LatLng(
swCoordinates.lat.toDouble(),
swCoordinates.lng.toDouble(),
),
ne: LatLng(
neCoordinates.lat.toDouble(),
neCoordinates.lng.toDouble(),
),
zoom: camera.zoom,
rotation: camera.bearing,
);
} catch (e) {
_log('coordinateBoundsForCamera unavailable/error: $e');
return null;
}
}
Future<void> _updateVisibleRegion({required String reason}) async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
if (_mapSize.width <= 0 || _mapSize.height <= 0) return;
final camera = await mapboxMap.getCameraState();
_rememberCameraState(camera);
final queryBounds = await _queryBoundsForCamera(camera);
if (queryBounds != null) {
_updatingBoundsFromMap = true;
try {
widget.boundsNotifier.value = queryBounds;
} finally {
_updatingBoundsFromMap = false;
}
_log(
'visible bounds native-query '
'reason=$reason '
'sw=${queryBounds.sw} '
'ne=${queryBounds.ne} '
'zoom=${camera.zoom} '
'bearing=${camera.bearing} '
'pitch=${camera.pitch}',
);
return;
}
final corners = [
Offset.zero,
Offset(_mapSize.width, 0),
Offset(_mapSize.width, _mapSize.height),
Offset(0, _mapSize.height),
];
final points = <LatLng>[];
for (final corner in corners) {
final point = await _latLngForScreenOffset(corner);
if (point != null) {
points.add(point);
}
}
if (points.isEmpty) {
_log('visible bounds skipped no corner points reason=$reason');
return;
}
final south = points.map((v) => v.latitude).reduce(min);
final north = points.map((v) => v.latitude).reduce(max);
final west = points.map((v) => v.longitude).reduce(min);
final east = points.map((v) => v.longitude).reduce(max);
_updatingBoundsFromMap = true;
try {
widget.boundsNotifier.value = ZoomedBounds(
sw: LatLng(south, west),
ne: LatLng(north, east),
zoom: camera.zoom,
rotation: camera.bearing,
);
} finally {
_updatingBoundsFromMap = false;
}
_log(
'visible bounds '
'reason=$reason '
'sw=${widget.boundsNotifier.value.sw} '
'ne=${widget.boundsNotifier.value.ne} '
'zoom=${camera.zoom} '
'bearing=${camera.bearing} '
'pitch=${camera.pitch}',
);
}
Future<void> _moveTo(LatLng point) async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
await mapboxMap.flyTo(
mbx.CameraOptions(
center: mbx.Point(
coordinates: mbx.Position(
point.longitude,
point.latitude,
),
),
),
mbx.MapAnimationOptions(
duration: _cameraAnimationDurationMs,
),
);
}
Future<void> _zoomBy(
double amount, {
LatLng? focalPoint,
}) async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
final camera = await mapboxMap.getCameraState();
final endZoom = (camera.zoom + amount).clamp(
widget.minZoom,
widget.maxZoom,
);
widget.onUserZoomChange?.call(endZoom);
await mapboxMap.flyTo(
mbx.CameraOptions(
center: focalPoint != null
? mbx.Point(
coordinates: mbx.Position(
focalPoint.longitude,
focalPoint.latitude,
),
)
: null,
zoom: endZoom,
),
mbx.MapAnimationOptions(
duration: _cameraAnimationDurationMs,
),
);
}
Future<void> _resetRotation() async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) return;
await mapboxMap.flyTo(
mbx.CameraOptions(
bearing: 0,
),
mbx.MapAnimationOptions(
duration: _cameraAnimationDurationMs,
),
);
}
}