1199 lines
31 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|