import 'dart:math'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/widgets/common/map/attribution.dart'; import 'package:aves/widgets/common/map/buttons.dart'; import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/decorator.dart'; import 'package:aves/widgets/common/map/geo_entry.dart'; import 'package:aves/widgets/common/map/google/map.dart'; import 'package:aves/widgets/common/map/leaflet/map.dart'; import 'package:aves/widgets/common/map/marker.dart'; import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:fluster/fluster.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class GeoMap extends StatefulWidget { final AvesMapController? controller; final List entries; final AvesEntry? initialEntry; final ValueNotifier isAnimatingNotifier; final ValueNotifier? dotEntryNotifier; final UserZoomChangeCallback? onUserZoomChange; final VoidCallback? onMapTap; final MarkerTapCallback? onMarkerTap; final MapOpener? openMapPage; static const markerImageExtent = 48.0; static const markerArrowSize = Size(8, 6); const GeoMap({ Key? key, this.controller, required this.entries, this.initialEntry, required this.isAnimatingNotifier, this.dotEntryNotifier, this.onUserZoomChange, this.onMapTap, this.onMarkerTap, this.openMapPage, }) : super(key: key); @override _GeoMapState createState() => _GeoMapState(); } class _GeoMapState extends State { // as of google_maps_flutter v2.0.6, Google map initialization is blocking // cf https://github.com/flutter/flutter/issues/28493 // it is especially severe the first time, but still significant afterwards // so we prevent loading it while scrolling or animating bool _googleMapsLoaded = false; late final ValueNotifier _boundsNotifier; late final Fluster _defaultMarkerCluster; Fluster? _slowMarkerCluster; List get entries => widget.entries; // cap initial zoom to avoid a zoom change // when toggling overlay on Google map initial state static const double minInitialZoom = 3; @override void initState() { super.initState(); final initialEntry = widget.initialEntry; final points = (initialEntry != null ? [initialEntry] : entries).map((v) => v.latLng!).toSet(); final bounds = ZoomedBounds.fromPoints( points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]}, collocationZoom: settings.infoMapZoom, ); _boundsNotifier = ValueNotifier(bounds.copyWith( zoom: max(bounds.zoom, minInitialZoom), )); _defaultMarkerCluster = _buildFluster(); } @override Widget build(BuildContext context) { void _onMarkerTap(GeoEntry geoEntry) { final onTap = widget.onMarkerTap; if (onTap == null) return; final clusterId = geoEntry.clusterId; Set getClusterEntries() { if (clusterId == null) { return {geoEntry.entry!}; } var points = _defaultMarkerCluster.points(clusterId); if (points.length != geoEntry.pointsSize) { // `Fluster.points()` method does not always return all the points contained in a cluster // the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`) _slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(entries.length)); points = _slowMarkerCluster!.points(clusterId); assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry'); } return points.map((geoEntry) => geoEntry.entry!).toSet(); } AvesEntry? markerEntry; if (clusterId != null) { final uri = geoEntry.childMarkerId; markerEntry = entries.firstWhereOrNull((v) => v.uri == uri); } else { markerEntry = geoEntry.entry; } if (markerEntry != null) { onTap(markerEntry, getClusterEntries); } } return FutureBuilder( future: availability.isConnected, builder: (context, snapshot) { if (snapshot.data != true) return const SizedBox(); return Selector( selector: (context, s) => s.infoMapStyle, builder: (context, mapStyle, child) { final isGoogleMaps = mapStyle.isGoogleMaps; final progressive = !isGoogleMaps; Widget _buildMarkerWidget(MarkerKey key) => ImageMarker( key: key, entry: key.entry, count: key.count, extent: GeoMap.markerImageExtent, arrowSize: GeoMap.markerArrowSize, progressive: progressive, ); Widget child = isGoogleMaps ? EntryGoogleMap( controller: widget.controller, boundsNotifier: _boundsNotifier, minZoom: 0, maxZoom: 20, style: mapStyle, markerClusterBuilder: _buildMarkerClusters, markerWidgetBuilder: _buildMarkerWidget, dotEntryNotifier: widget.dotEntryNotifier, onUserZoomChange: widget.onUserZoomChange, onMapTap: widget.onMapTap, onMarkerTap: _onMarkerTap, openMapPage: widget.openMapPage, ) : EntryLeafletMap( controller: widget.controller, boundsNotifier: _boundsNotifier, minZoom: 2, maxZoom: 16, style: mapStyle, markerClusterBuilder: _buildMarkerClusters, markerWidgetBuilder: _buildMarkerWidget, dotEntryNotifier: widget.dotEntryNotifier, markerSize: Size( GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2, GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.markerArrowSize.height, ), dotMarkerSize: const Size( DotMarker.diameter + ImageMarker.outerBorderWidth * 2, DotMarker.diameter + ImageMarker.outerBorderWidth * 2, ), onUserZoomChange: widget.onUserZoomChange, onMapTap: widget.onMapTap, onMarkerTap: _onMarkerTap, openMapPage: widget.openMapPage, ); final mapHeight = context.select((v) => v.mapHeight); child = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ mapHeight != null ? SizedBox( height: mapHeight, child: child, ) : Expanded(child: child), SafeArea( top: false, bottom: false, child: Attribution(style: mapStyle), ), ], ); return AnimatedSize( alignment: Alignment.topCenter, curve: Curves.easeInOutCubic, duration: Durations.mapStyleSwitchAnimation, child: ValueListenableBuilder( valueListenable: widget.isAnimatingNotifier, builder: (context, animating, child) { if (!animating && isGoogleMaps) { _googleMapsLoaded = true; } Widget replacement = Stack( children: [ const MapDecorator(), MapButtonPanel( boundsNotifier: _boundsNotifier, openMapPage: widget.openMapPage, ), ], ); if (mapHeight != null) { replacement = SizedBox( height: mapHeight, child: replacement, ); } return Visibility( visible: !isGoogleMaps || _googleMapsLoaded, replacement: replacement, child: child!, ); }, child: child, ), ); }, ); }, ); } Fluster _buildFluster({int nodeSize = 64}) { final markers = entries.map((entry) { final latLng = entry.latLng!; return GeoEntry( entry: entry, latitude: latLng.latitude, longitude: latLng.longitude, markerId: entry.uri, ); }).toList(); return Fluster( // we keep clustering on the whole range of zooms (including the maximum) // to avoid collocated entries overlapping minZoom: 0, maxZoom: 22, // TODO TLAD [map] derive `radius` / `extent`, from device pixel ratio and marker extent? // (radius=120, extent=2 << 8) is equivalent to (radius=240, extent=2 << 9) radius: 240, extent: 2 << 9, // node size: 64 by default, higher means faster indexing but slower search nodeSize: nodeSize, points: markers, createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat), ); } Map _buildMarkerClusters() { final bounds = _boundsNotifier.value; final geoEntries = _defaultMarkerCluster.clusters(bounds.boundingBox, bounds.zoom.round()); return Map.fromEntries(geoEntries.map((v) { if (v.isCluster!) { final uri = v.childMarkerId; final entry = entries.firstWhere((v) => v.uri == uri); return MapEntry(MarkerKey(entry, v.pointsSize), v); } return MapEntry(MarkerKey(v.entry!, null), v); })); } } @immutable class MarkerKey extends LocalKey with EquatableMixin { final AvesEntry entry; final int? count; @override List get props => [entry, count]; const MarkerKey(this.entry, this.count); } typedef MarkerClusterBuilder = Map Function(); typedef MarkerWidgetBuilder = Widget Function(MarkerKey key); typedef UserZoomChangeCallback = void Function(double zoom); typedef MarkerTapCallback = void Function(AvesEntry markerEntry, Set Function() getClusterEntries);