From a86e4e2a6f5e9c3382e3fbacc65c898aeab1037e Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Fri, 11 Sep 2026 14:05:09 +0800 Subject: [PATCH 1/6] fix(map): keep the layer options menu open while a row is toggled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 圖層選項選單改成點選項目後不再自動關閉,只有點篩選器或空白處才會關閉 Fix(en-US): Keep the layer options menu open when a row is tapped; only the chip or a tap outside closes it --- .../map/presentation/layers/rain_layer.dart | 4 +++ .../widgets/disaster_map_overlay_menu.dart | 3 ++ .../widgets/satellite_style_menu.dart | 3 ++ .../widgets/typhoon_overlay_menu.dart | 6 ++++ lib/shared/map/map_gsi_overlay.dart | 1 - lib/shared/widgets/map_menu_toggle_row.dart | 15 ++++++---- .../features/map/radar_overlay_menu_test.dart | 28 +++++++++++++++++++ 7 files changed, 54 insertions(+), 6 deletions(-) diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index a0f265f31..7044830ca 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -231,6 +231,9 @@ class RainMapLayer for (final option in RainInterval.values) MenuItemButton( onPressed: () => setInterval(option), + // Picking a step keeps the menu open, like every + // other row — see [MapMenuToggleRow]. + closeOnActivate: false, trailingIcon: option == current ? Icon(Icons.check, size: 18, color: colors.primary) : null, @@ -241,6 +244,7 @@ class RainMapLayer for (final option in RainColorScale.values) MenuItemButton( onPressed: () => setColorScale(option), + closeOnActivate: false, trailingIcon: option == scale ? Icon(Icons.check, size: 18, color: colors.primary) : null, diff --git a/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart b/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart index 5a1517cc7..0687c9a3f 100644 --- a/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart @@ -120,6 +120,9 @@ class _ToggleRow extends StatelessWidget { message: tooltip, child: MenuItemButton( onPressed: onTap, + // Settings panel, not a command menu: the chip or a tap outside + // closes it, never a row — see [MapMenuToggleRow]. + closeOnActivate: false, style: MapChipButton.rowStyle( selected ? colors.primaryContainer.withValues(alpha: 0.45) diff --git a/lib/features/map/presentation/widgets/satellite_style_menu.dart b/lib/features/map/presentation/widgets/satellite_style_menu.dart index db9f87cbd..4af1acf53 100644 --- a/lib/features/map/presentation/widgets/satellite_style_menu.dart +++ b/lib/features/map/presentation/widgets/satellite_style_menu.dart @@ -233,6 +233,9 @@ class _StyleRow extends StatelessWidget { message: tooltip, child: MenuItemButton( onPressed: onTap, + // Settings panel, not a command menu: the chip or a tap outside + // closes it, never a row — see [MapMenuToggleRow]. + closeOnActivate: false, style: MapChipButton.rowStyle( selected ? colors.primaryContainer.withValues(alpha: 0.45) diff --git a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart index 1a095a183..754ec2a98 100644 --- a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart @@ -235,6 +235,9 @@ class _StormBandRow extends StatelessWidget { message: tooltip, child: MenuItemButton( onPressed: onTap, + // Settings panel, not a command menu: the chip or a tap outside + // closes it, never a row — see [MapMenuToggleRow]. + closeOnActivate: false, style: MapChipButton.rowStyle( selected ? accent.withValues(alpha: 0.14) : Colors.transparent, ), @@ -381,6 +384,9 @@ class _WeatherRow extends StatelessWidget { message: tooltip, child: MenuItemButton( onPressed: onTap, + // Settings panel, not a command menu: the chip or a tap outside + // closes it, never a row — see [MapMenuToggleRow]. + closeOnActivate: false, style: MapChipButton.rowStyle( selected ? colors.primaryContainer.withValues(alpha: 0.45) diff --git a/lib/shared/map/map_gsi_overlay.dart b/lib/shared/map/map_gsi_overlay.dart index dc55d718d..b1d5df738 100644 --- a/lib/shared/map/map_gsi_overlay.dart +++ b/lib/shared/map/map_gsi_overlay.dart @@ -819,7 +819,6 @@ class MapGsiOverlayControls extends StatelessWidget { title: l10n.mapOsmOverlay, subtitle: l10n.mapOsmOverlayHint, tooltip: l10n.mapOsmOverlayHint, - closeOnActivate: false, onTap: () => controller.setEnabled(!controller.enabled), ), if (controller.enabled) diff --git a/lib/shared/widgets/map_menu_toggle_row.dart b/lib/shared/widgets/map_menu_toggle_row.dart index 08e20a30c..a93913e57 100644 --- a/lib/shared/widgets/map_menu_toggle_row.dart +++ b/lib/shared/widgets/map_menu_toggle_row.dart @@ -11,6 +11,15 @@ import 'package:flutter/material.dart'; /// Shared so every layer's options menu reads identically — a toggle in the /// radar menu must not look like a different kind of control from the same /// toggle in the typhoon menu. +/// +/// **The menu stays open when a row is tapped.** These dropdowns are settings +/// panels, not command menus: the choices interact (two overlays that exclude +/// each other, borders judged against the layer under them), and the map +/// behind the menu updates live, so a reader is normally changing several rows +/// while watching the result. Closing after each tap would make them reopen +/// the menu for every one. The chip itself and a tap outside are what close +/// it — a row that leaves for somewhere else (a sheet, a page) is the one +/// exception, and is not built from this widget. class MapMenuToggleRow extends StatelessWidget { const MapMenuToggleRow({ super.key, @@ -20,7 +29,6 @@ class MapMenuToggleRow extends StatelessWidget { required this.tooltip, required this.onTap, this.subtitle, - this.closeOnActivate = true, }); /// Whether the overlay is currently on. @@ -37,9 +45,6 @@ class MapMenuToggleRow extends StatelessWidget { final String tooltip; final VoidCallback onTap; - /// Whether activating this row closes its surrounding [MenuAnchor]. - final bool closeOnActivate; - /// Row width — fixed so a dropdown's rows line up regardless of label length. static const double width = 228; @@ -51,7 +56,7 @@ class MapMenuToggleRow extends StatelessWidget { message: tooltip, child: MenuItemButton( onPressed: onTap, - closeOnActivate: closeOnActivate, + closeOnActivate: false, style: MapChipButton.rowStyle( selected ? colors.primaryContainer.withValues(alpha: 0.45) diff --git a/test/features/map/radar_overlay_menu_test.dart b/test/features/map/radar_overlay_menu_test.dart index dcb8495ed..48f8fb1a0 100644 --- a/test/features/map/radar_overlay_menu_test.dart +++ b/test/features/map/radar_overlay_menu_test.dart @@ -117,6 +117,34 @@ void main() { ); }); + testWidgets('a row leaves the menu open; the chip closes it', (tester) async { + _useTallSurface(tester); + final layer = testRadarLayer(_FakeRadarRepository()); + await tester.pumpWidget(_wrap(layer)); + + final l10n = await _l10n(); + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.radarCountyOutline)); + await tester.pumpAndSettle(); + + // The map behind the menu has already changed; a reader comparing two or + // three toggles against it must not have to reopen the menu each time. + expect(layer.showCountyOutline, isFalse); + expect(find.text(l10n.radarCountyOutline), findsOneWidget); + + // A second toggle, from the menu still standing open. + await tester.tap(find.text(l10n.radarTownOutline)); + await tester.pumpAndSettle(); + expect(layer.showTownOutline, isFalse); + expect(find.text(l10n.radarTownOutline), findsOneWidget); + + // The chip is what closes it — the same tap that opened it. + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + expect(find.text(l10n.radarCountyOutline), findsNothing); + }); + testWidgets('tapping the terrain-relief row reports the flip upward', ( tester, ) async { From e6b2a048d5d6693b4cc1dcbd2a69b4fc140fb168 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Fri, 11 Sep 2026 14:05:38 +0800 Subject: [PATCH 2/6] feat(map): overlay same-frame station wind on the radar echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 雷達回波圖新增「顯示風向」圖層,畫出同一時刻的測站風向風速,和顯示閃電兩者只能擇一 New(en-US): Add a wind overlay to the radar echo drawing the same frame's station wind, exclusive with the lightning overlay --- lib/core/settings/setting_keys.dart | 9 + .../map/presentation/layers/radar_layer.dart | 241 +++++++-- .../layers/wind_arrow_overlay.dart | 464 ++++++++++++++++++ .../map/presentation/layers/wind_layer.dart | 220 +-------- .../map/presentation/pages/map_page.dart | 3 + .../widgets/radar_overlay_menu.dart | 33 +- lib/l10n/app_en.arb | 12 + lib/l10n/app_fil.arb | 5 +- lib/l10n/app_id.arb | 5 +- lib/l10n/app_ja.arb | 5 +- lib/l10n/app_ko.arb | 5 +- lib/l10n/app_th.arb | 5 +- lib/l10n/app_vi.arb | 5 +- lib/l10n/app_yue.arb | 5 +- lib/l10n/app_zh.arb | 5 +- lib/l10n/app_zh_Hans.arb | 5 +- lib/l10n/app_zh_Hant_HK.arb | 5 +- lib/l10n/app_zh_TW.arb | 5 +- lib/l10n/gen/app_localizations.dart | 18 + lib/l10n/gen/app_localizations_en.dart | 10 + lib/l10n/gen/app_localizations_fil.dart | 11 + lib/l10n/gen/app_localizations_id.dart | 11 + lib/l10n/gen/app_localizations_ja.dart | 9 + lib/l10n/gen/app_localizations_ko.dart | 10 + lib/l10n/gen/app_localizations_th.dart | 10 + lib/l10n/gen/app_localizations_vi.dart | 10 + lib/l10n/gen/app_localizations_yue.dart | 9 + lib/l10n/gen/app_localizations_zh.dart | 36 ++ test/features/map/radar_layer_test.dart | 296 +++++++++++ .../features/map/radar_overlay_menu_test.dart | 72 ++- .../features/map/raster_timeline_harness.dart | 35 +- 31 files changed, 1300 insertions(+), 274 deletions(-) create mode 100644 lib/features/map/presentation/layers/wind_arrow_overlay.dart diff --git a/lib/core/settings/setting_keys.dart b/lib/core/settings/setting_keys.dart index c8b980f16..55532ab7d 100644 --- a/lib/core/settings/setting_keys.dart +++ b/lib/core/settings/setting_keys.dart @@ -146,6 +146,15 @@ abstract final class SettingKeys { 'map.radarShowLightning', ); + /// Whether the radar echo also draws the station wind arrows of the frame it + /// is showing (absent = false, for the same reason as the lightning one). + /// Mutually exclusive with [mapRadarShowLightning] — the two sets of marks + /// cover each other, so `RadarMapLayer` turns one off when the other goes on + /// and both may be saved off, never both on. + static const SettingKey mapRadarShowWind = SettingKey._( + 'map.radarShowWind', + ); + /// Saved Home township codes (ordered list). See `RegionStore`. static const SettingKey> savedRegionCodes = SettingKey>._('home.savedRegionCodes'); diff --git a/lib/features/map/presentation/layers/radar_layer.dart b/lib/features/map/presentation/layers/radar_layer.dart index fffe03f09..2ec3725b1 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -9,8 +9,10 @@ import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart' import 'package:dpip/features/map/presentation/layers/lightning_strike_overlay.dart'; import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; import 'package:dpip/features/map/presentation/layers/scan_range_overlay_chrome.dart'; +import 'package:dpip/features/map/presentation/layers/wind_arrow_overlay.dart'; import 'package:dpip/features/map/presentation/widgets/radar_overlay_menu.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; +import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_layer.dart'; @@ -33,26 +35,47 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// all: while they came through from underneath there was no way to get an /// uninterrupted raster. /// -/// The options chip also carries the **lightning** overlay: the strikes of the -/// frame the echo is showing, drawn by the same [LightningStrikeOverlay] the -/// standalone 閃電 layer uses. It follows the timeline rather than the wall -/// clock — scrubbing back an hour moves the strikes back with the echo — which -/// is the whole point of putting it here instead of asking the user to compare +/// The options chip also carries two **data** overlays: the **lightning** +/// strikes of the frame the echo is showing, drawn by the same +/// [LightningStrikeOverlay] the standalone 閃電 layer uses, and the station +/// **wind** arrows of that same frame, drawn by the same [WindArrowOverlay] the +/// standalone 風向 layer uses. Both follow the timeline rather than the wall +/// clock — scrubbing back an hour moves the marks back with the echo — which is +/// the whole point of putting them here instead of asking the user to compare /// two layers by memory. +/// +/// The two feeds run on their own clocks, so "the same frame" is resolved per +/// overlay rather than assumed: strikes take the snapshot nearest the frame +/// ([_lightningTolerance]), hourly observations the one in effect at it +/// ([_windMaxAge]). +/// +/// **At most one of the two is ever on.** Strikes and arrows are both dense +/// point marks over the whole island at the same on-screen size, so together +/// they cover each other and the echo underneath; switching one on therefore +/// switches the other off rather than offering a third, unreadable, state. class RadarMapLayer extends RasterTimelineLayer with AdminOutlineChrome, ScanRangeOverlayChrome { RadarMapLayer( RadarRepository super.repository, this.referenceOutline, { required MeteorLightningRepository lightning, + required MeteorWeatherRepository weather, required SettingsStore settings, }) : _settings = settings, _lightning = LightningStrikeOverlay( lightning, namespace: 'radar-lightning', ), + _wind = WindArrowOverlay(weather, namespace: 'radar-wind'), showLightning = ValueNotifier( settings.getBool(SettingKeys.mapRadarShowLightning) ?? false, + ), + // A stored `true` on both (an older build, a hand-edited store) would + // mount two overlays the UI can only describe as one, so lightning — + // the older option — wins and wind stays off until it is asked for. + showWind = ValueNotifier( + (settings.getBool(SettingKeys.mapRadarShowWind) ?? false) && + !(settings.getBool(SettingKeys.mapRadarShowLightning) ?? false), ); @override @@ -64,35 +87,60 @@ class RadarMapLayer extends RasterTimelineLayer /// here can never collide with the standalone 閃電 layer's mount. final LightningStrikeOverlay _lightning; + /// The wind arrows, likewise on this layer's own ids so they cannot collide + /// with the standalone 風向 layer's mount. + final WindArrowOverlay _wind; + /// Whether the echo also draws its frame's strikes. Persisted, and off by /// default: the echo alone is what a reader came for, and every extra mark /// on it is one the reader did not ask for. final ValueNotifier showLightning; - /// The lightning snapshot times (Unix seconds, ascending) the strike overlay - /// can be asked for — the radar timeline has its own, coarser steps, so the - /// two lists are matched by [_lightningIdFor] rather than assumed aligned. + /// Whether the echo also draws its frame's station wind arrows. Same default + /// and same reason as [showLightning]; never on at the same time as it. + final ValueNotifier showWind; + + /// The lightning / weather snapshot times (Unix seconds, ascending) each + /// overlay can be asked for — the radar timeline has its own, coarser steps, + /// so the lists are matched by [_nearestId] rather than assumed aligned. List _lightningSeconds = const []; + List _windSeconds = const []; - /// How far a lightning snapshot may sit from the radar frame and still be - /// drawn on it. + /// How far a strike snapshot may sit from the radar frame and still be drawn + /// on it. /// /// The radar composite publishes every ten minutes and the strike snapshots - /// on their own cadence, so an exact match is not on offer and some slack is - /// required. Beyond this the overlay draws nothing rather than something: - /// strikes half an hour out of step with the echo under them are not a - /// slightly stale picture, they are a different storm. + /// every five, so an exact match is not on offer and some slack is required — + /// but only that much. Beyond this the overlay draws nothing rather than + /// something: strikes half an hour out of step with the echo under them are + /// not a slightly stale picture, they are a different storm. static const Duration _lightningTolerance = Duration(minutes: 10); - /// Serialises the overlay's map mutations. A scrub can deliver frames faster - /// than a fetch completes, and two interleaved `setGeoJsonSource` calls on - /// one source leave whichever finished last on screen — not whichever frame - /// the timeline is actually on. - Future _lightningChain = Future.value(); + /// How old the station observation drawn on a radar frame may be. + /// + /// The observation feed is **hourly** — `/meteor/weather/list` steps in + /// 3600s, and even `latest` lands on the hour — against the echo's ten + /// minutes. Matched the way the strikes are, nearest-within-ten-minutes, five + /// of every six frames would have no observation near enough and the arrows + /// would blink out for most of a scrub. So wind is matched the way an hourly + /// reading actually applies: each frame draws the newest observation taken + /// **at or before** it, which is the wind that was blowing under that echo, + /// and stays drawn until the next hour's reading replaces it. This bounds how + /// far that can be stretched — a gap in the feed must still go blank rather + /// than paint two-hour-old wind over a live echo. + static const Duration _windMaxAge = Duration(hours: 1); + + /// Serialises **both** overlays' map mutations. A scrub can deliver frames + /// faster than a fetch completes, and two interleaved `setGeoJsonSource` + /// calls on one source leave whichever finished last on screen — not + /// whichever frame the timeline is actually on. One chain rather than two, + /// because switching overlays queues a clear of one and a draw of the other, + /// and those must not interleave either. + Future _overlayChain = Future.value(); /// The controller this layer is mounted on, and the frame it was last asked - /// to show — what the lightning toggle needs to catch up to the echo the - /// moment it is switched on, rather than at the next timeline step. + /// to show — what a data toggle needs to catch up to the echo the moment it + /// is switched on, rather than at the next timeline step. MapLibreMapController? _controller; MapFrame? _currentFrame; @@ -178,35 +226,66 @@ class RadarMapLayer extends RasterTimelineLayer (65, ColorVisionFilter.rasterExemptHex('#9600FF')), ]; - /// The legend follows the lightning toggle too, so switching the strikes on - /// brings their key with them. + /// The legend follows the data toggles too, so switching an overlay on brings + /// its key with it. @override Listenable get chromeListenable => - Listenable.merge([super.chromeListenable, showLightning]); + Listenable.merge([super.chromeListenable, showLightning, showWind]); - /// The strike key is appended only while the strikes are actually drawn — a - /// legend naming marks that are not on the map is worse than no legend. + /// An overlay's key is appended only while its marks are actually drawn — a + /// legend naming marks that are not on the map is worse than no legend. At + /// most one of the two can be on, so at most one key is ever appended. @override List chromeLegendItems(BuildContext context) => [ ...super.chromeLegendItems(context), if (showLightning.value) ...LightningStrikeOverlay.legendItems(context), + // With the unit, unlike the 風向 layer's own card: this key lands inside + // the echo's legend under a dBZ scale, with nowhere else to say m/s. + if (showWind.value) + ...WindArrowOverlay.legendItems(context, withUnit: true), ]; - /// Turns the strike overlay on/off and remembers the choice. + /// Turns the strike overlay on/off and remembers the choice. Switching it on + /// switches the wind arrows off — see the class doc. void setShowLightning(bool value) { if (showLightning.value == value) return; + if (value) _setShowWind(false); showLightning.value = value; unawaited(_settings.setBool(SettingKeys.mapRadarShowLightning, value)); + _syncOverlay(value, _applyLightning, _lightning.clear); + } + /// Turns the wind overlay on/off and remembers the choice. Switching it on + /// switches the strikes off — see the class doc. + void setShowWind(bool value) { + if (showWind.value == value) return; + if (value) setShowLightning(false); + _setShowWind(value); + } + + /// The wind half of [setShowWind], without the mutual-exclusion step — so + /// [setShowLightning] can turn the arrows off without recursing back into it. + void _setShowWind(bool value) { + if (showWind.value == value) return; + showWind.value = value; + unawaited(_settings.setBool(SettingKeys.mapRadarShowWind, value)); + _syncOverlay(value, _applyWind, _wind.clear); + } + + /// Queues the map work a toggle implies: catch [apply] up to the frame + /// already on screen (the reader switched this on to see *this* echo's + /// marks, not the next one's), or [teardown] the overlay. + /// + /// Silent with no controller — the layer is not on a map, and [render] mounts + /// whatever is on when it next is. + void _syncOverlay( + bool value, + Future Function() apply, + Future Function(MapLibreMapController) teardown, + ) { final controller = _controller; if (controller == null) return; - if (value) { - // Catch up to the frame already on screen — the reader switched this on - // to see *this* echo's strikes, not the next one's. - _enqueueLightning(() => _applyLightning()); - } else { - _enqueueLightning(() => _lightning.clear(controller)); - } + _enqueueOverlay(value ? apply : () => teardown(controller)); } @override @@ -216,7 +295,8 @@ class RadarMapLayer extends RasterTimelineLayer ) async { _controller = controller; await super.prepare(controller, frames); - if (showLightning.value) _enqueueLightning(_ensureLightningFrames); + if (showLightning.value) _enqueueOverlay(_ensureLightningFrames); + if (showWind.value) _enqueueOverlay(_ensureWindFrames); } @override @@ -228,10 +308,13 @@ class RadarMapLayer extends RasterTimelineLayer _controller = controller; _currentFrame = frame; // Deliberately not awaited, and deliberately before the raster call: the - // strikes are an extra on top of the echo, and making the echo's reveal - // wait on a lightning fetch would put a network round-trip inside a scrub. + // overlay marks are an extra on top of the echo, and making the echo's + // reveal wait on their fetch would put a network round-trip inside a scrub. if (showLightning.value) { - _enqueueLightning(() => _applyLightning(scrubbing: scrubbing)); + _enqueueOverlay(() => _applyLightning(scrubbing: scrubbing)); + } + if (showWind.value) { + _enqueueOverlay(() => _applyWind(scrubbing: scrubbing)); } return super.show(controller, frame, scrubbing: scrubbing); } @@ -241,26 +324,28 @@ class RadarMapLayer extends RasterTimelineLayer _currentFrame = null; _controller = null; await _lightning.clear(controller); + await _wind.clear(controller); await super.clear(controller); } @override void onStyleReset() { _lightning.onStyleReset(); + _wind.onStyleReset(); super.onStyleReset(); } - /// Runs [work] after whatever lightning work is already in flight. + /// Runs [work] after whatever overlay work is already in flight. /// /// A scrub delivers frames faster than a snapshot fetch completes, and two /// overlapping `setGeoJsonSource` calls on one source leave whichever /// finished last on screen — not whichever frame the timeline is on. - void _enqueueLightning(Future Function() work) { - _lightningChain = _lightningChain.then((_) => work()).catchError(( + void _enqueueOverlay(Future Function() work) { + _overlayChain = _overlayChain.then((_) => work()).catchError(( Object error, StackTrace stackTrace, ) { - Log.handle(error, stackTrace, 'radar lightning overlay'); + Log.handle(error, stackTrace, 'radar data overlay'); }); } @@ -294,7 +379,7 @@ class RadarMapLayer extends RasterTimelineLayer final frame = _currentFrame; if (controller == null || !showLightning.value) return; - final id = frame == null ? null : _lightningIdFor(frame.time); + final id = frame == null ? null : _nearestId(_lightningSeconds, frame.time); if (id == null) { // Mounted and empty rather than absent: "the overlay is on and this // frame has no matching strike data" is not the same as "off". @@ -304,14 +389,53 @@ class RadarMapLayer extends RasterTimelineLayer await _lightning.show(controller, id, scrubbing: scrubbing); } - /// The strike snapshot nearest [frameTime], or null when the closest one is - /// further away than [_lightningTolerance]. - String? _lightningIdFor(DateTime frameTime) { - if (_lightningSeconds.isEmpty) return null; + /// Loads the observation snapshot times once, and registers them with the + /// overlay so it can prefetch around whatever frame is shown. + Future _ensureWindFrames() async { + final controller = _controller; + if (controller == null || _windSeconds.isNotEmpty) return; + final result = await _wind.history(); + result.when( + ok: (seconds) { + _windSeconds = List.of(seconds)..sort(); + }, + err: (failure) { + // Left empty, so the next frame retries: the observation history is a + // side dish here, and a failed fetch must not disable the toggle. + Log.warning('radar wind history: ${failure.message}'); + }, + ); + if (_windSeconds.isEmpty) return; + await _wind.prepare(controller, [for (final sec in _windSeconds) '$sec']); + } + + /// Draws the station wind belonging to the frame the echo is showing. + Future _applyWind({bool scrubbing = false}) async { + if (!showWind.value) return; + await _ensureWindFrames(); + final controller = _controller; + final frame = _currentFrame; + if (controller == null || !showWind.value) return; + + final id = frame == null ? null : _activeId(_windSeconds, frame.time); + if (id == null) { + // Mounted and empty — see [_applyLightning]. Reached only at the far end + // of the timeline, where the echo predates the observation history, or + // through a gap in the feed. + await _wind.showEmpty(controller); + return; + } + await _wind.show(controller, id, scrubbing: scrubbing); + } + + /// The snapshot in [seconds] nearest [frameTime], or null when the closest + /// one is further away than [_lightningTolerance]. + static String? _nearestId(List seconds, DateTime frameTime) { + if (seconds.isEmpty) return null; final target = frameTime.millisecondsSinceEpoch ~/ 1000; - var best = _lightningSeconds.first; + var best = seconds.first; var bestDelta = (best - target).abs(); - for (final sec in _lightningSeconds) { + for (final sec in seconds) { final delta = (sec - target).abs(); if (delta < bestDelta) { best = sec; @@ -321,6 +445,25 @@ class RadarMapLayer extends RasterTimelineLayer return bestDelta > _lightningTolerance.inSeconds ? null : '$best'; } + /// The newest snapshot in [seconds] taken **at or before** [frameTime] — the + /// reading that was in effect when the frame was captured — or null when + /// there is none, or the newest one is older than [_windMaxAge]. + /// + /// Deliberately never the *nearest*: the snapshot after a frame was taken + /// later than the echo under it, and drawing it would put a reading from the + /// future on a past frame. Scrubbing back an hour must show the wind of an + /// hour ago, not the wind that came next. + static String? _activeId(List seconds, DateTime frameTime) { + final target = frameTime.millisecondsSinceEpoch ~/ 1000; + int? best; + for (final sec in seconds) { + if (sec > target) continue; + if (best == null || sec > best) best = sec; + } + if (best == null) return null; + return target - best > _windMaxAge.inSeconds ? null : '$best'; + } + @override Widget buildLegend(BuildContext context) => ListenableBuilder( listenable: chromeListenable, diff --git a/lib/features/map/presentation/layers/wind_arrow_overlay.dart b/lib/features/map/presentation/layers/wind_arrow_overlay.dart new file mode 100644 index 000000000..a17afb590 --- /dev/null +++ b/lib/features/map/presentation/layers/wind_arrow_overlay.dart @@ -0,0 +1,464 @@ +/// The wind arrows themselves — the MapLibre source, symbol layer, baked arrow +/// images and snapshot cache that draw one station-wind frame. +/// +/// Split out of [WindMapLayer] for the same reason the strike marks were split +/// out of the 閃電 layer: the arrows have two homes now, the standalone 風向 +/// layer and the radar echo's own wind overlay, which draws the observations +/// matching whichever radar frame is on screen. Both need the identical +/// arrows — the same speed ramp, the same baked PNGs, the same legend — so the +/// drawing lives here once and each host supplies only *which* frame to show. +/// +/// Rotation is where the wind blows **toward** (meteorological "from" + 180°); +/// colour is the discrete speed bucket ([windBuckets]). +library; + +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:dpip/core/a11y/color_vision.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/map/presentation/wind_speed.dart'; +import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/weather_snapshot.dart'; +import 'package:dpip/features/weather/domain/weather_station.dart'; +import 'package:dpip/shared/color_hex.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// Draws station wind arrows on one map surface. +/// +/// [namespace] scopes the MapLibre source/layer ids, so the 風向 layer and the +/// radar overlay can each own a mount without colliding over one id. The baked +/// arrow images deliberately stay on shared ids: they are style-global, +/// identical, and registering one set twice would only cost memory. +class WindArrowOverlay { + WindArrowOverlay(this._repository, {String namespace = 'wind-arrow'}) + : _sourceId = '$namespace-src', + _layerId = '$namespace-lyr'; + + final MeteorWeatherRepository _repository; + + final String _sourceId; + final String _layerId; + + /// The reading unit the arrows encode — the legend's footer when a host asks + /// for it, and the same string the 風向 layer's sheet prints. + static const String unit = 'm/s'; + + static const String _imagePrefix = 'wind-arrow'; + + static const Map _empty = { + 'type': 'FeatureCollection', + 'features': [], + }; + + final Map _cache = {}; + Map _stations = const {}; + List _orderedIds = const []; + Map _indexById = const {}; + bool _mounted = false; + // Baked bitmaps carry the corrected colours painted into them, so they must + // be re-baked when the setting moves — see [VisionCache]. + bool _imagesReady = false; + ColorVision? _imagesVision; + String? _shownFrameId; + + /// Available snapshot times (Unix seconds, ascending). + Future>> history() => _repository.history(); + + /// The shared image id for speed bucket [bucket] (0 = calm … 4 = strongest). + static String imageIdFor(int bucket) => '$_imagePrefix-$bucket'; + + /// Registers one pre-coloured, outline-baked arrow image per speed bucket. + /// + /// Shared with [WindMapLayer], which mounts its own arrow layer on its own + /// station source: the ids are style-global, so whichever surface bakes first + /// serves both, and the bake is idempotent. + static Future registerImages(MapLibreMapController controller) async { + final bytes = await _bakeArrows(); + for (var i = 0; i < bytes.length; i++) { + await controller.addImage(imageIdFor(i), bytes[i], false); + } + } + + /// Speed → pre-coloured arrow image, a `step` over the [windBuckets] + /// thresholds (weakest first). + static List iconExpression() => [ + 'step', + ['get', 'value'], + imageIdFor(0), + for (var i = 1; i < windBuckets.length; i++) ...[ + windBuckets[i].$1, + imageIdFor(i), + ], + ]; + + /// Size scales with wind speed (bigger = stronger) and with zoom. Zoom must + /// be the OUTERMOST interpolate input (MapLibre only allows `[zoom]` at the + /// top level), with the speed interpolate nested per zoom stop. Tuned for the + /// 96 px glyph: ~32–80 px on screen at Taiwan overview zooms. + static List sizeExpression() => [ + 'interpolate', + ['linear'], + ['zoom'], + 5, + [ + 'interpolate', + ['linear'], + ['get', 'value'], + 0.0, + 0.35, + 3.4, + 0.42, + 8.0, + 0.52, + 13.9, + 0.65, + 32.7, + 0.85, + ], + 11, + [ + 'interpolate', + ['linear'], + ['get', 'value'], + 0.0, + 0.70, + 3.4, + 0.85, + 8.0, + 1.05, + 13.9, + 1.30, + 32.7, + 1.70, + ], + ]; + + /// The speed key, strongest first — the same buckets the arrows are coloured + /// by, drawn with the same glyph so the legend matches the map. + /// + /// [withUnit] appends the unit to the top row: a host that renders these + /// inside a shared symbol block (the radar echo's chrome legend) has nowhere + /// else to say what the numbers are, while the 風向 layer's own card prints + /// the unit under the list and must not say it twice. + static List legendItems( + BuildContext context, { + bool withUnit = false, + }) { + // Corrected here, exactly as [windBuckets] is at its own definition: the + // arrows are app-drawn glyphs, so the key follows the setting with them. + final rows = <(String, String)>[ + ('≥ 32.7', '#FF006B'.vision), + ('13.9 – 32.6', '#8000FF'.vision), + ('8.0 – 13.8', '#0085FF'.vision), + ('3.4 – 7.9', '#00FFF0'.vision), + ('0.1 – 3.3', '#FFFFFF'.vision), + ]; + final outline = Theme.of(context).colorScheme.outline; + return [ + for (final (index, (label, hex)) in rows.indexed) + SymbolLegendItem( + // The arrow carries the same black outline as the map; the dark disc + // behind pale / white glyphs keeps them readable on the frosted card. + swatch: Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + color: outline.withValues(alpha: 0.35), + shape: BoxShape.circle, + ), + child: WindArrowIcon( + size: 14, + outline: 1.5, + color: colorFromHexRgb(hex) ?? Colors.white, + ), + ), + label: withUnit && index == 0 ? '$label $unit' : label, + ), + ]; + } + + /// Registers the frame set (Unix-second ids, chronological) and warms the + /// newest few so the first show is instant. + Future prepare( + MapLibreMapController controller, + List frameIds, + ) async { + _orderedIds = List.of(frameIds); + _indexById = { + for (var i = 0; i < _orderedIds.length; i++) _orderedIds[i]: i, + }; + await _ensureImages(controller); + await _ensureStations(); + await _ensureSource(controller); + if (_orderedIds.isNotEmpty) { + await _fetchIntoCache(_orderedIds.last); + final start = _orderedIds.length > 3 ? _orderedIds.length - 3 : 0; + for (var i = start; i < _orderedIds.length - 1; i++) { + unawaited(_fetchIntoCache(_orderedIds[i])); + } + } + } + + /// Draws [frameId]'s arrows. + Future show( + MapLibreMapController controller, + String frameId, { + bool scrubbing = false, + }) async { + // Same frame already on screen — a scrub settle re-shows the same frame. + // The cache check matters: a failed fetch leaves [_shownFrameId] set (with + // an empty payload on screen), and the data may land in the cache later — + // that frame must still be (re)shown. + if (_shownFrameId == frameId && _cache.containsKey(frameId)) return; + await _ensureImages(controller); + await _ensureStations(); + await _ensureSource(controller); + + var snapshot = _cache[frameId]; + if (snapshot == null) { + if (scrubbing) return; + snapshot = await _fetchIntoCache(frameId); + if (snapshot == null) { + try { + await controller.setGeoJsonSource(_sourceId, _empty); + } catch (_) {} + _shownFrameId = frameId; + return; + } + } + + try { + await controller.setGeoJsonSource(_sourceId, _geoJson(snapshot)); + _shownFrameId = frameId; + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'wind arrows show $frameId'); + } + + if (!scrubbing) { + final i = _indexById[frameId]; + if (i != null) { + for (final j in [i - 1, i + 1]) { + if (j >= 0 && j < _orderedIds.length) { + unawaited(_fetchIntoCache(_orderedIds[j])); + } + } + } + } + } + + /// Mounts the layer with no arrows on it — for a host whose current frame has + /// no observation snapshot near enough to be honest about. Clearing the + /// features rather than removing the layer keeps "the overlay is on, there is + /// nothing to draw" distinct from "the overlay is off". + Future showEmpty(MapLibreMapController controller) async { + await _ensureImages(controller); + await _ensureSource(controller); + _shownFrameId = null; + try { + await controller.setGeoJsonSource(_sourceId, _empty); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'wind arrows clear features'); + } + } + + Future clear(MapLibreMapController controller) async { + await _removeFromMap(controller); + _mounted = false; + _shownFrameId = null; + } + + void onStyleReset() { + _mounted = false; + _imagesReady = false; + _shownFrameId = null; + } + + Future _fetchIntoCache(String frameId) async { + final existing = _cache[frameId]; + if (existing != null) return existing; + final sec = int.tryParse(frameId); + if (sec == null) return null; + final result = await _repository.at(sec); + return result.when( + ok: (snapshot) { + _cache[frameId] = snapshot; + // Bound memory — keep ~40 frames: drop the oldest (ids are Unix + // seconds), never the frame that is on screen. + if (_cache.length > 40) { + final ids = _cache.keys.toList(growable: false) + ..sort((a, b) => int.parse(a).compareTo(int.parse(b))); + for (final id in ids.take(_cache.length - 40)) { + if (id != _shownFrameId) _cache.remove(id); + } + } + return snapshot; + }, + err: (failure) { + Log.warning('wind arrows frame $frameId: ${failure.message}'); + return null; + }, + ); + } + + /// The station directory — the arrows' geometry. Fetched once; left empty on + /// failure so the next frame retries rather than pinning an empty map. + Future _ensureStations() async { + if (_stations.isNotEmpty) return; + final result = await _repository.stations(); + result.when( + ok: (stations) => _stations = stations, + err: (failure) => Log.warning('wind arrows stations: ${failure.message}'), + ); + } + + Future _ensureImages(MapLibreMapController controller) async { + if (_imagesReady && _imagesVision == AppColorVision.current) return; + _imagesVision = AppColorVision.current; + try { + await registerImages(controller); + _imagesReady = true; + } catch (error, stackTrace) { + // Style reload may leave images; retry next show. + Log.handle(error, stackTrace, 'wind arrow addImage'); + } + } + + Future _ensureSource(MapLibreMapController controller) async { + if (_mounted) return; + await _removeFromMap(controller); + await controller.addSource( + _sourceId, + GeojsonSourceProperties(data: _empty), + ); + await controller.addSymbolLayer( + _sourceId, + _layerId, + SymbolLayerProperties( + // The image is picked by speed, carrying the pre-baked colour + black + // outline — no `iconColor` tint, which would replace the baked-in + // outline on a non-SDF image. + iconImage: iconExpression(), + iconRotate: ['get', 'blow_to'], + iconSize: sizeExpression(), + iconAllowOverlap: true, + iconIgnorePlacement: true, + // Rotate with the map so a bearing stays geographically correct. + iconRotationAlignment: 'map', + ), + enableInteraction: false, + ); + _mounted = true; + } + + /// One point per station that reported both a speed and a direction — a + /// direction-less reading has no arrow to draw, and drawing it pointing north + /// would be an invented bearing. + Map _geoJson(WeatherSnapshot snapshot) { + final features = >[]; + for (final observation in snapshot.stations) { + final station = _stations[observation.id]; + final speed = observation.windSpeed; + final from = observation.windDirection; + if (station == null || speed == null || from == null) continue; + features.add({ + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [station.longitude, station.latitude], + }, + 'properties': { + 'id': observation.id, + 'value': speed, + // Meteorological "from" + 180° = where the wind blows toward, which + // is the way the glyph points. + 'blow_to': (from + 180) % 360, + }, + }); + } + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Renders [Icons.navigation] (points north at 0°) as one PNG per speed + /// bucket: each is the bucket colour with a black offset-outline baked in, so + /// the arrow silhouette stays readable over pale tiles and bright echo. + /// + /// These are plain bitmaps — **not** SDF. MapLibre's SDF halos only work on + /// true signed-distance-field images (which require a blurred source), and + /// treating this glyph as one made `icon-halo-width` paint ~nothing. + static Future> _bakeArrows() async { + // 96 px base so iconSize ≈ 0.5–1.5 reads as a clear arrow (48 px + the old + // 0.18 floors was sub-10 px on calm stations). + const size = 96; + const icon = Icons.navigation; + // Outline thickness on the 96 px canvas — 8 offset copies around the glyph. + const halo = 5.5; + final glyph = String.fromCharCode(icon.codePoint); + final outline = _painter(glyph, icon, const Color(0xFF000000).vision); + final center = Offset( + (size - outline.width) / 2, + (size - outline.height) / 2, + ); + return [ + for (final (_, fill) in windBuckets) + await _bakeOne( + outline, + _painter(glyph, icon, fill), + center: center, + size: size, + halo: halo, + ), + ]; + } + + static TextPainter _painter(String glyph, IconData icon, Color color) => + TextPainter( + textDirection: TextDirection.ltr, + text: TextSpan( + text: glyph, + style: TextStyle( + fontSize: 80, + fontFamily: icon.fontFamily, + package: icon.fontPackage, + color: color, + ), + ), + )..layout(); + + static Future _bakeOne( + TextPainter outline, + TextPainter fill, { + required Offset center, + required int size, + required double halo, + }) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + for (final (dx, dy) in windOutlineDirs) { + outline.paint(canvas, center + Offset(dx * halo, dy * halo)); + } + fill.paint(canvas, center); + // Dispose the picture and image on the way out — the bakes run again on + // every colour-vision change, and both native handles leaked otherwise. + final picture = recorder.endRecording(); + final image = await picture.toImage(size, size); + picture.dispose(); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return data!.buffer.asUint8List(); + } + + Future _removeFromMap(MapLibreMapController controller) async { + try { + await controller.removeLayer(_layerId); + } catch (_) {} + try { + await controller.removeSource(_sourceId); + } catch (_) {} + } +} diff --git a/lib/features/map/presentation/layers/wind_layer.dart b/lib/features/map/presentation/layers/wind_layer.dart index 6e5372618..1b32cbae0 100644 --- a/lib/features/map/presentation/layers/wind_layer.dart +++ b/lib/features/map/presentation/layers/wind_layer.dart @@ -1,14 +1,16 @@ /// The wind layer — rotated arrows pointing where the wind blows toward, /// coloured by wind speed (legacy look). The tap reading carries the exact /// degrees and the sheet chart colours the curve by the same speed ramp. +/// +/// Every arrow on the map is drawn by [WindArrowOverlay], which the radar +/// echo's wind option mounts too, so the two surfaces cannot drift into two +/// different-looking keys; this layer supplies only the station source the +/// arrows sit on, the tap reading and the trend sheet. library; -import 'dart:typed_data'; -import 'dart:ui' as ui; - -import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/core/geo/geo_math.dart'; import 'package:dpip/features/map/presentation/layers/weather_station_layer.dart'; +import 'package:dpip/features/map/presentation/layers/wind_arrow_overlay.dart'; import 'package:dpip/features/map/presentation/wind_speed.dart'; import 'package:dpip/features/map/presentation/widgets/station_sheet.dart'; import 'package:dpip/features/weather/domain/weather_snapshot.dart'; @@ -24,22 +26,12 @@ class WindMapLayer WeatherStationLayer { WindMapLayer(super.repository); - /// Shared arrow image ids (registered once per render) and the arrow layer - /// id. One pre-coloured PNG per speed bucket — the black outline is baked - /// into each (see [_renderArrow]), because MapLibre's `icon-halo-*` only - /// works on *true* SDF images and this glyph is a plain bitmap. - static const String _arrowImageId = 'wind-arrow'; + /// The arrow layer this layer adds on its station source. The glyphs + /// themselves — the per-bucket images, the `step` that picks one, the zoom × + /// speed size ramp — come from [WindArrowOverlay], shared with the radar + /// echo's wind option. String get _arrowLayerId => 'wx-$id-arrow'; - /// The rendered arrow PNGs, cached — the glyph never changes; each bucket's - /// colour + black outline are baked in at render time. - // Baked bitmaps carry the corrected colours painted into them, so they - // must be re-baked when the setting moves — see [VisionCache]. - List? _arrowBytes; - ColorVision? _arrowVision; - - String _arrowImageFor(int bucket) => '$_arrowImageId-$bucket'; - @override String get id => 'wind'; @@ -96,13 +88,7 @@ class WindMapLayer MapLibreMapController controller, String sourceId, ) async { - if (_arrowVision != AppColorVision.current) _arrowBytes = null; - _arrowVision = AppColorVision.current; - final bytes = _arrowBytes ??= await _renderArrow(); - // One coloured, outline-baked PNG per bucket (no SDF — see [_renderArrow]). - for (var i = 0; i < windBuckets.length; i++) { - await controller.addImage(_arrowImageFor(i), bytes[i], false); - } + await WindArrowOverlay.registerImages(controller); await controller.addSymbolLayer( sourceId, _arrowLayerId, @@ -111,50 +97,9 @@ class WindMapLayer // runtime. (A single SDF tinted via iconColor was tried first, but // MapLibre's icon halo — the only outline SDF supports — renders ~0 on // a plain bitmap marked `sdf`, so the arrows had no readable edge.) - iconImage: _arrowIconExpression(), + iconImage: WindArrowOverlay.iconExpression(), iconRotate: ['get', 'blow_to'], - // Size scales with wind speed (bigger = stronger) and with zoom. Zoom - // must be the OUTERMOST interpolate input (MapLibre only allows [zoom] - // at the top level), with the speed interpolate nested per zoom stop. - // Tuned for the 96 px glyph: ~32–80 px on screen at Taiwan overview - // zooms. The previous 48 px glyph + 0.18 floors made calm arrows ~9 px. - iconSize: [ - 'interpolate', - ['linear'], - ['zoom'], - 5, - [ - 'interpolate', - ['linear'], - ['get', 'value'], - 0.0, - 0.35, - 3.4, - 0.42, - 8.0, - 0.52, - 13.9, - 0.65, - 32.7, - 0.85, - ], - 11, - [ - 'interpolate', - ['linear'], - ['get', 'value'], - 0.0, - 0.70, - 3.4, - 0.85, - 8.0, - 1.05, - 13.9, - 1.30, - 32.7, - 1.70, - ], - ], + iconSize: WindArrowOverlay.sizeExpression(), iconAllowOverlap: true, iconIgnorePlacement: true, // Rotate with the map so a bearing stays geographically correct. @@ -167,97 +112,6 @@ class WindMapLayer ); } - /// Speed → pre-coloured arrow image, a `step` over the same 3.4 / 8.0 / - /// 13.9 / 32.7 m/s thresholds as the legend (weakest first). - List _arrowIconExpression() => [ - 'step', - ['get', 'value'], - _arrowImageFor(0), - for (var i = 1; i < windBuckets.length; i++) ...[ - windBuckets[i].$1, - _arrowImageFor(i), - ], - ]; - - /// Renders [Icons.navigation] (points north at 0°) as five PNGs, one per - /// speed bucket: each is the bucket colour with a black offset-outline baked - /// in, so the arrow silhouette stays readable over pale tiles. - /// - /// These are plain bitmaps — **not** SDF. MapLibre's SDF halos only work on - /// true signed-distance-field images (which require a blurred source), - /// and treating this glyph as one made `icon-halo-width` paint ~nothing. - Future> _renderArrow() async { - // 96 px base so iconSize ≈ 0.5–1.5 reads as a clear arrow (48 px + the - // old 0.18 floors was sub-10 px on calm stations). - const size = 96; - const icon = Icons.navigation; - // Outline thickness on the 96 px canvas — 8 offset copies around the glyph. - const halo = 5.5; - final outline = TextPainter( - textDirection: TextDirection.ltr, - text: TextSpan( - text: String.fromCharCode(icon.codePoint), - style: TextStyle( - fontSize: 80, - fontFamily: icon.fontFamily, - package: icon.fontPackage, - color: const Color(0xFF000000).vision, - ), - ), - )..layout(); - final center = Offset( - (size - outline.width) / 2, - (size - outline.height) / 2, - ); - final glyph = String.fromCharCode(icon.codePoint); - return [ - for (final (_, fill) in windBuckets) - await _renderOne( - outline, - fill, - glyph: glyph, - fontFamily: icon.fontFamily, - fontPackage: icon.fontPackage, - center: center, - size: size, - halo: halo, - ), - ]; - } - - Future _renderOne( - TextPainter outline, - Color fill, { - required String glyph, - required String? fontFamily, - required String? fontPackage, - required Offset center, - required int size, - required double halo, - }) async { - final fillPainter = TextPainter( - textDirection: TextDirection.ltr, - text: TextSpan( - text: glyph, - style: TextStyle( - fontSize: 80, - fontFamily: fontFamily, - package: fontPackage, - color: fill, - ), - ), - )..layout(); - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - for (final (dx, dy) in windOutlineDirs) { - outline.paint(canvas, center + Offset(dx * halo, dy * halo)); - } - fillPainter.paint(canvas, center); - final image = await recorder.endRecording().toImage(size, size); - final data = await image.toByteData(format: ui.ImageByteFormat.png); - return data!.buffer.asUint8List(); - } - /// Speed reading plus the direction it blows from (degrees). The arrow is /// drawn as a rotated glyph by [readingIcon], not as a text arrow. @override @@ -311,44 +165,12 @@ class WindMapLayer /// Discrete speed buckets (strongest first) — same thresholds / colours as /// the arrow `step`, with a navigation glyph so the legend matches the map. @override - Widget buildLegend(BuildContext context) { - // Corrected here, exactly as [windBuckets] is at its own definition: the - // arrows are app-drawn glyphs, so the key follows the setting with them. - final rows = <(String, String)>[ - ('≥ 32.7', '#FF006B'.vision), - ('13.9 – 32.6', '#8000FF'.vision), - ('8.0 – 13.8', '#0085FF'.vision), - ('3.4 – 7.9', '#00FFF0'.vision), - ('0.1 – 3.3', '#FFFFFF'.vision), - ]; - final outline = Theme.of(context).colorScheme.outline; - return MapLegendCard( - child: SymbolLegend( - unit: unit, - items: [ - for (final (label, hex) in rows) - SymbolLegendItem( - // The arrow carries the same black outline as the map; the dark - // disc behind pale / white glyphs keeps them readable on the - // frosted card. - swatch: Container( - width: 18, - height: 18, - alignment: Alignment.center, - decoration: BoxDecoration( - color: outline.withValues(alpha: 0.35), - shape: BoxShape.circle, - ), - child: WindArrowIcon( - size: 14, - outline: 1.5, - color: colorFromHexRgb(hex) ?? Colors.white, - ), - ), - label: label, - ), - ], - ), - ); - } + Widget buildLegend(BuildContext context) => MapLegendCard( + // The unit rides under the list here (this card has room for it), so the + // rows themselves are asked for without it. + child: SymbolLegend( + unit: unit, + items: WindArrowOverlay.legendItems(context), + ), + ); } diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index 5a8ca8a59..51a19573e 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -81,6 +81,9 @@ class _MapPageState extends State { // The echo's optional lightning overlay reads the same strike repository // the standalone 閃電 layer does — one cache, one source of marks. lightning: context.read(), + // …and its wind overlay reads the same observation repository the + // standalone 風向 layer does, for the same reason. + weather: context.read(), settings: context.read(), ), // The wind-forecast block sits right after radar: the picker groups by diff --git a/lib/features/map/presentation/widgets/radar_overlay_menu.dart b/lib/features/map/presentation/widgets/radar_overlay_menu.dart index f206616c3..8598ae68f 100644 --- a/lib/features/map/presentation/widgets/radar_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/radar_overlay_menu.dart @@ -11,9 +11,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// The radar layer's own options chip — the shared chrome menu, titled for -/// radar and carrying one extra row the other rasters do not have: the -/// lightning overlay, which draws the strikes belonging to whichever echo frame -/// is on screen. +/// radar and carrying two extra rows the other rasters do not have: the +/// lightning and wind overlays, which draw the strikes / station readings +/// belonging to whichever echo frame is on screen. +/// +/// They read as two checkboxes but behave as a three-way choice (neither / +/// lightning / wind): turning one on turns the other off, because two dense +/// mark sets over one island hide each other. Both rows stay tappable — a +/// greyed-out row would make the user turn one off before they could turn the +/// other on, to reach a state they can reach in one tap. class RadarOverlayMenu extends StatelessWidget { const RadarOverlayMenu({ super.key, @@ -32,10 +38,12 @@ class RadarOverlayMenu extends StatelessWidget { final ValueChanged onShowTerrainChanged; @override - Widget build(BuildContext context) => ValueListenableBuilder( - valueListenable: layer.showLightning, - builder: (context, showLightning, _) { + Widget build(BuildContext context) => ListenableBuilder( + listenable: Listenable.merge([layer.showLightning, layer.showWind]), + builder: (context, _) { final l10n = AppLocalizations.of(context); + final showLightning = layer.showLightning.value; + final showWind = layer.showWind.value; return ScanRangeOverlayMenu( layer: layer, tooltip: l10n.radarOverlayMenuTooltip, @@ -43,8 +51,9 @@ class RadarOverlayMenu extends StatelessWidget { onShowTownLabelsChanged: onShowTownLabelsChanged, showTerrain: showTerrain, onShowTerrainChanged: onShowTerrainChanged, - // Off by default, so having it on is a departure worth the chip's dot. - extraActive: showLightning, + // Both off by default, so either one on is a departure worth the chip's + // dot. + extraActive: showLightning || showWind, extraSections: [ const MapMenuDivider(), SectionHeader(l10n.mapOverlaySectionData), @@ -56,6 +65,14 @@ class RadarOverlayMenu extends StatelessWidget { tooltip: l10n.radarLightningOverlaySubtitle, onTap: () => layer.setShowLightning(!showLightning), ), + MapMenuToggleRow( + selected: showWind, + icon: Icons.air, + title: l10n.radarWindOverlay, + subtitle: l10n.radarWindOverlayHint, + tooltip: l10n.radarWindOverlaySubtitle, + onTap: () => layer.setShowWind(!showWind), + ), ], ); }, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 21f857d9c..bcbfafba5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4004,5 +4004,17 @@ "radarLightningOverlaySubtitle": "Overlays the lightning strikes recorded at the same time as the radar frame you are looking at.", "@radarLightningOverlaySubtitle": { "description": "Tooltip for the lightning toggle in the radar overlay menu." + }, + "radarWindOverlay": "Show wind", + "@radarWindOverlay": { + "description": "Wind overlay toggle in the map's radar overlay menu. Mutually exclusive with the lightning one." + }, + "radarWindOverlayHint": "Station wind from the frame on screen", + "@radarWindOverlayHint": { + "description": "Hint under the wind toggle in the radar overlay menu." + }, + "radarWindOverlaySubtitle": "Overlays the station wind direction and speed recorded at the same time as the radar frame you are looking at.", + "@radarWindOverlaySubtitle": { + "description": "Tooltip for the wind toggle in the radar overlay menu." } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index e51e0113b..a23101097 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1991,5 +1991,8 @@ "mapOverlaySectionData": "Mga layer ng datos", "radarLightningOverlay": "Ipakita ang kidlat", "radarLightningOverlayHint": "Kidlat sa oras ng frame na nakikita", - "radarLightningOverlaySubtitle": "Ipinapatong ang mga kidlat na naitala sa parehong oras ng radar na tinitingnan mo." + "radarLightningOverlaySubtitle": "Ipinapatong ang mga kidlat na naitala sa parehong oras ng radar na tinitingnan mo.", + "radarWindOverlay": "Ipakita ang hangin", + "radarWindOverlayHint": "Hangin ng istasyon sa oras ng frame na nakikita", + "radarWindOverlaySubtitle": "Ipinapatong ang direksyon at bilis ng hangin mula sa mga istasyon na naitala sa parehong oras ng radar na tinitingnan mo." } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 103c6f543..a52c268ea 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1991,5 +1991,8 @@ "mapOverlaySectionData": "Lapisan data", "radarLightningOverlay": "Tampilkan petir", "radarLightningOverlayHint": "Petir pada waktu bingkai yang tampil", - "radarLightningOverlaySubtitle": "Menampilkan sambaran petir yang tercatat pada waktu yang sama dengan citra radar yang sedang dilihat." + "radarLightningOverlaySubtitle": "Menampilkan sambaran petir yang tercatat pada waktu yang sama dengan citra radar yang sedang dilihat.", + "radarWindOverlay": "Tampilkan angin", + "radarWindOverlayHint": "Angin stasiun pada waktu bingkai yang tampil", + "radarWindOverlaySubtitle": "Menampilkan arah dan kecepatan angin dari stasiun pengamatan pada waktu yang sama dengan citra radar yang sedang dilihat." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 9426e3a4d..36ac0e8c6 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1991,5 +1991,8 @@ "mapOverlaySectionData": "データレイヤー", "radarLightningOverlay": "雷を表示", "radarLightningOverlayHint": "表示中のエコーと同時刻の落雷", - "radarLightningOverlaySubtitle": "表示中のレーダーエコーと同じ時刻の落雷を重ねて表示します。" + "radarLightningOverlaySubtitle": "表示中のレーダーエコーと同じ時刻の落雷を重ねて表示します。", + "radarWindOverlay": "風向を表示", + "radarWindOverlayHint": "表示中のエコーと同時刻の観測風向", + "radarWindOverlaySubtitle": "表示中のレーダーエコーと同じ時刻の観測所の風向・風速を重ねて表示します。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index e56868936..66d634dd5 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1991,5 +1991,8 @@ "mapOverlaySectionData": "데이터 레이어", "radarLightningOverlay": "번개 표시", "radarLightningOverlayHint": "화면에 표시된 시각의 낙뢰", - "radarLightningOverlaySubtitle": "현재 보고 있는 레이더 영상과 같은 시각의 낙뢰를 겹쳐서 표시합니다." + "radarLightningOverlaySubtitle": "현재 보고 있는 레이더 영상과 같은 시각의 낙뢰를 겹쳐서 표시합니다.", + "radarWindOverlay": "바람 표시", + "radarWindOverlayHint": "화면에 표시된 시각의 관측 바람", + "radarWindOverlaySubtitle": "현재 보고 있는 레이더 영상과 같은 시각의 관측소 풍향·풍속을 겹쳐서 표시합니다." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 7e376bb27..7c8855b3f 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1991,5 +1991,8 @@ "mapOverlaySectionData": "ชั้นข้อมูล", "radarLightningOverlay": "แสดงฟ้าผ่า", "radarLightningOverlayHint": "ฟ้าผ่าในเวลาเดียวกับภาพที่แสดง", - "radarLightningOverlaySubtitle": "ซ้อนตำแหน่งฟ้าผ่าที่บันทึกในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง" + "radarLightningOverlaySubtitle": "ซ้อนตำแหน่งฟ้าผ่าที่บันทึกในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง", + "radarWindOverlay": "แสดงลม", + "radarWindOverlayHint": "ลมจากสถานีในเวลาเดียวกับภาพที่แสดง", + "radarWindOverlaySubtitle": "ซ้อนทิศทางและความเร็วลมจากสถานีตรวจวัดในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index c08630102..23725ad4e 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1991,5 +1991,8 @@ "mapOverlaySectionData": "Lớp dữ liệu", "radarLightningOverlay": "Hiện sét", "radarLightningOverlayHint": "Sét cùng thời điểm với ảnh đang xem", - "radarLightningOverlaySubtitle": "Chồng các cú sét được ghi nhận cùng thời điểm với ảnh radar đang hiển thị." + "radarLightningOverlaySubtitle": "Chồng các cú sét được ghi nhận cùng thời điểm với ảnh radar đang hiển thị.", + "radarWindOverlay": "Hiện gió", + "radarWindOverlayHint": "Gió trạm cùng thời điểm với ảnh đang xem", + "radarWindOverlaySubtitle": "Chồng hướng và tốc độ gió từ các trạm quan trắc cùng thời điểm với ảnh radar đang hiển thị." } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index a53cedaf4..7fafcd6b7 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1991,5 +1991,8 @@ "mapOverlaySectionData": "資料圖層", "radarLightningOverlay": "顯示閃電", "radarLightningOverlayHint": "顯示同畫面回波同一時間嘅落雷", - "radarLightningOverlaySubtitle": "喺而家嘅雷達回波上面疊加同一時間嘅閃電落雷。" + "radarLightningOverlaySubtitle": "喺而家嘅雷達回波上面疊加同一時間嘅閃電落雷。", + "radarWindOverlay": "顯示風向", + "radarWindOverlayHint": "顯示同畫面回波同一時間嘅測站風向", + "radarWindOverlaySubtitle": "喺而家嘅雷達回波上面疊加同一時間嘅測站風向同風速。" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 96de7d2d4..c6beaf1a9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1983,5 +1983,8 @@ "mapOverlaySectionData": "数据图层", "radarLightningOverlay": "显示闪电", "radarLightningOverlayHint": "显示与画面回波同一时间的落雷", - "radarLightningOverlaySubtitle": "在当前的雷达回波上叠加同一时间的闪电落雷。" + "radarLightningOverlaySubtitle": "在当前的雷达回波上叠加同一时间的闪电落雷。", + "radarWindOverlay": "显示风向", + "radarWindOverlayHint": "显示与画面回波同一时间的测站风向", + "radarWindOverlaySubtitle": "在当前的雷达回波上叠加同一时间的测站风向与风速。" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index e9b67118e..c883aa69f 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1991,5 +1991,8 @@ "mapOverlaySectionData": "数据图层", "radarLightningOverlay": "显示闪电", "radarLightningOverlayHint": "显示与画面回波同一时间的落雷", - "radarLightningOverlaySubtitle": "在当前的雷达回波上叠加同一时间的闪电落雷。" + "radarLightningOverlaySubtitle": "在当前的雷达回波上叠加同一时间的闪电落雷。", + "radarWindOverlay": "显示风向", + "radarWindOverlayHint": "显示与画面回波同一时间的测站风向", + "radarWindOverlaySubtitle": "在当前的雷达回波上叠加同一时间的测站风向与风速。" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index a40ea0a62..c36eb1c22 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1991,5 +1991,8 @@ "mapOverlaySectionData": "資料圖層", "radarLightningOverlay": "顯示閃電", "radarLightningOverlayHint": "顯示與畫面回波同一時間嘅落雷", - "radarLightningOverlaySubtitle": "喺目前嘅雷達回波上疊加同一時間嘅閃電落雷。" + "radarLightningOverlaySubtitle": "喺目前嘅雷達回波上疊加同一時間嘅閃電落雷。", + "radarWindOverlay": "顯示風向", + "radarWindOverlayHint": "顯示與畫面回波同一時間嘅測站風向", + "radarWindOverlaySubtitle": "喺目前嘅雷達回波上疊加同一時間嘅測站風向同風速。" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7d7b8fb83..19da8014a 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1991,5 +1991,8 @@ "mapOverlaySectionData": "資料圖層", "radarLightningOverlay": "顯示閃電", "radarLightningOverlayHint": "顯示與畫面回波同時間的落雷", - "radarLightningOverlaySubtitle": "在目前的雷達回波上疊加同一時間的閃電落雷。" + "radarLightningOverlaySubtitle": "在目前的雷達回波上疊加同一時間的閃電落雷。", + "radarWindOverlay": "顯示風向", + "radarWindOverlayHint": "顯示與畫面回波同時間的測站風向", + "radarWindOverlaySubtitle": "在目前的雷達回波上疊加同一時間的測站風向與風速。" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index ba3a78975..047e8372a 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6328,6 +6328,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Overlays the lightning strikes recorded at the same time as the radar frame you are looking at.'** String get radarLightningOverlaySubtitle; + + /// Wind overlay toggle in the map's radar overlay menu. Mutually exclusive with the lightning one. + /// + /// In en, this message translates to: + /// **'Show wind'** + String get radarWindOverlay; + + /// Hint under the wind toggle in the radar overlay menu. + /// + /// In en, this message translates to: + /// **'Station wind from the frame on screen'** + String get radarWindOverlayHint; + + /// Tooltip for the wind toggle in the radar overlay menu. + /// + /// In en, this message translates to: + /// **'Overlays the station wind direction and speed recorded at the same time as the radar frame you are looking at.'** + String get radarWindOverlaySubtitle; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index b5d09fa01..b5b6c0b9d 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3333,4 +3333,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get radarLightningOverlaySubtitle => 'Overlays the lightning strikes recorded at the same time as the radar frame you are looking at.'; + + @override + String get radarWindOverlay => 'Show wind'; + + @override + String get radarWindOverlayHint => 'Station wind from the frame on screen'; + + @override + String get radarWindOverlaySubtitle => + 'Overlays the station wind direction and speed recorded at the same time as the radar frame you are looking at.'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 91a1c2d01..77c05f493 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3351,4 +3351,15 @@ class AppLocalizationsFil extends AppLocalizations { @override String get radarLightningOverlaySubtitle => 'Ipinapatong ang mga kidlat na naitala sa parehong oras ng radar na tinitingnan mo.'; + + @override + String get radarWindOverlay => 'Ipakita ang hangin'; + + @override + String get radarWindOverlayHint => + 'Hangin ng istasyon sa oras ng frame na nakikita'; + + @override + String get radarWindOverlaySubtitle => + 'Ipinapatong ang direksyon at bilis ng hangin mula sa mga istasyon na naitala sa parehong oras ng radar na tinitingnan mo.'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index d443d53c4..c0720d6dc 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3345,4 +3345,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get radarLightningOverlaySubtitle => 'Menampilkan sambaran petir yang tercatat pada waktu yang sama dengan citra radar yang sedang dilihat.'; + + @override + String get radarWindOverlay => 'Tampilkan angin'; + + @override + String get radarWindOverlayHint => + 'Angin stasiun pada waktu bingkai yang tampil'; + + @override + String get radarWindOverlaySubtitle => + 'Menampilkan arah dan kecepatan angin dari stasiun pengamatan pada waktu yang sama dengan citra radar yang sedang dilihat.'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 49593f6de..7197c25ba 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3271,4 +3271,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get radarLightningOverlaySubtitle => '表示中のレーダーエコーと同じ時刻の落雷を重ねて表示します。'; + + @override + String get radarWindOverlay => '風向を表示'; + + @override + String get radarWindOverlayHint => '表示中のエコーと同時刻の観測風向'; + + @override + String get radarWindOverlaySubtitle => '表示中のレーダーエコーと同じ時刻の観測所の風向・風速を重ねて表示します。'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index e440e530d..ea253e3d1 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3272,4 +3272,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String get radarLightningOverlaySubtitle => '현재 보고 있는 레이더 영상과 같은 시각의 낙뢰를 겹쳐서 표시합니다.'; + + @override + String get radarWindOverlay => '바람 표시'; + + @override + String get radarWindOverlayHint => '화면에 표시된 시각의 관측 바람'; + + @override + String get radarWindOverlaySubtitle => + '현재 보고 있는 레이더 영상과 같은 시각의 관측소 풍향·풍속을 겹쳐서 표시합니다.'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 602a1bc4a..6b7bb54aa 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3326,4 +3326,14 @@ class AppLocalizationsTh extends AppLocalizations { @override String get radarLightningOverlaySubtitle => 'ซ้อนตำแหน่งฟ้าผ่าที่บันทึกในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง'; + + @override + String get radarWindOverlay => 'แสดงลม'; + + @override + String get radarWindOverlayHint => 'ลมจากสถานีในเวลาเดียวกับภาพที่แสดง'; + + @override + String get radarWindOverlaySubtitle => + 'ซ้อนทิศทางและความเร็วลมจากสถานีตรวจวัดในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 511d72c07..721d385cf 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3334,4 +3334,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String get radarLightningOverlaySubtitle => 'Chồng các cú sét được ghi nhận cùng thời điểm với ảnh radar đang hiển thị.'; + + @override + String get radarWindOverlay => 'Hiện gió'; + + @override + String get radarWindOverlayHint => 'Gió trạm cùng thời điểm với ảnh đang xem'; + + @override + String get radarWindOverlaySubtitle => + 'Chồng hướng và tốc độ gió từ các trạm quan trắc cùng thời điểm với ảnh radar đang hiển thị.'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 14b2b0f57..5a2707e34 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3253,4 +3253,13 @@ class AppLocalizationsYue extends AppLocalizations { @override String get radarLightningOverlaySubtitle => '喺而家嘅雷達回波上面疊加同一時間嘅閃電落雷。'; + + @override + String get radarWindOverlay => '顯示風向'; + + @override + String get radarWindOverlayHint => '顯示同畫面回波同一時間嘅測站風向'; + + @override + String get radarWindOverlaySubtitle => '喺而家嘅雷達回波上面疊加同一時間嘅測站風向同風速。'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index a15f4b666..146442cab 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3253,6 +3253,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get radarLightningOverlaySubtitle => '在当前的雷达回波上叠加同一时间的闪电落雷。'; + + @override + String get radarWindOverlay => '显示风向'; + + @override + String get radarWindOverlayHint => '显示与画面回波同一时间的测站风向'; + + @override + String get radarWindOverlaySubtitle => '在当前的雷达回波上叠加同一时间的测站风向与风速。'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6503,6 +6512,15 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get radarLightningOverlaySubtitle => '在当前的雷达回波上叠加同一时间的闪电落雷。'; + + @override + String get radarWindOverlay => '显示风向'; + + @override + String get radarWindOverlayHint => '显示与画面回波同一时间的测站风向'; + + @override + String get radarWindOverlaySubtitle => '在当前的雷达回波上叠加同一时间的测站风向与风速。'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -9753,6 +9771,15 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get radarLightningOverlaySubtitle => '喺目前嘅雷達回波上疊加同一時間嘅閃電落雷。'; + + @override + String get radarWindOverlay => '顯示風向'; + + @override + String get radarWindOverlayHint => '顯示與畫面回波同一時間嘅測站風向'; + + @override + String get radarWindOverlaySubtitle => '喺目前嘅雷達回波上疊加同一時間嘅測站風向同風速。'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -13003,4 +13030,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get radarLightningOverlaySubtitle => '在目前的雷達回波上疊加同一時間的閃電落雷。'; + + @override + String get radarWindOverlay => '顯示風向'; + + @override + String get radarWindOverlayHint => '顯示與畫面回波同時間的測站風向'; + + @override + String get radarWindOverlaySubtitle => '在目前的雷達回波上疊加同一時間的測站風向與風速。'; } diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index 0c55bf146..4d7e715d7 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -1,8 +1,13 @@ import 'dart:async'; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/features/weather/domain/lightning_snapshot.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; +import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/weather_snapshot.dart'; +import 'package:dpip/features/weather/domain/weather_station.dart'; import 'package:dpip/shared/map/admin_outline.dart'; import 'package:dpip/shared/map/map_style.dart' show outlineLayerId, townLabelLayerId; @@ -1500,6 +1505,228 @@ void main() { expect(controller.calls, contains('removeSource:radar-lightning-src')); }); }); + + group('wind overlay', () { + // The observation feed is hourly against the echo's ten minutes, so a frame + // almost never lands on a snapshot: one reading covers the six frames after + // it. `- 60` is the one in effect at frame 4, `+ 540` the one that has not + // been taken yet when it was captured. + const radarFrame = 1700000000 + 4 * 600; + List windNear() => const [ + radarFrame - 3660, + radarFrame - 60, + radarFrame + 540, + ]; + + Future<(RadarMapLayer, RecordingMapController, _FakeWeather)> shown({ + required List history, + bool enabled = true, + }) async { + final weather = _FakeWeather(history); + final layer = testRadarLayer( + _FakeRadarRepository(_ids(9)), + weather: weather, + ); + if (enabled) layer.setShowWind(true); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + await layer.prepare(controller, frames); + await layer.show(controller, frames[4]); + // The arrow work is off the echo's critical path, same as the strikes'. + await pumpEventQueue(); + return (layer, controller, weather); + } + + test('stays off the map until it is switched on', () async { + final (_, controller, weather) = await shown( + history: windNear(), + enabled: false, + ); + expect(controller.calls, isNot(contains('addSource:radar-wind-src'))); + expect( + weather.historyCalls, + 0, + reason: + 'an overlay nobody asked for must not cost a request — the ' + 'observation history is only fetched once the toggle is on', + ); + }); + + test('draws the observation in effect at the frame on screen', () async { + final (_, controller, weather) = await shown(history: windNear()); + + expect(controller.calls, contains('addSource:radar-wind-src')); + expect(controller.calls, contains('addSymbolLayer:radar-wind-lyr')); + expect( + weather.fetched, + contains(radarFrame - 60), + reason: 'the reading in effect at the shown frame, not the newest', + ); + final features = + controller.sourceData['radar-wind-src']!['features'] as List; + expect( + features, + hasLength(1), + reason: + 'the second station reported a speed but no direction, and an ' + 'arrow drawn for it would be an invented bearing', + ); + }); + + test('the arrow points where the wind blows toward', () async { + final (_, controller, _) = await shown(history: windNear()); + + final features = + controller.sourceData['radar-wind-src']!['features'] as List; + final properties = + (features.single as Map)['properties'] as Map; + expect( + properties['blow_to'], + (_FakeWeather.windFrom + 180) % 360, + reason: + 'the reading is the direction the wind comes *from*; the glyph ' + 'points the other way', + ); + expect(properties['value'], _FakeWeather.windSpeed); + }); + + test('one hourly reading covers every frame until the next', () async { + // Fifty minutes old at the frame on screen. Matched the way the strikes + // are — nearest within ten minutes — this frame would have had nothing + // to draw, and so would four of the five before it: an hourly feed only + // ever lands on one radar step in six. It is still the wind that was + // blowing under this echo, which is what the arrows claim to show. + final (_, controller, weather) = await shown( + history: const [radarFrame - 3000], + ); + + expect(weather.fetched, contains(radarFrame - 3000)); + final features = + controller.sourceData['radar-wind-src']!['features'] as List; + expect(features, hasLength(1)); + }); + + test('a reading taken after the frame is never drawn on it', () async { + // Nine minutes after this echo — nearer to it than the hourly reading + // before it would be, and still the wrong wind: it is what came next, + // not what was blowing. Scrubbing back must not show the future. + final (_, controller, _) = await shown(history: const [radarFrame + 540]); + + expect(controller.calls, contains('addSource:radar-wind-src')); + final features = + controller.sourceData['radar-wind-src']!['features'] as List; + expect(features, isEmpty); + }); + + test('draws nothing when the last reading is more than an hour old', () async { + // A gap in the feed. Two-hour-old wind painted over a live echo is not a + // slightly stale picture, so the overlay stays mounted and empty. + final (_, controller, _) = await shown( + history: const [radarFrame - 7200, radarFrame + 3600], + ); + + expect(controller.calls, contains('addSource:radar-wind-src')); + final features = + controller.sourceData['radar-wind-src']!['features'] as List; + expect(features, isEmpty); + }); + + test('switching it back off takes the arrows off the map', () async { + final (layer, controller, _) = await shown(history: windNear()); + controller.calls.clear(); + + layer.setShowWind(false); + await pumpEventQueue(); + + expect(controller.calls, contains('removeLayer:radar-wind-lyr')); + expect(controller.calls, contains('removeSource:radar-wind-src')); + }); + }); + + group('the two data overlays exclude each other', () { + const radarFrame = 1700000000 + 4 * 600; + const near = [radarFrame - 60]; + + /// A radar layer on a map with both overlays available, showing frame 4. + Future<(RadarMapLayer, RecordingMapController)> mounted([ + SettingsStore? settings, + ]) async { + final layer = testRadarLayer( + _FakeRadarRepository(_ids(9)), + lightning: _FakeLightning(near), + weather: _FakeWeather(near), + settings: settings, + ); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + await layer.prepare(controller, frames); + await layer.show(controller, frames[4]); + await pumpEventQueue(); + return (layer, controller); + } + + test('switching the wind on takes the strikes off', () async { + final (layer, controller) = await mounted(); + layer.setShowLightning(true); + await pumpEventQueue(); + controller.calls.clear(); + + layer.setShowWind(true); + await pumpEventQueue(); + + expect(layer.showLightning.value, isFalse); + expect(controller.calls, contains('removeLayer:radar-lightning-lyr')); + expect(controller.calls, contains('addSymbolLayer:radar-wind-lyr')); + }); + + test('switching the strikes on takes the wind off', () async { + final (layer, controller) = await mounted(); + layer.setShowWind(true); + await pumpEventQueue(); + controller.calls.clear(); + + layer.setShowLightning(true); + await pumpEventQueue(); + + expect(layer.showWind.value, isFalse); + expect(controller.calls, contains('removeLayer:radar-wind-lyr')); + expect(controller.calls, contains('addSymbolLayer:radar-lightning-lyr')); + }); + + test('a store holding both on restores only the strikes', () async { + // An older build wrote the lightning flag alone, and nothing stops a + // hand-edited store from carrying both — the UI can only describe one, + // so the older option wins rather than mounting two mark sets. + final (layer, controller) = await mounted( + SettingsStore.inMemory({ + 'map.radarShowLightning': true, + 'map.radarShowWind': true, + }), + ); + + expect(layer.showLightning.value, isTrue); + expect(layer.showWind.value, isFalse); + expect(controller.calls, contains('addSymbolLayer:radar-lightning-lyr')); + expect(controller.calls, isNot(contains('addSource:radar-wind-src'))); + }); + + test('each toggle is remembered on its own key', () async { + final settings = SettingsStore.inMemory({}); + final (layer, _) = await mounted(settings); + + layer.setShowWind(true); + await pumpEventQueue(); + + expect(settings.getBool(SettingKeys.mapRadarShowWind), isTrue); + expect( + settings.getBool(SettingKeys.mapRadarShowLightning), + isNot(isTrue), + reason: + 'turning one on must also persist the other going off, or the ' + 'next launch restores the pair the UI cannot describe', + ); + }); + }); } /// A strike repository with a fixed history and one cloud-to-ground strike in @@ -1541,6 +1768,75 @@ class _FakeLightning implements MeteorLightningRepository { } } +/// An observation repository with a fixed history and two stations in every +/// snapshot: one reporting both speed and direction (the arrow), one reporting +/// a speed with the direction missing (no arrow — there is no bearing to draw). +class _FakeWeather implements MeteorWeatherRepository { + _FakeWeather(this._history); + + /// The reading the drawn arrow carries, so a test can assert the rotation + /// without restating the numbers. + static const int windFrom = 90; + static const double windSpeed = 7.4; + + final List _history; + + /// Snapshot seconds actually requested, in order. + final List fetched = []; + int historyCalls = 0; + + @override + Future>> history() async { + historyCalls++; + return Ok(_history); + } + + @override + Future>> stations() async => const Ok({ + 'A0A010': WeatherStation( + name: '測站一', + county: '臺北市', + town: '中正區', + altitude: 6, + latitude: 25.04, + longitude: 121.51, + ), + 'A0A020': WeatherStation( + name: '測站二', + county: '高雄市', + town: '苓雅區', + altitude: 3, + latitude: 22.62, + longitude: 120.31, + ), + }); + + @override + Future> latest() => at(_history.last); + + @override + Future> at(int second) async { + fetched.add(second); + return Ok( + WeatherSnapshot( + time: second, + stations: const [ + WeatherObservation( + id: 'A0A010', + weatherCode: 100, + windDirection: windFrom, + windSpeed: windSpeed, + ), + WeatherObservation(id: 'A0A020', weatherCode: 100, windSpeed: 3.1), + ], + ), + ); + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + /// [count] frame ids, newest first (the wire order). List _ids(int count) => [ for (var i = count - 1; i >= 0; i--) '${1700000000 + i * 600}', diff --git a/test/features/map/radar_overlay_menu_test.dart b/test/features/map/radar_overlay_menu_test.dart index 48f8fb1a0..0ad66e862 100644 --- a/test/features/map/radar_overlay_menu_test.dart +++ b/test/features/map/radar_overlay_menu_test.dart @@ -56,7 +56,7 @@ void _useTallSurface(WidgetTester tester) { } void main() { - testWidgets('the chip opens a menu carrying all seven overlay toggles', ( + testWidgets('the chip opens a menu carrying all eight overlay toggles', ( tester, ) async { _useTallSurface(tester); @@ -77,16 +77,18 @@ void main() { expect(find.text(l10n.mapTownLabels), findsOneWidget); expect(find.text(l10n.mapTerrainRelief), findsOneWidget); expect(find.text(l10n.radarLightningOverlay), findsOneWidget); + expect(find.text(l10n.radarWindOverlay), findsOneWidget); // The menu is sectioned like the typhoon one: the raster's reference // chrome first, then the base-map settings. expect(find.text(l10n.mapOverlaySectionReference), findsOneWidget); expect(find.text(l10n.mapOverlaySectionMap), findsOneWidget); expect(find.text(l10n.mapOverlaySectionData), findsOneWidget); // Reference chrome (scan range, county, town, 國界) and the name and - // relief toggles all ship on. Lightning is the one that ships off: it is - // extra data drawn over the echo, not chrome, so it is opt-in. + // relief toggles all ship on. The two data rows are the ones that ship + // off: they draw extra data over the echo, not chrome, so they are opt-in + // — and only ever one at a time. expect(find.byIcon(Icons.check_box), findsNWidgets(6)); - expect(find.byIcon(Icons.check_box_outline_blank), findsOneWidget); + expect(find.byIcon(Icons.check_box_outline_blank), findsNWidgets(2)); }); testWidgets('the lightning row toggles the overlay and its chip dot', ( @@ -117,6 +119,68 @@ void main() { ); }); + testWidgets('the wind row toggles the overlay and its chip dot', ( + tester, + ) async { + _useTallSurface(tester); + final layer = testRadarLayer(_FakeRadarRepository()); + await tester.pumpWidget(_wrap(layer)); + + final l10n = await _l10n(); + expect(layer.showWind.value, isFalse); + expect( + tester.widget(find.byType(MapChipButton)).active, + isFalse, + ); + + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.radarWindOverlay)); + await tester.pumpAndSettle(); + + expect(layer.showWind.value, isTrue); + // The dot means "this echo carries something extra", whichever of the two + // data overlays supplied it. + expect( + tester.widget(find.byType(MapChipButton)).active, + isTrue, + ); + }); + + testWidgets('the two data rows read as a three-way choice', (tester) async { + _useTallSurface(tester); + final layer = testRadarLayer(_FakeRadarRepository()); + await tester.pumpWidget(_wrap(layer)); + + final l10n = await _l10n(); + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.radarLightningOverlay)); + await tester.pumpAndSettle(); + + // Seven ticks and one blank, whichever data row is the ticked one: the + // pair is a three-way choice, so both ticked is a state the menu never + // shows. Asserted in the open menu, which is where the reader sees it. + expect(find.byIcon(Icons.check_box), findsNWidgets(7)); + expect(find.byIcon(Icons.check_box_outline_blank), findsOneWidget); + + // Tapped from the menu as it stands, with lightning on — the row is live + // rather than greyed out, which is the whole point of the choice. + await tester.tap(find.text(l10n.radarWindOverlay)); + await tester.pumpAndSettle(); + + expect(layer.showWind.value, isTrue); + expect( + layer.showLightning.value, + isFalse, + reason: + 'the wind row is tappable while lightning is on precisely so the ' + 'reader can swap in one tap — and the swap must turn the other off', + ); + expect(find.byIcon(Icons.check_box), findsNWidgets(7)); + expect(find.byIcon(Icons.check_box_outline_blank), findsOneWidget); + }); + testWidgets('a row leaves the menu open; the chip closes it', (tester) async { _useTallSurface(tester); final layer = testRadarLayer(_FakeRadarRepository()); diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index dcb16f630..5949b61f3 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -9,7 +9,10 @@ import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; import 'package:dpip/features/weather/domain/lightning_snapshot.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; +import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; +import 'package:dpip/features/weather/domain/weather_snapshot.dart'; +import 'package:dpip/features/weather/domain/weather_station.dart'; import 'package:dpip/shared/map/map_style.dart' show countyFillLayerId, @@ -28,18 +31,21 @@ MapReferenceOutlineController testReferenceOutline() => MapReferenceOutlineController(SettingsStore.inMemory({})); /// A [RadarMapLayer] wired for a test: a fresh reference-outline controller, a -/// fresh settings store, and a lightning repository that answers "no snapshots" -/// so the strike overlay (off by default) stays out of every assertion about -/// the echo. A test that is *about* the lightning overlay passes its own. +/// fresh settings store, and lightning / weather repositories that answer "no +/// snapshots" so the data overlays (both off by default) stay out of every +/// assertion about the echo. A test that is *about* one of the overlays passes +/// its own. RadarMapLayer testRadarLayer( RadarRepository source, { MapReferenceOutlineController? referenceOutline, MeteorLightningRepository? lightning, + MeteorWeatherRepository? weather, SettingsStore? settings, }) => RadarMapLayer( source, referenceOutline ?? testReferenceOutline(), lightning: lightning ?? EmptyLightningRepository(), + weather: weather ?? EmptyWeatherRepository(), settings: settings ?? SettingsStore.inMemory({}), ); @@ -57,6 +63,29 @@ class EmptyLightningRepository implements MeteorLightningRepository { Ok(LightningSnapshot(time: second, strikes: const [])); } +/// A weather repository with nothing in it — the default for radar tests. +/// +/// Only the four calls the wind overlay makes are answered; the rest belong to +/// the 風向 layer's sheet, which no radar test goes near. +class EmptyWeatherRepository implements MeteorWeatherRepository { + @override + Future>> history() async => const Ok([]); + + @override + Future>> stations() async => const Ok({}); + + @override + Future> latest() async => + const Ok(WeatherSnapshot(time: 0, stations: [])); + + @override + Future> at(int second) async => + Ok(WeatherSnapshot(time: second, stations: const [])); + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + /// A [RasterFrameSource] that records the tile-memory calls a layer makes. /// /// Those calls are the contract that keeps a scrub cheap — which frames were From 08e77382bbce5eeef5b7401784f3a20ff21dca56 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 12 Sep 2026 14:29:21 +0800 Subject: [PATCH 3/6] fix(map): keep the grey island opaque under the shaking wash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 震度速報期間,震度 0 的鄉鎮不再被 S 波圓盤染上顏色 Fix(en-US): Stop the EEW S-wave disc showing through townships the estimate puts at 0 --- .../pages/report_replay_page.dart | 32 +++++----- .../map/presentation/layers/rts_layer.dart | 32 +++++----- lib/shared/map/map_style.dart | 17 +++-- .../layers/rts_layer_demo_test.dart | 62 +++++++++++++++++++ 4 files changed, 109 insertions(+), 34 deletions(-) diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 94a469fae..1900ab939 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -944,9 +944,11 @@ class _ReplayMapState extends State<_ReplayMap> { /// Tints the whole island by estimated shaking while an EEW alert is up — /// the legacy monitor's county/town fill behaviour, driven by the same /// [`EewEstimator.areaPga`] math. The base style's own `town` fill layer is - /// recoloured with a `match` on each township's `CODE` (hidden counties - /// beneath), so the felt-intensity wash reads over the base map without a - /// second geometry source; when the alerts clear the fills are restored. + /// recoloured with a `match` on each township's `CODE`, so the felt-intensity + /// wash reads over the base map without a second geometry source; when the + /// alerts clear the wash is cleared. The county fill underneath stays the + /// opaque grey island throughout — the wash is transparent wherever the + /// estimate reads 0, and something has to be opaque over Taiwan there. /// /// With two simultaneous quakes this must use whichever alert /// [_ReplayMap.eewIndex] currently selects (the same one the card above is @@ -966,11 +968,17 @@ class _ReplayMapState extends State<_ReplayMap> { final baseFill = MapColors.of(Theme.of(context).brightness).fill; try { + // The opaque grey island is this layer's job in *every* state, alert or + // not, so it is restored before the branch rather than hidden under the + // wash. Hiding it during an alert left nothing opaque over Taiwan + // wherever the estimate reads 0 — the wash falls back to transparent + // there — and the EEW S-wave disc, anchored below [landLayerId] so it + // washes open sea only, came through the island instead. + await controller.setLayerProperties( + countyFillLayerId, + FillLayerProperties(fillColor: baseFill, fillOpacity: 1), + ); if (selected == null) { - await controller.setLayerProperties( - countyFillLayerId, - FillLayerProperties(fillColor: baseFill, fillOpacity: 1), - ); // Back to the baked default. This layer is the wash and nothing else: // with no alert up it paints nothing, so the OSM detailed ground — // which mounts directly beneath it — keeps showing. The grey island is @@ -1002,10 +1010,6 @@ class _ReplayMapState extends State<_ReplayMap> { }); if (entries.isEmpty) return; - await controller.setLayerProperties( - countyFillLayerId, - const FillLayerProperties(fillColor: '#00000000', fillOpacity: 0), - ); await controller.setLayerProperties( townFillLayerId, FillLayerProperties( @@ -1015,9 +1019,9 @@ class _ReplayMapState extends State<_ReplayMap> { ...entries, // Transparent, not the palette grey: a township the estimate puts // at 0 has to leave whatever is under it showing — the OSM - // detailed ground when that layer is on, the grey `land` fill when - // it is not. Falling back to grey painted a flat sheet over the - // detailed map everywhere the shaking was 0. + // detailed ground when that layer is on, the county fill's grey + // when it is not. Falling back to grey painted a flat sheet over + // the detailed map everywhere the shaking was 0. 'rgba(0, 0, 0, 0)', ], fillOpacity: 1, diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index a52aa32b9..d89ea3ad4 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -748,9 +748,11 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// — the legacy monitor's county/town fill behaviour, driven by the same /// [EewEstimator.areaPga] math the replay page uses. The base style's own /// `town` fill layer is recoloured with a `match` on each township's - /// `CODE` (hidden counties beneath), so the felt-intensity wash reads over - /// the base map without a second geometry source; when the alerts clear - /// the fills are restored. + /// `CODE`, so the felt-intensity wash reads over the base map without a + /// second geometry source; when the alerts clear the wash is cleared. The + /// county fill underneath stays the opaque grey island throughout — the + /// wash is transparent wherever the estimate reads 0, and something has to + /// be opaque over Taiwan there. /// /// With two simultaneous quakes this must use whichever alert [eewIndex] /// currently selects (the same one the monitor card is showing), not just @@ -774,11 +776,17 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { final baseFill = MapColors.of(_dark ? Brightness.dark : Brightness.light) .fill; try { + // The opaque grey island is this layer's job in *every* state, alert or + // not, so it is restored before the branch rather than hidden under the + // wash. Hiding it during an alert left nothing opaque over Taiwan + // wherever the estimate reads 0 — the wash falls back to transparent + // there — and the EEW S-wave disc, anchored below [landLayerId] so it + // washes open sea only, came through the island instead. + await controller.setLayerProperties( + countyFillLayerId, + FillLayerProperties(fillColor: baseFill, fillOpacity: 1), + ); if (selected == null) { - await controller.setLayerProperties( - countyFillLayerId, - FillLayerProperties(fillColor: baseFill, fillOpacity: 1), - ); // Back to the baked default. This layer is the wash and nothing else: // with no alert up it paints nothing, so the OSM detailed ground — // which mounts directly beneath it — keeps showing. The grey island is @@ -810,10 +818,6 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { }); if (entries.isEmpty) return; - await controller.setLayerProperties( - countyFillLayerId, - const FillLayerProperties(fillColor: '#00000000', fillOpacity: 0), - ); await controller.setLayerProperties( townFillLayerId, FillLayerProperties( @@ -823,9 +827,9 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { ...entries, // Transparent, not the palette grey: a township the estimate puts // at 0 has to leave whatever is under it showing — the OSM - // detailed ground when that layer is on, the grey `land` fill when - // it is not. Falling back to grey painted a flat sheet over the - // detailed map everywhere the shaking was 0. + // detailed ground when that layer is on, the county fill's grey + // when it is not. Falling back to grey painted a flat sheet over + // the detailed map everywhere the shaking was 0. 'rgba(0, 0, 0, 0)', ], fillOpacity: 1, diff --git a/lib/shared/map/map_style.dart b/lib/shared/map/map_style.dart index 8db54d310..dfb241963 100644 --- a/lib/shared/map/map_style.dart +++ b/lib/shared/map/map_style.dart @@ -118,8 +118,14 @@ const String landLayerId = 'land'; const String outlineLayerId = 'county-outline'; /// Id of the base county-fill layer (`city` source-layer) — a runtime overlay -/// can recolour it to tint each county by a reading, and hide it while a -/// township-level tint takes over (the replay EEW area-intensity wash). +/// can recolour it to tint each county by a reading. +/// +/// This is the layer that makes Taiwan opaque, and it has to stay that way +/// even while a township-level tint takes over above it (the EEW +/// area-intensity wash): that wash is transparent wherever the estimate reads +/// 0, so hiding the county fill leaves nothing over the island at all and +/// whatever is anchored below [landLayerId] — the EEW S-wave disc — shows +/// through it. const String countyFillLayerId = 'county'; /// Id of the base township-fill layer (`town` source-layer) — recoloured the @@ -127,10 +133,9 @@ const String countyFillLayerId = 'county'; /// estimated shaking (legacy monitor behaviour). /// /// Mounted at `fill-opacity: 0`: this layer paints the shaking wash and -/// nothing else. The grey landmass under it is [countyFillLayerId]'s job — -/// the two cover the same island in the same [MapPalette.fill], which is why -/// the wash can already hide the county fill outright and still leave a whole -/// grey island where the estimate is 0. +/// nothing else. The grey landmass under it is [countyFillLayerId]'s job and +/// stays up while the wash does — the wash is transparent where the estimate +/// reads 0, so it cannot be the island's only opaque layer. /// /// It has to start invisible so that something can be drawn *between* the /// landmass and the wash: the OSM detailed ground mounts here (see diff --git a/test/features/map/presentation/layers/rts_layer_demo_test.dart b/test/features/map/presentation/layers/rts_layer_demo_test.dart index 3e6124f77..d62c67c8d 100644 --- a/test/features/map/presentation/layers/rts_layer_demo_test.dart +++ b/test/features/map/presentation/layers/rts_layer_demo_test.dart @@ -23,6 +23,8 @@ import 'package:dpip/features/earthquake/domain/seismic_station.dart'; import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/features/earthquake/domain/trem_station_repository.dart'; import 'package:dpip/features/map/presentation/layers/rts_layer.dart'; +import 'package:dpip/shared/map/map_style.dart' + show countyFillLayerId, townFillLayerId; import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter_test/flutter_test.dart'; @@ -600,4 +602,64 @@ void main() { 'tint too — the card and the map are one choice, not two', ); }); + + test('the grey island stays opaque under the shaking wash, so the S-wave ' + 'disc cannot bleed through a township reading 0', () async { + // Two towns either side of the epicentre distance: 新城 is on top of it + // (a non-zero estimate, so it gets a colour) and 恆春 is ~250 km away + // (an estimate of 0, so the wash leaves it transparent) — the case that + // decides what shows through. + const directory = TownDirectory({ + '100': Town( + code: '100', + city: '花蓮', + town: '新城', + lat: 24.1, + lng: 121.6, + cityLevel: '縣', + townLevel: '鄉', + ), + '200': Town( + code: '200', + city: '屏東', + town: '恆春', + lat: 22.0, + lng: 120.7, + cityLevel: '縣', + townLevel: '鎮', + ), + }); + final origin = DateTime.now().toUtc().subtract(const Duration(seconds: 5)); + final built = await _build( + alerts: [_alert(origin: origin, longitude: 121.6, latitude: 24.1)], + table: table, + grid: grid, + townDirectory: directory, + ); + final controller = _RecordingController(); + await built.layer.render(controller); + await pumpEventQueue(); + + // The wash is up, and a township the estimate puts at 0 is left + // transparent — that part of 'keep the OSM ground visible' stands. + final town = controller.lastProperties[townFillLayerId]; + expect(town, isNotNull, reason: 'a live alert must paint the wash'); + expect(town!['fill-color'], isA>()); + expect((town['fill-color'] as List).first, 'match'); + expect((town['fill-color'] as List).last, 'rgba(0, 0, 0, 0)'); + + // …which is exactly why the county fill underneath must not be hidden. + // The EEW S-wave disc is anchored below the land layer so it washes open + // sea only; with nothing opaque over Taiwan it washed the island too. + final county = controller.lastProperties[countyFillLayerId]; + expect(county, isNotNull, reason: 'the grey island must be (re)asserted'); + expect( + county!['fill-opacity'], + 1, + reason: + 'hiding the county fill during an alert leaves a transparent hole ' + 'wherever the estimate reads 0, and the S-wave disc shows through', + ); + expect(county['fill-color'], isNot('#00000000')); + }); } From 30640bebfabddd846888de42c169aded14ecfadb Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 12 Sep 2026 14:51:30 +0800 Subject: [PATCH 4/6] feat(eew): let the replay page's intensity legend collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 報告重播的震度圖例可以收起來了,預設收合成一顆「圖例」按鈕 New(en-US): The report replay's intensity legend now collapses, and starts collapsed as a legend chip --- .../pages/report_replay_page.dart | 36 ++++-- .../widgets/intensity_legend_chip_test.dart | 119 ++++++++++++++++++ 2 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 test/features/earthquake/presentation/widgets/intensity_legend_chip_test.dart diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 1900ab939..aff27e056 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -63,6 +63,7 @@ import 'package:dpip/shared/map/map_style.dart' townLabelLayerId; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/widgets/frosted_surface.dart'; +import 'package:dpip/shared/widgets/collapsible_map_legend.dart'; import 'package:dpip/shared/widgets/intensity_legend.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; import 'package:flutter/material.dart'; @@ -202,18 +203,29 @@ class _ReportReplayPageState extends State { // the same intensity legend the live monitor carries, // switching to the EEW felt-scale while an alert is up // (the legacy monitor did exactly this on active EEW). - ListenableBuilder( - listenable: _session.eew, - builder: (context, _) { - final hasEew = _session.eew.alerts.isNotEmpty; - return MapLegendCard( - child: IntensityLegend( - mode: hasEew - ? IntensityLegendMode.eew - : IntensityLegendMode.rts, - ), - ); - }, + // + // Collapsible, and collapsed to a chip to start with, + // exactly as [MapScaffold] mounts every layer's legend: + // this page is a full-screen map too, and an eleven-row + // scale pinned open covers the north-west corner of the + // island for the whole replay. The wrap sits *outside* + // the mode swap on purpose — an alert arriving mid-replay + // changes the scale being shown, not whether the user + // asked to see it. + CollapsibleMapLegend( + legend: ListenableBuilder( + listenable: _session.eew, + builder: (context, _) { + final hasEew = _session.eew.alerts.isNotEmpty; + return MapLegendCard( + child: IntensityLegend( + mode: hasEew + ? IntensityLegendMode.eew + : IntensityLegendMode.rts, + ), + ); + }, + ), ), ], ), diff --git a/test/features/earthquake/presentation/widgets/intensity_legend_chip_test.dart b/test/features/earthquake/presentation/widgets/intensity_legend_chip_test.dart new file mode 100644 index 000000000..f261b0c25 --- /dev/null +++ b/test/features/earthquake/presentation/widgets/intensity_legend_chip_test.dart @@ -0,0 +1,119 @@ +/// The intensity legend as the replay page mounts it — inside +/// [CollapsibleMapLegend], with the RTS/EEW mode swap *under* the collapse +/// wrap rather than around it. +/// +/// The replay page is a full-screen map with no [MapScaffold], so it wires the +/// chip itself; this pins the two things that wiring decides. An eleven-row +/// scale left pinned open sits over the north-west of the island for the whole +/// replay, so it has to start collapsed — and an alert arriving mid-replay +/// swaps which scale is drawn, which must not throw away the user's choice to +/// have the legend open. +library; + +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/collapsible_map_legend.dart'; +import 'package:dpip/shared/widgets/intensity_legend.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Mirrors the replay page's own subtree: a top-left overlay whose +/// `AnimatedSize` lays the legend out with unbounded width, and a listenable +/// standing in for the EEW feed that flips the scale. +Future _pumpLegend(WidgetTester tester, ValueNotifier hasEew) { + return tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh'), + home: Scaffold( + body: Stack( + children: [ + Positioned( + top: 0, + left: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: CollapsibleMapLegend( + legend: ListenableBuilder( + listenable: hasEew, + builder: (context, _) => MapLegendCard( + child: IntensityLegend( + mode: hasEew.value + ? IntensityLegendMode.eew + : IntensityLegendMode.rts, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); +} + +Future _settle(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); +} + +void main() { + testWidgets('the replay legend starts collapsed and opens on tap', ( + tester, + ) async { + final hasEew = ValueNotifier(false); + addTearDown(hasEew.dispose); + await _pumpLegend(tester, hasEew); + + expect( + find.byType(IntensityLegend), + findsNothing, + reason: 'the scale must not cover the island before it is asked for', + ); + expect(find.byIcon(Icons.legend_toggle), findsOneWidget); + + await tester.tap(find.byIcon(Icons.legend_toggle)); + await _settle(tester); + + expect(tester.takeException(), isNull); + expect(find.byType(IntensityLegend), findsOneWidget); + // The continuous instrumental scale runs down to −3, which the discrete + // felt scale has no row for — so this is the mode, not just "a legend". + expect(find.text('-3'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.expand_less)); + await _settle(tester); + expect(find.byType(IntensityLegend), findsNothing); + }); + + testWidgets('an alert arriving mid-replay swaps the scale without closing ' + 'the legend the user opened', (tester) async { + final hasEew = ValueNotifier(false); + addTearDown(hasEew.dispose); + await _pumpLegend(tester, hasEew); + + await tester.tap(find.byIcon(Icons.legend_toggle)); + await _settle(tester); + expect(find.text('-3'), findsOneWidget); + + hasEew.value = true; + await _settle(tester); + + expect(tester.takeException(), isNull); + expect( + find.byType(IntensityLegend), + findsOneWidget, + reason: 'the mode swap must not collapse the legend back to the chip', + ); + // Now the discrete felt scale: 6⁺ exists here and nowhere on the + // instrumental ramp, whose lowest rows (−3) are gone. + expect(find.text('6⁺'), findsOneWidget); + expect(find.text('-3'), findsNothing); + }); +} From 2d71020990cefc7b71770cb14267e93501a32787 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 12 Sep 2026 19:51:05 +0800 Subject: [PATCH 5/6] test(map): await the radar overlay chain instead of pumping turns --- .../map/presentation/layers/radar_layer.dart | 13 +++++++++ test/features/map/radar_layer_test.dart | 27 ++++++++++--------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/lib/features/map/presentation/layers/radar_layer.dart b/lib/features/map/presentation/layers/radar_layer.dart index 2ec3725b1..29be93c88 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -138,6 +138,19 @@ class RadarMapLayer extends RasterTimelineLayer /// and those must not interleave either. Future _overlayChain = Future.value(); + /// Completes once the overlay work queued so far has run — for tests, which + /// otherwise have to guess how many event-loop turns it takes. + /// + /// Guessing does not work here. Mounting either overlay bakes its icons + /// through `Picture.toImage` + `toByteData`, and those complete from the + /// engine rather than the microtask queue: sixteen round-trips that each land + /// in a turn of their own, against `pumpEventQueue()`'s twenty for the whole + /// chain — history fetch, `addSource`, `addSymbolLayer` and the first + /// `setGeoJsonSource` included. It fits on an idle machine because some + /// completions share a turn, and stops fitting under load. + @visibleForTesting + Future get overlaySettled => _overlayChain; + /// The controller this layer is mounted on, and the frame it was last asked /// to show — what a data toggle needs to catch up to the echo the moment it /// is switched on, rather than at the next timeline step. diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index 4d7e715d7..5c5f30fac 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -1442,8 +1442,10 @@ void main() { await layer.prepare(controller, frames); await layer.show(controller, frames[4]); // The strike work is deliberately off the echo's critical path, so it - // lands a microtask or two behind the frame it belongs to. - await pumpEventQueue(); + // lands behind the frame it belongs to — and it is awaited on its own + // chain rather than by pumping turns, because mounting the overlay bakes + // eight icons through the engine (see [RadarMapLayer.overlaySettled]). + await layer.overlaySettled; return (layer, controller, lightning); } @@ -1499,7 +1501,7 @@ void main() { controller.calls.clear(); layer.setShowLightning(false); - await pumpEventQueue(); + await layer.overlaySettled; expect(controller.calls, contains('removeLayer:radar-lightning-lyr')); expect(controller.calls, contains('removeSource:radar-lightning-src')); @@ -1532,8 +1534,9 @@ void main() { final controller = RecordingMapController(); await layer.prepare(controller, frames); await layer.show(controller, frames[4]); - // The arrow work is off the echo's critical path, same as the strikes'. - await pumpEventQueue(); + // The arrow work is off the echo's critical path, same as the strikes', + // and awaited the same way — the arrows are baked through the engine too. + await layer.overlaySettled; return (layer, controller, weather); } @@ -1636,7 +1639,7 @@ void main() { controller.calls.clear(); layer.setShowWind(false); - await pumpEventQueue(); + await layer.overlaySettled; expect(controller.calls, contains('removeLayer:radar-wind-lyr')); expect(controller.calls, contains('removeSource:radar-wind-src')); @@ -1661,18 +1664,18 @@ void main() { final controller = RecordingMapController(); await layer.prepare(controller, frames); await layer.show(controller, frames[4]); - await pumpEventQueue(); + await layer.overlaySettled; return (layer, controller); } test('switching the wind on takes the strikes off', () async { final (layer, controller) = await mounted(); layer.setShowLightning(true); - await pumpEventQueue(); + await layer.overlaySettled; controller.calls.clear(); layer.setShowWind(true); - await pumpEventQueue(); + await layer.overlaySettled; expect(layer.showLightning.value, isFalse); expect(controller.calls, contains('removeLayer:radar-lightning-lyr')); @@ -1682,11 +1685,11 @@ void main() { test('switching the strikes on takes the wind off', () async { final (layer, controller) = await mounted(); layer.setShowWind(true); - await pumpEventQueue(); + await layer.overlaySettled; controller.calls.clear(); layer.setShowLightning(true); - await pumpEventQueue(); + await layer.overlaySettled; expect(layer.showWind.value, isFalse); expect(controller.calls, contains('removeLayer:radar-wind-lyr')); @@ -1715,7 +1718,7 @@ void main() { final (layer, _) = await mounted(settings); layer.setShowWind(true); - await pumpEventQueue(); + await layer.overlaySettled; expect(settings.getBool(SettingKeys.mapRadarShowWind), isTrue); expect( From 90293c797f1906a5a7d6a898e95684c57e862826 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 14 Sep 2026 01:37:55 +0800 Subject: [PATCH 6/6] fix(data): give a narrow phone the data hub's two columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): iPhone SE 這類較窄的螢幕上,「資料」分頁不再排成一欄通欄長條 Fix(en-US): The data tab no longer collapses into one full-width column on a narrow phone --- .../data/presentation/pages/data_page.dart | 32 ++++++++++++--- test/features/data/data_page_test.dart | 40 +++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/lib/features/data/presentation/pages/data_page.dart b/lib/features/data/presentation/pages/data_page.dart index 5e7b0b133..7f52add6e 100644 --- a/lib/features/data/presentation/pages/data_page.dart +++ b/lib/features/data/presentation/pages/data_page.dart @@ -246,15 +246,35 @@ class _SeismicCard extends StatelessWidget { /// tall and its single line of label floated in the middle of a sea of tonal /// grey. /// -/// The height is stated outright instead, and follows the text scale rather -/// than the window: the tile holds a 34 pt icon badge beside a label of at most -/// two lines, so that is what it is tall enough for at any text size. The 80 -/// floor is the height a phone drew before this, kept so the phone layout is +/// Two columns is a floor, not an outcome of that division. A 340 pt tile +/// against the 343 pt of content a 375 pt iPhone SE has left after the page +/// padding divides to **one** column, so the SE drew full-width bars where the +/// 390 pt iPhone 12 next to it drew a grid — the same page, unrecognisable on +/// the smaller phone. The width still decides how many columns a wider window +/// gets; it just may not decide fewer than two. +/// +/// The height is stated outright, and follows the text scale rather than the +/// window: the tile holds a 34 pt icon badge beside a label of at most two +/// lines, so that is what it is tall enough for at any text size. The 80 floor +/// is the height a phone drew before this, kept so the phone layout is /// untouched. SliverGridDelegate _rankingGrid(BuildContext context) { const twoLabelLines = 44.0; - return SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 340, + const maxTileWidth = 340.0; + // Measured the way the grid is actually laid out: the window, less the + // display cutouts [SafeArea] takes off the sides in landscape, less the + // page's own horizontal padding. + final insets = MediaQuery.paddingOf(context); + final width = + MediaQuery.sizeOf(context).width - + insets.left - + insets.right - + AppSpacing.lg * 2; + return SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: math.max( + 2, + (width / (maxTileWidth + AppSpacing.sm)).ceil(), + ), crossAxisSpacing: AppSpacing.sm, mainAxisSpacing: AppSpacing.sm, mainAxisExtent: math.max( diff --git a/test/features/data/data_page_test.dart b/test/features/data/data_page_test.dart index 29ca65389..3408c0521 100644 --- a/test/features/data/data_page_test.dart +++ b/test/features/data/data_page_test.dart @@ -122,6 +122,46 @@ void main() { expect(tile.width, lessThan(400)); }); + // The 375 pt SE, the 390 pt iPhone 12, and the 320 pt phone below both. The + // SE used to fall on the far side of the grid's width test and draw the hub + // as a column of full-width bars while the phone next to it drew a grid. + for (final (name, logical) in const <(String, Size)>[ + ('iPhone SE', Size(375, 667)), + ('iPhone 12', Size(390, 844)), + ('a 320 pt phone', Size(320, 568)), + ]) { + testWidgets('$name lays the hub out in two columns', (tester) async { + tester.view.physicalSize = logical * 2; + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: _router([]), + ), + ); + await tester.pump(); + + Rect tileOf(String label) => tester.getRect( + find.ancestor(of: find.text(label), matching: find.byType(InkWell)), + ); + final rain = tileOf('Rainfall'); + final temperature = tileOf('Temperature'); + + expect( + temperature.top, + rain.top, + reason: 'the first two metrics share a row, they do not stack', + ); + expect( + rain.width, + lessThan(logical.width / 2), + reason: 'a tile is a grid cell, not a full-width bar', + ); + }); + } + for (final (route, label) in _astronomyTiles) { testWidgets('the $label tile navigates to $route', (tester) async { // A fresh router per tile: a tile wired to the wrong route would