import 'dart:async'; import 'dart:ui'; import 'package:maplibre_gl/maplibre_gl.dart'; import '../../core/region/geo.dart'; /// Marks the point the map is pointed at. /// /// The blue dot answers "where am I". This answers "where is the place I /// chose": without it, picking a town moves the camera and then leaves the /// reader to guess which of the settlements now on screen was the one they /// asked for. A town has no precise position of its own, so what gets marked is /// its centre. /// /// Drawn as a style layer rather than as a widget stacked over the map. The /// native renderer keeps a layer pinned to its coordinates through every pan, /// zoom and camera animation; a widget would have to be repositioned from Dart /// on every frame, one asynchronous coordinate conversion at a time, and would /// visibly swim behind the map while it moved. class PlaceMarker { PlaceMarker({required this.map, required this.color}); static const String sourceId = 'nuvolari-place-marker'; /// The lower of the two layers. /// /// Public because layer order is a property of the whole map, not of this /// class: the radar frames are added *below* this id, which is what stops a /// band of rain from being painted over the marker. static const String haloLayerId = 'nuvolari-place-marker-halo'; static const String dotLayerId = 'nuvolari-place-marker-dot'; final MapLibreMapController map; /// Fill colour of the dot. The white ring underneath carries the contrast, so /// this only has to identify the app. final Color color; bool _attached = false; GeoPoint? _point; bool get isAttached => _attached; /// The point currently marked, or null when nothing is. GeoPoint? get point => _point; /// Serialises platform-channel work, so a target change that lands while the /// previous one is still in flight cannot apply out of order. Future _pending = Future.value(); /// Creates the source and layers, marking nothing yet. /// /// Called before the radar overlay attaches so that the marker's layers are /// already in the style for the frames to be inserted underneath. Future attach() => _serialise(() async { if (_attached) return; await map.addGeoJsonSource(sourceId, _featureCollection(null)); // A white disc under a coloured dot. The radar palette runs through blues // and greens, and a marker in any single colour disappears into some part // of it; a ring of the background colour reads against all of them. await map.addCircleLayer( sourceId, haloLayerId, const CircleLayerProperties( circleRadius: 12.0, circleColor: '#FFFFFF', circleOpacity: 0.95, ), enableInteraction: false, ); await map.addCircleLayer( sourceId, dotLayerId, CircleLayerProperties(circleRadius: 6.0, circleColor: _hex(color)), enableInteraction: false, ); _attached = true; _point = null; }); /// Moves the marker to [point], or clears it when [point] is null. Future show(GeoPoint? point) => _serialise(() async { if (!_attached) return; if (point == _point) return; // Emptying the feature collection hides the marker without removing the // layers, which keeps them above the radar for the next time it is shown. await map.setGeoJsonSource(sourceId, _featureCollection(point)); _point = point; }); /// Removes the layers and the source. Future detach() => _serialise(() async { if (!_attached) return; // Layers reference the source, so they go first. Each removal tolerates // failing: the style may already have dropped them on a reload, and the // goal is to end up with nothing rather than to prove what was there. for (final layer in [dotLayerId, haloLayerId]) { try { await map.removeLayer(layer); } on Object { continue; } } try { await map.removeSource(sourceId); } on Object { // Nothing left to do; the state below is reset either way. } _attached = false; _point = null; }); Map _featureCollection(GeoPoint? point) => { 'type': 'FeatureCollection', 'features': >[ if (point != null) { 'type': 'Feature', 'geometry': { 'type': 'Point', 'coordinates': [point.longitude, point.latitude], }, 'properties': {}, }, ], }; Future _serialise(Future Function() action) { final next = _pending.then((_) => action()); // Swallowed for the chain's sake only: one platform-channel failure must // not deadlock every later update queued behind it. _pending = next.catchError((Object _) {}); return next; } } /// The `#RRGGBB` string the style expects. String _hex(Color color) { final rgb = color.toARGB32() & 0xFFFFFF; return '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}'; }