import 'dart:async'; import 'dart:typed_data'; import 'package:maplibre_gl/maplibre_gl.dart'; import '../../core/region/geo.dart'; /// Draws radar frames over the map, double buffered. /// /// Two image sources alternate: while one is on screen the next frame is /// uploaded into the other, then visibility swaps. Updating a single source in /// place flickers, because the layer is briefly showing a half-written texture; /// adding every frame as its own layer avoids that too but pins all of them in /// GPU memory at once, and twenty 512×512 RGBA frames is about 80 MB. Two /// buffers cost the same whether the timeline holds six frames or sixty. class RadarOverlay { RadarOverlay({required this.map, required GeoBounds bounds}) : _quad = LatLngQuad( topLeft: LatLng(bounds.north, bounds.west), topRight: LatLng(bounds.north, bounds.east), bottomRight: LatLng(bounds.south, bounds.east), bottomLeft: LatLng(bounds.south, bounds.west), ); static const String _sourceA = 'nuvolari-radar-a'; static const String _sourceB = 'nuvolari-radar-b'; static const String _layerA = 'nuvolari-radar-layer-a'; static const String _layerB = 'nuvolari-radar-layer-b'; final MapLibreMapController map; /// Corners of the published frames. The worker renders in EPSG:3857 cropped /// to exactly this box, so the quad is exact and nothing is warped client /// side. final LatLngQuad _quad; bool _attached = false; bool _showingA = true; String? _visiblePath; /// Serialises platform-channel work. `show` can be called faster than the /// round trip completes — during playback, or while scrubbing — and /// overlapping updates would swap visibility out of order and strobe. Future _pending = Future.value(); bool get isAttached => _attached; /// The frame currently on screen, or null before the first one. String? get visiblePath => _visiblePath; /// Creates the sources and layers, showing [bytes] immediately. /// /// Safe to call again after the style reloads: the previous layers are torn /// down first. Future attach(String path, Uint8List bytes) => _serialise(() async { if (_attached) await _detachUnsafe(); await map.addImageSource(_sourceA, bytes, _quad); await map.addImageSource(_sourceB, bytes, _quad); await map.addImageLayer(_layerA, _sourceA); await map.addImageLayer(_layerB, _sourceB); await map.setLayerVisibility(_layerB, false); _attached = true; _showingA = true; _visiblePath = path; }); /// Swaps [bytes] onto the map. /// /// A no-op when [path] is already visible, so a rebuild that does not change /// the playhead costs nothing. Future show(String path, Uint8List bytes) { if (!_attached) return attach(path, bytes); if (path == _visiblePath) return Future.value(); return _serialise(() async { final hiddenSource = _showingA ? _sourceB : _sourceA; final hiddenLayer = _showingA ? _layerB : _layerA; final visibleLayer = _showingA ? _layerA : _layerB; // Upload first, reveal second: the new texture is complete before it is // ever shown, which is what removes the flicker. await map.updateImageSource(hiddenSource, bytes, null); await map.setLayerVisibility(hiddenLayer, true); await map.setLayerVisibility(visibleLayer, false); _showingA = !_showingA; _visiblePath = path; }); } /// Removes the layers and sources. Future detach() => _serialise(_detachUnsafe); Future _detachUnsafe() async { // Layers reference sources, so they have to go first. Each removal is // tolerated failing: the style may already have dropped them, and the goal // here is to end up with nothing rather than to prove what was there. for (final layer in [_layerA, _layerB]) { try { await map.removeLayer(layer); } on Object { continue; } } for (final source in [_sourceA, _sourceB]) { try { await map.removeSource(source); } on Object { continue; } } _attached = false; _visiblePath = null; } Future _serialise(Future Function() action) { final next = _pending.then((_) => action()); // Swallow the failure for the chain's sake only: a platform-channel error // on one frame must not deadlock every later frame behind it. _pending = next.catchError((Object _) {}); return next; } }