// 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 extends StatefulWidget { final AvesMapController controller; final Listenable clusterListenable; final ValueNotifier boundsNotifier; final double minZoom, maxZoom; final EntryMapStyle style; final TransitionBuilder decoratorBuilder; final WidgetBuilder buttonPanelBuilder; final MarkerClusterBuilder markerClusterBuilder; final MarkerWidgetBuilder markerWidgetBuilder; final ValueNotifier? dotLocationNotifier; final Size markerSize, dotMarkerSize; final ValueNotifier? overlayOpacityNotifier; final MapOverlay? overlayEntry; final Set>? tracks; final UserZoomChangeCallback? onUserZoomChange; final MapTapCallback? onMapTap; final MarkerTapCallback? onMarkerTap; final MarkerLongPressCallback? onMarkerLongPress; final ValueChanged>>? 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> createState() => _EntryMapboxMapState(); } class _EntryMapboxMapState extends State> { static const bool _debugMapbox = true; static const bool _debugCameraMove = false; static const bool _debugOverlayBuild = true; final Set _subscriptions = {}; final Debouncer _debouncer = Debouncer( delay: ADurations.mapIdleDebounceDelay, ); mbx.MapboxMap? _mapboxMap; Map, GeoEntry> _geoEntryByMarkerKey = {}; Map, Offset> _screenOffsetByMarkerKey = {}; Map, Offset> _lastKnownScreenOffsetByMarkerKey = {}; Map _lastKnownScreenOffsetByStableKey = {}; Set> _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 markerKey, GeoEntry 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> 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 _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 _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 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 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 widget) { widget.clusterListenable.removeListener(_clusterListenableListener); widget.boundsNotifier.removeListener(_onBoundsChanged); widget.dotLocationNotifier?.removeListener(_updateDotScreenPosition); _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); } Future _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 _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 _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 _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 _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( (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( 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 _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 _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 _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 _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 _updateMarkerScreenPositions({ required String reason, required bool keepPreviousOnEmpty, }) async { final mapboxMap = _mapboxMap; if (mapboxMap == null) return; final generation = ++_screenPositionGeneration; final result = , 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 _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 _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 _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 = []; 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 _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 _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 _resetRotation() async { final mapboxMap = _mapboxMap; if (mapboxMap == null) return; await mapboxMap.flyTo( mbx.CameraOptions( bearing: 0, ), mbx.MapAnimationOptions( duration: _cameraAnimationDurationMs, ), ); } }