Animate the radar timeline with a double-buffered overlay

Completes milestone 3: frames render over the map, play as a loop, and can be
scrubbed, with the legend drawn from the manifest rather than a constant that
could drift from what the worker actually rendered.

The overlay alternates two MapLibre image sources. Updating one source in place
flickers, because the layer briefly shows a half-written texture; adding every
frame as its own layer avoids that but pins them all in GPU memory, and twenty
512x512 RGBA frames is about 80 MB. Two buffers cost the same whether the
timeline holds six frames or sixty. Platform-channel work is serialised because
`show` is called faster than the round trip completes during playback, and
overlapping updates would swap visibility out of order and strobe.

Prefetching loads a window around the playhead, nearest first and forward
before backward, since playback moves forward and that frame is needed
soonest. `FrameCache` is byte-budgeted rather than entry-counted because frame
size tracks how much precipitation is on screen, and it evicts by distance from
the playhead: plain LRU would keep frames the prefetcher touched a moment ago
even after the playhead moved to the far end of the timeline.

Also adds DpcRadarSource, which reads published frames from our CDN and never
from the DPC API. Without it, the `dpc` adapter would have had to fall back to
mock, putting demo frames on screen under the label of live data — exactly the
confusion the adapter split exists to prevent. It now fails naming the missing
setting instead.

Running it on the emulator caught three things the tests had not:

- The notifier wrote to `state` from inside `build()`, which Riverpod rejects
  as an uninitialised provider. That broke startup, not just tests.
- Eight-month-old demo frames rendered as "Aggiornato 342535 minuti fa". The
  age formatter now steps up to hours and days.
- Demo mode sat permanently behind a stale-data warning and so never showed the
  working state it exists to demonstrate. MockRadarSource now shifts the
  bundled timestamps onto the present, leaving images, order and spacing
  untouched, so the timeline behaves exactly as it would on live data.

Corrects docs/stack-decisions.md, which described a disk cache that was not
built: mock frames already live in the asset bundle, so a disk layer belongs
with the network adapter where it would save a real request.

Verified: analyze clean, 129 tests passing, and on the emulator the loop
advances, wraps, and reports "Aggiornato ora".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 12:57:16 +02:00
co-authored by Claude Opus 5
parent 8ff8a903b8
commit 91f9ac0fbf
18 changed files with 1856 additions and 26 deletions
+122
View File
@@ -0,0 +1,122 @@
import 'dart:collection';
import 'dart:typed_data';
/// A byte-budgeted LRU cache for radar frame images.
///
/// Radar animation needs several frames resident at once, and a phone cannot
/// hold twenty decoded images. The budget is in bytes rather than entries
/// because frame sizes vary with how much precipitation is on screen — a clear
/// sky compresses to almost nothing, a storm does not — so counting entries
/// would let a stormy loop use several times the memory of a calm one.
///
/// Not persistent. Frames that came from the asset bundle are already on disk,
/// and network frames are re-fetched from the CDN; a disk layer belongs with
/// the network adapter, not here.
class FrameCache {
FrameCache({this.maxBytes = defaultMaxBytes})
: assert(maxBytes > 0, 'maxBytes must be positive');
/// Roughly twenty 512×512 frames of typical weather, with headroom.
static const int defaultMaxBytes = 24 * 1024 * 1024;
final int maxBytes;
/// Insertion-ordered, and re-inserted on every read, so the first key is
/// always the least recently used.
final LinkedHashMap<String, Uint8List> _entries =
LinkedHashMap<String, Uint8List>();
/// In-flight loads, so two prefetches of the same frame do not both hit the
/// network. This is what makes scrubbing back and forth over a partly loaded
/// timeline cheap.
final Map<String, Future<Uint8List>> _inFlight =
<String, Future<Uint8List>>{};
int _currentBytes = 0;
int get currentBytes => _currentBytes;
int get length => _entries.length;
bool contains(String key) => _entries.containsKey(key);
/// Keys currently resident, least recently used first.
Iterable<String> get keys => _entries.keys;
/// Returns the cached bytes for [key], marking it most recently used.
Uint8List? get(String key) {
final value = _entries.remove(key);
if (value == null) return null;
_entries[key] = value;
return value;
}
/// Stores [bytes] under [key], evicting least recently used entries until the
/// total fits the budget.
///
/// A single frame larger than the whole budget is stored anyway and then
/// immediately becomes the only entry: refusing it would mean the timeline
/// could never display that frame at all, which is worse than briefly
/// exceeding the target.
void put(String key, Uint8List bytes) {
final existing = _entries.remove(key);
if (existing != null) {
_currentBytes -= existing.lengthInBytes;
}
_entries[key] = bytes;
_currentBytes += bytes.lengthInBytes;
while (_currentBytes > maxBytes && _entries.length > 1) {
final oldest = _entries.keys.first;
final evicted = _entries.remove(oldest)!;
_currentBytes -= evicted.lengthInBytes;
}
}
/// Returns the bytes for [key], loading them with [fetch] on a miss.
///
/// Concurrent calls for the same key share one [fetch]. A failed load is not
/// cached, so a transient error does not poison the frame for the rest of the
/// session.
Future<Uint8List> load(String key, Future<Uint8List> Function() fetch) async {
final cached = get(key);
if (cached != null) return cached;
final pending = _inFlight[key];
if (pending != null) return pending;
final future = fetch();
_inFlight[key] = future;
try {
final bytes = await future;
put(key, bytes);
return bytes;
} finally {
// removeWhere rather than remove: the latter hands back the Future that
// was stored, and discarding a Future is exactly what the lint is there
// to catch elsewhere.
_inFlight.removeWhere((candidate, _) => candidate == key);
}
}
/// Drops everything not in [keep].
///
/// Used when the playhead moves far enough that a whole stretch of the
/// timeline is no longer worth holding. Plain LRU would keep frames the
/// prefetcher touched a moment ago even though they are now at the far end of
/// the timeline, so the caller gets to say what still matters.
void retainOnly(Set<String> keep) {
final doomed = _entries.keys
.where((key) => !keep.contains(key))
.toList(growable: false);
for (final key in doomed) {
_currentBytes -= _entries.remove(key)!.lengthInBytes;
}
}
void clear() {
_entries.clear();
_currentBytes = 0;
}
}