import 'dart:convert'; import 'dart:ui' show Color; import '../../core/region/geo.dart'; /// One radar image in the timeline. class RadarFrame { const RadarFrame({required this.timestamp, required this.path}); factory RadarFrame.fromJson(Map json) { final ts = json['ts']; if (ts is! int) { throw const FormatException('frame ts must be an integer epoch in ms'); } final url = json['url']; if (url is! String || url.isEmpty) { throw const FormatException('frame url must be a non-empty string'); } return RadarFrame( timestamp: DateTime.fromMillisecondsSinceEpoch(ts, isUtc: true), path: url, ); } /// When the radar sweep was taken, in UTC. final DateTime timestamp; /// Location of the PNG, relative to the manifest that listed it, so the whole /// published tree can be moved between hosts without rewriting the frames. final String path; @override String toString() => 'RadarFrame(${timestamp.toIso8601String()}, $path)'; } /// One step of the colour ramp, at and above [value]. class LegendStop { const LegendStop({required this.value, required this.color}); factory LegendStop.fromJson(Map json) { final value = json['value']; if (value is! num) { throw const FormatException('legend stop value must be a number'); } final color = json['color']; if (color is! String) { throw const FormatException('legend stop color must be a string'); } return LegendStop(value: value.toDouble(), color: _parseHexColor(color)); } final double value; final Color color; } /// The colour ramp used to render the frames, published alongside them. /// /// The legend travels with the data rather than living as a constant in the /// app: the worker chose these colours when it rendered the PNGs, so a /// hardcoded copy here could drift and mislabel what the user is looking at. class RadarLegend { const RadarLegend({required this.unit, required this.stops}); factory RadarLegend.fromJson(Map json) { final unit = json['unit']; if (unit is! String || unit.isEmpty) { throw const FormatException('legend unit must be a non-empty string'); } final rawStops = json['stops']; if (rawStops is! List || rawStops.isEmpty) { throw const FormatException('legend stops must be a non-empty list'); } final stops = rawStops .map((entry) { if (entry is! Map) { throw const FormatException('legend stop must be an object'); } return LegendStop.fromJson(entry); }) .toList(growable: false); for (var i = 1; i < stops.length; i++) { if (stops[i].value <= stops[i - 1].value) { throw FormatException( 'legend stops must ascend: ${stops[i - 1].value} then ${stops[i].value}', ); } } return RadarLegend(unit: unit, stops: stops); } /// Physical unit of the values, for example `dBZ`. final String unit; /// Ascending by [LegendStop.value]. final List stops; double get minimumValue => stops.first.value; /// The colour for [value], or null below the lowest stop — which means "no /// precipitation", and must render as transparent rather than as the first /// colour of the ramp. Color? colorFor(double value) { if (value < stops.first.value) return null; var chosen = stops.first.color; for (final stop in stops) { if (value >= stop.value) { chosen = stop.color; } else { break; } } return chosen; } } /// The index of published radar frames, as written by the worker. /// /// Shape is documented in docs/architecture.md and produced by both /// tool/generate_mock_frames.py and the Python worker, so the mock and live /// adapters parse exactly the same document. class RadarManifest { const RadarManifest({ required this.regionId, required this.product, required this.generatedAt, required this.bounds, required this.frames, required this.legend, required this.attribution, }); /// The projection the frames are rendered in. /// /// The map overlays each PNG on a lat/lng quad, which only lines up if the /// image is already in Web Mercator. Anything else would render visibly /// skewed, so it is rejected at parse time. static const String requiredCrs = 'EPSG:3857'; factory RadarManifest.fromJson(Map json) { final crs = json['crs']; if (crs != requiredCrs) { throw FormatException( 'manifest crs must be $requiredCrs, got ${crs ?? 'nothing'}', ); } final generatedAt = json['generatedAt']; if (generatedAt is! int) { throw const FormatException('generatedAt must be an integer epoch in ms'); } final rawFrames = json['frames']; if (rawFrames is! List) { throw const FormatException('frames must be a list'); } final frames = rawFrames.map((entry) { if (entry is! Map) { throw const FormatException('frame must be an object'); } return RadarFrame.fromJson(entry); }).toList()..sort((a, b) => a.timestamp.compareTo(b.timestamp)); final legend = json['legend']; if (legend is! Map) { throw const FormatException('legend must be an object'); } final region = json['region']; if (region is! String || region.isEmpty) { throw const FormatException('region must be a non-empty string'); } final product = json['product']; if (product is! String || product.isEmpty) { throw const FormatException('product must be a non-empty string'); } final attribution = json['attribution']; if (attribution is! String || attribution.isEmpty) { throw const FormatException( 'attribution must be a non-empty string: the frames carry a credit ' 'obligation and the manifest is where it travels', ); } return RadarManifest( regionId: region, product: product, generatedAt: DateTime.fromMillisecondsSinceEpoch( generatedAt, isUtc: true, ), bounds: GeoBounds.fromJson(json['bbox']), frames: List.unmodifiable(frames), legend: RadarLegend.fromJson(legend), attribution: attribution, ); } factory RadarManifest.parse(String source) { final decoded = jsonDecode(source); if (decoded is! Map) { throw const FormatException('manifest must be a JSON object'); } return RadarManifest.fromJson(decoded); } final String regionId; /// Radar-DPC product code the frames were rendered from, for example `VMI`. final String product; final DateTime generatedAt; /// Geographic extent of every frame; all frames share it. final GeoBounds bounds; /// Ascending by timestamp. final List frames; final RadarLegend legend; /// Credit line for the frames, shown wherever they are. final String attribution; bool get isEmpty => frames.isEmpty; RadarFrame? get newestFrame => frames.isEmpty ? null : frames.last; /// How stale the newest frame is relative to [now]. /// /// Radar shown without its age is radar the user will assume is current, so /// every view that renders a frame also renders this. Duration ageAt(DateTime now) { final newest = newestFrame; if (newest == null) return Duration.zero; return now.toUtc().difference(newest.timestamp); } } Color _parseHexColor(String value) { final hex = value.startsWith('#') ? value.substring(1) : value; if (hex.length != 6 && hex.length != 8) { throw FormatException('colour must be #RRGGBB or #AARRGGBB, got $value'); } final parsed = int.tryParse(hex, radix: 16); if (parsed == null) { throw FormatException('colour is not hexadecimal: $value'); } return Color(hex.length == 6 ? 0xFF000000 | parsed : parsed); }