diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 67177fbd3..028a85ff3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -33,7 +33,7 @@ android { applicationId = "com.exptech.dpip" minSdk = 26 targetSdk = flutter.targetSdkVersion - versionCode = 300909001 + versionCode = 300909009 versionName = flutter.versionName } diff --git a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt index de4b7045b..a18d3f729 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt @@ -4,12 +4,24 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel +import org.maplibre.android.MapLibre /** * Hosts the app's native platform channels. Each capability that replaces a * third-party plugin is registered here against the engine's binary messenger. */ class MainActivity : FlutterActivity() { + override fun onCreate(savedInstanceState: android.os.Bundle?) { + // The maplibre_gl fork's HttpRequestImpl class-init calls + // MapLibre.getApplicationContext(), which throws unless MapLibre was + // initialized first — and it runs during automatic plugin registration + // (inside super.onCreate). Init before the engine attaches or startup + // dies with ExceptionInInitializerError and stalls on the splash. + // getInstance is idempotent; MapSnapshotChannel re-inits later. + MapLibre.getInstance(applicationContext) + super.onCreate(savedInstanceState) + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) val messenger = flutterEngine.dartExecutor.binaryMessenger diff --git a/api.md b/api.md index ff86eea42..30de24a39 100644 --- a/api.md +++ b/api.md @@ -28,7 +28,6 @@ | `openRtsSse` | `/api/v2/trem/rts?sse=1&compress=1` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | | `getRtsRealtime` | `/api/v2/trem/rts` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | | `getEewRealtime` | `/api/v2/eq/eew` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` | -| `getLatestReports` | `/api/v1/eq/report` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | | `getReportList` | `/api/v2/eq/report` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | | `getReport` | `/api/v2/eq/report/{id}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | @@ -63,14 +62,31 @@ max-age=300`)。`{sec}` 就是解出清單後的 10 位數秒,直接使用 ### 衛星雲圖(v2)—— `core-tnn1` -Himawari Band-13 IR XYZ WebP。時間清單是差量編碼的 Unix 秒(`[baseSec, Δ, …]`), -在 API 主機上帶 ETag/304;tile 在 **static** 主機。`{sec}` 就是解出清單後的 -10 分鐘秒,直接使用。 +Himawari-9 AHI 的 XYZ WebP,預設是 Band-13 IR。時間清單是差量編碼的 +Unix 秒(`[baseSec, Δ, …]`),在 API 主機上帶 ETag/304;tile 在 **static** +主機。`{sec}` 就是解出清單後的 10 分鐘秒,直接使用。 + +`?channel=` 選取渲染的頻道或產品 —— 單一頻道用數字(`13`)、命名產品用名稱 +(`btd_wvirw`、`cloudtop`…,即 `satellite-tiles-go/docs.md` 的產品目錄)。 +帶 channel 時時間清單為該 channel 的交集(產品需要的頻道缺一就不可渲染, +`list` 只列齊全的時刻)。App 的圖層選擇器為每個 channel 註冊一個獨立圖層 +(`satellite` 保留給 B13,其餘為 `satellite-`)。 + +| 方法 | 路徑 | 層級 | 主機 | +|---|---|---|---| +| `getFrames` | `/api/v2/tiles/satellite/list[?channel=…]` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | +| `tileUrl` | `/api/v2/tiles/satellite/{sec}/{z}/{x}/{y}.webp[?channel=…]` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | + +### 未來1小時降水預報 QPESUMS(v2)—— `core-tnn1` + +QPESUMS 定量降水預報 XYZ WebP。時間清單是差量編碼的 Unix **毫秒** +(`[baseMs, Δ, …]`);tile 在 **static** 主機。`{ms}` 就是解出清單後的 13 位數 +毫秒,直接使用(時間軸解析已同時支援秒與毫秒)。 | 方法 | 路徑 | 層級 | 主機 | |---|---|---|---| -| `getFrames` | `/api/v2/tiles/satellite/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | -| `tileUrl` | `/api/v2/tiles/satellite/{sec}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | +| `getFrames` | `/api/v2/tiles/qpesums/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` | +| `tileUrl` | `/api/v2/tiles/qpesums/{ms}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` | ### 防災地圖 DPM(v2)—— `core-tnn1` @@ -172,6 +188,7 @@ typhoon)共用同一組形狀:`/api/v5/meteor/{family}` 是最新快照、`/ |---|---| | `getLocalizationProgress` | `https://exptech.dev/api/v1/dpip/locale` | | `getReleases` | `https://api.github.com/repos/ExpTechTW/DPIP/releases`(ETag;`per_page=30`) | +| `getRainHourForecast` | `https://exptech.dingbot.tw/api/weather/rainforecast/{code}`(`{code}` = 鄉鎮 3 碼;回應為單 series 信封 `{"<系列名>": [{"start": 秒, "rain": [60 × mm]}]}`;空 series `[]` = 該小時無雨,卡片隱藏) | ## curl 可用性(2026-08-02,HTTP 狀態碼) diff --git a/assets/weather/clouds/00.webp b/assets/weather/clouds/00.webp new file mode 100644 index 000000000..1f2c7a9b4 Binary files /dev/null and b/assets/weather/clouds/00.webp differ diff --git a/assets/weather/clouds/01.webp b/assets/weather/clouds/01.webp new file mode 100644 index 000000000..44f3d8be8 Binary files /dev/null and b/assets/weather/clouds/01.webp differ diff --git a/assets/weather/clouds/02.webp b/assets/weather/clouds/02.webp new file mode 100644 index 000000000..8221dfe28 Binary files /dev/null and b/assets/weather/clouds/02.webp differ diff --git a/assets/weather/clouds/03.webp b/assets/weather/clouds/03.webp new file mode 100644 index 000000000..c1c6485ba Binary files /dev/null and b/assets/weather/clouds/03.webp differ diff --git a/assets/weather/clouds/04.webp b/assets/weather/clouds/04.webp new file mode 100644 index 000000000..681ceddd5 Binary files /dev/null and b/assets/weather/clouds/04.webp differ diff --git a/assets/weather/clouds/05.webp b/assets/weather/clouds/05.webp new file mode 100644 index 000000000..6c688bd12 Binary files /dev/null and b/assets/weather/clouds/05.webp differ diff --git a/assets/weather/clouds/06.webp b/assets/weather/clouds/06.webp new file mode 100644 index 000000000..2dca57407 Binary files /dev/null and b/assets/weather/clouds/06.webp differ diff --git a/assets/weather/clouds/07.webp b/assets/weather/clouds/07.webp new file mode 100644 index 000000000..cd0773a92 Binary files /dev/null and b/assets/weather/clouds/07.webp differ diff --git a/assets/weather/clouds/08.webp b/assets/weather/clouds/08.webp new file mode 100644 index 000000000..07281d640 Binary files /dev/null and b/assets/weather/clouds/08.webp differ diff --git a/assets/weather/clouds/09.webp b/assets/weather/clouds/09.webp new file mode 100644 index 000000000..84f9ee7f2 Binary files /dev/null and b/assets/weather/clouds/09.webp differ diff --git a/assets/weather/clouds/10.webp b/assets/weather/clouds/10.webp new file mode 100644 index 000000000..ea0390439 Binary files /dev/null and b/assets/weather/clouds/10.webp differ diff --git a/assets/weather/clouds/11.webp b/assets/weather/clouds/11.webp new file mode 100644 index 000000000..20ea1a84e Binary files /dev/null and b/assets/weather/clouds/11.webp differ diff --git a/assets/weather/particles/drop_normal.webp b/assets/weather/particles/drop_normal.webp new file mode 100644 index 000000000..2c761d019 Binary files /dev/null and b/assets/weather/particles/drop_normal.webp differ diff --git a/assets/weather/particles/particle_blurred.webp b/assets/weather/particles/particle_blurred.webp new file mode 100644 index 000000000..e5429f07b Binary files /dev/null and b/assets/weather/particles/particle_blurred.webp differ diff --git a/assets/weather/particles/rain_drop.webp b/assets/weather/particles/rain_drop.webp new file mode 100644 index 000000000..e4948ba37 Binary files /dev/null and b/assets/weather/particles/rain_drop.webp differ diff --git a/assets/weather/particles/snow_flake.webp b/assets/weather/particles/snow_flake.webp new file mode 100644 index 000000000..077f24765 Binary files /dev/null and b/assets/weather/particles/snow_flake.webp differ diff --git a/assets/weather/sky/annulus.webp b/assets/weather/sky/annulus.webp new file mode 100644 index 000000000..0bf363771 Binary files /dev/null and b/assets/weather/sky/annulus.webp differ diff --git a/assets/weather/sky/starmap.webp b/assets/weather/sky/starmap.webp new file mode 100644 index 000000000..267f96095 Binary files /dev/null and b/assets/weather/sky/starmap.webp differ diff --git a/assets/weather/sky/sun_profile.webp b/assets/weather/sky/sun_profile.webp new file mode 100644 index 000000000..07e7d1298 Binary files /dev/null and b/assets/weather/sky/sun_profile.webp differ diff --git a/assets/weather/sky/sun_rays.webp b/assets/weather/sky/sun_rays.webp new file mode 100644 index 000000000..8257379bf Binary files /dev/null and b/assets/weather/sky/sun_rays.webp differ diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 000000000..1322c1d86 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,4 @@ +# Development-only previews (they write PNGs); excluded from the default run. +tags: + preview: + skip: "development preview — run with: flutter test --tags preview" diff --git a/lib/app/app.dart b/lib/app/app.dart index 9eeafdd37..86bc8a6bd 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -6,7 +6,6 @@ import 'package:dpip/core/geo/device_location_reporter.dart'; import 'package:dpip/core/geo/location_monitor.dart'; import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/core/notifications/notification_service.dart'; -import 'package:dpip/core/notifications/notification_tap.dart'; import 'package:dpip/core/notifications/notification_taps.dart'; import 'package:dpip/core/platform/background_location.dart'; import 'package:dpip/core/realtime/realtime_lifecycle.dart'; @@ -75,8 +74,8 @@ class DpipApp extends StatelessWidget { /// Owns app-level service wiring that needs a [State]/lifecycle: /// - the realtime spine's start/pause/resume (via [RealtimeLifecycleObserver]); -/// - routing a tapped notification to the right tab (the channel-key → route -/// mapping lives here because this layer owns the router); +/// - registering the notification tap → route handler ([routeNotificationTap], +/// whose channel→screen table lives in `notification_routes.dart`); /// - requesting notification permission once, after the first frame; /// - resolving the current GPS township into the [RegionStore] so 所在地 shows /// the real location (and the "can't get location" state when GPS is off). @@ -117,7 +116,7 @@ class _AppServicesHostState extends State<_AppServicesHost> super.initState(); _observer = RealtimeLifecycleObserver(widget.realtimeService); WidgetsBinding.instance.addObserver(this); - NotificationTaps.onTap = _routeNotificationTap; + NotificationTaps.onTap = routeNotificationTap; widget.onboarding.addListener(_onOnboardingChanged); WidgetsBinding.instance.addPostFrameCallback((_) { widget.realtimeService.startAll(); @@ -188,13 +187,6 @@ class _AppServicesHostState extends State<_AppServicesHost> super.dispose(); } - /// Sends a tapped notification to a screen. The destination is resolved by the - /// declarative channel→route table; the tap's [NotificationTap.id] is carried - /// for the per-item detail routes to come. - void _routeNotificationTap(NotificationTap tap) { - appRouter.goNamed(routeForNotificationChannel(tap.channelKey)); - } - @override Widget build(BuildContext context) => widget.child; } diff --git a/lib/app/router/notification_routes.dart b/lib/app/router/notification_routes.dart index 67b470efe..dfa92db68 100644 --- a/lib/app/router/notification_routes.dart +++ b/lib/app/router/notification_routes.dart @@ -1,7 +1,36 @@ +import 'package:dpip/app/router/app_router.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/notifications/notification_channels.dart'; +import 'package:dpip/core/notifications/notification_tap.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; +/// The slice of the router a notification tap needs — [GoRouter.goNamed]. +typedef NotificationRouteNavigator = + void Function( + String name, { + Map pathParameters, + Map queryParameters, + String? fragment, + Object? extra, + }); + +/// Single owner of notification → destination, mirroring the legacy +/// `notify.dart` tap table in one file: [NotificationTaps] carries the tap +/// intent and calls [routeNotificationTap] once the router is live (replaying a +/// cold-start tap through [NotificationTaps.drainPending]). The channel +/// resolves to a route name via the declarative group table below, then the +/// router navigates — no widget hosts this logic, so adding an alert family is +/// one row in the table and nothing else. +/// +/// [navigate] is injectable for tests; it defaults to the app router's +/// [GoRouter.goNamed]. +void routeNotificationTap( + NotificationTap tap, { + NotificationRouteNavigator? navigate, +}) { + (navigate ?? appRouter.goNamed)(routeForNotificationChannel(tap.channelKey)); +} + /// Resolves a tapped notification's channel to a destination route. /// /// Declarative and group-driven (via [NotificationChannels.groupOf]) instead of diff --git a/lib/app/theme/app_glass.dart b/lib/app/theme/app_glass.dart index 57e7a5f3c..74f9a7b22 100644 --- a/lib/app/theme/app_glass.dart +++ b/lib/app/theme/app_glass.dart @@ -1,3 +1,4 @@ +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/settings/weather_mode.dart'; import 'package:flutter/material.dart'; @@ -10,30 +11,117 @@ import 'package:flutter/material.dart'; /// — because dark-theme [ColorScheme.onSurface] is white and vanishes on clear /// daylight. +/// The card fill the reference paints once the weather is showing: **the sky's own +/// colour at 20 % alpha**, with only its HSL lightness moved. +/// +/// The reference's `D()` converts the sky-gradient colour to HSL, shifts `L` +/// alone — −0.30 in the morning bucket, −0.40 around midday, +0.20 otherwise — +/// converts back, and returns `Color.argb(51, …)`. Hue and saturation are never +/// touched, which is why the card reads as a pane of the sky rather than a grey +/// plate laid over it, and 51/255 = 0.20 is a literal in that method rather +/// than anything device- or theme-dependent. +/// +/// [hour] is the local hour that picks the bucket (the reference +/// buckets 4–9 / 10–14 / rest); it is a parameter so this stays pure. +Color skyCardTint(Color sky, {required int hour}) { + final shift = hour >= 4 && hour <= 9 + ? -0.30 + : hour >= 10 && hour <= 14 + ? -0.40 + : 0.20; + final hsl = HSLColor.fromColor(sky); + return hsl + .withLightness((hsl.lightness + shift).clamp(0.0, 1.0)) + .toColor() + .withValues(alpha: glassRevealedAlpha); +} + +/// The reference's card alpha once the weather backdrop is showing (`Color.argb(51,…)`). +/// +/// It is this low on purpose: the falling rain has to be visible *through* the +/// card. A card opaque enough to hide it stops the rain dead at its edge, which +/// is the single thing that most gives a port away. +const double glassRevealedAlpha = 0.20; + /// The translucent card/surface tint: resting -/// [ColorScheme.surfaceContainerHighest] → near-opaque theme surface as +/// [ColorScheme.surfaceContainerHighest] → the reference's sky-tinted 20 % fill as /// [reveal] rises. -Color glassSurface(ColorScheme colors, double reveal) => Color.lerp( +/// +/// Without a [sky] colour (no backdrop running, or before the first LUT bake) +/// it falls back to a near-opaque theme surface, because 20 % of nothing is an +/// unreadable card. +Color glassSurface( + ColorScheme colors, + double reveal, { + Color? sky, + int? hour, +}) => Color.lerp( colors.surfaceContainerHighest.withValues(alpha: 0.55), - colors.surface.withValues(alpha: 0.92), + sky == null + ? colors.surface.withValues(alpha: 0.92) + : skyCardTint(sky, hour: hour ?? AppTime.utc8.hour), reveal, )!; -/// Ink for content **inside** a [glassSurface] card — always the theme -/// on-surface roles. Cards carry their own plate; do not wash these toward -/// white or black for the sky. -Color glassOnSurface(ColorScheme colors) => colors.onSurface; +/// Ink for content **inside** a [glassSurface] card. +/// +/// At rest the card is its own plate and the theme's on-surface roles are +/// right. Once [reveal] dissolves it to the 20 % fill above, the card is no +/// longer a plate — it is a pane of the sky — so its ink has to follow the sky +/// exactly the way [inkOverWeather] does. Leaving it on [ColorScheme.onSurface] +/// puts near-black text on a rain sky in a light theme. +Color glassOnSurface( + ColorScheme colors, { + double reveal = 0, + bool skyIsLight = false, +}) => inkOverWeather(colors, reveal, skyIsLight: skyIsLight); /// Secondary ink for content inside a [glassSurface] card. -Color glassOnSurfaceVariant(ColorScheme colors) => colors.onSurfaceVariant; +Color glassOnSurfaceVariant( + ColorScheme colors, { + double reveal = 0, + bool skyIsLight = false, +}) => inkOverWeatherVariant(colors, reveal, skyIsLight: skyIsLight); /// Whether [mode]'s sky is light enough that dark foregrounds read better than /// white once the weather backdrop is showing. +/// +/// A **fallback only**, for the brief window before the sky has actually +/// baked (`SkyLutCache.panelAmbient` is still null): the table only knows the +/// weather *type*, not the time of day, so `clear`/`cloudy`/`snow`/`fog`/`auto` +/// all resolve to "light" even at night — a clear night sky is the darkest +/// thing on screen, and this alone would pick near-black ink for it. Once a +/// sky colour exists, [skyIsLightFrom] must be used instead. bool weatherSkyIsLight(WeatherMode mode) => switch (mode) { - WeatherMode.rain || WeatherMode.thunderstorm => false, - WeatherMode.clear || WeatherMode.fog || WeatherMode.auto => true, + WeatherMode.rain || + WeatherMode.thunderstorm || + WeatherMode.overcast || + WeatherMode.sand => false, + WeatherMode.clear || + WeatherMode.cloudy || + WeatherMode.snow || + WeatherMode.fog || + WeatherMode.auto => true, }; +/// Whether [sky] — the actual rendered sky colour (`SkyLutCache.panelAmbient`) +/// — is light enough that dark foregrounds read better than white. +/// +/// This is what every ink-on-sky decision should call: [weatherSkyIsLight] +/// alone can only guess from the weather *type*, and the same "clear" sky is +/// bright blue at noon and near-black past sunset. Falls back to +/// [weatherSkyIsLight] only while [sky] is still null (nothing has baked yet), +/// so ink is never left unset before the first frame. +/// +/// Uses [ThemeData.estimateBrightnessForColor] rather than a hand-picked +/// luminance cutoff — it is the same "is this background light or dark" +/// judgment Flutter already ships and tunes, so a border-hue sky (dawn, a +/// hazy overcast) resolves the way the rest of the framework would resolve it. +bool skyIsLightFrom(Color? sky, WeatherMode fallbackMode) { + if (sky == null) return weatherSkyIsLight(fallbackMode); + return ThemeData.estimateBrightnessForColor(sky) == Brightness.light; +} + /// Ink for content drawn **on** the weather sky (header, region badges). /// /// As [reveal] rises, shifts toward dark ink on a light sky (critical in dark diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index ef1518964..120cfe21d 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -26,6 +26,7 @@ import 'package:dpip/core/geo/town_boundaries.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/locale_controller.dart'; +import 'package:dpip/core/settings/map_layer_order_controller.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/core/settings/prefs.dart'; import 'package:dpip/core/settings/region_store.dart'; @@ -85,6 +86,7 @@ Future bootstrap() async { final locale = LocaleController(prefs); final theme = ThemeController(prefs); final defaultMapLayer = DefaultMapLayerController(prefs); + final mapLayerOrder = MapLayerOrderController(prefs); final cache = await _openCache(); // MapLibre asks Dart for every ExpTech tile before it asks the network, so // this must be bound before the first map is built. @@ -184,6 +186,7 @@ Future bootstrap() async { locale: locale, theme: theme, defaultMapLayer: defaultMapLayer, + mapLayerOrder: mapLayerOrder, etagCache: cache?.etag, networkUsage: cache?.usage, mapTileCache: mapTileCache, diff --git a/lib/core/di/core_providers.dart b/lib/core/di/core_providers.dart index b807d2e52..d53c0de6c 100644 --- a/lib/core/di/core_providers.dart +++ b/lib/core/di/core_providers.dart @@ -12,6 +12,7 @@ import 'package:dpip/core/realtime/realtime_service.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/locale_controller.dart'; +import 'package:dpip/core/settings/map_layer_order_controller.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/theme_controller.dart'; @@ -33,6 +34,9 @@ List coreProviders(SharedDeps deps) => [ ChangeNotifierProvider.value( value: deps.defaultMapLayer, ), + ChangeNotifierProvider.value( + value: deps.mapLayerOrder, + ), Provider.value(value: deps.townDirectory), Provider>.value(value: deps.townBoundaries), Provider.value(value: deps.locationService), diff --git a/lib/core/di/shared_deps.dart b/lib/core/di/shared_deps.dart index 1e2b2577a..023b15725 100644 --- a/lib/core/di/shared_deps.dart +++ b/lib/core/di/shared_deps.dart @@ -14,6 +14,7 @@ import 'package:dpip/core/realtime/server_clock.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/locale_controller.dart'; +import 'package:dpip/core/settings/map_layer_order_controller.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/core/settings/prefs.dart'; import 'package:dpip/core/settings/region_store.dart'; @@ -48,6 +49,7 @@ class SharedDeps { required this.locale, required this.theme, required this.defaultMapLayer, + required this.mapLayerOrder, this.etagCache, this.networkUsage, this.mapTileCache, @@ -113,6 +115,9 @@ class SharedDeps { /// Default Map-tab overlay (also provided; drives nav chrome + map open). final DefaultMapLayerController defaultMapLayer; + /// User-customised map layer-picker order (also provided). + final MapLayerOrderController mapLayerOrder; + /// On-disk ETag HTTP cache (also provided) — null if the cache DB couldn't be /// opened. Exposed for the Debug page's cache stats. final EtagCacheStore? etagCache; diff --git a/lib/core/format.dart b/lib/core/format.dart new file mode 100644 index 000000000..dc1dba8bd --- /dev/null +++ b/lib/core/format.dart @@ -0,0 +1,10 @@ +/// Number-to-text helpers shared across display sites. +library; + +/// Formats [value] to [digits] decimals, dropping a trailing `.0` — e.g. a +/// latitude `25.0` reads as `25`, matching what the chart / fact rows did by +/// hand in the typhoon panel and the station trend axis. +String trimTrailingZero(double value, {int digits = 1}) { + final s = value.toStringAsFixed(digits); + return s.endsWith('.0') ? s.substring(0, s.length - 2) : s; +} diff --git a/lib/core/geo/geo_math.dart b/lib/core/geo/geo_math.dart new file mode 100644 index 000000000..0aef14f13 --- /dev/null +++ b/lib/core/geo/geo_math.dart @@ -0,0 +1,8 @@ +/// Angular + distance helpers shared by geo code and map layers. +library; + +import 'dart:math' as math; + +/// Degrees → radians — the conversion used whenever a latitude / longitude / +/// heading is fed to a trig function. +double degToRad(double degrees) => degrees * math.pi / 180.0; diff --git a/lib/core/geo/location_api.dart b/lib/core/geo/location_api.dart index 5bdc84950..207028147 100644 --- a/lib/core/geo/location_api.dart +++ b/lib/core/geo/location_api.dart @@ -1,4 +1,5 @@ import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/api_region.dart'; /// Device-location endpoint on the region-aware [ApiClient]. @@ -24,6 +25,6 @@ class LocationApi { required double lng, }) => _client.get( ApiTier.coreExclusiveApi, - '/api/v2/location/$platform/$token/$version/$lat,$lng', + '${ApiPaths.location}$platform/$token/$version/$lat,$lng', ); } diff --git a/lib/core/geo/location_monitor.dart b/lib/core/geo/location_monitor.dart index 8fdd13226..3afd6af41 100644 --- a/lib/core/geo/location_monitor.dart +++ b/lib/core/geo/location_monitor.dart @@ -87,9 +87,19 @@ class LocationMonitor extends ChangeNotifier with WidgetsBindingObserver { final previous = _status; final status = await _location.status(); _status = status; + final wasUsable = _usable(previous); + final nowUsable = _usable(status); + // GPS lost (services off / permission revoked): 所在地 must not keep + // presenting the last-known township as where the user is. Clearing the + // code puts the whole app back in the "can't locate" state (nationwide map + // + the header's notice) instead of a stale place. + if (wasUsable && !nowUsable) { + _publishedCode = null; + _regions.setCurrentCode(null); + } // Recover only on an unusable → usable transition (services back / granted), // not on every resume — avoids re-subscribing the position stream needlessly. - if (_seeded && _usable(status) && !_usable(previous)) { + if (_seeded && nowUsable && !wasUsable) { _reporter.restart(); final code = (await _location.currentTown())?.code; _publishedCode = code; diff --git a/lib/core/geo/location_service.dart b/lib/core/geo/location_service.dart index 6c0044aca..9ae9b33e1 100644 --- a/lib/core/geo/location_service.dart +++ b/lib/core/geo/location_service.dart @@ -26,8 +26,12 @@ class LocationService { this._boundaries, Future Function()? isAvailable, Future Function()? fix, + Future Function()? lastKnown, + Future Function()? status, }) : _isAvailable = isAvailable ?? _geolocatorAvailable, - _fix = fix ?? _geolocatorFix; + _fix = fix ?? _geolocatorFix, + _lastKnownFix = lastKnown ?? _geolocatorLastKnown, + _status = status ?? _geolocatorStatus; final TownDirectory _directory; @@ -37,21 +41,14 @@ class LocationService { final Future Function() _isAvailable; final Future Function() _fix; + final Future Function() _lastKnownFix; + final Future Function() _status; /// The combined location-availability [LocationStatus] (services + permission). /// Never throws — a fault degrades to [LocationStatus.denied]. Future status() async { try { - if (!await Geolocator.isLocationServiceEnabled()) { - return LocationStatus.serviceOff; - } - return switch (await Geolocator.checkPermission()) { - LocationPermission.always => LocationStatus.ready, - LocationPermission.whileInUse => LocationStatus.whileInUseOnly, - LocationPermission.deniedForever => LocationStatus.deniedForever, - LocationPermission.denied || - LocationPermission.unableToDetermine => LocationStatus.denied, - }; + return await _status(); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'location status'); return LocationStatus.denied; @@ -182,6 +179,22 @@ class LocationService { } } + /// The most recent cached fix if it's fresh enough ([_maxLastKnownAge]), + /// else null — never a live read, so it returns immediately. + /// + /// For surfaces that must frame instantly (the home backdrop) and would + /// otherwise wait out a GPS timeout: on a simulator with no location the + /// cached fix is null, so it can fall back to the nationwide view right away + /// instead of holding the previous frame for the live-read window. + Future lastKnownFix() async { + try { + return await _lastKnownFix(); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'last-known GPS fix'); + return null; + } + } + /// The current township from GPS, or null if unavailable / no fix. Future currentTown() async { try { @@ -224,6 +237,27 @@ class LocationService { permission == LocationPermission.whileInUse; } + static Future _geolocatorLastKnown() async { + final cached = await Geolocator.getLastKnownPosition(); + if (cached != null && _isFresh(cached.timestamp)) { + return (lat: cached.latitude, lng: cached.longitude); + } + return null; + } + + static Future _geolocatorStatus() async { + if (!await Geolocator.isLocationServiceEnabled()) { + return LocationStatus.serviceOff; + } + return switch (await Geolocator.checkPermission()) { + LocationPermission.always => LocationStatus.ready, + LocationPermission.whileInUse => LocationStatus.whileInUseOnly, + LocationPermission.deniedForever => LocationStatus.deniedForever, + LocationPermission.denied || + LocationPermission.unableToDetermine => LocationStatus.denied, + }; + } + /// How old a cached OS fix may be and still stand in for a live one. The /// cached fix is only a head start on the first frame, so it has to be recent /// enough that it still describes where the user *is*: an hours-old fix from diff --git a/lib/core/geo/town_directory.dart b/lib/core/geo/town_directory.dart index 9aea454e7..06ecbcaf5 100644 --- a/lib/core/geo/town_directory.dart +++ b/lib/core/geo/town_directory.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; +import 'package:dpip/core/geo/geo_math.dart'; import 'package:dpip/core/geo/town.dart'; import 'package:flutter/services.dart' show rootBundle; @@ -64,7 +65,7 @@ class TownDirectory { /// directory is empty. Distance is cos-weighted planar (fine at Taiwan's /// extent) so no per-town trig is needed. Town? nearest(double lat, double lng) { - final cosLat = math.cos(lat * math.pi / 180); + final cosLat = math.cos(degToRad(lat)); Town? best; var bestSq = double.infinity; for (final town in _byCode.values) { diff --git a/lib/core/logging/log.dart b/lib/core/logging/log.dart index 8c3e8ef30..1d0543ce0 100644 --- a/lib/core/logging/log.dart +++ b/lib/core/logging/log.dart @@ -1,5 +1,6 @@ import 'package:dpip/core/logging/crash_sink.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:talker_flutter/talker_flutter.dart'; /// Application-wide logging facade. @@ -56,6 +57,15 @@ abstract final class Log { /// Call once during start-up (see `bootstrap`). static void installErrorHandlers() { FlutterError.onError = (details) { + // iOS hot-restart race, not a crash: the engine can still own the + // previous isolate's platform views, so a fresh isolate re-issues the + // same view id and UiKitView creation fails with `recreating_view`. + // [BaseMap] remounts itself with a fresh id (readiness retry), so the + // map recovers — report it as neither a log entry nor a fatal crash. + if (details.exception is PlatformException && + (details.exception as PlatformException).code == 'recreating_view') { + return; + } // Overriding onError replaces the framework's own console presentation, // whose dump carries the diagnostics our summary drops — for a layout // fault (e.g. a RenderFlex overflow) that includes *which* widget and its diff --git a/lib/core/models/lat_lng.dart b/lib/core/models/lat_lng.dart index fe75f38cf..89b13c283 100644 --- a/lib/core/models/lat_lng.dart +++ b/lib/core/models/lat_lng.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; +import 'package:dpip/core/geo/geo_math.dart'; + /// An immutable WGS84 geographic coordinate. /// /// Deliberately dependency-free so domain logic (EEW estimation, geofencing) @@ -21,12 +23,12 @@ class LatLng { /// seismic distance calculations produce the same results. double distanceTo(LatLng other) { const earthRadius = 6378137.0; - final dLat = _toRadians(other.latitude - latitude); - final dLng = _toRadians(other.longitude - longitude); + final dLat = degToRad(other.latitude - latitude); + final dLng = degToRad(other.longitude - longitude); final a = math.sin(dLat / 2) * math.sin(dLat / 2) + - math.cos(_toRadians(latitude)) * - math.cos(_toRadians(other.latitude)) * + math.cos(degToRad(latitude)) * + math.cos(degToRad(other.latitude)) * math.sin(dLng / 2) * math.sin(dLng / 2); final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); @@ -41,9 +43,9 @@ class LatLng { LatLng destinationPoint(double bearing, double distance) { const earthRadius = 6378137.0; final delta = distance / earthRadius; - final theta = _toRadians(bearing); - final lat1 = _toRadians(latitude); - final lon1 = _toRadians(longitude); + final theta = degToRad(bearing); + final lat1 = degToRad(latitude); + final lon1 = degToRad(longitude); final lat2 = math.asin( math.sin(lat1) * math.cos(delta) + @@ -59,8 +61,6 @@ class LatLng { return LatLng(_toDegrees(lat2), _toDegrees(lon2)); } - static double _toRadians(double degrees) => degrees * math.pi / 180.0; - static double _toDegrees(double radians) => radians * 180.0 / math.pi; @override @@ -75,3 +75,13 @@ class LatLng { @override String toString() => 'LatLng($latitude, $longitude)'; } + +/// Maps a single GeoJSON `[lng, lat]` pair to a [LatLng] (mind the axis order). +LatLng latLngFromPair(List pair) => + LatLng((pair[1] as num).toDouble(), (pair[0] as num).toDouble()); + +/// Maps a GeoJSON `[[lng, lat], …]` list to [LatLng]s; absent/empty in → empty +/// out. +List latLngsFromPairs(Object? raw) => [ + for (final p in (raw as List? ?? const [])) latLngFromPair(p as List), +]; diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart new file mode 100644 index 000000000..a37a836f9 --- /dev/null +++ b/lib/core/network/api_paths.dart @@ -0,0 +1,32 @@ +/// Wire path constants shared by the region-aware API clients and the ETag +/// interceptor, so the cache's URL markers can never drift from the calls. +library; + +/// Single source of truth for the path segments that appear in both a feature's +/// API client and [EtagInterceptor]'s cache policy. +abstract final class ApiPaths { + /// v2 raster / disaster overlay tiles root (`/api/v2/tiles//…`). + static const String tiles = '/api/v2/tiles'; + + /// v1 wind-field binaries (`/api/v1/wind//.bin`) — the WND1 + /// grids that drive the particle animation over the wind tiles. + static const String windV1 = '/api/v1/wind'; + + /// Disaster-point tiles: `/api/v2/tiles/dpm/{layer}/{z}/{x}/{y}.mvt`. + static const String dpm = '/api/v2/tiles/dpm'; + + /// v1 basemap vector tiles (`/api/v1/map/tiles/…`). + static const String mapTilesV1 = '/api/v1/map/tiles/'; + + /// Live EEW feed (with `?sse=1&compress=1` for the stream). + static const String eew = '/api/v2/eq/eew'; + + /// Live RTS feed (with `?sse=1&compress=1` for the stream). + static const String rts = '/api/v2/trem/rts'; + + /// Device location registration — `/api/v2/location/{platform}/{token}/…`. + static const String location = '/api/v2/location/'; + + /// Push notification config — `/api/v2/notify/{token}/…`. + static const String notify = '/api/v2/notify/'; +} diff --git a/lib/core/network/etag_interceptor.dart b/lib/core/network/etag_interceptor.dart index 6cfa74974..49e098ecd 100644 --- a/lib/core/network/etag_interceptor.dart +++ b/lib/core/network/etag_interceptor.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:dio/dio.dart'; +import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; @@ -47,10 +48,10 @@ class EtagInterceptor extends Interceptor { /// Paths that must never enter the ETag store (live / unique / personal). static bool isUncacheablePath(String path) { - if (path == '/api/v2/eq/eew' || path == '/api/v2/trem/rts') return true; - if (path.startsWith('/api/v2/location/')) return true; + if (path == ApiPaths.eew || path == ApiPaths.rts) return true; + if (path.startsWith(ApiPaths.location)) return true; // getNotify + setNotify — token-keyed, must not stick in SQLite. - return path.startsWith('/api/v2/notify/'); + return path.startsWith(ApiPaths.notify); } static bool _isBytes(RequestOptions o) => @@ -59,7 +60,7 @@ class EtagInterceptor extends Interceptor { /// Bare-host basemap vector tiles (no server ETag). static bool isBasemapPbf(Uri uri) => uri.host == 'lb.exptech.dev' && - uri.path.contains('/api/v1/map/tiles/') && + uri.path.contains(ApiPaths.mapTilesV1) && uri.path.endsWith('.pbf'); /// URL fragments marking a **content-addressed** asset: the URL fully @@ -76,10 +77,11 @@ class EtagInterceptor extends Interceptor { /// MapLibre's own ambient database, which meant they were invisible to the /// app's usage accounting. static const List immutableAssetMarkers = [ - '/api/v1/map/tiles/', // basemap vector tiles - '/api/v2/tiles/radar/', - '/api/v2/tiles/satellite/', - '/api/v2/tiles/dpm/', + ApiPaths.mapTilesV1, // basemap vector tiles + '${ApiPaths.tiles}/radar/', + '${ApiPaths.tiles}/satellite/', + '${ApiPaths.tiles}/wind/', + '${ApiPaths.dpm}/', '/gh/exptechtw/map-assets/', // glyph PBFs (jsDelivr) ]; diff --git a/lib/core/network/network_usage_store.dart b/lib/core/network/network_usage_store.dart index 276787798..17bd6b145 100644 --- a/lib/core/network/network_usage_store.dart +++ b/lib/core/network/network_usage_store.dart @@ -60,6 +60,25 @@ class _Pending { int misses = 0; } +/// One hour's recorded usage — the granularity the Debug page's trend chart +/// plots. +class HourUsage { + const HourUsage({ + required this.hour, + required this.down, + required this.saved, + required this.hits, + required this.misses, + }); + + /// UTC epoch hour — the same key the store buckets by. + final int hour; + final int down; + final int saved; + final int hits; + final int misses; +} + /// Persisted network-usage accounting backed by the shared SQLite database. /// /// Everything — bytes downloaded, bytes saved, and cache hits / misses — @@ -260,6 +279,51 @@ class NetworkUsageStore { } } + /// Usage over the trailing window, oldest first — the trend series for the + /// Debug page's chart. + /// + /// [hours] is the window length and [bucketHours] how many hours each sample + /// aggregates, so `history()` gives 24 hourly points and + /// `history(hours: 24 * 7, bucketHours: 6)` gives 28 six-hour points for the + /// 7-day view. Every slot in the window is present: a silent bucket is a + /// zero bar, not a gap, so the chart stays continuous. Best-effort: empty on + /// error. + Future> history({int hours = 24, int bucketHours = 1}) async { + await flush(); + try { + final hour = _now().millisecondsSinceEpoch ~/ _hourMs; + final bucket = hour ~/ bucketHours; + final count = hours ~/ bucketHours; + final rows = await _db.rawQuery( + 'SELECT hour / ? AS bucket, ' + 'COALESCE(SUM(down), 0) AS down, ' + 'COALESCE(SUM(saved), 0) AS saved, ' + 'COALESCE(SUM(hits), 0) AS hits, ' + 'COALESCE(SUM(misses), 0) AS misses ' + 'FROM $_buckets WHERE hour >= ? ' + 'GROUP BY bucket ORDER BY bucket', + [bucketHours, (bucket - count + 1) * bucketHours], + ); + final byBucket = >{ + for (final row in rows) row['bucket'] as int: row, + }; + return [ + for (var b = bucket - count + 1; b <= bucket; b++) + HourUsage( + hour: b * bucketHours, + down: _counter(byBucket[b]?['down']), + saved: _counter(byBucket[b]?['saved']), + hits: _counter(byBucket[b]?['hits']), + misses: _counter(byBucket[b]?['misses']), + ), + ]; + } catch (_) { + return const []; + } + } + + static int _counter(Object? value) => (value as num?)?.toInt() ?? 0; + // Update-then-insert instead of UPSERT, so it works on any bundled SQLite. Future _addToBucket(DatabaseExecutor db, int hour, _Pending add) async { final sets = _columns.map((c) => '$c = $c + ?').join(', '); diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 824f8eb6d..3ebf37d9e 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -185,7 +185,13 @@ class NotificationService { await _prefs.setString(PreferenceKeys.pushToken, pushToken); } } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'getToken (APNs may not be ready)'); + // The failure mode is platform-specific: on iOS an unready APNs token is + // the usual cause, on Android getToken() fails at FCM registration (e.g. + // the app's signing SHA-1 not registered in the Firebase console). + final title = defaultTargetPlatform == TargetPlatform.iOS + ? 'getToken (APNs may not be ready)' + : 'getToken (FCM registration failed)'; + Log.handle(error, stackTrace, title); } } diff --git a/lib/core/realtime/app_time.dart b/lib/core/realtime/app_time.dart index ef1ab1529..116029ce7 100644 --- a/lib/core/realtime/app_time.dart +++ b/lib/core/realtime/app_time.dart @@ -28,7 +28,13 @@ abstract final class AppTime { static DateTime get utc => _clock?.now() ?? DateTime.now().toUtc(); /// Calibrated current time at Taiwan's fixed +8 offset. - static DateTime get utc8 => utc.add(const Duration(hours: 8)); + static DateTime get utc8 => taipei(utc); + + /// [utc] at Taiwan's fixed +8 offset — its year/month/day/hour/… fields read + /// as Taipei wall time regardless of the device timezone (UTC-flagged). For + /// a server timestamp, feed it the UTC instant this calendar moment holds. + static DateTime taipei(DateTime utc) => + utc.toUtc().add(const Duration(hours: 8)); /// Whether the clock has completed at least one NTP sync. static bool get isSynced => _clock?.isSynced ?? false; diff --git a/lib/core/settings/default_map_layer.dart b/lib/core/settings/default_map_layer.dart index 0fe202916..ed88b5210 100644 --- a/lib/core/settings/default_map_layer.dart +++ b/lib/core/settings/default_map_layer.dart @@ -5,6 +5,7 @@ library; /// the matching `MapLayer.id` (`radar`, `monitor`, `dpm`, …). enum DefaultMapLayer { radar, + qpesums, satellite, lightning, typhoon, diff --git a/lib/core/settings/experimental_settings.dart b/lib/core/settings/experimental_settings.dart index de1841eee..2dab82ca6 100644 --- a/lib/core/settings/experimental_settings.dart +++ b/lib/core/settings/experimental_settings.dart @@ -1,6 +1,7 @@ import 'package:dpip/core/settings/persisted.dart'; import 'package:dpip/core/settings/preference_keys.dart'; import 'package:dpip/core/settings/prefs.dart'; +import 'package:dpip/core/settings/sky_time_mode.dart'; import 'package:dpip/core/settings/weather_mode.dart'; import 'package:flutter/foundation.dart'; @@ -8,17 +9,42 @@ import 'package:flutter/foundation.dart'; /// /// Provided near the app root so any feature (e.g. the home weather backdrop) /// can react to changes. +/// +/// The whole surface is gated behind [unlocked], which starts false and is +/// turned on by ten taps on the Developer page's version row — the More-menu +/// entry is hidden until then. class ExperimentalSettings extends ChangeNotifier { /// Loads persisted values from [prefs]. ExperimentalSettings(Prefs prefs) - : _weatherMode = PersistedEnum( + : _prefs = prefs, + _weatherMode = PersistedEnum( prefs, key: PreferenceKeys.weatherMode, values: WeatherMode.values, fallback: WeatherMode.auto, + ), + _skyTimeMode = PersistedEnum( + prefs, + key: PreferenceKeys.skyTimeMode, + values: SkyTimeMode.values, + fallback: SkyTimeMode.auto, ); + final Prefs _prefs; final PersistedEnum _weatherMode; + final PersistedEnum _skyTimeMode; + + /// Whether the experimental-features menu is unlocked. Defaults to false; see + /// the class doc for how it flips. + bool get unlocked => + _prefs.getBool(PreferenceKeys.experimentalUnlocked) ?? false; + + /// Persists the unlock and notifies listeners (the More menu shows the + /// entry only after this). + Future unlock() async { + await _prefs.setBool(PreferenceKeys.experimentalUnlocked, true); + notifyListeners(); + } /// The forced weather-animation mode, or [WeatherMode.auto] to follow real /// conditions. @@ -27,4 +53,12 @@ class ExperimentalSettings extends ChangeNotifier { set weatherMode(WeatherMode mode) { if (_weatherMode.set(mode)) notifyListeners(); } + + /// The forced time of day for the backdrop, or [SkyTimeMode.auto] to follow + /// the clock. + SkyTimeMode get skyTimeMode => _skyTimeMode.value; + + set skyTimeMode(SkyTimeMode mode) { + if (_skyTimeMode.set(mode)) notifyListeners(); + } } diff --git a/lib/core/settings/map_layer_order_controller.dart b/lib/core/settings/map_layer_order_controller.dart new file mode 100644 index 000000000..e7b01ac71 --- /dev/null +++ b/lib/core/settings/map_layer_order_controller.dart @@ -0,0 +1,72 @@ +/// Persisted custom order for the map layer picker — both the layer order and +/// the category order, so a reordered picker keeps its groups *and* their +/// internal layer sequence across launches. +library; + +import 'package:dpip/core/settings/preference_keys.dart'; +import 'package:dpip/core/settings/prefs.dart'; +import 'package:flutter/foundation.dart'; + +/// Holds the user's saved layer order (layer ids) and category order (category +/// names), persisted across launches and shared by every map surface. Each +/// surface resolves them against its own layer set / the current category set — +/// see `orderedLayers` / `orderedCategories` in `shared/map/map_layer_order.dart`. +class MapLayerOrderController extends ChangeNotifier { + MapLayerOrderController(this._prefs) + : _order = _prefs.getStringList(PreferenceKeys.mapLayerOrder) ?? const [], + _categoryOrder = + _prefs.getStringList(PreferenceKeys.mapLayerCategoryOrder) ?? + const []; + + final Prefs _prefs; + List _order; + List _categoryOrder; + + /// Saved layer-id order, most-preferred first. Empty when the user has never + /// customised it — layers then keep the surface's declared order. + List get order => List.unmodifiable(_order); + + /// Saved category-name order, most-preferred first. Empty when the user has + /// never customised it — categories then keep their declared order. + List get categoryOrder => List.unmodifiable(_categoryOrder); + + /// Persists [order] and notifies watchers (the layer picker rebuilds in the + /// new order). Ids of layers a surface doesn't offer are tolerated by + /// `orderedLayers`, so no validation happens here. + Future setOrder(List order) async { + if (listEquals(order, _order)) return; + _order = List.of(order); + await _prefs.setStringList(PreferenceKeys.mapLayerOrder, _order); + notifyListeners(); + } + + /// Persists [order] (category names) and notifies watchers. Names of + /// categories that no longer exist are tolerated by `orderedCategories`, so + /// no validation happens here. + Future setCategoryOrder(List order) async { + if (listEquals(order, _categoryOrder)) return; + _categoryOrder = List.of(order); + await _prefs.setStringList( + PreferenceKeys.mapLayerCategoryOrder, + _categoryOrder, + ); + notifyListeners(); + } + + /// Clears the saved layer and category order — every surface falls back to + /// its declared order. + Future reset() async { + var changed = false; + if (_order.isNotEmpty) { + _order = const []; + await _prefs.remove(PreferenceKeys.mapLayerOrder); + changed = true; + } + if (_categoryOrder.isNotEmpty) { + _categoryOrder = const []; + await _prefs.remove(PreferenceKeys.mapLayerCategoryOrder); + changed = true; + } + if (changed) notifyListeners(); + } +} diff --git a/lib/core/settings/preference_keys.dart b/lib/core/settings/preference_keys.dart index 69aa1afc9..af7605952 100644 --- a/lib/core/settings/preference_keys.dart +++ b/lib/core/settings/preference_keys.dart @@ -54,6 +54,19 @@ abstract final class PreferenceKeys { 'map.defaultLayer', ); + /// User-customised layer order in the map layer picker (layer ids, + /// most-preferred first; empty = the surface's declared order). See + /// `MapLayerOrderController`. + static const PrefKey> mapLayerOrder = PrefKey>._( + 'map.layerOrder', + ); + + /// User-customised category order in the map layer picker (category names, + /// most-preferred first; empty = the declared category order). See + /// `MapLayerOrderController`. + static const PrefKey> mapLayerCategoryOrder = + PrefKey>._('map.layerCategoryOrder'); + /// Saved Home township codes (ordered list). See `RegionStore`. static const PrefKey> savedRegionCodes = PrefKey>._( 'home.savedRegionCodes', @@ -64,6 +77,19 @@ abstract final class PreferenceKeys { 'experimental.weatherMode', ); + /// Experimental time-of-day override for the backdrop. See + /// `ExperimentalSettings`. + static const PrefKey skyTimeMode = PrefKey._( + 'experimental.skyTimeMode', + ); + + /// Whether the experimental-features menu is unlocked. Hidden behind ten taps + /// on the Developer page's version row so accidental users never see it. See + /// `ExperimentalSettings`. + static const PrefKey experimentalUnlocked = PrefKey._( + 'experimental.unlocked', + ); + /// Last push token — the FCM registration token on Android, the raw APNs /// device token on iOS (backend registration keys on whichever this /// platform actually uses). See `NotificationService`. diff --git a/lib/core/settings/sky_time_mode.dart b/lib/core/settings/sky_time_mode.dart new file mode 100644 index 000000000..144c715ad --- /dev/null +++ b/lib/core/settings/sky_time_mode.dart @@ -0,0 +1,54 @@ +/// Forces the sky to a point in the day, independent of the weather. +/// +/// The backdrop's look is the product of two things — the weather type and +/// where the keyframe ring currently sits — so testing it needs both axes. +/// Keeping time separate from [WeatherMode] gives every combination without +/// squaring the number of weather entries. +enum SkyTimeMode { + /// Follow the real clock. + auto, + + /// Just before sunrise — the blue hour. + dawn, + + /// Sunrise. + sunrise, + + /// Mid-morning. + morning, + + /// Solar noon, the ring's peak. + noon, + + /// Mid-afternoon. + afternoon, + + /// The golden hour before sunset. + golden, + + /// Sunset. + sunset, + + /// After sunset — the blue hour again. + dusk, + + /// Deep night. + night, +} + +/// The local hour each mode pins the sky to, or `null` for [SkyTimeMode.auto]. +/// +/// Hours are chosen against the 17-keyframe ring's own phase: it peaks at +/// keyframe 8, which an even split places at 11.3 h. +double? skyTimeHour(SkyTimeMode mode) => switch (mode) { + SkyTimeMode.auto => null, + SkyTimeMode.dawn => 5.0, + SkyTimeMode.sunrise => 6.0, + SkyTimeMode.morning => 8.5, + SkyTimeMode.noon => 11.3, + SkyTimeMode.afternoon => 15.0, + SkyTimeMode.golden => 17.5, + SkyTimeMode.sunset => 18.4, + SkyTimeMode.dusk => 19.2, + SkyTimeMode.night => 1.0, +}; diff --git a/lib/core/settings/weather_mode.dart b/lib/core/settings/weather_mode.dart index 63cb2fe9a..4b1f20ee4 100644 --- a/lib/core/settings/weather_mode.dart +++ b/lib/core/settings/weather_mode.dart @@ -9,9 +9,21 @@ enum WeatherMode { /// Clear sky. clear, + /// Broken cloud. + cloudy, + + /// Solid overcast. + overcast, + /// Rain. rain, + /// Snow. + snow, + + /// Blowing dust. + sand, + /// Heavy fog / mist. fog, diff --git a/lib/core/weather/weather_condition.dart b/lib/core/weather/weather_condition.dart new file mode 100644 index 000000000..b1ac2b2c7 --- /dev/null +++ b/lib/core/weather/weather_condition.dart @@ -0,0 +1,178 @@ +/// CWB weather-condition codes → icon and backdrop mode. +/// +/// l10n-ignore-file: CJK substrings match the API's weather tokens (晴/多雲/…), +/// not user-facing copy — the server labels are Traditional Chinese only. +library; + +import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/core/weather/weather_icons.dart'; +import 'package:flutter/material.dart'; + +/// The single mapping for CWB's weather-code table: families 100 (晴) / 200 +/// (多雲) / 300 (陰), each carrying the same 20 phenomenon suffixes (ones +/// digits 1–19, `0` = the plain family sky). `0` alone means 缺值/未知. +/// +/// Consumers never read the raw table — they call [weatherVisual] for the +/// icon/accent and [weatherModeFor] for the backdrop, so the code→look +/// decisions live in exactly one place. + +/// Ones digit → backdrop mode. The suffix describes a phenomenon the family +/// base already classifies as sky cover; where the two conflict the phenomenon +/// wins — a clear-code 106 (有雨) is still rain. +const Map _phenomenonMode = { + 1: WeatherMode.fog, // 有霾 + 2: WeatherMode.fog, // 有靄 + 3: WeatherMode.thunderstorm, // 有閃電 + 4: WeatherMode.thunderstorm, // 有雷聲 + 5: WeatherMode.fog, // 有霧 + 6: WeatherMode.rain, // 有雨 + 7: WeatherMode.rain, // 有雨雪 — a rain-snow mix; rain is the dominant hazard + 8: WeatherMode.snow, // 有大雪 + 9: WeatherMode.snow, // 有雪珠 + 10: WeatherMode.snow, // 有冰珠 + 11: WeatherMode.rain, // 有陣雨 + 12: WeatherMode.snow, // 陣雨雪 + 13: WeatherMode.rain, // 有雹 + 14: WeatherMode.thunderstorm, // 有雷雨 + 15: WeatherMode.thunderstorm, // 有雷雪 + 16: WeatherMode.thunderstorm, // 有雷雹 + 17: WeatherMode.thunderstorm, // 大雷雨 + 18: WeatherMode.thunderstorm, // 大雷雹 + 19: WeatherMode.thunderstorm, // 有雷 +}; + +/// Ones digit → a distinct icon, finer than the eight backdrop modes. +/// +/// `0` (the plain family sky) is absent here on purpose: a clear-day 100 must +/// stay a sun, not fall through to the text fallback. The [weatherModeFor] +/// mode still picks the accent colour, so a phenomenon only refines the glyph. +const Map _phenomenonIcon = { + 1: Icons.blur_on_outlined, // 有霾 — suspended dust, not a fog bank + 2: Icons.blur_on_outlined, // 有靄 + 3: Icons.bolt_outlined, // 有閃電 — lightning with no rain on the ground + 4: Icons.bolt_outlined, // 有雷聲 + 5: Icons.foggy, // 有霧 — a real fog bank reads denser than 霾 + 6: rainy, // 有雨 — the cloud-and-rain mark, not a single drop + 7: Icons.sunny_snowing, // 有雨雪 — the sun-through-snow glyph reads as the mix + 8: Icons.snowing, // 有大雪 + 9: Icons.ac_unit_outlined, // 有雪珠 + 10: Icons.ac_unit_outlined, // 有冰珠 + 11: rainyLight, // 有陣雨 — a shower, lighter drops than steady 有雨 + 12: Icons.cloudy_snowing, // 陣雨雪 + 13: Icons.grain_outlined, // 有雹 + 14: Icons.thunderstorm_outlined, // 有雷雨 + 15: Icons.thunderstorm_outlined, // 有雷雪 + 16: Icons.thunderstorm_outlined, // 有雷雹 + 17: Icons.thunderstorm, // 大雷雨 — filled: the heaviest rung, visually heavier + 18: Icons.thunderstorm, // 大雷雹 + 19: Icons.bolt_outlined, // 有雷 +}; + +/// The plain sky of a code's family (its hundreds digit), used when the ones +/// digit is `0` or unknown. `0` (缺值) and any unrecognised family fall back +/// to [WeatherMode.auto]. +WeatherMode _familyMode(int code) => switch (code ~/ 100) { + 1 => WeatherMode.clear, + 2 => WeatherMode.cloudy, + 3 => WeatherMode.overcast, + _ => WeatherMode.auto, +}; + +/// The backdrop mode for a CWB [code]: the phenomenon (ones digit) wins over +/// the family sky, and `0`/unknown codes fall back to [WeatherMode.auto]. +WeatherMode weatherModeFor(int code) { + if (code <= 0) return WeatherMode.auto; + return _phenomenonMode[code % 100] ?? _familyMode(code); +} + +/// Rain intensity for a CWB [code], on the painter's continuous ladder where +/// `0.321` is the 小雨 rung and `1.0` is 暴雨 — `null` when the code carries no +/// rain (the sky may still be rainy-looking through its mode's keyframes). +double? weatherRainIntensity(int code) { + if (code <= 0) return null; + return switch (code % 100) { + // Lightning-only phenomena — the storm look, but the rain barely starts. + 3 || 4 || 19 => 0.15, + 6 || 11 => 0.35, // 有雨 / 有陣雨 + 7 || 12 => 0.40, // 有雨雪 / 陣雨雪 — mixed; rain is the visible hazard + 13 => 0.50, // 有雹 + 14 => 0.70, // 有雷雨 + 16 || 18 => 0.85, // 有雷雹 / 大雷雹 + 17 => 1.00, // 大雷雨 + _ => null, + }; +} + +/// Snow intensity for a CWB [code], on the painter's continuous snow channel; +/// `null` when the code carries no snow. +double? weatherSnowIntensity(int code) { + if (code <= 0) return null; + return switch (code % 100) { + 8 => 1.0, // 有大雪 + 10 => 0.6, // 有冰珠 + 9 || 12 || 15 => 0.5, // 有雪珠 / 陣雨雪 / 有雷雪 + _ => null, + }; +} + +/// Icon + accent for a forecast point's [weather] text and [weatherCode]. +/// +/// Codes are authoritative: the phenomenon's own icon ([_phenomenonIcon]) wins +/// when the suffix has one, then the family sky ([weatherModeFor]) for the +/// plain `0` suffix, and only a missing/unknown code falls back to the text. +/// The accent colour always follows the resolved mode, so 陣雨 and 大雷雨 share +/// their family's tint while still reading as different glyphs. +(IconData, Color?) weatherVisual( + String weather, + int weatherCode, + ColorScheme colors, +) { + if (weatherCode > 0) { + final phenomenon = _phenomenonIcon[weatherCode % 100]; + if (phenomenon != null) { + return (phenomenon, _accent(weatherModeFor(weatherCode), colors)); + } + } + final mode = weatherModeFor(weatherCode); + return switch (mode) { + // A missing/unknown code has no mode to key off — fall back to the text. + WeatherMode.auto => _fallback(weather, colors), + WeatherMode.thunderstorm => (Icons.thunderstorm_outlined, colors.tertiary), + WeatherMode.snow => (Icons.ac_unit_outlined, colors.primary), + WeatherMode.rain => (rainy, colors.primary), + WeatherMode.fog => (Icons.blur_on_outlined, colors.onSurfaceVariant), + WeatherMode.sand => (Icons.air_outlined, colors.onSurfaceVariant), + WeatherMode.clear => (Icons.wb_sunny_outlined, colors.tertiary), + WeatherMode.cloudy => (Icons.wb_cloudy_outlined, colors.onSurfaceVariant), + WeatherMode.overcast => (Icons.cloud_outlined, colors.onSurfaceVariant), + }; +} + +/// The accent colour a mode's icons share. +Color? _accent(WeatherMode mode, ColorScheme colors) => switch (mode) { + WeatherMode.clear || WeatherMode.thunderstorm => colors.tertiary, + WeatherMode.rain || WeatherMode.snow => colors.primary, + WeatherMode.fog || + WeatherMode.sand || + WeatherMode.cloudy || + WeatherMode.overcast => colors.onSurfaceVariant, + WeatherMode.auto => null, +}; + +/// Text-only fallback for codes that resolve to [WeatherMode.auto] (missing or +/// unknown) — the pre-table substring matching. +(IconData, Color?) _fallback(String weather, ColorScheme colors) { + if (weather.contains('雷')) { + return (Icons.thunderstorm_outlined, colors.tertiary); + } + if (weather.contains('雪')) { + return (Icons.ac_unit_outlined, colors.primary); + } + if (weather.contains('雨')) { + return (rainy, colors.primary); + } + if (weather.contains('晴')) { + return (Icons.wb_sunny_outlined, colors.tertiary); + } + return (Icons.cloud_outlined, colors.onSurfaceVariant); +} diff --git a/lib/core/weather/weather_icons.dart b/lib/core/weather/weather_icons.dart new file mode 100644 index 000000000..93aee76ba --- /dev/null +++ b/lib/core/weather/weather_icons.dart @@ -0,0 +1,20 @@ +/// Weather glyphs from the bundled Material Symbols font that Flutter's +/// [Icons] class does not expose as named constants. +/// +/// `rainy` (U+F07C2) / `rainy_light` (U+F07C4) / `rainy_heavy` (U+F07C3) were +/// added to Material Symbols v184 and ship inside Flutter's +/// `MaterialIcons-Regular.otf`, so referencing them by codepoint stays within +/// the "built-in icons only" rule — the glyphs are verified present in the +/// bundled font (the [Icons] class just doesn't name them). +library; + +import 'package:flutter/material.dart'; + +/// Cloud with falling rain — the standard "raining" mark, for steady rain. +const IconData rainy = IconData(0xf07c2, fontFamily: 'MaterialIcons'); + +/// Light rain — a few scattered drops, for showers. +const IconData rainyLight = IconData(0xf07c4, fontFamily: 'MaterialIcons'); + +/// Heavy rain — dense falling drops, for downpour-scale phenomena. +const IconData rainyHeavy = IconData(0xf07c3, fontFamily: 'MaterialIcons'); diff --git a/lib/features/data/presentation/pages/data_page.dart b/lib/features/data/presentation/pages/data_page.dart index a779207ac..c5de7e17b 100644 --- a/lib/features/data/presentation/pages/data_page.dart +++ b/lib/features/data/presentation/pages/data_page.dart @@ -12,6 +12,10 @@ import 'package:go_router/go_router.dart'; /// Catalogue of in-app data surfaces. The shell's 4th tab; entries push nested /// routes under `/data/…` so the bottom nav stays visible. +/// +/// Laid out as a catalogue rather than a plain list: the seismic entry is a +/// full-width featured card (the primary hazard surface), and the weather +/// rankings fill a two-column grid so every metric reads at a glance. class DataPage extends StatelessWidget { const DataPage({super.key}); @@ -44,6 +48,7 @@ class DataPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); + final colors = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar(title: Text(l10n.navData)), body: ListView( @@ -53,24 +58,44 @@ class DataPage extends StatelessWidget { ), children: [ SectionHeader(l10n.dataSectionSeismic), - _DataGroup( - children: [ - _DataTile( - icon: Icons.monitor_heart_outlined, - title: l10n.navEarthquake, - subtitle: l10n.dataEarthquakeSubtitle, - onTap: () => context.pushNamed(AppRoutes.earthquake), - ), - ], + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.sm, + ), + child: _SeismicCard( + icon: Icons.monitor_heart_outlined, + title: l10n.navEarthquake, + subtitle: l10n.dataEarthquakeSubtitle, + onTap: () => context.pushNamed(AppRoutes.earthquake), + ), ), SectionHeader(l10n.dataSectionWeather), - _DataGroup( + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.sm, + ), + crossAxisSpacing: AppSpacing.sm, + mainAxisSpacing: AppSpacing.sm, + childAspectRatio: 1.45, children: [ for (final (tab, icon) in _weatherRankingEntries) - _DataTile( + _RankingGridTile( icon: icon, title: _weatherRankingLabel(l10n, tab), - subtitle: l10n.dataWeatherRankingSubtitle, + accent: switch (tab) { + 'rain' => colors.primary, + 'temperature' => colors.tertiary, + _ => colors.secondary, + }, onTap: () => context.pushNamed( AppRoutes.weatherRanking, queryParameters: {'tab': tab}, @@ -84,67 +109,134 @@ class DataPage extends StatelessWidget { } } -/// Tonal card wrapping one or more [_DataTile]s — same Material recipe as More. -class _DataGroup extends StatelessWidget { - const _DataGroup({required this.children}); +/// The seismic entry — a full-width highlighted card so the primary hazard +/// surface stands out from the ranking grid below it. +class _SeismicCard extends StatelessWidget { + const _SeismicCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); - final List children; + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final rows = []; - for (var i = 0; i < children.length; i++) { - if (i > 0) { - rows.add( - Divider( - height: 1, - thickness: 1, - indent: AppSpacing.xxl + AppSpacing.xl, - color: theme.colorScheme.outlineVariant, + final colors = Theme.of(context).colorScheme; + return Material( + color: colors.primaryContainer, + borderRadius: AppRadius.large, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colors.secondaryContainer, + borderRadius: AppRadius.medium, + ), + child: Icon(icon, color: colors.onSecondaryContainer), + ), + const SizedBox(width: AppSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colors.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: colors.onPrimaryContainer), + ], ), - ); - } - rows.add(children[i]); - } - return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - 0, - AppSpacing.lg, - AppSpacing.sm, - ), - child: Material( - color: theme.colorScheme.surfaceContainer, - borderRadius: AppRadius.medium, - clipBehavior: Clip.antiAlias, - child: Column(children: rows), + ), ), ); } } -class _DataTile extends StatelessWidget { - const _DataTile({ +/// A metric card in the weather-ranking grid — tonal surface, icon badge, and +/// the ranking label with a forward affordance. +class _RankingGridTile extends StatelessWidget { + const _RankingGridTile({ required this.icon, required this.title, + required this.accent, required this.onTap, - this.subtitle, }); final IconData icon; final String title; - final String? subtitle; + final Color accent; final VoidCallback onTap; @override Widget build(BuildContext context) { - return ListTile( - leading: Icon(icon), - title: Text(title), - subtitle: subtitle == null ? null : Text(subtitle!), - trailing: const Icon(Icons.chevron_right), - onTap: onTap, + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Material( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(AppSpacing.sm), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: AppRadius.small, + ), + child: Icon(icon, size: 22, color: accent), + ), + const Spacer(), + Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall, + ), + ), + const SizedBox(width: AppSpacing.xs), + Icon( + Icons.chevron_right, + size: 18, + color: colors.onSurfaceVariant, + ), + ], + ), + ], + ), + ), + ), ); } } diff --git a/lib/features/disaster_map/data/disaster_map_api.dart b/lib/features/disaster_map/data/disaster_map_api.dart index 60028f392..77031f7dc 100644 --- a/lib/features/disaster_map/data/disaster_map_api.dart +++ b/lib/features/disaster_map/data/disaster_map_api.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/api_region.dart'; /// Tile templates + JSON detail for `/api/v2/tiles/dpm/…`. @@ -20,21 +21,20 @@ class DisasterMapApi { /// `https://static.core-tnn1.exptech.dev/api/v2/tiles/dpm/{layer}/{z}/{x}/{y}.mvt` String tileUrl(String layer) => '${_client.hostsFor(_tier).first}' - '/api/v2/tiles/dpm/$layer/{z}/{x}/{y}.mvt'; + '${ApiPaths.dpm}/$layer/{z}/{x}/{y}.mvt'; - /// `GET /api/v2/tiles/dpm/aed/{id}` - Future> getAedDetail(int id) async { - final raw = await _client.get(_tier, '/api/v2/tiles/dpm/aed/$id'); - return Map.from(raw as Map); - } + Future> getAedDetail(int id) => _detail('aed', id); - Future> getRestroomDetail(int id) async { - final raw = await _client.get(_tier, '/api/v2/tiles/dpm/restroom/$id'); - return Map.from(raw as Map); - } + Future> getRestroomDetail(int id) => + _detail('restroom', id); + + Future> getShelterDetail(int id) => + _detail('shelter', id); - Future> getShelterDetail(int id) async { - final raw = await _client.get(_tier, '/api/v2/tiles/dpm/shelter/$id'); + /// `GET /api/v2/tiles/dpm//{id}` — the three venue detail endpoints + /// share shape and response decoding. + Future> _detail(String kind, int id) async { + final raw = await _client.get(_tier, '${ApiPaths.dpm}/$kind/$id'); return Map.from(raw as Map); } } diff --git a/lib/features/disaster_map/data/dpm_tile_prefetcher.dart b/lib/features/disaster_map/data/dpm_tile_prefetcher.dart index ab6a20bbe..f3441428d 100644 --- a/lib/features/disaster_map/data/dpm_tile_prefetcher.dart +++ b/lib/features/disaster_map/data/dpm_tile_prefetcher.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/api_region.dart'; import 'package:dpip/shared/map/map_tile_warmer.dart'; @@ -26,7 +27,7 @@ class DpmTilePrefetcher { }) => _warmer.warmViewport( client: _client, tier: _tier, - pathFor: (z, x, y) => '/api/v2/tiles/dpm/$layer/$z/$x/$y.mvt', + pathFor: (z, x, y) => '${ApiPaths.dpm}/$layer/$z/$x/$y.mvt', south: south, west: west, north: north, diff --git a/lib/features/earthquake/data/earthquake_api.dart b/lib/features/earthquake/data/earthquake_api.dart index 0ab39fa00..659830b4c 100644 --- a/lib/features/earthquake/data/earthquake_api.dart +++ b/lib/features/earthquake/data/earthquake_api.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/api_region.dart'; import 'package:dpip/core/network/sse_client.dart'; import 'package:dpip/core/network/sse_event.dart'; @@ -19,8 +20,7 @@ class EarthquakeApi { /// Latest real-time station (RTS) shaking data (one-shot snapshot). /// /// `https://api.lb-{tpe1,khh1}.exptech.dev/api/v2/trem/rts` - Future getRtsRealtime() => - _client.get(ApiTier.lbApi, '/api/v2/trem/rts'); + Future getRtsRealtime() => _client.get(ApiTier.lbApi, ApiPaths.rts); /// Opens the **live** RTS feed as a Server-Sent Events stream — the transport /// the realtime channel runs on. A continuous ~1 Hz snapshot stream. @@ -32,7 +32,7 @@ class EarthquakeApi { /// A fresh stream per call, so the source can reconnect by calling again. Stream openRtsSse() => HttpSseClient(_client).connect( ApiTier.lbApi, - '/api/v2/trem/rts', + ApiPaths.rts, query: const {'sse': 1, 'compress': 1}, ); @@ -50,7 +50,7 @@ class EarthquakeApi { Future getRtsAt(int seconds) async { final data = await _client.get( ApiTier.legacyApi, - '/api/v2/trem/rts/$seconds', + '${ApiPaths.rts}/$seconds', ); // Verified 2026-08-09: unlike every other endpoint here (including the // bare `/trem/rts` and `/trem/station` on this same host), api-1 serves @@ -65,7 +65,7 @@ class EarthquakeApi { /// /// `https://api.lb-{tpe1,khh1}.exptech.dev/api/v2/eq/eew` Future> getEewRealtime() async => - (await _client.get(ApiTier.lbApi, '/api/v2/eq/eew')) as List; + (await _client.get(ApiTier.lbApi, ApiPaths.eew)) as List; /// Opens the **live** EEW feed as a Server-Sent Events stream — the transport /// the realtime channel runs on, replacing per-second polling with one @@ -78,7 +78,7 @@ class EarthquakeApi { /// A fresh stream per call, so the source can reconnect by calling again. Stream openEewSse() => HttpSseClient(_client).connect( ApiTier.lbApi, - '/api/v2/eq/eew', + ApiPaths.eew, query: const {'sse': 1, 'compress': 1}, ); @@ -87,18 +87,7 @@ class EarthquakeApi { /// /// `https://api.core-{tyo1,tnn1}.exptech.dev/api/v2/eq/eew/{seconds}` Future> getEewAt(int seconds) async => - (await _client.get(ApiTier.coreApi, '/api/v2/eq/eew/$seconds')) - as List; - - /// Latest earthquake reports including area `list` (no pagination filters). - /// - /// `https://api.core-{tyo1,tnn1}.exptech.dev/api/v1/eq/report?limit=` - Future> getLatestReports({int limit = 1}) async => - (await _client.get( - ApiTier.coreApi, - '/api/v1/eq/report', - query: {'limit': limit}, - )) + (await _client.get(ApiTier.coreApi, '${ApiPaths.eew}/$seconds')) as List; /// Paginated earthquake report list (no area `list`; includes `md5` / `int`). diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart index ad3a88f31..c34f6c552 100644 --- a/lib/features/earthquake/presentation/pages/report_detail_page.dart +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -4,7 +4,6 @@ library; import 'dart:async'; -import 'dart:math' as math; import 'package:dpip/app/theme/app_motion.dart'; import 'package:dpip/app/theme/app_radius.dart'; @@ -13,6 +12,7 @@ import 'package:dpip/core/geo/town.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/models/lat_lng.dart' as geo; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/earthquake/domain/earthquake_report.dart'; @@ -21,12 +21,15 @@ import 'package:dpip/features/earthquake/domain/report_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/camera_fit.dart'; +import 'package:dpip/shared/map/map_compass.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/seismic/report_colors.dart'; import 'package:dpip/shared/widgets/async_view.dart'; import 'package:dpip/shared/widgets/frosted_surface.dart'; import 'package:dpip/shared/widgets/intensity_badge.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -111,22 +114,11 @@ class _ReportDetailPageState extends State { /// The report's epicentre + station bounds, in the map library's coordinate /// type — computed here (not on the domain model) so the domain layer stays /// free of a `maplibre_gl` dependency. -LatLngBounds _reportBounds(EarthquakeReport report) { - var minLat = report.latitude, maxLat = report.latitude; - var minLon = report.longitude, maxLon = report.longitude; - for (final area in report.list.values) { - for (final town in area.town.values) { - minLat = math.min(minLat, town.latitude); - maxLat = math.max(maxLat, town.latitude); - minLon = math.min(minLon, town.longitude); - maxLon = math.max(maxLon, town.longitude); - } - } - return LatLngBounds( - southwest: LatLng(minLat, minLon), - northeast: LatLng(maxLat, maxLon), - ); -} +LatLngBounds _reportBounds(EarthquakeReport report) => boundsFromPoints([ + LatLng(report.latitude, report.longitude), + for (final area in report.list.values) + for (final town in area.town.values) LatLng(town.latitude, town.longitude), +])!; /// One 震度 level's counties (縣市) and, per county, the felt area/town names — /// grouping by intensity first (highest first), not by county, mirrors the @@ -219,10 +211,44 @@ class _ReportMapDetailState extends State<_ReportMapDetail> { MapLibreMapController? _controller; bool _iconsLoaded = false; + /// Feeds the Flutter [MapCompass] needle — camera heading, ° clockwise from + /// north. Kept in sync from [BaseMap.onCameraMove] so the needle tracks + /// rotation live, matching the map tab's compass. + final ValueNotifier _bearing = ValueNotifier(0); + + @override + void dispose() { + _bearing.dispose(); + super.dispose(); + } + void _onMapCreated(MapLibreMapController controller) { _controller = controller; } + /// Re-points the camera north, keeping centre / zoom. Mirrors + /// [MapScaffold._resetNorth]: the needle is settled directly because a + /// programmatic move may not emit a final north-up camera event. + void _resetNorth() { + final controller = _controller; + if (controller == null) return; + final position = controller.cameraPosition; + if (position == null) return; + _bearing.value = 0; + unawaited( + controller.moveCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: position.target, + zoom: position.zoom, + bearing: 0, + tilt: 0, + ), + ), + ), + ); + } + Future _onStyleLoaded() async { final controller = _controller; if (controller == null) return; @@ -321,8 +347,10 @@ class _ReportMapDetailState extends State<_ReportMapDetail> { Positioned.fill( child: BaseMap( showUserLocation: false, + compassEnabled: false, onMapCreated: _onMapCreated, onStyleLoaded: () => unawaited(_onStyleLoaded()), + onCameraMove: (position) => _bearing.value = position.bearing, ), ), Positioned.fill( @@ -331,6 +359,18 @@ class _ReportMapDetailState extends State<_ReportMapDetail> { expandedNotifier: widget.sheetExpanded, ), ), + // North indicator above the sheet so a dragged-up sheet can never hide + // it — same Flutter [MapCompass] the map tab uses, parked at top-right. + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: MapCompass(bearing: _bearing, onPressed: _resetNorth), + ), + ), + ), ], ); } @@ -576,7 +616,7 @@ class _ReportPeekSummary extends StatelessWidget { report.maxIntensity, report.originTimeUtc, ); - final taipei = report.originTimeUtc.add(const Duration(hours: 8)); + final taipei = AppTime.taipei(report.originTimeUtc); final time = DateFormat('yyyy/MM/dd HH:mm:ss').format(taipei); return Column( @@ -789,7 +829,7 @@ class _ReportInfoCard extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).colorScheme; - final taipei = report.originTimeUtc.add(const Duration(hours: 8)); + final taipei = AppTime.taipei(report.originTimeUtc); final originTime = DateFormat('yyyy/MM/dd HH:mm:ss').format(taipei); final coordinates = '${report.latitude.toStringAsFixed(2)}°N・' @@ -850,14 +890,7 @@ class _InfoRow extends StatelessWidget { ), const Spacer(), if (dotColor != null) ...[ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: dotColor, - shape: BoxShape.circle, - ), - ), + LegendDot(color: dotColor!), const SizedBox(width: AppSpacing.sm), ], Text( @@ -1407,7 +1440,7 @@ class _ReportImageCardState extends State<_ReportImageCard> { width: double.infinity, color: colors.surfaceContainer, alignment: Alignment.center, - child: const CircularProgressIndicator(), + child: const InlineLoading(size: 36), ); }, errorBuilder: (context, error, stackTrace) { diff --git a/lib/features/earthquake/presentation/pages/report_list_page.dart b/lib/features/earthquake/presentation/pages/report_list_page.dart index d52bd4952..575a5a0c4 100644 --- a/lib/features/earthquake/presentation/pages/report_list_page.dart +++ b/lib/features/earthquake/presentation/pages/report_list_page.dart @@ -163,13 +163,7 @@ class _ReportListPageState extends State { if (c.loadingMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), + child: Center(child: InlineLoading()), ); } return Padding( @@ -228,7 +222,7 @@ List groupReportsByTaipeiDay( /// Taipei calendar date of a UTC instant (date-only, comparable with `==`). @visibleForTesting DateTime taipeiCalendarDay(DateTime utc) { - final t = utc.toUtc().add(const Duration(hours: 8)); + final t = AppTime.taipei(utc); return DateTime(t.year, t.month, t.day); } @@ -327,7 +321,7 @@ class _ReportTile extends StatelessWidget { final theme = Theme.of(context); final colors = theme.colorScheme; final l10n = AppLocalizations.of(context); - final taipei = report.originTimeUtc.toUtc().add(const Duration(hours: 8)); + final taipei = AppTime.taipei(report.originTimeUtc); final stamp = DateFormat('HH:mm:ss').format(taipei); final intensity = Intensity.displayForReport( report.intensity, diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 48559de91..000b29bcf 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -17,6 +17,7 @@ import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/realtime_service.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/core/realtime/replay_clock.dart'; @@ -32,10 +33,12 @@ import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/camera_fit.dart'; import 'package:dpip/shared/map/geo_circle.dart'; +import 'package:dpip/shared/map/map_compass.dart'; import 'package:dpip/shared/map/map_station_labels.dart'; import 'package:dpip/shared/map/map_style.dart' show landLayerId; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/widgets/frosted_surface.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; @@ -248,6 +251,10 @@ class _ReplayMapState extends State<_ReplayMap> { RtsBoxGrid? _boxGrid; bool _boxActive = false; + /// Feeds the Flutter [MapCompass] needle — camera heading, ° clockwise from + /// north, kept in sync from [BaseMap.onCameraMove]. + final ValueNotifier _bearing = ValueNotifier(0); + @override void initState() { super.initState(); @@ -269,6 +276,7 @@ class _ReplayMapState extends State<_ReplayMap> { void dispose() { widget.rts.removeListener(_onRts); widget.tick.removeListener(_onTick); + _bearing.dispose(); super.dispose(); } @@ -276,6 +284,29 @@ class _ReplayMapState extends State<_ReplayMap> { _controller = controller; } + /// Re-points the camera north, keeping centre / zoom. Mirrors + /// [MapScaffold._resetNorth]: the needle is settled directly because a + /// programmatic move may not emit a final north-up camera event. + void _resetNorth() { + final controller = _controller; + if (controller == null) return; + final position = controller.cameraPosition; + if (position == null) return; + _bearing.value = 0; + unawaited( + controller.moveCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: position.target, + zoom: position.zoom, + bearing: 0, + tilt: 0, + ), + ), + ), + ); + } + Future _onStyleLoaded() async { final controller = _controller; if (controller == null) return; @@ -593,10 +624,30 @@ class _ReplayMapState extends State<_ReplayMap> { @override Widget build(BuildContext context) { - return BaseMap( - showUserLocation: false, - onMapCreated: _onMapCreated, - onStyleLoaded: () => unawaited(_onStyleLoaded()), + return Stack( + children: [ + Positioned.fill( + child: BaseMap( + showUserLocation: false, + compassEnabled: false, + onMapCreated: _onMapCreated, + onStyleLoaded: () => unawaited(_onStyleLoaded()), + onCameraMove: (position) => _bearing.value = position.bearing, + ), + ), + // North indicator, matching the map tab's Flutter [MapCompass] — + // parked at top-right, level with the page's back button. + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: MapCompass(bearing: _bearing, onPressed: _resetNorth), + ), + ), + ), + ], ); } } @@ -633,7 +684,7 @@ class _ReplayStatusBar extends StatelessWidget { final theme = Theme.of(context); final colors = theme.colorScheme; - final taipeiTime = clock.now().add(const Duration(hours: 8)); + final taipeiTime = AppTime.taipei(clock.now()); final timeText = DateFormat('HH:mm:ss').format(taipeiTime); final (Color dot, String? statusWord) = switch (rts.status) { @@ -662,11 +713,7 @@ class _ReplayStatusBar extends StatelessWidget { ), child: Row( children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: dot, shape: BoxShape.circle), - ), + LegendDot(color: dot), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( diff --git a/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart b/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart index 4d67b3be1..d5707e3e6 100644 --- a/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart +++ b/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart @@ -12,7 +12,8 @@ import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/features/earthquake/domain/intensity.dart'; import 'package:dpip/features/earthquake/domain/report_list_query.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; -import 'package:flutter/foundation.dart'; +import 'package:dpip/shared/widgets/sheet_extent.dart'; +import 'package:dpip/shared/widgets/sheet_surface.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; @@ -55,9 +56,6 @@ Future showReportFilterSheet( const double _restExtent = 0.72; const double _minExtent = 0.45; const double _maxExtent = 1.0; -const double _atTopEpsilon = 0.02; - -bool _isAtTop(double extent) => extent >= _maxExtent - _atTopEpsilon; const double _magMin = 0; const double _magMax = 8; @@ -310,11 +308,8 @@ class _ReportFilterSheetState extends State<_ReportFilterSheet> { ReportFilterSheetResult(query: _build(), search: false), ); }, - child: NotificationListener( - onNotification: (notification) { - _extent.value = notification.extent; - return false; - }, + child: SheetExtentListener( + extent: _extent, child: DraggableScrollableSheet( expand: true, initialChildSize: _restExtent, @@ -332,7 +327,7 @@ class _ReportFilterSheetState extends State<_ReportFilterSheet> { // make dragging this sheet janky. final content = Column( children: [ - _SheetGrip(extent: _extent), + SheetGrip(extent: _extent), Expanded( child: ListView( controller: scrollController, @@ -613,7 +608,7 @@ class _ReportFilterSheetState extends State<_ReportFilterSheet> { valueListenable: _extent, child: content, builder: (context, extent, content) { - final atTop = _isAtTop(extent); + final atTop = sheetExtentIsAtTop(extent); final viewInsets = MediaQuery.viewInsetsOf(context); final topInset = widget.systemPadding.top; return AnnotatedRegion( @@ -629,7 +624,7 @@ class _ReportFilterSheetState extends State<_ReportFilterSheet> { ), // Sheet paints under the status bar; pad content only when // flush so mid-height has no top gap (same as StationSheet). - child: _SheetSurface( + child: SheetSurface( flushTop: atTop, child: Padding( padding: EdgeInsets.only( @@ -664,100 +659,6 @@ class _ReportFilterSheetState extends State<_ReportFilterSheet> { } } -/// Rounded panel mid-height; flush (no radius / shadow) when at the top. -class _SheetSurface extends StatelessWidget { - const _SheetSurface({required this.child, this.flushTop = false}); - - final Widget child; - final bool flushTop; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final radius = flushTop ? BorderRadius.zero : AppRadius.topSheet; - return DecoratedBox( - decoration: BoxDecoration( - color: colors.surface, - borderRadius: radius, - boxShadow: flushTop - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 16, - offset: const Offset(0, -2), - ), - ], - ), - child: ClipRRect(borderRadius: radius, child: child), - ); - } -} - -/// Grab handle — hidden once the sheet is flush with the top. -class _SheetGrip extends StatefulWidget { - const _SheetGrip({required this.extent}); - - final ValueListenable extent; - - @override - State<_SheetGrip> createState() => _SheetGripState(); -} - -class _SheetGripState extends State<_SheetGrip> { - late bool _show = !_isAtTop(widget.extent.value); - - @override - void initState() { - super.initState(); - widget.extent.addListener(_onExtent); - } - - @override - void didUpdateWidget(_SheetGrip old) { - super.didUpdateWidget(old); - if (old.extent != widget.extent) { - old.extent.removeListener(_onExtent); - widget.extent.addListener(_onExtent); - _onExtent(); - } - } - - @override - void dispose() { - widget.extent.removeListener(_onExtent); - super.dispose(); - } - - void _onExtent() { - final next = !_isAtTop(widget.extent.value); - if (next == _show) return; - // Defer — flipping grip mid-DSS-layout dirties parentData for semantics. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final show = !_isAtTop(widget.extent.value); - if (show != _show) setState(() => _show = show); - }); - } - - @override - Widget build(BuildContext context) { - if (!_show) return const SizedBox.shrink(); - final colors = Theme.of(context).colorScheme; - return Center( - child: Container( - margin: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - width: 40, - height: 4, - decoration: BoxDecoration( - color: colors.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - ); - } -} - class _SectionCard extends StatelessWidget { const _SectionCard({ required this.icon, diff --git a/lib/features/events/domain/event.dart b/lib/features/events/domain/event.dart index 6ec002df3..3efdf84a6 100644 --- a/lib/features/events/domain/event.dart +++ b/lib/features/events/domain/event.dart @@ -22,6 +22,19 @@ enum EventType { null => EventType.other, _ => EventType.weatherWarning, }; + + /// Semantic icon key for the shared [eventTypeIcon] resolver — kept a plain + /// string so this domain enum never imports Material. + String get iconKey => switch (this) { + EventType.earthquake => 'earthquake', + EventType.report => 'report', + EventType.intensity => 'intensity', + EventType.thunderstorm => 'thunderstorm', + EventType.heavyRain => 'heavy_rain', + EventType.weatherWarning => 'weather_warning', + EventType.tsunami => 'tsunami', + EventType.other => 'other', + }; } /// One item in the events timeline — an earthquake report, weather warning, etc. diff --git a/lib/features/events/presentation/pages/events_page.dart b/lib/features/events/presentation/pages/events_page.dart index 2e05b28d4..79fa47137 100644 --- a/lib/features/events/presentation/pages/events_page.dart +++ b/lib/features/events/presentation/pages/events_page.dart @@ -1,7 +1,9 @@ import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/events/presentation/widgets/event_timeline.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; +import 'package:dpip/shared/widgets/empty_view.dart'; import 'package:dpip/shared/widgets/region_bar.dart'; import 'package:dpip/shared/widgets/region_pager.dart'; import 'package:dpip/shared/widgets/region_swipe_area.dart'; @@ -10,7 +12,8 @@ import 'package:provider/provider.dart'; /// Disaster-event feed — a per-area timeline of events from the DPIP history /// feed. The region bar switches the area (a swipe pages the whole timeline); -/// each page fetches the events for its own township. +/// each page fetches the events for its own township. 所在地 without a GPS fix +/// says so instead of showing a feed (see [_pageAt]). /// /// The feed re-fetches whenever the tab is opened or the app returns from the /// background ([RefreshOnAppear]): a hazard list is only worth reading if it is @@ -46,14 +49,7 @@ class _EventsPageState extends State { child: Column( children: [ const RegionBar(), - Expanded( - child: RegionPager( - itemBuilder: (context, index) => EventTimeline( - regionCode: _codeAt(context, index), - refreshSignal: _refresh, - ), - ), - ), + Expanded(child: RegionPager(itemBuilder: _pageAt)), ], ), ), @@ -61,16 +57,32 @@ class _EventsPageState extends State { ); } - /// The township whose events the page at [index] shows — null for 全國, and - /// also for 所在地 with no GPS fix, which then falls back to the nationwide - /// feed rather than showing an empty thread for an unknown place. - static String? _codeAt(BuildContext context, int index) { + /// The body for the area at [index]. + /// + /// 所在地 with no GPS fix gets a notice, not a feed: the nationwide list would + /// be indistinguishable from "these are the events where you are", which in a + /// disaster app is the one thing it must not be mistaken for. Every other area + /// is a township code, or null for 全國. + Widget _pageAt(BuildContext context, int index) { + // `read`, not `watch`: [RegionPager] already watches the store, so a fix + // arriving rebuilds every page through it. final areas = context.read().areas; - if (index < 0 || index >= areas.length) return null; - return switch (areas[index]) { - NationwideArea() => null, - CurrentArea(:final code) => code, - SavedArea(:final code) => code, - }; + final area = index >= 0 && index < areas.length + ? areas[index] + : const NationwideArea(); + if (area is CurrentArea && area.code == null) { + return EmptyView( + icon: Icons.location_off_outlined, + message: AppLocalizations.of(context).regionCurrentUnavailable, + ); + } + return EventTimeline( + regionCode: switch (area) { + NationwideArea() => null, + CurrentArea(:final code) => code, + SavedArea(:final code) => code, + }, + refreshSignal: _refresh, + ); } } diff --git a/lib/features/events/presentation/widgets/event_timeline.dart b/lib/features/events/presentation/widgets/event_timeline.dart index 0cb91eece..4f0462776 100644 --- a/lib/features/events/presentation/widgets/event_timeline.dart +++ b/lib/features/events/presentation/widgets/event_timeline.dart @@ -2,6 +2,7 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/features/events/domain/event.dart'; import 'package:dpip/features/events/domain/event_repository.dart'; import 'package:dpip/shared/widgets/async_view.dart'; +import 'package:dpip/shared/widgets/event_icon.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -70,7 +71,7 @@ class _EventTile extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Connector( - icon: _iconFor(event.type), + icon: eventTypeIcon(event.type.iconKey), isFirst: isFirst, isLast: isLast, ), @@ -159,14 +160,3 @@ class _Connector extends StatelessWidget { ); } } - -IconData _iconFor(EventType type) => switch (type) { - EventType.earthquake => Icons.crisis_alert, - EventType.report => Icons.description_outlined, - EventType.intensity => Icons.graphic_eq, - EventType.thunderstorm => Icons.thunderstorm_outlined, - EventType.heavyRain => Icons.water_drop_outlined, - EventType.weatherWarning => Icons.warning_amber_rounded, - EventType.tsunami => Icons.tsunami_outlined, - EventType.other => Icons.notifications_outlined, -}; diff --git a/lib/features/home/home_providers.dart b/lib/features/home/home_providers.dart index 957cd5944..0480037b3 100644 --- a/lib/features/home/home_providers.dart +++ b/lib/features/home/home_providers.dart @@ -6,6 +6,7 @@ import 'package:dpip/features/home/presentation/home_reset_signal.dart'; import 'package:dpip/features/home/presentation/home_sheet_extent.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:dpip/shared/map/map_station_handoff.dart'; import 'package:provider/provider.dart'; @@ -22,6 +23,7 @@ List homeProviders() => [ ChangeNotifierProvider( create: (context) => HomeWeatherController( context.read(), + context.read(), context.read(), context.read(), ), diff --git a/lib/features/home/presentation/home_active_events_controller.dart b/lib/features/home/presentation/home_active_events_controller.dart index d3e9d7b9a..20496e4ee 100644 --- a/lib/features/home/presentation/home_active_events_controller.dart +++ b/lib/features/home/presentation/home_active_events_controller.dart @@ -59,8 +59,10 @@ class HomeActiveEventsController extends ChangeNotifier { _loadedKey = key; if (key == 'none') { // 所在地 selected but no GPS — nothing local to show; clear rather than - // silently substituting the national feed (Events tab does that fallback; - // home keeps the distinction visible). + // silently substituting the national feed, which would read as "these are + // the events where you are". (Home drops the section entirely in this + // state and the Events tab shows a notice; this keeps the data honest for + // whoever reads it.) _events = const []; _failure = null; notifyListeners(); diff --git a/lib/features/home/presentation/home_chrome.dart b/lib/features/home/presentation/home_chrome.dart index 235d9e77e..8453f49f1 100644 --- a/lib/features/home/presentation/home_chrome.dart +++ b/lib/features/home/presentation/home_chrome.dart @@ -1,11 +1,13 @@ +import 'package:dpip/features/home/presentation/home_sheet_extent.dart'; + /// Pure choreography of the Home chrome as the sheet climbs from collapsed (0) /// to full (1). /// /// Every layer that dresses the Home backdrop reads these same thresholds so the /// pieces move in step: the feature sheet reveals its weather, the shared region -/// bar blends then dismisses, and the app shell's bottom nav slides away. Kept in -/// core (and dependency-free) so `shared/` and `app/` can reach it without -/// importing the Home feature. +/// bar blends then dismisses, and the app shell's bottom nav slides away. Lives +/// in the Home feature but stays free of presentation deps (beyond +/// [HomeSheetExtent]) so `app/`'s shell can reach it without importing widgets. /// /// The sequence, by extent, is deliberately staged: /// - the bottom nav clears out first ([navDismiss], as the sheet commits up), @@ -34,6 +36,14 @@ class HomeChrome { /// Whether the weather backdrop should be playing (any of it revealed). static bool weatherActive(double extent) => extent > _weatherFrom; + /// Map dim progress, `0` (at rest) → `1` (by the time the weather starts to + /// reveal). The consumer scales this onto its own peak — the exposed map + /// darkens as the sheet climbs so its cards gain contrast against a quieter + /// backdrop; it tops out at [_weatherFrom] so the sky is never dimmed once it + /// takes over. + static double mapDim(double extent) => + _ramp(extent, HomeSheetExtent.rest, _weatherFrom); + /// Bottom-nav dismissal, `0` (shown) → `1` (slid away) — the first to clear. static double navDismiss(double extent) => _ramp(extent, _navFrom, _navTo); diff --git a/lib/features/home/presentation/home_weather_controller.dart b/lib/features/home/presentation/home_weather_controller.dart index 6a834f3ed..0a6d4b36b 100644 --- a/lib/features/home/presentation/home_weather_controller.dart +++ b/lib/features/home/presentation/home_weather_controller.dart @@ -1,4 +1,5 @@ -/// Loads the home header's realtime weather and the township 24h forecast. +/// Loads the home header's realtime weather, the township 24h forecast, and the +/// next-hour rain trend. library; import 'package:dpip/core/error/failure.dart'; @@ -6,30 +7,40 @@ import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; import 'package:dpip/features/weather/domain/weather_forecast.dart'; import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:flutter/foundation.dart'; -/// Fetches nearest-station realtime weather and the township hourly forecast for -/// the home sheet, following the selected [RegionStore] township. 全國 has no -/// point weather — [areaCode] is null and both feeds stay cleared. Reloads when -/// the area changes; the last values are kept while a new fetch runs so the -/// sheet never blanks. +/// Fetches nearest-station realtime weather, the township hourly forecast, and +/// the next-hour rain trend for the home sheet, following the selected +/// [RegionStore] township. 全國 has no point weather — [areaCode] is null and +/// all feeds stay cleared. Reloads when the area changes; the last values are +/// kept while a new fetch runs so the sheet never blanks. class HomeWeatherController extends ChangeNotifier { - HomeWeatherController(this._repository, this._regions, this._directory) { + HomeWeatherController( + this._repository, + this._hourTrendRepository, + this._regions, + this._directory, + ) { _regions.addListener(_sync); _sync(); } final MeteorWeatherRepository _repository; + final RainHourTrendRepository _hourTrendRepository; final RegionStore _regions; final TownDirectory _directory; WeatherRealtime? _weather; WeatherForecast? _forecast; + RainHourTrend? _hourTrend; bool _loading = false; Failure? _failure; Failure? _forecastFailure; + Failure? _hourTrendFailure; String? _loadedCode; /// The latest realtime observation, or null before the first load / at sea. @@ -38,6 +49,10 @@ class HomeWeatherController extends ChangeNotifier { /// The latest township 24h forecast, or null before the first load / no code. WeatherForecast? get forecast => _forecast; + /// The next-hour per-minute rain trend, or null before the first load / + /// no code. + RainHourTrend? get hourTrend => _hourTrend; + /// Whether a fetch is in flight. bool get loading => _loading; @@ -47,6 +62,9 @@ class HomeWeatherController extends ChangeNotifier { /// The last forecast failure, if the most recent forecast fetch failed. Failure? get forecastFailure => _forecastFailure; + /// The last rain-trend failure, if the most recent trend fetch failed. + Failure? get hourTrendFailure => _hourTrendFailure; + /// The township code driving the weather. Null for 全國 (no point weather) and /// for 所在地 without a GPS fix. String? get areaCode => switch (_regions.selected) { @@ -72,8 +90,10 @@ class HomeWeatherController extends ChangeNotifier { if (town == null || code == null) { _weather = null; _forecast = null; + _hourTrend = null; _failure = null; _forecastFailure = null; + _hourTrendFailure = null; notifyListeners(); return; } @@ -84,12 +104,15 @@ class HomeWeatherController extends ChangeNotifier { _loading = true; _failure = null; _forecastFailure = null; + _hourTrendFailure = null; notifyListeners(); final realtimeFuture = _repository.realtime(lat, lng); final forecastFuture = _repository.forecast(code); + final hourTrendFuture = _hourTrendRepository.hourTrend(code); final realtime = await realtimeFuture; final forecast = await forecastFuture; + final hourTrend = await hourTrendFuture; // Drop a superseded response if the user switched area mid-flight. if (_loadedCode != code) return; @@ -108,6 +131,13 @@ class HomeWeatherController extends ChangeNotifier { _forecast = null; }, ); + hourTrend.when( + ok: (value) => _hourTrend = value, + err: (failure) { + _hourTrendFailure = failure; + _hourTrend = null; + }, + ); notifyListeners(); } diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 44ed884a5..22b790633 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -1,14 +1,20 @@ +import 'dart:ui' show ImageFilter; + import 'package:dpip/app/theme/app_glass.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/core/weather/weather_condition.dart'; import 'package:dpip/features/home/presentation/home_active_events_controller.dart'; import 'package:dpip/features/home/presentation/home_chrome.dart'; import 'package:dpip/features/home/presentation/home_sheet_extent.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; import 'package:dpip/features/home/presentation/home_reset_signal.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:dpip/features/home/presentation/widgets/home_map_backdrop.dart'; import 'package:dpip/features/home/presentation/widgets/home_sheet.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; @@ -42,6 +48,16 @@ class _HomePageState extends State { HomeSheetExtent? _extent; HomeResetSignal? _resetSignal; + /// Peak blur sigma over the exposed map once the sheet is fully up — matches + /// the sheet's own frosted blur ([HomeSheet] at full opacity) so the map's + /// edge crossing under the sheet doesn't read as a hard transition. + static const double _mapBlurPeak = 24; + + /// Peak dim applied to the same scrim — darkens the exposed map so the + /// sheet's cards gain contrast against it. The frosted panel itself is normal + /// content and is never dimmed with it. + static const double _mapDimPeak = 0.35; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -87,7 +103,19 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - final weatherMode = context.watch().weatherMode; + final experimental = context.watch(); + final skyTimeMode = experimental.skyTimeMode; + // The station's live reading drives both the look and the continuous + // channels: the code picks the mode (keyframes/clouds), and the rain/snow + // intensity + humidity ride along as overrides where the code carries them. + final data = context.watch().weather?.data; + final backdrop = resolveBackdrop(experimental.weatherMode, data); + final weatherMode = backdrop.mode; + final rainIntensity = backdrop.rain; + final snowIntensity = backdrop.snow; + // Record fields don't promote, so bind the humidity before dividing. + final humidityPct = backdrop.humidity; + final humidity = humidityPct == null ? null : humidityPct / 100; final extent = context.read(); return RefreshOnAppear( tabIndex: HomePage.tabIndex, @@ -125,6 +153,38 @@ class _HomePageState extends State { child: const HomeMapBackdrop(), ), ), + // Blurs and dims the map backdrop as the sheet climbs + // ([HomeChrome.mapDim]), so the exposed map recedes behind the + // sheet's frosted chrome and its cards gain contrast. Ignored for + // pointers so the map tap still passes. + Positioned.fill( + child: IgnorePointer( + child: ValueListenableBuilder( + valueListenable: extent, + builder: (context, extentValue, _) { + final t = HomeChrome.mapDim(extentValue); + final sigma = t * _mapBlurPeak; + final dim = t * _mapDimPeak; + // The tree's shape never changes — no SizedBox/ImageFiltered + // swap at t=0, which would re-parent the subtree right over + // the map platform view at the exact edge the sheet starts + // climbing (the same re-parent flash as the sheet's sky). + // `enabled` makes the filter a no-op at rest without + // touching the tree. + return ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: sigma, + sigmaY: sigma, + ), + enabled: t > 0, + child: ColoredBox( + color: Colors.black.withValues(alpha: dim), + ), + ); + }, + ), + ), + ), // The one weather sheet — full-screen behind the region bar so its // weather fills up into (and past) the bar. Positioned.fill( @@ -145,6 +205,10 @@ class _HomePageState extends State { scrollController: scrollController, extent: extent, weatherMode: weatherMode, + skyTimeMode: skyTimeMode, + rainIntensity: rainIntensity, + snowIntensity: snowIntensity, + humidity: humidity == null ? null : humidity / 100, ), ), ), @@ -159,18 +223,28 @@ class _HomePageState extends State { top: 0, left: 0, right: 0, - child: - Selector( - selector: (_, sheetExtent) => ( - blend: HomeChrome.regionBlend(sheetExtent.value), - dismiss: HomeChrome.regionDismiss(sheetExtent.value), + // The bar sits directly on the raw sky, same as the sheet + // header — it needs the actual sky colour, not just the + // weather type, or its badge ink goes near-invisible on a + // clear night (see `skyIsLightFrom`). + child: ValueListenableBuilder( + valueListenable: SkyLutCache.panelAmbient, + builder: (context, sky, _) => + Selector< + HomeSheetExtent, + ({double blend, double dismiss}) + >( + selector: (_, sheetExtent) => ( + blend: HomeChrome.regionBlend(sheetExtent.value), + dismiss: HomeChrome.regionDismiss(sheetExtent.value), + ), + builder: (context, dials, _) => RegionBar( + blend: dials.blend, + dismiss: dials.dismiss, + skyIsLight: skyIsLightFrom(sky, weatherMode), + ), ), - builder: (context, dials, _) => RegionBar( - blend: dials.blend, - dismiss: dials.dismiss, - skyIsLight: weatherSkyIsLight(weatherMode), - ), - ), + ), ), ], ), @@ -179,3 +253,25 @@ class _HomePageState extends State { ); } } + +/// Resolves the home backdrop's inputs from the experimental force setting and +/// the station's live reading. +/// +/// A forced [forced] mode shows exactly that authored look — the live code's +/// continuous channels (rain/snow intensity, humidity) belong to the +/// real-conditions path only. Otherwise forcing 晴天 while the code says 有雨 +/// would render sunny keyframes with rain drops on top. [humidity] stays on the +/// API's 0..100 scale; the sheet divides by 100 when it feeds the sky. +({WeatherMode mode, double? rain, double? snow, int? humidity}) resolveBackdrop( + WeatherMode forced, + WeatherRealtimeData? data, +) { + final live = forced == WeatherMode.auto; + final code = data?.weatherCode ?? 0; + return ( + mode: live ? weatherModeFor(code) : forced, + rain: live ? weatherRainIntensity(code) : null, + snow: live ? weatherSnowIntensity(code) : null, + humidity: live ? data?.humidity : null, + ); +} diff --git a/lib/features/home/presentation/widgets/forecast_weather_visual.dart b/lib/features/home/presentation/widgets/forecast_weather_visual.dart deleted file mode 100644 index 73aa887a3..000000000 --- a/lib/features/home/presentation/widgets/forecast_weather_visual.dart +++ /dev/null @@ -1,42 +0,0 @@ -/// Icon + accent for a forecast weather condition. -/// -/// l10n-ignore-file: CJK substrings match API `weather` tokens (晴/雨/…), not -/// user-facing copy — the server labels are Traditional Chinese only. -library; - -import 'package:flutter/material.dart'; - -/// Maps a forecast point's [weather] text and [weatherCode] to a Material icon -/// and an optional accent colour. -/// -/// Legacy matched Chinese substrings in `weather` (晴/雨/雲/陰/雷/雪). Rewrite -/// prefers [weatherCode] hundreds (100 clear / 200 cloudy / 300 overcast) and -/// still falls back to the text for rain / thunder / snow, which the hundreds -/// band alone does not distinguish. -(IconData, Color?) forecastWeatherVisual( - String weather, - int weatherCode, - ColorScheme colors, -) { - if (weather.contains('雷')) { - return (Icons.thunderstorm_outlined, colors.tertiary); - } - if (weather.contains('雪')) { - return (Icons.ac_unit_outlined, colors.primary); - } - if (weather.contains('雨')) { - return (Icons.water_drop_outlined, colors.primary); - } - if (weather.contains('晴')) { - return (Icons.wb_sunny_outlined, colors.tertiary); - } - return switch (weatherCode ~/ 100) { - 1 => (Icons.wb_sunny_outlined, colors.tertiary), - 2 => (Icons.wb_cloudy_outlined, colors.onSurfaceVariant), - 3 => (Icons.cloud_outlined, colors.onSurfaceVariant), - _ => - weather.contains('雲') || weather.contains('陰') - ? (Icons.cloud_outlined, colors.onSurfaceVariant) - : (Icons.cloud_outlined, colors.onSurfaceVariant), - }; -} diff --git a/lib/features/home/presentation/widgets/home_active_events_section.dart b/lib/features/home/presentation/widgets/home_active_events_section.dart index 2e71aaafa..1c083854b 100644 --- a/lib/features/home/presentation/widgets/home_active_events_section.dart +++ b/lib/features/home/presentation/widgets/home_active_events_section.dart @@ -4,11 +4,12 @@ library; import 'package:dpip/app/theme/app_glass.dart'; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; -import 'package:dpip/core/settings/home_area.dart'; -import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/core/settings/weather_mode.dart'; import 'package:dpip/features/events/domain/event.dart'; import 'package:dpip/features/home/presentation/home_active_events_controller.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/event_icon.dart'; +import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -16,11 +17,17 @@ import 'package:provider/provider.dart'; /// Active (realtime) disaster notices under the home header. While the sheet is /// at rest the list sits flush on the sheet (no card fill); once flush /// full-screen it picks up the same glass card as the weather blocks above. +/// +/// Always shows *some* area's notices, so it is only mounted for an area we can +/// name — `HomeContent` drops it entirely for 所在地 with no GPS fix rather than +/// having this widget carry a "nowhere" state. class HomeActiveEventsSection extends StatelessWidget { const HomeActiveEventsSection({ super.key, this.reveal = 0, this.expanded = false, + this.sky, + this.weatherMode = WeatherMode.auto, }); /// Weather-backdrop reveal (0→1) — glass + lightened foregrounds. @@ -29,18 +36,36 @@ class HomeActiveEventsSection extends StatelessWidget { /// Sheet is flush full-screen — apply the glass card surface. final bool expanded; + /// The sky colour the card tints itself from — `SkyLutCache.panelAmbient`, or + /// null when no backdrop is running. The reference's card is a 20 % pane of the sky, + /// so without one there is nothing for it to be a pane *of* and it falls back + /// to an opaque plate. + final Color? sky; + + /// Backdrop sky mode — decides whether card ink goes dark or white as the + /// card dissolves into the sky. + final WeatherMode weatherMode; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - // Card / sheet ink — never lightened toward white (that fought the light - // glass plate). Header-over-sky contrast is handled separately. - final foreground = glassOnSurface(colors); - final secondary = glassOnSurfaceVariant(colors); + // Card ink follows the sky: once [reveal] dissolves the card to the reference's + // 20 % fill it is a pane of the sky, not a plate, and theme on-surface ink + // stops being legible. At rest (reveal 0) these resolve to the theme roles. + final skyIsLight = skyIsLightFrom(sky, weatherMode); + final foreground = glassOnSurface( + colors, + reveal: reveal, + skyIsLight: skyIsLight, + ); + final secondary = glassOnSurfaceVariant( + colors, + reveal: reveal, + skyIsLight: skyIsLight, + ); final controller = context.watch(); - final area = context.watch().selected; - final noGps = area is CurrentArea && area.code == null; final body = Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -62,24 +87,11 @@ class HomeActiveEventsSection extends StatelessWidget { ), ), ), - if (controller.loading) - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: secondary, - ), - ), + if (controller.loading) InlineLoading(size: 14, color: secondary), ], ), const SizedBox(height: AppSpacing.md), - if (noGps) - Text( - l10n.homeActiveEventsEmpty, - style: theme.textTheme.bodyMedium?.copyWith(color: secondary), - ) - else if (controller.failure != null && controller.events.isEmpty) + if (controller.failure != null && controller.events.isEmpty) Row( children: [ Expanded( @@ -115,7 +127,7 @@ class HomeActiveEventsSection extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( - color: glassSurface(colors, reveal), + color: glassSurface(colors, reveal, sky: sky), borderRadius: AppRadius.medium, ), child: Padding(padding: const EdgeInsets.all(AppSpacing.lg), child: body), @@ -149,7 +161,7 @@ class _ActiveEventRow extends StatelessWidget { shape: BoxShape.circle, ), child: Icon( - _iconFor(event.type), + eventTypeIcon(event.type.iconKey), size: 18, color: colors.onPrimaryContainer, ), @@ -197,14 +209,3 @@ class _ActiveEventRow extends StatelessWidget { ); } } - -IconData _iconFor(EventType type) => switch (type) { - EventType.earthquake => Icons.crisis_alert, - EventType.report => Icons.description_outlined, - EventType.intensity => Icons.graphic_eq, - EventType.thunderstorm => Icons.thunderstorm_outlined, - EventType.heavyRain => Icons.water_drop_outlined, - EventType.weatherWarning => Icons.warning_amber_rounded, - EventType.tsunami => Icons.tsunami_outlined, - EventType.other => Icons.notifications_outlined, -}; diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 37d3f33bf..3adc8c07d 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -5,10 +5,14 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/features/home/presentation/home_weather_controller.dart'; import 'package:dpip/features/home/presentation/widgets/home_active_events_section.dart'; import 'package:dpip/features/home/presentation/widgets/home_forecast_section.dart'; import 'package:dpip/features/home/presentation/widgets/home_rain_trend_section.dart'; import 'package:dpip/features/home/presentation/widgets/home_sheet_header.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/rain_on_card.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -20,9 +24,17 @@ import 'package:provider/provider.dart'; /// area slides only that panel (`_AreaSlide`); the list and its controller stay /// put, which is what keeps the sheet height stable across a switch. /// -/// Below the header: **active events** while collapsed; once flush full-screen -/// on a township, **rain trend** + **24h forecast**, then active events. 全國 -/// skips weather entirely (name + events only). +/// Below the header: **active events** while collapsed. Once flush full-screen +/// on a township, the header and the **rain trend** card share one block sized +/// to exactly fill the visible sheet ([_heroHeight]) — the header stays at the +/// top, the trend card sits flush against the bottom edge, and the open sky +/// animates in the gap between them instead of being crowded out by cards. The +/// **24h forecast** and active events sit below that block, off-screen until +/// the sheet is scrolled past the trend card; `HomeSheet` blurs the sky +/// backdrop in step with that same scroll so the list reads clearly once it +/// arrives. 全國 skips weather entirely (name + events only, no hero block); +/// 所在地 with no GPS fix shows the header's "can't locate you" notice **alone** +/// — no cards at all. class HomeContent extends StatelessWidget { const HomeContent({ super.key, @@ -61,56 +73,342 @@ class HomeContent extends StatelessWidget { Widget build(BuildContext context) { final store = context.watch(); final areaIndex = store.selectedIndex; - final showWeather = store.selected is! NationwideArea; - return ListView( - controller: scrollController, - padding: EdgeInsets.fromLTRB( - AppSpacing.lg, - topInset, - AppSpacing.lg, - // Clear the bottom nav (the body extends behind it — extendBody). - AppSpacing.lg + MediaQuery.paddingOf(context).bottom, - ), - children: [ - Opacity(opacity: handleOpacity, child: const HomeSheetHandle()), - // The grab handle above stays put; only the per-area panel slides. - _AreaSlide( - index: areaIndex, - child: Column( - key: ValueKey(areaIndex), - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - HomeSheetHeader( - reveal: reveal, - expanded: expanded, - weatherMode: weatherMode, - ), - const SizedBox(height: AppSpacing.lg), - // Collapsed: active events. Full-screen township: rain → forecast - // → events. 全國: events only (no point weather). - if (expanded && showWeather) ...[ - HomeRainTrendSection( - key: ValueKey('rain-$areaIndex'), - reveal: reveal, - ), - const SizedBox(height: AppSpacing.lg), - HomeForecastSection( - key: ValueKey('forecast-$areaIndex'), - reveal: reveal, - ), - const SizedBox(height: AppSpacing.lg), - ], - HomeActiveEventsSection( - key: ValueKey('active-$areaIndex'), - reveal: reveal, - expanded: expanded, - ), - ], + final area = store.selected; + // The hero block's bottom card slot swaps between the rain-trend chart and + // a compact 24h summary. Track the trend alone — not the whole controller — + // so a forecast/event update (which changes the trend not at all) doesn't + // rebuild this whole dashboard. + final hourTrend = context.select( + (controller) => controller.hourTrend, + ); + // 所在地 with no GPS fix has no township to report on. The header already + // says so; showing cards below it would only be rows of dashes and empty + // feeds pretending to be readings for a place we could not identify. + final located = area is! CurrentArea || area.code != null; + final showWeather = area is! NationwideArea && located; + // MediaQuery.paddingOf(context).bottom is *not* the device's real bottom + // safe area in this subtree — MainShell's Scaffold(extendBody: true) + // overrides it to the bottom-nav bar's reserved height, so this reads + // straight off the platform view (see the original `_build` doc). Computed + // once here, not per scroll tick. + final bottomSafeArea = MediaQueryData.fromView( + View.of(context), + ).padding.bottom; + // The sky re-bakes rarely; the scroll focus dial moves on every tick. The + // ListView's *shell* rebuilds only when the sky changes — the scroll-driven + // reveal/focus dial lives one level down, on the panel's own listenable, + // so a scroll tick rebuilds the cards it changes instead of the whole list. + return ListenableBuilder( + listenable: SkyLutCache.panelAmbient, + builder: (context, _) { + final sky = SkyLutCache.panelAmbient.value; + return _build( + context, + areaIndex, + located, + showWeather, + sky, + hourTrend, + bottomSafeArea, + ); + }, + ); + } + + /// Scroll distance over which the sheet's cards solidify out of their + /// sky-glass panes back into solid plates — matched to + /// `HomeSheet._ScrollBlurredWeather`'s own ramp so the backdrop dims and the + /// cards turn opaque in the same swipe. + static const double _focusRampExtent = 140; + + /// Focus dial for the content, `0` (resting) → `1` (scrolled past the hero). + /// See [HomeContent._focusRampExtent]. + static double _focus(double offset) => + (offset / _focusRampExtent).clamp(0.0, 1.0); + + Widget _build( + BuildContext context, + int areaIndex, + bool located, + bool showWeather, + Color? sky, + RainHourTrend? hourTrend, + double bottomSafeArea, + ) { + // A dry hour (empty `[]` response, or an all-zero series) hides the + // rain-trend card entirely; the hero block's bottom slot is taken over by + // a compact 24h forecast so the sheet still has a weather card to read at + // rest. Null (still loading / failed) keeps the trend card's own pane. + final dryTrend = hourTrend?.summary.grade == RainHourTrendGrade.none; + // The hero block needs the sheet's own live pixel height, which only a + // LayoutBuilder can give — MediaQuery reports the *screen*, not the + // sheet's current size mid-expand. Null outside the hero layout + // (collapsed, or nothing to anchor a hero to) doubles as the switch + // between the header's two homes below, and as a fallback if the incoming + // height is ever unbounded (nothing upstream does that today, but an + // infinite SizedBox is a hard crash, not a bad-looking frame). + // + // heroHeight itself always fills the *entire* viewport (topInset to its + // bottom edge) rather than stopping short of the safe area — the safe + // area is reserved *inside* the block instead (see the Padding around + // the hero Column below). Stopping short here, as an earlier version of + // this did, left the block's declared bottom edge short of the screen's + // own bottom edge, and whatever the *next* list item happened to be — + // the AppSpacing.lg spacer before the forecast section, or the forecast + // card itself once the spacer was too short to cover the gap — was free + // to paint into that leftover strip. Filling the whole viewport here + // guarantees nothing after the hero block is ever visible at rest, + // regardless of how the safe area compares to that spacer's size. + return LayoutBuilder( + builder: (context, constraints) { + final rawHeight = constraints.maxHeight; + final heroHeight = expanded && showWeather && rawHeight.isFinite + ? _heroHeight(rawHeight) + : null; + return ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + topInset, + AppSpacing.lg, + AppSpacing.lg + bottomSafeArea, ), - ), - ], + children: [ + Opacity(opacity: handleOpacity, child: const HomeSheetHandle()), + // The grab handle above stays put; only the per-area panel slides. + _AreaSlide( + index: areaIndex, + // The scroll focus dial drives every card's reveal and the hero + // block's trailing reserve — one listenable, one builder — so a + // scroll tick rebuilds this panel's cards rather than the whole + // ListView shell around it (which rebuilds only when the sky + // re-bakes, in [build]). + child: ListenableBuilder( + listenable: scrollController, + builder: (context, _) { + final offset = scrollController.hasClients + ? scrollController.offset + : 0.0; + // The cards read as a pane of the sky only while the sky is + // the point (hero showing). Once the list scrolls, they + // solidify back into solid plates and their ink back onto + // the theme surface — a transparent 20 % sky-pane with + // sky-tuned ink is exactly what makes scrolled content hard + // to read, no matter how dimmed the backdrop behind it is. + final reveal = this.reveal * (1 - _focus(offset)); + return Column( + key: ValueKey(areaIndex), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (heroHeight != null) + SizedBox( + height: heroHeight, + // Only the trailing gap depends on scroll offset, so + // that is all this block's Padding re-reads per tick. + child: Padding( + // Deflates the tight SizedBox height so the Expanded + // gap between header and trend card shrinks by exactly + // this much and heroHeight itself — and with it the + // forecast/events fold below — never moves. Collapses + // to 0 as the sheet scrolls — see + // [_bottomGapRampExtent] on why that includes the + // safe area, not just the nicety gap on top of it. + padding: EdgeInsets.only( + bottom: _heroBottomGap(offset, bottomSafeArea), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RainOnCard( + // Text and icons, not a solid card face — a + // flat top-edge collision would pool water in + // every gap between glyphs. Silhouette + // rasterises the header itself and catches drops + // on its actual outline instead. + // + // Not gated — see [RainOnCard.gated]. The gate + // closes once the card has risen [_gateCloseDistance] + // above where it settled, which is exactly what + // pulling the sheet up does to this header: it + // starts most of a screen down (rest detent) and + // rides up with the drag. Gating it there would + // cut the header's water off the moment the sheet + // is dragged, and the low-water mark ([_restTopY]) + // pins it shut afterwards. The surrounding scroll + // view culls its paint once it truly scrolls out. + intensity: _cardRain(weatherMode), + opacity: reveal, + glass: false, + silhouette: true, + gated: false, + child: HomeSheetHeader( + reveal: reveal, + expanded: expanded, + weatherMode: weatherMode, + sky: sky, + ), + ), + // The gap is the point — open sky between the two + // fixed edges, not a forgotten card. `HomeSheet` + // blurs it back in once the scroll below carries + // the trend card past the top. + const Expanded(child: SizedBox.shrink()), + if (dryTrend) + // A dry hour has no rain chart, so the forecast + // card itself takes the hero slot — a one-glance + // summary (title + hour chips) at rest that + // grows into the full card as the sheet is + // pulled up, and rains on the card like the + // trend card does. + RainOnCard( + intensity: _cardRain(weatherMode), + opacity: reveal, + child: HomeForecastSection( + key: ValueKey('forecast-hero-$areaIndex'), + expansion: _forecastExpansion(offset), + reveal: reveal, + sky: sky, + weatherMode: weatherMode, + ), + ) + else + HomeRainTrendSection( + key: ValueKey('rain-$areaIndex'), + reveal: reveal, + sky: sky, + weatherMode: weatherMode, + // Only this card gets wet. The reference rains + // on the card, not the page, and the effect + // belongs on the one block that is *about* + // rain. The grade is the weather's; [reveal] + // gates visibility separately inside the + // section, the way the engine's scene alpha + // does — multiplying them together washed the + // water down to a third of its opacity. + rain: _cardRain(weatherMode), + ), + ], + ), + ), + ) + else + HomeSheetHeader( + reveal: reveal, + expanded: expanded, + weatherMode: weatherMode, + sky: sky, + ), + const SizedBox(height: AppSpacing.lg), + // Collapsed, or nothing to anchor a hero to: active events + // only. Full-screen township: the hero above, then forecast + // → events reached by scrolling past it. 全國: events only + // (no point weather). A dry hour carries its one forecast + // card in the hero itself, so nothing repeats below the fold. + if (heroHeight != null && !dryTrend) ...[ + HomeForecastSection( + key: ValueKey('forecast-$areaIndex'), + reveal: reveal, + sky: sky, + weatherMode: weatherMode, + ), + const SizedBox(height: AppSpacing.lg), + ], + if (located) + HomeActiveEventsSection( + key: ValueKey('active-$areaIndex'), + reveal: reveal, + expanded: expanded, + sky: sky, + weatherMode: weatherMode, + ), + ], + ); + }, + ), + ), + ], + ); + }, ); } + + /// Height of the hero block (header + open sky + rain trend card): the + /// entire viewport below [topInset]. [viewportHeight] is the sheet's live + /// pixel height from the [LayoutBuilder] in [_build], not derived from + /// [MediaQuery] — the sheet is mid-expand (not yet at the screen's full + /// height) for the first several frames after [expanded] turns true, and + /// this has to track that, not the eventual rest state. + /// + /// Deliberately fills the whole viewport rather than stopping short of the + /// safe area — see [_build]'s comment on why the safe area is reserved + /// *inside* the block instead of by shrinking this. + /// + /// Floored at 0 only as a degenerate-constraint guard. On every device this + /// app ships to, the header and trend card together are a fraction of a + /// screen's height, so the clamp never actually decides the block's size — + /// it exists so a pathological constraint shrinks the gap to nothing rather + /// than handing [SizedBox] a negative height. + double _heroHeight(double viewportHeight) { + final height = viewportHeight - topInset; + return height < 0 ? 0 : height; + } + + /// Resting-state breathing room between the trend card's own bottom edge + /// and the safe area below it, on top of the safe area itself, so the card + /// doesn't read as glued to the very edge of the screen. + static const double _restBottomGap = AppSpacing.xl; + + /// Scroll distance over which the hero block's *entire* trailing reserve — + /// [_restBottomGap] and the safe area alike — collapses to 0. Short on + /// purpose, same reasoning as `HomeSheet._ScrollBlurredWeather`'s own ramp: + /// both are a resting-state concern, not something to keep paying for once + /// the sheet is actually moving. + /// + /// The safe area has to ramp away too, not just stay as a fixed floor under + /// [_restBottomGap] — it exists only because the trend card sits at the + /// physical bottom of the screen *at rest*. The moment the list scrolls, + /// the card is no longer there and nothing about the device's home + /// indicator applies to it anymore; holding that reserve open regardless + /// just leaves it as dead space between the trend card and the forecast + /// card once scrolled — taller than [AppSpacing.lg], the gap every other + /// pair of cards on the second page actually uses, and visibly + /// inconsistent with them. + static const double _bottomGapRampExtent = 32; + + /// Current size of the hero block's trailing reserve — [_restBottomGap] + /// plus [bottomSafeArea] — for the live scroll [offset]. + static double _heroBottomGap(double offset, double bottomSafeArea) { + final rest = _restBottomGap + bottomSafeArea; + final t = (offset / _bottomGapRampExtent).clamp(0.0, 1.0); + return rest * (1 - t); + } + + /// Scroll distance over which the hero's forecast card grows from its + /// one-glance summary (title + hour chips) to the full card (sparkline + + /// detail band). Independent of the shorter resting-state ramps around it — + /// this one is the *point* of the gesture on a dry hour, so it deserves the + /// distance. + static const double _forecastExpandExtent = 200; + + /// Current growth of the hero forecast card for [offset]. + static double _forecastExpansion(double offset) => + (offset / _forecastExpandExtent).clamp(0.0, 1.0); + + /// How wet the rain-trend card gets for a given backdrop. + /// + /// These are positions on the reference's own weather-type ladder, not a free dial. + /// Both effects switch whole parameter sets by the weather-type enum, + /// and the ladder is counter-intuitive: **lighter rain means smaller, denser + /// beads and sparser edge water**. DPIP's plain rain backdrop is the + /// light-rain end of that ladder — which is what the shipped light-rain + /// capture shows — and only thunderstorm reaches the downpour band. Sitting + /// rain in the middle, as an earlier version did, gave it beads the reference keeps + /// for a storm and an edge-water band the reference never shows at that grade. + static double _cardRain(WeatherMode mode) => switch (mode) { + WeatherMode.rain => 0.3, + WeatherMode.thunderstorm => 0.85, + _ => 0.0, + }; } /// Slides its [child] in from the side when the area [index] changes, so a diff --git a/lib/features/home/presentation/widgets/home_forecast_section.dart b/lib/features/home/presentation/widgets/home_forecast_section.dart index b2a606fe5..7283fc5d5 100644 --- a/lib/features/home/presentation/widgets/home_forecast_section.dart +++ b/lib/features/home/presentation/widgets/home_forecast_section.dart @@ -2,15 +2,18 @@ library; import 'dart:math' as math; +import 'dart:ui' show lerpDouble; import 'package:dpip/app/theme/app_glass.dart'; import 'package:dpip/app/theme/app_motion.dart'; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/settings/weather_mode.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; -import 'package:dpip/features/home/presentation/widgets/forecast_weather_visual.dart'; +import 'package:dpip/core/weather/weather_condition.dart'; import 'package:dpip/features/weather/domain/weather_forecast.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -21,13 +24,41 @@ import 'package:provider/provider.dart'; /// rain chance %, [WeatherForecastPoint.temperature] = air temp °C. Layout is /// new: sparkline + selectable hour chips + a detail band for feels-like / /// humidity / wind (fields the final legacy strip hid). +/// +/// [expansion] animates between the one-glance summary (title + hour chips) +/// and the full card (with the temperature sparkline and detail band). The +/// hero block's card slot uses it to grow the single forecast card into its +/// complete form as the sheet is pulled up — the summary and the full card are +/// one widget, not two. class HomeForecastSection extends StatefulWidget { - const HomeForecastSection({super.key, this.reveal = 0}); + const HomeForecastSection({ + super.key, + this.reveal = 0, + this.sky, + this.weatherMode = WeatherMode.auto, + this.expansion = 1, + }); /// Weather-backdrop reveal (0→1) — drives glass card opacity only; ink stays /// theme on-surface (cards are light plates). final double reveal; + /// The sky colour the card tints itself from — `SkyLutCache.panelAmbient`, or + /// null when no backdrop is running. The reference's card is a 20 % pane of the sky, + /// so without one there is nothing for it to be a pane *of* and it falls back + /// to an opaque plate. + final Color? sky; + + /// Backdrop sky mode — decides whether card ink goes dark or white as the + /// card dissolves into the sky. + final WeatherMode weatherMode; + + /// How fully the card is revealed, `0` (title + hour chips only) → `1` (the + /// full card, with the temperature sparkline and the feels-like detail + /// band). The hero slot drives this from the sheet's scroll; cards outside + /// the hero sit at 1. + final double expansion; + @override State createState() => _HomeForecastSectionState(); } @@ -40,11 +71,21 @@ class _HomeForecastSectionState extends State { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; + final expansion = widget.expansion.clamp(0.0, 1.0); final controller = context.watch(); final reveal = widget.reveal; - final foreground = glassOnSurface(colors); - final secondary = glassOnSurfaceVariant(colors); - final cardColor = glassSurface(colors, reveal); + final skyIsLight = skyIsLightFrom(widget.sky, widget.weatherMode); + final foreground = glassOnSurface( + colors, + reveal: reveal, + skyIsLight: skyIsLight, + ); + final secondary = glassOnSurfaceVariant( + colors, + reveal: reveal, + skyIsLight: skyIsLight, + ); + final cardColor = glassSurface(colors, reveal, sky: widget.sky); final code = controller.areaCode; @@ -64,17 +105,8 @@ class _HomeForecastSectionState extends State { return _Shell( color: cardColor, child: SizedBox( - height: 168, - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: secondary, - ), - ), - ), + height: lerpDouble(120, 168, expansion)!, + child: Center(child: InlineLoading(color: secondary)), ), ); } @@ -145,20 +177,34 @@ class _HomeForecastSectionState extends State { ], ), const SizedBox(height: AppSpacing.md), - SizedBox( - height: 36, - child: CustomPaint( - painter: _TempSparklinePainter( - temps: temps, - selected: selected, - line: colors.primary, - fill: colors.primary.withValues(alpha: 0.18), - mark: foreground, + // The sparkline and detail band reveal with [expansion] — the summary + // card shows only the title + hour chips, and pulling the sheet up + // grows this same card into its full height. Clipped so the not-yet- + // revealed parts never bleed over the hour chips below. + ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: expansion, + child: Opacity( + opacity: expansion, + child: SizedBox( + height: 36, + width: double.infinity, + child: CustomPaint( + painter: _TempSparklinePainter( + temps: temps, + selected: selected, + line: colors.primary, + fill: colors.primary.withValues(alpha: 0.18), + mark: foreground, + ), + child: const SizedBox.expand(), + ), + ), ), - child: const SizedBox.expand(), ), ), - const SizedBox(height: AppSpacing.md), + SizedBox(height: AppSpacing.md * expansion), SizedBox( height: 108, child: ListView.separated( @@ -167,7 +213,7 @@ class _HomeForecastSectionState extends State { separatorBuilder: (_, _) => const SizedBox(width: AppSpacing.sm), itemBuilder: (context, index) { final p = points[index]; - final (icon, accent) = forecastWeatherVisual( + final (icon, accent) = weatherVisual( p.weather, p.weatherCode, colors, @@ -188,21 +234,31 @@ class _HomeForecastSectionState extends State { }, ), ), - const SizedBox(height: AppSpacing.md), - _DetailBand( - weather: point.weather, - time: point.time, - feelsLike: l10n.homeForecastFeelsLike( - point.apparentTemp.round().toString(), - ), - humidity: l10n.homeForecastHumidity(point.humidity.toString()), - wind: l10n.homeForecastWind( - point.wind.direction, - point.wind.beaufort.toString(), + ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: expansion, + child: Opacity( + opacity: expansion, + child: _DetailBand( + weather: point.weather, + time: point.time, + feelsLike: l10n.homeForecastFeelsLike( + point.apparentTemp.round().toString(), + ), + humidity: l10n.homeForecastHumidity( + point.humidity.toString(), + ), + wind: l10n.homeForecastWind( + point.wind.direction, + point.wind.beaufort.toString(), + ), + foreground: foreground, + secondary: secondary, + divider: secondary.withValues(alpha: 0.35), + ), + ), ), - foreground: foreground, - secondary: secondary, - divider: secondary.withValues(alpha: 0.35), ), ], ), diff --git a/lib/features/home/presentation/widgets/home_map_backdrop.dart b/lib/features/home/presentation/widgets/home_map_backdrop.dart index 51d5b52f2..fdcac1ff4 100644 --- a/lib/features/home/presentation/widgets/home_map_backdrop.dart +++ b/lib/features/home/presentation/widgets/home_map_backdrop.dart @@ -1,11 +1,13 @@ import 'dart:async'; +import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/core/geo/town_boundaries.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/home/presentation/home_reset_signal.dart'; import 'package:dpip/features/home/presentation/home_sheet_extent.dart'; +import 'package:dpip/shared/map/admin_outline.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/camera_fit.dart'; @@ -37,6 +39,18 @@ import 'package:provider/provider.dart'; /// mutations run through one serial queue and are guarded by a style **epoch** and /// per-concern **generations**; every add is idempotent (tolerant remove-then-add) /// so a reload race or a partial failure re-syncs on the next apply instead of +/// Which administrative borders the home backdrop draws for [code]. +/// +/// The county frame is always there — it is how a reader places what they are +/// looking at. The township mesh only joins it once a township is the subject: +/// the nationwide view is framed far enough out that 368 outlines collapse into +/// a grey wash over the echo, where the coarse frame is the only boundary still +/// carrying information. +List backdropBoundaries(String? code) => [ + if (code != null) AdminBoundary.town, + AdminBoundary.county, +]; + /// wedging. class HomeMapBackdrop extends StatefulWidget { const HomeMapBackdrop({super.key}); @@ -59,6 +73,12 @@ class _HomeMapBackdropState extends State /// viewport). Raise for a looser frame. static const double _frameExpansion = 1; + /// Half-width (degrees) of the GPS-centred frame for 所在地 — the current + /// fix sits at the centre of a small span that [_frameExpansion] then pushes + /// out. ~0.05° ≈ 5.5 km, township scale: the point is to show *where the + /// user is*, not to frame the whole township like a saved area does. + static const double _gpsSpan = 0.05; + MapLibreMapController? _controller; bool _styleReady = false; RegionStore? _regions; @@ -139,13 +159,6 @@ class _HomeMapBackdropState extends State _refreshRadar(); } - /// The selected township code, or null for the nationwide (whole-island) view. - String? get _selectedCode => switch (_regions?.selected) { - SavedArea(:final code) => code, - CurrentArea(:final code) => code, - _ => null, - }; - /// Frames the map on the selected township and highlights it — or fits the /// whole island for the nationwide view. Skips unchanged selections. Future _applySelection() async { @@ -159,25 +172,64 @@ class _HomeMapBackdropState extends State // ends. Frame into that band, not the whole map. final topInset = MediaQuery.paddingOf(context).top + RegionBar.height; final boundariesFuture = context.read>(); - final code = _selectedCode; + final selected = _regions?.selected; + final code = switch (selected) { + SavedArea(:final code) => code, + CurrentArea(:final code) => code, + _ => null, + }; final styleEpoch = _styleEpoch; + // 所在地 is a *place*, not a region: the current GPS fix is the point, so + // the camera centres on it (a fixed span around the fix) instead of framing + // the whole township the way a saved area does. + final gpsCentred = selected is CurrentArea; + final location = context.read(); // Nothing changed since the last apply (an unrelated RegionStore notify) — // don't re-add layers or re-run the camera. if (code == _appliedCode && styleEpoch == _appliedCodeEpoch) return; - final townCode = code == null ? null : int.tryParse(code); final gen = ++_selectionGen; + // The GPS fix decides 所在地's subject. No fix — services off, permission + // denied, or a fix timeout — degrades 所在地 to the nationwide view: the + // last-known township is not where the user is, so it must not frame or + // outline it. Falls back to the township box — or the whole island when no + // code is had — when a fix exists but the township isn't resolved. + // + // A *cached* fix, never a live read: `currentFix` can wait out a 10s GPS + // timeout, and while it did the backdrop sat on the base map's pre-layout + // camera (a tiny Taiwan) instead of framing. The cached fix answers + // instantly, and the position stream's own updates re-apply this when they + // land. A `CurrentArea` whose code is null means 所在地 has no township at + // all (GPS lost) — skip the lookup entirely and go nationwide. + GpsFix? fix; + if (gpsCentred && code != null) { + fix = await location.lastKnownFix(); + if (!mounted || gen != _selectionGen || styleEpoch != _styleEpoch) return; + } + final effective = gpsCentred && fix == null ? null : code; + List? bounds; - if (code != null) { + if (effective != null) { final boundaries = await boundariesFuture; if (!mounted || gen != _selectionGen || styleEpoch != _styleEpoch) return; - bounds = boundaries.boundsFor(code); + bounds = boundaries.boundsFor(effective); + } + + LatLngBounds frame; + if (gpsCentred && fix != null) { + frame = _latLngBounds([ + fix.lng - _gpsSpan, + fix.lat - _gpsSpan, + fix.lng + _gpsSpan, + fix.lat + _gpsSpan, + ]); + } else { + frame = _latLngBounds(bounds); } - final filter = _filterFor(townCode); // Normalise the fit box: a degenerate/non-finite township box would make // MapLibre's native camera fit abort the app (see camera_fit.dart). - final box = safeFitBounds(_latLngBounds(bounds)); + final box = safeFitBounds(frame); if (box == null) return; // Hand the framed geography to the map tab; it fits the same box into its // own visible band (different chrome, so a different camera). @@ -189,7 +241,7 @@ class _HomeMapBackdropState extends State final bottomInset = size.height * HomeSheetExtent.rest; _queue(() async { if (!mounted || gen != _selectionGen || styleEpoch != _styleEpoch) return; - await _addSelectedLayers(controller, filter); + await _restackOverlays(controller, code: effective); // Frame the box in the band above the resting sheet — computed in Dart // (boundsFitCamera) and applied as a plain centre+zoom, so a degenerate or // not-yet-laid-out viewport can't abort the app the way MapLibre's native @@ -205,11 +257,44 @@ class _HomeMapBackdropState extends State CameraUpdate.newLatLngZoom(fit.target, fit.zoom), ); } - _appliedCode = code; + _appliedCode = effective; _appliedCodeEpoch = styleEpoch; }); } + /// Re-adds everything that belongs **above** the echo, bottom-up. + /// + /// The radar layer is swapped on every refresh (remove-then-add), which lands + /// it back on top of the style — so these cannot simply be added once. Each + /// step removes its own copy first, making the whole thing idempotent and + /// safe to run from either trigger. + /// + /// The township mesh is drawn only when a township is the subject. The + /// whole-island framing is far enough out that 368 outlines collapse into a + /// grey wash over the echo, and there the county frame is the only boundary + /// carrying information. + Future _restackOverlays( + MapLibreMapController controller, { + required String? code, + }) async { + // The subject the frame is drawn for. Callers pass the *effective* code + // (GPS-aware): a deliberate `null` (GPS lost, or the nationwide view) must + // reach the outline layers unchanged — falling back to a stale code here + // would re-draw the previous township's mesh and purple frame. A radar + // refresh has no selection context, so it passes the last applied one. + final wanted = backdropBoundaries(code); + // Bottom-up, so a later add lands above an earlier one: the coarse county + // frame should win where the two run together along a coastline. + for (final boundary in [AdminBoundary.town, AdminBoundary.county]) { + await AdminOutline.remove(controller, boundary); + if (wanted.contains(boundary)) { + await AdminOutline.add(controller, boundary); + } + } + // The selection is the point of the backdrop — always last, always on top. + await _addSelectedLayers(controller, _filterFor(_townCode(code))); + } + /// (Re)adds the purple selection outline on the vector `town` layer (filtered /// by `CODE`), left on top so it sits above the radar/borders. Outline only — /// no fill, so the township's radar/base stays visible inside it. Idempotent: @@ -230,7 +315,8 @@ class _HomeMapBackdropState extends State ); } - /// Fetches the newest radar frame and swaps it in (below the borders). A + /// Fetches the newest radar frame and swaps it in, then restacks the + /// borders over it. A /// failed fetch, an unchanged frame, or a superseded refresh is a no-op, so /// the current echo stays. The swap is idempotent (tolerant remove-then-add) /// so a partial failure can't strand or duplicate the source. @@ -259,8 +345,13 @@ class _HomeMapBackdropState extends State _radarSource, _radarLayer, const RasterLayerProperties(rasterOpacity: _radarOpacity), - belowLayerId: outlineLayerId, + // On top of the base style's own borders, as on the radar map: those + // are hairlines tuned for a bare basemap and they wash out under the + // echo. [_restackOverlays] puts a legible set back over it. ); + // Re-stack the admin frame + selection over the fresh echo, keeping the + // subject that was last applied (a radar refresh has no selection context). + await _restackOverlays(controller, code: _appliedCode); _radarFrameOnMap = latest; _radarFrameEpoch = styleEpoch; }); @@ -290,6 +381,10 @@ class _HomeMapBackdropState extends State } } + /// The selected township as an int, or null for the nationwide view. + static int? _townCode(String? code) => + code == null ? null : int.tryParse(code); + /// A `CODE` equality filter — the impossible `-1` matches nothing, hiding the /// selection layers for the nationwide view. List _filterFor(int? code) => [ diff --git a/lib/features/home/presentation/widgets/home_rain_trend_section.dart b/lib/features/home/presentation/widgets/home_rain_trend_section.dart index c7449631a..8a870df24 100644 --- a/lib/features/home/presentation/widgets/home_rain_trend_section.dart +++ b/lib/features/home/presentation/widgets/home_rain_trend_section.dart @@ -1,153 +1,566 @@ -/// Home-sheet card: next-hour per-minute rainfall as a bar chart. +/// Home-sheet card: next-hour per-minute precipitation as a bar chart. library; +import 'dart:math' as math; + import 'package:dpip/app/theme/app_glass.dart'; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/features/home/presentation/home_weather_controller.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/rain_on_card.dart'; import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; +// intl 0.20 defines its own `TextDirection` class, which would shadow dart:ui's +// enum used by `TextPainter` — pull only `DateFormat` to keep them apart. +import 'package:intl/intl.dart' show DateFormat; +import 'package:provider/provider.dart'; /// Bar chart of the coming hour's rain (one rod per minute). Y has no mm labels; -/// the bottom axis shows a few clock ticks. Uses [RainHourTrend.placeholder] -/// until the forecast API is wired. +/// the bottom axis shows a few clock ticks. +/// +/// Reads the live trend from [HomeWeatherController] (which follows the +/// selected township). The title carries the data's update time — the server's +/// `start` stamp as Taipei wall clock. While no data is available the chart +/// area is a grey pane: a spinner during the first fetch, an error + retry row +/// on failure — never a fabricated chart. class HomeRainTrendSection extends StatelessWidget { - const HomeRainTrendSection({super.key, this.reveal = 0, this.trend}); + const HomeRainTrendSection({ + super.key, + this.reveal = 0, + this.trend, + this.rain = 0, + this.sky, + this.weatherMode = WeatherMode.auto, + }); /// Weather-backdrop reveal (0→1) — glass + lightened foregrounds. final double reveal; - /// Live trend when the API is ready; null → [RainHourTrend.placeholder]. + /// Live trend override (widget tests); null → the controller's own data. final RainHourTrend? trend; + /// The sky colour the card tints itself from — `SkyLutCache.panelAmbient`, or + /// null when no backdrop is running. The reference's card is a 20 % pane of the sky, + /// so without one there is nothing for it to be a pane *of* and it falls back + /// to an opaque plate. + final Color? sky; + + /// Backdrop sky mode — decides whether card ink goes dark or white as the + /// card dissolves into the sky. + final WeatherMode weatherMode; + + /// How hard it is raining *on this card* (0→1): droplets clinging to its face + /// and water splashing off its top edge. 0 leaves the card untouched. + final double rain; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - final foreground = glassOnSurface(colors); - final secondary = glassOnSurfaceVariant(colors); - final cardColor = glassSurface(colors, reveal); - final data = trend ?? RainHourTrend.placeholder(); - final maxY = data.mm.fold(0, (a, b) => a > b ? a : b); - // Keep a floor so an all-zero hour still draws a readable empty chart. - final chartMaxY = maxY <= 0 ? 1.0 : maxY * 1.15; - final barColor = colors.primary; - - return DecoratedBox( + final skyIsLight = skyIsLightFrom(sky, weatherMode); + final foreground = glassOnSurface( + colors, + reveal: reveal, + skyIsLight: skyIsLight, + ); + final secondary = glassOnSurfaceVariant( + colors, + reveal: reveal, + skyIsLight: skyIsLight, + ); + final cardColor = glassSurface(colors, reveal, sky: sky); + + final controller = context.watch(); + final data = trend ?? controller.hourTrend; + + // An empty `[]` response (or an all-zero series) means no rain this hour — + // the card has nothing to chart, so it disappears. The hero block's bottom + // card slot is taken over by the compact 24h forecast instead (see + // `HomeContent`); this guard keeps the section a no-op wherever else it is + // placed with a dry trend. + if (data?.isDry ?? false) { + return const SizedBox.shrink(); + } + + final Widget body; + if (data != null) { + body = _Chart( + data: data, + now: AppTime.utc, + barColor: colors.primary, + secondary: secondary, + ); + } else { + body = _NoData( + loading: controller.loading, + failure: controller.hourTrendFailure, + secondary: secondary, + onRetry: controller.refresh, + ); + } + + // The one-line reading under the title — intensity and whether the rain + // keeps up through the hour. Nothing for an all-dry hour. + String? subtitle; + if (data != null) { + final s = data.summary; + subtitle = switch (s.grade) { + RainHourTrendGrade.none => null, + RainHourTrendGrade.scattered => l10n.homeRainTrendScattered, + RainHourTrendGrade.light => + s.sustained + ? l10n.homeRainTrendLightSustained + : l10n.homeRainTrendLightStopping(s.stopInMinutes!), + RainHourTrendGrade.heavy => + s.sustained + ? l10n.homeRainTrendHeavySustained + : l10n.homeRainTrendHeavyStopping(s.stopInMinutes!), + }; + } + + return RainOnCard( + intensity: rain, + // The backdrop reveal is the scene alpha: the effect fades in with the + // sky and runs at full strength once the sheet is up. + opacity: reveal, + // glass defaults to true: the face gets the refracting droplets, same + // as every other card — gated off automatically once the card's own + // position gate closes, on its way up past the fold. + child: DecoratedBox( + decoration: BoxDecoration( + color: cardColor, + borderRadius: AppRadius.medium, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(Icons.water_drop_outlined, size: 18, color: secondary), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + l10n.homeRainTrendTitle, + style: theme.textTheme.titleSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + if (data != null) + Text( + l10n.homeRainTrendUpdated(_updatedClock(data)), + style: theme.textTheme.labelSmall?.copyWith( + color: secondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (subtitle != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + subtitle, + style: theme.textTheme.labelMedium?.copyWith( + color: secondary, + fontWeight: FontWeight.w500, + ), + ), + ], + const SizedBox(height: AppSpacing.md), + body, + ], + ), + ), + ), + ); + } + + /// Taipei wall-clock `HH:mm` of the trend's server `start` stamp — the + /// data's own moment, so it stays honest even if the fetch was a while ago. + static String _updatedClock(RainHourTrend data) { + final taipei = AppTime.taipei(data.startUtc); + return DateFormat('HH:mm').format(taipei); + } +} + +/// Grey pane replacing the chart while no trend data is available: the same +/// 120px band the chart would occupy, tinted by the card's surface role. +class _NoData extends StatelessWidget { + const _NoData({ + required this.loading, + required this.failure, + required this.secondary, + required this.onRetry, + }); + + final bool loading; + final Failure? failure; + final Color secondary; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final Widget child; + if (loading) { + child = InlineLoading(color: secondary); + } else if (failure != null) { + child = Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context).homeForecastEmpty, + style: theme.textTheme.bodyMedium?.copyWith(color: secondary), + ), + ), + TextButton( + onPressed: onRetry, + child: Text(AppLocalizations.of(context).commonRetry), + ), + ], + ); + } else { + child = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off_outlined, size: 22, color: secondary), + const SizedBox(height: AppSpacing.xs), + Text( + AppLocalizations.of(context).homeRainTrendNoData, + style: theme.textTheme.labelMedium?.copyWith(color: secondary), + ), + ], + ); + } + return Container( + height: 120, decoration: BoxDecoration( - color: cardColor, + color: colors.surfaceContainerHighest.withValues(alpha: 0.55), borderRadius: AppRadius.medium, ), - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.lg, - AppSpacing.lg, - AppSpacing.md, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon(Icons.water_drop_outlined, size: 18, color: secondary), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - l10n.homeRainTrendTitle, - style: theme.textTheme.titleSmall?.copyWith( - color: foreground, - fontWeight: FontWeight.w700, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Center(child: child), + ); + } +} + +/// The trend's bar chart: one rod per minute, no mm / numeric Y labels — height +/// alone carries intensity; the bottom axis labels minutes from now. +class _Chart extends StatelessWidget { + const _Chart({ + required this.data, + required this.now, + required this.barColor, + required this.secondary, + }); + + /// Fixed Y ceiling in mm — the API can forecast above this, so each bar is + /// clamped at [_maxMm]. The scale's top gridline is excluded by fl_chart's + /// `maxIncluded: false`, so the ceiling is one interval above the last guide + /// (35) to keep the 30 mm line visible. + static const double _maxMm = 35; + + /// X-axis ticks, minutes from [RainHourTrend.startSecond]. + static const List _ticks = [0, 10, 20, 30, 40, 50]; + + /// Chart height and the plot band it leaves for the X-axis labels (the chart + /// reserves `_titleBand` px of its height for bottom titles). The hatch + /// overlay covers only the plot band so it never bleeds under the labels. + static const double _chartHeight = 120; + static const double _titleBand = 22; + static const double _plotBand = _chartHeight - _titleBand; + + /// Pixels the label occupies, so the tick can be re-centred on its axis. + static double _textWidth(String text, TextStyle? style) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + return painter.width; + } + + final RainHourTrend data; + + /// Corrected "now" (UTC). The chart is anchored to it: bar i is the minute + /// `now + i`, so the data window [data.startUtc, +60 m) lands partway in and + /// minutes past its end carry no forecast (full-height grey rods). + final DateTime now; + + final Color barColor; + final Color secondary; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + // The chart runs from "now"; the data covers [start, start+60 m) only. + // Minutes outside that window have no forecast — [headEnd] bars before the + // data begins (a fetch newer than its stamp) and everything from + // [tailStart] on (the usual case: the hour ran out ahead of the clock). + final elapsed = now.difference(data.startUtc).inMinutes; + final headEnd = math.min(math.max(0, -elapsed), data.mm.length); + final tailStart = math.min( + math.max(data.mm.length - elapsed, 0), + data.mm.length, + ); + + double valueAt(int i) { + final source = i + elapsed; + if (source < 0 || source >= data.mm.length) return 0; + return math.min(data.mm[source], _maxMm); + } + + // Minutes outside the forecast window carry no forecast — render their rods + // invisible (toY 0) and paint one continuous grey band behind them via + // fl_chart's own range annotation. The band's x1/x2 are fl_chart axis + // values (a linear 0→1 map across the plot width), not bar indices — bar i + // sits at `w/2 + i·eachSpace` px with `eachSpace = (width − 60w)/59` under + // spaceBetween, so the width from LayoutBuilder converts index → axis value. + bool hasDataAt(int i) => i >= headEnd && i < tailStart; + + final chart = LayoutBuilder( + builder: (context, constraints) { + const w = 2.5; + final width = constraints.maxWidth; + // Under spaceBetween every bar's centre sits `w/2 + k·(w + eachSpace)` + // px — the bar's own width counts into the distribution, so adjacent + // left edges are `(width − w)/(N−1)` apart. Left edge of bar [k] as a + // 0→1 axis value: + double barLeftEdge(int k) => + k * (width - w) / ((data.mm.length - 1) * width); + final bar = BarChart( + BarChartData( + minY: 0, + maxY: _maxMm, + alignment: BarChartAlignment.spaceBetween, + groupsSpace: 0, + barGroups: [ + for (var i = 0; i < data.mm.length; i++) + BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: hasDataAt(i) ? valueAt(i) : 0, + width: w, + color: hasDataAt(i) ? barColor : Colors.transparent, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(1), + ), ), - ), + ], ), + ], + // Minutes outside the forecast window are one continuous grey band + // — drawn by fl_chart as a vertical range annotation, aligned to the + // exact left/right edges of the missing bars. The hatch is painted + // by a second, clipped overlay (below) that reuses the same edges. + rangeAnnotations: RangeAnnotations( + verticalRangeAnnotations: [ + if (headEnd > 0) + VerticalRangeAnnotation( + x1: 0, + x2: barLeftEdge(headEnd), + color: secondary.withValues(alpha: 0.12), + ), + if (tailStart < data.mm.length) + VerticalRangeAnnotation( + x1: barLeftEdge(tailStart), + x2: 1, + color: secondary.withValues(alpha: 0.12), + ), ], ), - const SizedBox(height: AppSpacing.md), - SizedBox( - height: 120, - child: BarChart( - BarChartData( - minY: 0, - maxY: chartMaxY, - alignment: BarChartAlignment.spaceBetween, - groupsSpace: 0, - barGroups: [ - for (var i = 0; i < data.mm.length; i++) - BarChartGroupData( - x: i, - barRods: [ - BarChartRodData( - toY: data.mm[i], - width: 2.5, - color: barColor, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(1), - ), + // A solid X axis, plus 10 / 20 / 30 mm dashed guides — the fixed + // Y ceiling makes them land on stable fractions of the chart + // height. The 0 line is suppressed: it sits flush on the X axis, + // where it would read as an axis rule rather than a guide. + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 10, + getDrawingHorizontalLine: (value) => value == 0 + ? FlLine(color: Colors.transparent, strokeWidth: 0) + : FlLine( + color: secondary.withValues(alpha: 0.35), + strokeWidth: 1, + dashArray: const [4, 4], + ), + ), + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide( + color: secondary.withValues(alpha: 0.4), + width: 1, + ), + ), + ), + barTouchData: const BarTouchData(enabled: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + // No mm / numeric Y labels — height alone carries intensity. + leftTitles: const AxisTitles(), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 22, + interval: 1, + getTitlesWidget: (value, meta) { + final minute = value.round(); + if (!_ticks.contains(minute)) { + return const SizedBox.shrink(); + } + final label = minute == 0 + ? l10n.mapTimelineNow + : l10n.homeRainTrendMinute(minute); + final style = Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: secondary); + // fl_chart centres every title widget on its tick's axis + // position, so a bare [tick, label] row would push the tick + // left of the bar (X=0's lands off the chart). Mirroring the + // label width on the left re-centres the tick on the axis. + final labelWidth = _textWidth(label, style); + return Padding( + padding: EdgeInsets.only( + left: labelWidth + AppSpacing.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 1, + height: 6, + color: secondary.withValues(alpha: 0.5), ), + const SizedBox(width: AppSpacing.xs), + Text(label, style: style), ], ), - ], - gridData: const FlGridData(show: false), - borderData: FlBorderData(show: false), - barTouchData: const BarTouchData(enabled: false), - titlesData: FlTitlesData( - topTitles: const AxisTitles(), - rightTitles: const AxisTitles(), - // No mm / numeric Y labels — height alone carries intensity. - leftTitles: const AxisTitles(), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 22, - interval: 1, - getTitlesWidget: (value, meta) { - final i = value.round(); - // Ticks at start / +15 / +30 / +45 / +60. - if (i != 0 && - i != 15 && - i != 30 && - i != 45 && - i != 59) { - return const SizedBox.shrink(); - } - final minuteOffset = i == 59 ? 60 : i; - final label = _tickLabel( - data.startSecond, - minuteOffset, - ); - return Padding( - padding: const EdgeInsets.only(top: AppSpacing.xs), - child: Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: secondary, - ), - ), - ); - }, - ), - ), - ), + ); + }, ), ), ), + ), + ); + + // Diagonal hatch + a centred "no data" label over the grey band. The + // hatch is a plain overlay on top of the chart (the same x mapping the + // annotation uses — no horizontal margin in fl_chart, so + // `barLeftEdge × width` is the plot pixel), clipped to its own rect so + // no line escapes into the data area, and limited to the plot band so + // it never bleeds under the X-axis labels. + final headX = barLeftEdge(headEnd) * width; + final tailX = barLeftEdge(tailStart) * width; + return Stack( + children: [ + SizedBox.expand(child: bar), + if (headEnd > 0) + Positioned( + left: 0, + top: 0, + width: headX, + height: _plotBand, + child: _NoDataLabel( + color: secondary, + text: l10n.homeRainTrendNoData, + ), + ), + if (tailStart < data.mm.length) + Positioned( + left: tailX, + top: 0, + right: 0, + height: _plotBand, + child: _NoDataLabel( + color: secondary, + text: l10n.homeRainTrendNoData, + ), + ), ], - ), - ), + ); + }, + ); + + return SizedBox(height: _chartHeight, child: chart); + } +} + +/// "No data" label centred on the diagonal hatch. On bands too narrow for a +/// horizontal line (a tail only a few minutes wide) the text rotates to read +/// top-down instead. +class _NoDataLabel extends StatelessWidget { + const _NoDataLabel({required this.color, required this.text}); + + final Color color; + final String text; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.labelSmall?.copyWith( + color: color.withValues(alpha: 0.6), + fontWeight: FontWeight.w600, + ); + return LayoutBuilder( + builder: (context, constraints) { + final portrait = constraints.maxWidth < 44; + final label = Text(text, style: style, softWrap: false); + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint(painter: _HatchPainter(color)), + Center( + child: portrait + ? RotatedBox(quarterTurns: 1, child: label) + : label, + ), + ], + ), + ); + }, ); } +} + +/// Diagonal-grey hatch for the no-forecast minutes, painted over the chart's +/// grey range annotation. +class _HatchPainter extends CustomPainter { + const _HatchPainter(this.color); + + final Color color; - /// Taipei wall clock (`HH:mm`) for [startSecond] + [minuteOffset]. - static String _tickLabel(int startSecond, int minuteOffset) { - final wall = DateTime.fromMillisecondsSinceEpoch( - (startSecond + minuteOffset * 60) * 1000, - isUtc: true, - ).add(const Duration(hours: 8)); - final hh = wall.hour.toString().padLeft(2, '0'); - final mm = wall.minute.toString().padLeft(2, '0'); - return '$hh:$mm'; + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + final line = Paint() + ..color = color.withValues(alpha: 0.4) + ..strokeWidth = 1.2; + const step = 8.0; + for (var x = -size.height; x < size.width + size.height; x += step) { + canvas.drawLine(Offset(x, 0), Offset(x + size.height, size.height), line); + } } + + @override + bool shouldRepaint(covariant _HatchPainter oldDelegate) => + oldDelegate.color != color; } diff --git a/lib/features/home/presentation/widgets/home_sheet.dart b/lib/features/home/presentation/widgets/home_sheet.dart index 9cd7a6b57..ec2bf2d43 100644 --- a/lib/features/home/presentation/widgets/home_sheet.dart +++ b/lib/features/home/presentation/widgets/home_sheet.dart @@ -3,9 +3,10 @@ import 'dart:ui' show ImageFilter, lerpDouble; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/features/home/presentation/home_chrome.dart'; import 'package:dpip/features/home/presentation/home_sheet_extent.dart'; +import 'package:dpip/core/settings/sky_time_mode.dart'; import 'package:dpip/core/settings/weather_mode.dart'; import 'package:dpip/features/home/presentation/widgets/home_content.dart'; -import 'package:dpip/features/home/presentation/widgets/weather_sky_background.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/weather_sky_background.dart'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -38,6 +39,10 @@ class HomeSheet extends StatelessWidget { required this.scrollController, required this.extent, required this.weatherMode, + required this.skyTimeMode, + this.rainIntensity, + this.snowIntensity, + this.humidity, }); /// The sheet's detents, sourced from [HomeSheetExtent] so the value notifier's @@ -67,6 +72,20 @@ class HomeSheet extends StatelessWidget { /// Which weather look the backdrop renders. final WeatherMode weatherMode; + /// Forced time of day for the backdrop, from experimental settings. + final SkyTimeMode skyTimeMode; + + /// Live rain intensity override for the backdrop, or null for the look's own + /// default — from the station's CWB code ([weatherRainIntensity]). + final double? rainIntensity; + + /// Live snow intensity override, or null for the look's default. + final double? snowIntensity; + + /// Live relative humidity 0..1 for the atmosphere blend, or null for the + /// backdrop's own default. + final double? humidity; + // The sheet floors at [restExtent], so surface opacity only ever ramps from // its resting translucency up to fully opaque as it climbs to full. double _surfaceAlpha(double e) { @@ -129,9 +148,32 @@ class HomeSheet extends StatelessWidget { IgnorePointer( child: Opacity( opacity: weatherOpacity, - child: WeatherSkyBackground( - mode: weatherMode, - active: HomeChrome.weatherActive(e), + child: _ScrollBlurredWeather( + scrollController: scrollController, + // The sky freezes the instant the list scrolls: the + // blur/dim it is about to be put under masks the hold, + // and holding the last frame lets the blur layer below + // cache instead of re-rendering the whole backdrop shader + // on every scroll tick. The state's own clock and ticker + // stop together (see `_syncRunning`), so when the list + // returns to the top the animation resumes where it left + // off — no jump. + child: ListenableBuilder( + listenable: scrollController, + builder: (context, _) { + final scrolled = + scrollController.hasClients && + scrollController.offset > 0; + return WeatherSkyBackground( + mode: weatherMode, + rainIntensity: rainIntensity, + snowIntensity: snowIntensity, + humidity: humidity ?? 0.65, + timeMode: skyTimeMode, + active: HomeChrome.weatherActive(e) && !scrolled, + ); + }, + ), ), ), ), @@ -150,6 +192,107 @@ class HomeSheet extends StatelessWidget { ((e - _flushFrom) / (maxExtent - _flushFrom)).clamp(0.0, 1.0); } +/// Blurs and dims [child] in step with [scrollController], so the sky reads as +/// depth of field behind the content once the sheet's list scrolls the hero's +/// rain trend card up past the fold — the counterpart to `HomeContent`'s hero +/// block, which leaves the sky untouched (and unblurred) for as long as the +/// trend card is still the last thing on screen. +/// +/// The dim rides the same scroll ramp as the blur: scrolling the list is what +/// shuts the cards' [RainOnGlass] refraction off (their position gate closes a +/// short distance into the gesture) and what solidifies `HomeContent`'s cards +/// out of their sky-glass back into solid plates — darkening the backdrop in +/// step keeps the space between cards quiet so the solid plates they arrive as +/// carry the reading. +/// +/// This is a *second*, independent blur from the one [HomeSheet.build] already +/// ramps off the sheet's `extent` — that one plays only while the sheet itself +/// is being dragged open and is already at ~0 by the time the content becomes +/// scrollable (`extent` is pinned at [HomeSheet.maxExtent] for the list to +/// scroll at all). Driving both off the same value would leave this one dead; +/// [scrollController] is the only signal that still moves once the sheet does +/// not. +/// +/// Listens directly on [scrollController] rather than through `HomeSheetExtent` +/// so only this small leaf repaints on every scroll tick — not the sheet's +/// whole frosted-chrome tree, which is the mistake `HomeSheet`'s own class doc +/// warns against for the drag case. +class _ScrollBlurredWeather extends StatelessWidget { + const _ScrollBlurredWeather({ + required this.scrollController, + required this.child, + }); + + final ScrollController scrollController; + final Widget child; + + /// Scroll distance over which blur reaches its peak. Short on purpose: the + /// trend card itself is most of a screen's scroll away, and holding the sky + /// crisp for that whole distance would make the blur feel disconnected from + /// the gesture that triggered it. This finishes within the first small + /// swipe, before the trend card has travelled far at all. + static const double _rampExtent = 140; + + /// Peak blur sigma — soft enough to read as out-of-focus depth, not a + /// frosted pane; well under the sheet's own drag blur (24, [HomeSheet.build]), + /// since this sits behind readable card content rather than standing in for + /// the sheet's surface. + static const double _maxSigma = 16; + + /// Peak backdrop dim on the same scroll ramp — enough to drop the sky out of + /// competition with the cards once the list is actually moving, without + /// turning the whole backdrop into a black hole at the top of the gesture. + static const double _maxDim = 0.45; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: scrollController, + child: child, + builder: (context, child) { + final offset = scrollController.hasClients + ? scrollController.offset + : 0.0; + final t = (offset / _rampExtent).clamp(0.0, 1.0); + // Quantised so the full-screen blur re-renders only on a few level + // changes through the gesture, not on every scroll tick — with the + // sky frozen behind it (see HomeSheet's build), the blur layer is + // cached between steps and reused as long as the list keeps moving. + // The dim rides the same ladder; a 4-step ramp reads as the same + // smooth fade. + final step = (t * 4).round() / 4; + final sigma = _maxSigma * step; + final dim = _maxDim * step; + // The tree's SHAPE never changes — a blur that toggles via `enabled` + // and an always-present transparent dim. Returning the bare child at + // rest (as an early version did) re-parents the sky's element the + // moment t crosses 0, which disposes and recreates the whole + // `WeatherSkyBackground` state: its LUT cache is null again, so the + // first blurred frame is the flat fallback colour until every shader + // re-decodes. That is the flash. RainOnGlass's doc warns about the + // same re-parenting for drags; here it cost a visible blink. + return Stack( + fit: StackFit.expand, + children: [ + ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + // No-op at rest, so no offscreen layer is composited — the sheet + // spends most of its time here, and ImageFiltered.enabled skips + // the filter without touching the tree shape. + enabled: step > 0, + child: child, + ), + // Dim sits *above* the blur so the backdrop darkens uniformly; the + // content layer renders above this whole stack in HomeSheet, so the + // cards are never dimmed with it. Transparent at rest. + ColoredBox(color: Colors.black.withValues(alpha: dim)), + ], + ); + }, + ); + } +} + /// The scrollable content ([HomeContent]) — forecast chart, sparkline, /// active-events list — re-deriving its own inputs from [HomeSheetExtent] /// instead of being handed them by the chrome's per-tick rebuild above. Each @@ -165,6 +308,7 @@ class _HomeContentLayer extends StatelessWidget { final ScrollController scrollController; final WeatherMode weatherMode; + final double regionBarInset; @override diff --git a/lib/features/home/presentation/widgets/home_sheet_header.dart b/lib/features/home/presentation/widgets/home_sheet_header.dart index 69a141be5..8acc755cb 100644 --- a/lib/features/home/presentation/widgets/home_sheet_header.dart +++ b/lib/features/home/presentation/widgets/home_sheet_header.dart @@ -6,6 +6,7 @@ import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/weather_mode.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; +import 'package:dpip/core/weather/weather_condition.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -28,6 +29,7 @@ class HomeSheetHeader extends StatelessWidget { this.reveal = 0, this.expanded = false, this.weatherMode = WeatherMode.auto, + this.sky, }); /// Weather-backdrop reveal (0→1) — drives sky-aware ink. @@ -36,15 +38,23 @@ class HomeSheetHeader extends StatelessWidget { /// Sheet is at (or past) the full-screen detent — drive larger type + layout. final bool expanded; - /// Which sky the backdrop is rendering — picks dark vs white ink. + /// Which sky the backdrop is rendering — the fallback [skyIsLightFrom] uses + /// before [sky] exists. final WeatherMode weatherMode; + /// The sky colour the header sits on — `SkyLutCache.panelAmbient`, or null + /// before the first bake. The header sits directly on the raw sky with no + /// card behind it, so this is the one place a wrong light/dark call is most + /// visible: a clear night sky and a clear noon sky are the same [weatherMode] + /// but opposite ends of the ink decision. + final Color? sky; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - final skyIsLight = weatherSkyIsLight(weatherMode); + final skyIsLight = skyIsLightFrom(sky, weatherMode); // Directly on the weather sky — not inside a glass card. Dark theme's // onSurface is white; on clear/fog daylight that must go dark with reveal. final foreground = inkOverWeather(colors, reveal, skyIsLight: skyIsLight); @@ -67,6 +77,13 @@ class HomeSheetHeader extends StatelessWidget { final temp = data?.temperature; final humidity = data?.humidity; final rain = data?.rain; + // The condition icon follows the station's code via the shared table; the + // cloud placeholder only shows before the first reading arrives. + final weather = data == null + ? null + : weatherVisual(data.weather, data.weatherCode, colors); + final conditionIcon = weather?.$1 ?? Icons.cloud_outlined; + final conditionAccent = weather?.$2 ?? secondary; // 全國 has no township weather — name only. 所在地 without GPS: say so // instead of a dashed reading row. @@ -140,9 +157,9 @@ class HomeSheetHeader extends StatelessWidget { Row( children: [ Icon( - Icons.cloud_outlined, + conditionIcon, size: iconSize, - color: secondary, + color: conditionAccent, ), const SizedBox(width: AppSpacing.md), Text.rich( @@ -195,9 +212,9 @@ class HomeSheetHeader extends StatelessWidget { child: Row( children: [ Icon( - Icons.cloud_outlined, + conditionIcon, size: iconSize, - color: secondary, + color: conditionAccent, ), const SizedBox(width: AppSpacing.md), Text.rich( diff --git a/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart new file mode 100644 index 000000000..0ec9f6e1f --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart @@ -0,0 +1,968 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +/// Rain landing on a card and pooling along its top edge — a CPU port of +/// The reference's **panel** water. +/// +/// This is not the same system as the water on the temperature digits +///, and porting that one by mistake produces the opposite effect. +/// The digits build a collision mesh from a bitmap and *throw water upward* off +/// whatever faces the sky. The panel does not: +/// +/// * The card is a plain `PolygonShape.setAsBox(halfWidth, 20)` — one big +/// rectangle, no mesh, no ray-casts. +/// * the emitter launches every drop **downward**: `Vec2((rand - 0.5) * spread, +/// -fallSpeed)`. Nothing is ever thrown up. +/// * The fixture is `restitution = 0`, `friction = 10`. Drops do not bounce and +/// barely slide — they stop dead where they land and pool. +/// +/// So what the eye reads as a splash on the card's upper edge is water falling +/// from above, being caught by the edge, and accumulating there until each +/// drop's lifetime expires. The engine spawns them 0.91 world units above the +/// edge, which at its scale is a third of the screen up. +/// +/// Scale: the reference builds the projection as `translate(-1,-1) · scale(2/aspect3, +/// 2/3)`, so the world is `3.0` units tall and **3 world units span the screen +/// height**. (The digit painter uses a different scale entirely — 40 units to +/// the screen *width* — which is why its numbers cannot be reused here.) +/// +/// The field only accumulates coverage. Turning that into water is +/// `shaders/weather/card_water.frag`, which is where the reference's threshold and +/// lighting live; drawing these particles directly would show the dots the +/// metaball is supposed to fuse. +/// +/// [update]'s [CardWaterSurface] parameter is a *third* variant, not the +/// digit system above: it keeps the panel's own falling-and-pooling model — +/// downward launch, no bounce, no mesh ray-casts — and only replaces the +/// single flat `topEdge` with a per-column one, so the pool follows whatever +/// silhouette is actually there (text, an icon) instead of a rectangle around +/// it. `topEdge` is still what a flat card uses; a surface is additive. +class CardWaterField { + /// The per-drop sprites: positive and negative halves of the signed normal. + /// + /// The reference shader writes the **signed** sphere normal into RGBA16F; 8-bit + /// surfaces are unsigned, so the sign is split across two buffers — + /// [spritePos] carries `(max(n,0), kernel)` and [spriteNeg] `max(−n, 0)` — + /// and the composite reassembles `Σn = pos − neg`. Mutable so the procedural + /// stand-ins can be swapped for sprites baked from the real extracted + /// textures the moment their async decode lands. + ui.Image spritePos; + ui.Image spriteNeg; + + /// Hard cap on live drops — `B.a.run()` clamps each world at 200. + final int capacity; + + final _Drops _p; + final math.Random _random; + + double _stepCarry = 0; + int _stepIndex = 0; + int _live = 0; + + CardWaterField({ + required this.spritePos, + required this.spriteNeg, + this.capacity = 200, + int seed = 7, + }) : _p = _Drops(capacity), + _random = math.Random(seed); + + /// Drops currently alive. + int get liveCount => _live; + + /// Live positions relative to the card's top-left; negative y is above it. + /// Runs the emitter alone, with no world step behind it. + /// + /// The emitter's output is not observable through [update]: the reference's timer + /// task emits and *then* steps, and a fresh drop crosses the 15 px spawn gap + /// in well under one 1/60 s step — so by the time a tick returns, the group + /// is already parked on the edge. This is the seam that lets a test see what + /// the emitter actually produced. + @visibleForTesting + void debugEmit({ + required double width, + required double screenHeight, + CardWaterPreset preset = CardWaterPreset.moderate, + double topEdge = 0, + CardWaterSurface? surface, + }) => _emit(width, screenHeight / _worldHeight, preset, topEdge, surface); + + @visibleForTesting + List get debugPositions => [ + for (var i = 0; i < _live; i++) Offset(_p.x[i], _p.y[i]), + ]; + + /// Live velocities, logical pixels per second. + @visibleForTesting + List get debugVelocities => [ + for (var i = 0; i < _live; i++) Offset(_p.vx[i], _p.vy[i]), + ]; + + /// Drops resting on or near the edge, within one particle diameter of it. + /// + /// There is no "settled" flag any more: every drop stays in the solver, which + /// is what lets the pile push new arrivals back out. + @visibleForTesting + int settledCount( + double screenHeight, { + double topEdge = 0, + CardWaterSurface? surface, + }) { + final band = 2 * _particleRadius * screenHeight / _worldHeight; + var n = 0; + for (var i = 0; i < _live; i++) { + final edge = surface?.heightAt(_p.x[i]) ?? topEdge; + if (_p.y[i] > edge - band) n++; + } + return n; + } + + /// Height of the particle render texture, in buffer pixels. + /// + /// The reference's FBO and its point sizes are both expressed against + /// this, so it is the unit that makes a drop the same fraction of the screen + /// on every device. + static const double bufferHeight = 800; + + /// The wall-clock period of the emitter's timer: the reference builds an + /// `Timer.schedule(task, 0, 20)` — **50 Hz**, not 60. + static const double emitterTick = 0.020; + + /// What one tick hands the solver: `world.step(0.016666668f, 6, 2, 5)`. + /// + /// The two together are the whole clock. A 20 ms timer advancing 1/60 s of + /// simulated time means the panel water runs at **0.833x real time**, which + /// is most of why the reference's drops crawl down the edge instead of snapping to + /// it, and lifetimes/spawn intervals are all counted in this simulated time. + static const double _worldStep = 1 / 60; + + /// How much room a field needs *above* the card, in logical pixels. + /// + /// Covers the short drop from the spawn line plus the spray thrown back up by + /// the solver, which goes higher than the spawn point does. + static double spawnHeadroom(double screenHeight) => + math.max(3 * spawnHeight * screenHeight / _worldHeight, 48); + + /// Height above the card's top edge that drops are born at, in world units. + /// + /// The emitter's spawn y is 0.91 for every grade, but that is an + /// **absolute** world y: the collision box sits 19.148 units below the panel + /// line with a half-height of 20, so the card's top face is 0.852 above it + /// and the spawn line sits only `0.91 - 0.852` above that. Reading 0.91 as a relative height — as an + /// earlier version did — starts drops a third of a screen up instead of a + /// centimetre, and the whole descent then happens off-card. + static const double spawnHeight = 0.91 - _boxTopOffset; + + /// `setAsBox(halfWidth, 20)`, centred 19.148 below the panel line. + static const double _boxTopOffset = 20.0 - 19.148; + + /// World units to the screen height. + static const double _worldHeight = 3.0; + + /// Advances the simulation by [dt] seconds. + /// + /// [width] is the card, [screenHeight] the scale the engine's world units are + /// defined against, and [topEdge] the y (in card space) drops land on — 0 is + /// the card's own top. [surface], when given, overrides [topEdge] per + /// column instead of everywhere at once — see [CardWaterSurface]. + void update( + double dt, { + required double width, + required double screenHeight, + required double intensity, + required CardWaterPreset preset, + double topEdge = 0, + double gravityScale = 1.0, + CardWaterSurface? surface, + }) { + if (dt <= 0 || width <= 0 || screenHeight <= 0) return; + // A backgrounded tab must not replay minutes of physics on resume. + final elapsed = math.min(dt, 0.1); + final unit = screenHeight / _worldHeight; + + // The timer is the clock, not the frame. Each 20 ms tick runs the emitter + // and then one world step, in that order — the reference's `TimerTask.run()` + // calls `m(particleSystem)` before `world.step(…)`. Driving the solver off + // the frame's own dt instead, as an earlier version did, runs the physics + // at 1.2x the reference's rate and makes every lifetime and spawn interval mean + // something different from what the presets say. + _stepCarry += elapsed; + final ticks = _stepCarry ~/ emitterTick; + _stepCarry -= ticks * emitterTick; + + for (var s = 0; s < ticks; s++) { + _stepIndex++; + // the emitter: one group every `interval` ticks, nothing in between. + if (_stepIndex % preset.interval == 0 && intensity > 0.001) { + _emit(width, unit, preset, topEdge, surface); + } + + // Five particle iterations per world step, each on `_worldStep/5`. + // Everything in `_solveIteration` is one iteration of + // `b2ParticleSystem::Solve` with the stages plain water never reaches + // (viscous, powder, tensile, rigid) removed. + const sub = _worldStep / _particleIterations; + for (var it = 0; it < _particleIterations; it++) { + _solveIteration(sub, unit, preset, topEdge, gravityScale, surface); + } + + for (var i = 0; i < _live; i++) { + _p.age[i] += _worldStep; + } + _compact(); + } + } + + /// `world.step(..., particleIterations = 5)`. + static const int _particleIterations = 5; + + /// One `b2ParticleSystem` particle iteration. + /// + /// The order is the engine's, and it is load-bearing: SolveForce (the + /// reaction SolveCollision stored last iteration) → SolveGravity → + /// contacts → ComputeWeight → SolvePressure → SolveDamping → LimitVelocity + /// → SolveCollision → integrate. An earlier version integrated first and + /// then clamped positions, which skipped the collision velocity fix + /// entirely. + /// + /// Two facts here were verified against the engine and both had been got + /// wrong: the fixture's **friction and restitution are never read by the + /// particle solver** (particle-vs-body response is pressure + damping + + /// collision, nothing else), and the body-contact distance is **signed** — + /// SolveCollision parks a particle's centre at `surface + b2_linearSlop`, + /// which is inside its own radius, so the contact survives instead of + /// vanishing the moment the drop lands. Clamping that distance at zero, as + /// an earlier version did, made the pile impossible. + /// + /// A parked particle's *body* weight is only `1 - 0.005/0.02` = 0.75, never + /// above 1. The `w > 1` SolvePressure needs comes from ComputeWeight's + /// **sum** — the body contact plus every neighbour within a diameter — which + /// is why water piles only once drops start landing on each other, and why + /// the pile is the port's one and only source of rebound. + void _solveIteration( + double dt, + double unit, + CardWaterPreset preset, + double topEdge, + double gravityScale, + CardWaterSurface? surface, + ) { + if (_live == 0) return; + final invDt = 1.0 / dt; + final diameter = 2 * _particleRadius * unit; + final slop = _b2LinearSlop * unit; + + // Resolved once per iteration, not re-looked-up per section: positions + // (and so each particle's column) are fixed until Integrate, at the very + // end. `double.infinity` for a column with nothing solid in it compares + // false against every finite y below, which is what lets the rest of this + // function stay unaware a surface is not flat at all — see + // [CardWaterSurface]. + final edge = Float32List(_live); + for (var i = 0; i < _live; i++) { + edge[i] = surface?.heightAt(_p.x[i]) ?? topEdge; + } + + // SolveForce — consumes what SolveCollision stored. + if (_hasForce) { + for (var i = 0; i < _live; i++) { + _p.vx[i] += dt * _particleInvMass * _p.fx[i]; + _p.vy[i] += dt * _particleInvMass * _p.fy[i]; + _p.fx[i] = 0; + _p.fy[i] = 0; + } + _hasForce = false; + } + + // SolveGravity. + final gravity = preset.gravity * gravityScale * unit; + for (var i = 0; i < _live; i++) { + _p.vy[i] += gravity * dt; + } + + // Contacts. Screen y grows downward, so "above the edge" is y < edge[i] + // and the signed surface distance is edge[i] - y. + final weight = Float32List(_live); + final bodyW = Float32List(_live); + for (var i = 0; i < _live; i++) { + final d = edge[i] - _p.y[i]; + if (d < diameter) { + bodyW[i] = 1.0 - d / diameter; // > 1 once the centre is inside + weight[i] += bodyW[i]; + } + } + final pairs = _buildPairs(diameter, weight); + + // SolvePressure. + final criticalVelocity = diameter * invDt; + final criticalPressure = _density * criticalVelocity * criticalVelocity; + final ppw = preset.pressure * criticalPressure; + final maxPressure = _b2MaxParticlePressure * criticalPressure; + final vpp = dt / (_density * diameter); + final accum = Float32List(_live); + for (var i = 0; i < _live; i++) { + accum[i] = math.min( + ppw * math.max(0.0, weight[i] - _b2MinParticleWeight), + maxPressure, + ); + } + // Body contacts first; `ppw * w` is the wall repulsion with no + // particle-particle counterpart. The normal points out of the surface, + // i.e. up the screen. + for (var i = 0; i < _live; i++) { + if (bodyW[i] == 0) continue; + _p.vy[i] -= vpp * bodyW[i] * (accum[i] + ppw * bodyW[i]); + } + for (final c in pairs) { + final f = vpp * c.w * (accum[c.a] + accum[c.b]); + _p.vx[c.a] -= f * c.nx; + _p.vy[c.a] -= f * c.ny; + _p.vx[c.b] += f * c.nx; + _p.vy[c.b] += f * c.ny; + } + + // SolveDamping. + final quad = 1.0 / criticalVelocity; + for (var i = 0; i < _live; i++) { + if (bodyW[i] == 0) continue; + // `UpdateBodyContacts` stores `contact.normal = -n`, pointing from the + // particle **into** the body — screen +y here, not up. With + // `v = bodyVelocity(0) - particleVelocity`, `vn = dot(v, n)` is `-vy`, + // so the gate below selects drops still *approaching* the face and the + // update reads `vy *= (1 - damp)`. + // + // Taking the normal as up instead flips both: the gate fires on drops + // already leaving, and `vy += damp * vn` becomes `vy *= (1 + damp)` — an + // amplifier of up to 1.75x per sub-step, five sub-steps a step. That is + // a geometric runaway, and it is what threw every drop clean out of the + // render buffer to be clipped at the same line, which reads as a bounce + // height that never varies. + final vn = -_p.vy[i]; + if (vn >= 0) continue; + final damp = math.max( + _dampingStrength * bodyW[i], + math.min(-quad * vn, 0.5), + ); + _p.vy[i] += damp * vn; + } + for (final c in pairs) { + final vn = + (_p.vx[c.b] - _p.vx[c.a]) * c.nx + (_p.vy[c.b] - _p.vy[c.a]) * c.ny; + if (vn >= 0) continue; + final damp = math.max(_dampingStrength * c.w, math.min(-quad * vn, 0.5)); + final f = damp * vn; + _p.vx[c.a] += f * c.nx; + _p.vy[c.a] += f * c.ny; + _p.vx[c.b] -= f * c.nx; + _p.vy[c.b] -= f * c.ny; + } + + // LimitVelocity — one diameter of travel per sub-step. + final maxV2 = criticalVelocity * criticalVelocity; + for (var i = 0; i < _live; i++) { + final v2 = _p.vx[i] * _p.vx[i] + _p.vy[i] * _p.vy[i]; + if (v2 <= maxV2) continue; + final sc = math.sqrt(maxV2 / v2); + _p.vx[i] *= sc; + _p.vy[i] *= sc; + } + + // SolveCollision. `b2PolygonShape::RayCast` returns false from inside, so + // this only catches a particle crossing *into* the box this sub-step; an + // already-embedded one is pushed back out by the pressure term alone. + for (var i = 0; i < _live; i++) { + if (_p.y[i] >= edge[i] || _p.vy[i] <= 0) continue; + final travel = _p.vy[i] * dt; + final frac = (edge[i] - _p.y[i]) / travel; + if (frac < 0 || frac > 1) continue; + final newX = _p.x[i] + frac * dt * _p.vx[i]; + final newY = edge[i] - slop; + final oldVx = _p.vx[i]; + final oldVy = _p.vy[i]; + _p.vx[i] = invDt * (newX - _p.x[i]); + _p.vy[i] = invDt * (newY - _p.y[i]); + _p.fx[i] += invDt * _particleMass * (oldVx - _p.vx[i]); + _p.fy[i] += invDt * _particleMass * (oldVy - _p.vy[i]); + _hasForce = true; + } + + // Integrate. + for (var i = 0; i < _live; i++) { + _p.x[i] += _p.vx[i] * dt; + _p.y[i] += _p.vy[i] * dt; + } + } + + bool _hasForce = false; + + /// LiquidFun's b2Settings, and the derived particle mass. + static const double _b2LinearSlop = 0.005; + static const double _b2MinParticleWeight = 1.0; + static const double _b2MaxParticlePressure = 0.25; + static const double _density = 1.0; + static const double _dampingStrength = 1.0; + + /// `GetParticleInvMass()` = 1/(density · particleStride²), stride = 0.75·d. + static const double _particleInvMass = + 1.0 / + (_density * + _b2ParticleStride * + 2 * + _particleRadius * + _b2ParticleStride * + 2 * + _particleRadius); + static const double _particleMass = 1.0 / _particleInvMass; + static const double _b2ParticleStride = 0.75; + + /// Contact pairs within one diameter, with their weights and normals. + List<_Contact> _buildPairs(double diameter, Float32List weight) { + final out = <_Contact>[]; + if (_live < 2) return out; + final buckets = >{}; + for (var i = 0; i < _live; i++) { + (buckets[_cellKey(_p.x[i], _p.y[i], diameter)] ??= []).add(i); + } + for (var i = 0; i < _live; i++) { + final cx = (_p.x[i] / diameter).floor(); + final cy = (_p.y[i] / diameter).floor(); + for (var ox = -1; ox <= 1; ox++) { + for (var oy = -1; oy <= 1; oy++) { + final cell = buckets[_key(cx + ox, cy + oy)]; + if (cell == null) continue; + for (final j in cell) { + if (j <= i) continue; + final dx = _p.x[j] - _p.x[i]; + final dy = _p.y[j] - _p.y[i]; + final d2 = dx * dx + dy * dy; + if (d2 >= diameter * diameter || d2 < 1e-12) continue; + final d = math.sqrt(d2); + final w = 1.0 - d / diameter; + weight[i] += w; + weight[j] += w; + out.add(_Contact(i, j, w, dx / d, dy / d)); + } + } + } + } + return out; + } + + /// the emitter's `particleSystem.setRadius(0.01)`, in world units. + static const double _particleRadius = 0.01; + + /// What one drop's full contribution is scaled to in the 8-bit accumulation + /// buffer (the 0x40 tint in [paintCoverage]). The composite threshold must + /// be scaled by the same factor; the decoded normal is a ratio and is not + /// affected. + static const double accumulationScale = 0x40 / 0xFF; + + static int _cellKey(double x, double y, double cell) => + _key((x / cell).floor(), (y / cell).floor()); + + /// Packs a cell coordinate into one int. The offset keeps negatives (drops + /// above the edge) on distinct keys. + static int _key(int cx, int cy) => (cx + 4096) * 8192 + (cy + 4096); + + void _compact() { + var write = 0; + for (var i = 0; i < _live; i++) { + // `destroyParticle` at lifetime expiry — abrupt, as the engine's is; the + // metaball threshold is what hides the pop for pooled water, and the + // per-group ±10% jitter keeps the deaths from beating in sync. + if (_p.age[i] >= _p.life[i]) continue; + if (write != i) _p.copy(i, write); + write++; + } + _live = write; + } + + /// One group, created the way `createParticleGroup` creates it. + /// + /// LiquidFun fills the group's `CircleShape` with particles on a + /// **world-aligned grid** whose stride is `0.75 × particleDiameter` + /// (`b2_particleStride`). The disc's random centre lands at a random phase + /// of that grid, so the particle count varies — 1 to 5, averaging + /// `πr²/stride²` ≈ 3 at the 0.015 radius, ≈ 2 at heavy's 0.012 — and with + /// it the size of the falling blob. Fixing the count per group, as an + /// earlier version did, made every falling drop identical. + /// + /// Per the emitter's caller, the **group** gets one lifetime jittered ±10% + /// (`life · ((rand−0.5)·0.2 + 1)`), which staggers deaths between groups; + /// and each **particle** gets its own tangential velocity — the spread is + /// not shared across the group. + void _emit( + double width, + double unit, + CardWaterPreset preset, + double topEdge, + CardWaterSurface? surface, + ) { + double originX; + double edgeAtOrigin; + if (surface != null) { + // A centre column with nothing solid under it has no finite height to + // spawn *above*, so the pick is restricted to columns that do — the + // group's own radius still lets it spill onto neighbouring empty + // columns and fall through them, same as any other drop would. + final x = surface.randomSolidX(_random); + if (x == null) return; // nothing solid anywhere — no group this tick + originX = x; + edgeAtOrigin = surface.heightAt(x); + } else { + originX = _random.nextDouble() * width; + edgeAtOrigin = topEdge; + } + final originY = edgeAtOrigin - spawnHeight * unit; + final radius = preset.groupRadius * unit; + final stride = 0.75 * 2 * _particleRadius * unit; + if (stride <= 0) return; + final groupLife = preset.life * ((_random.nextDouble() - 0.5) * 0.2 + 1.0); + final vy = preset.fallSpeed * unit; + + final minGx = ((originX - radius) / stride).ceil(); + final maxGx = ((originX + radius) / stride).floor(); + final minGy = ((originY - radius) / stride).ceil(); + final maxGy = ((originY + radius) / stride).floor(); + for (var gy = minGy; gy <= maxGy; gy++) { + for (var gx = minGx; gx <= maxGx; gx++) { + final px = gx * stride; + final py = gy * stride; + final dx = px - originX; + final dy = py - originY; + if (dx * dx + dy * dy > radius * radius) continue; + if (_live >= capacity) return; + final j = _live++; + _p.x[j] = px; + _p.y[j] = py; + _p.vx[j] = (_random.nextDouble() - 0.5) * preset.xSpread * unit; + _p.vy[j] = vy; + _p.age[j] = 0; + _p.life[j] = groupLife; + _p.seed[j] = _random.nextDouble(); + } + } + } + + /// Accumulates one half of each drop's contribution additively — the reference's + /// particle pass, run once with + /// [spritePos] and once with [spriteNeg] into separate buffers. + /// + /// Every drop draws at the same [dropSize] with no tint, fade, or size + /// jitter: the engine's `uPointSize` is one constant per grade and + /// The reference shader has no per-particle variation of any kind. The colour + /// multiplies in [CardWaterField.accumulationScale] so the 8-bit sum has + /// headroom for the stacked pool. + void paintCoverage( + Canvas canvas, { + required double dropSize, + required bool negative, + }) { + if (_live == 0) return; + final sprite = negative ? spriteNeg : spritePos; + + final transforms = Float32List(_live * 4); + final rects = Float32List(_live * 4); + final colors = Int32List(_live); + + final w = sprite.width.toDouble(); + final h = sprite.height.toDouble(); + final scale = dropSize / w; + + for (var i = 0; i < _live; i++) { + final o = i * 4; + transforms[o] = scale; + transforms[o + 1] = 0; + transforms[o + 2] = _p.x[i] - w * scale / 2; + transforms[o + 3] = _p.y[i] - h * scale / 2; + + rects[o] = 0; + rects[o + 1] = 0; + rects[o + 2] = w; + rects[o + 3] = h; + + // 0x40FFFFFF premultiplies to ×0.251 on *every* channel — exactly the + // accumulation scale. (An rgb of 0x40 here would premultiply twice.) + colors[i] = 0x40FFFFFF; + } + + canvas.drawRawAtlas( + sprite, + transforms, + rects, + colors, + BlendMode.modulate, + null, + Paint() + ..filterQuality = FilterQuality.medium + ..blendMode = BlendMode.plus, + ); + } + + /// Empties the field. + void clear() { + _live = 0; + _stepCarry = 0; + _stepIndex = 0; + } +} + +/// One of the reference's panel rain grades. +/// +/// The reference maps weather type 5/13 → 0, 6 → 1, 7 → 2, 8 → 3, i.e. light / +/// moderate / heavy / storm; 2 and 3 share a setting. Every value below is that +/// table, in world units. +@immutable +class CardWaterPreset { + const CardWaterPreset({ + required this.life, + required this.fallSpeed, + required this.gravity, + required this.xSpread, + required this.groupRadius, + required this.interval, + required this.pressure, + }); + + /// Seconds a drop lives. + final double life; + + /// Downward launch speed, world units/s. + final double fallSpeed; + + /// World gravity, units/s². + final double gravity; + + /// Horizontal jitter, world units/s. + final double xSpread; + + /// Group disc radius, world units. + final double groupRadius; + + /// Steps between groups: 8 light, 3 moderate, 1 heavy. + final int interval; + + /// LiquidFun's pressure strength for this grade. + final double pressure; + + /// Weather 5/13 — light rain. + static const CardWaterPreset light = CardWaterPreset( + life: 0.5, + fallSpeed: 8.0, + gravity: 4.5, + xSpread: 1.5, + groupRadius: 0.015, + interval: 8, + pressure: 0.024, + ); + + /// `x(1)` — weather 6, moderate rain. + static const CardWaterPreset moderate = CardWaterPreset( + life: 0.38, + fallSpeed: 8.0, + gravity: 4.25, + xSpread: 1.3, + groupRadius: 0.015, + interval: 3, + pressure: 0.024, + ); + + /// `x(2)`/`x(3)` — weather 7/8, heavy rain and storm. The engine runs two + /// worlds with *different* settings at this grade and draws both into the + /// same buffer; this is world 0, the slow fat drops. + static const CardWaterPreset heavy = CardWaterPreset( + life: 0.56, + fallSpeed: 2.0, + gravity: 12.0, + xSpread: 0.9, + groupRadius: 0.012, + interval: 1, + pressure: 0.025, + ); + + /// World 1 of the heavy grade: fast, short-lived, nearly vertical. Layered + /// over [heavy] it is what makes a storm read as driving rain rather than + /// more of the same drops. + static const CardWaterPreset heavySecondary = CardWaterPreset( + life: 0.25, + fallSpeed: 6.0, + gravity: 9.0, + xSpread: 0.1, + groupRadius: 0.015, + interval: 1, + pressure: 0.025, + ); +} + +/// A per-column collision surface: the topmost solid pixel's y in [heights], +/// one entry every [columnWidth] logical pixels starting at the field's own +/// x=0. In [CardWaterField.update], a drop at local x lands at +/// `heightAt(x)` instead of the flat `topEdge`. +/// +/// Columns are held flat between samples, not interpolated — a glyph's edge +/// is already jagged at the pixel level, and smoothing it into a slope no +/// letterform actually has would read as a rendering bug, not as water. +/// +/// [heightAt] answers `double.infinity` for a column with nothing solid in +/// it (the gap between two letters, say) as well as for any x outside +/// [heights]' own span. That is not a missing case to patch — it is the +/// correct physical answer, and every consumer below leans on it rather than +/// special-casing it: `double.infinity` compares false against every finite +/// y, so a drop over an empty column simply never triggers a contact and +/// keeps falling until its lifetime ends, exactly as it should. +@immutable +class CardWaterSurface { + CardWaterSurface({required this.heights, required this.columnWidth}) + : _solidColumns = [ + for (var i = 0; i < heights.length; i++) + if (heights[i].isFinite) i, + ]; + + /// Per-column height, card-local y. `double.infinity` — nothing solid. + final List heights; + + /// Sample spacing, logical pixels. + final double columnWidth; + + /// Indices into [heights] that are actually solid — precomputed once so + /// [randomSolidX] does not rescan the whole field on every emission. + final List _solidColumns; + + /// The surface height at local x. + double heightAt(double x) { + if (columnWidth <= 0 || heights.isEmpty) return double.infinity; + final i = (x / columnWidth).floor(); + if (i < 0 || i >= heights.length) return double.infinity; + return heights[i]; + } + + /// A uniformly random x with something solid under it, or null if + /// [heights] has no solid column at all — nothing was rasterised, or + /// everything sampled came back empty. + /// + /// Emission needs this rather than a plain `random() * width`: the group's + /// own spawn height is read from its centre column, and a centre with + /// nothing under it has no finite height to spawn *above*. + double? randomSolidX(math.Random random) { + if (_solidColumns.isEmpty) return null; + final i = _solidColumns[random.nextInt(_solidColumns.length)]; + return (i + 0.5) * columnWidth; + } +} + +/// Builds a [CardWaterSurface] from a rendered image's own alpha channel — +/// what turns "drops fall from above, caught by whatever they hit first" +/// into a surface that follows real text and icons instead of a hand-placed +/// shape. For each sampled column, the topmost pixel whose alpha crosses +/// [threshold] is that column's height; a column with nothing above +/// [threshold] anywhere in it is left at `double.infinity` — an empty gap +/// between two letters, honestly reported as nothing to land on, the same +/// answer [CardWaterSurface.heightAt] already gives past the edge of a +/// narrower field. +/// +/// [image] must be captured **1:1 with the widget's own logical pixels** +/// (`RenderRepaintBoundary.toImage(pixelRatio: 1.0)`) — a returned height is +/// used directly as a card-local y, with no separate scale factor carried +/// through the physics, and a mismatched capture scale would silently shift +/// the whole surface. +/// +/// Synchronous despite taking an already-decoded [ui.Image]: the one actually +/// async step, capturing the image itself, is the caller's concern — this +/// only reads pixels already sitting in memory, which is squarely a +/// same-frame operation and does not need `async` to say so. +CardWaterSurface silhouetteSurface( + ByteData pixels, + int width, + int height, { + double columnWidth = 3, + double threshold = 0.35, +}) { + final px = pixels.buffer.asUint8List(); + final columns = (width / columnWidth).ceil(); + final heights = List.filled(columns, double.infinity); + final cut = (threshold.clamp(0.0, 1.0) * 255).round(); + for (var col = 0; col < columns; col++) { + final xStart = (col * columnWidth).floor().clamp(0, width - 1); + final xEnd = ((col + 1) * columnWidth).ceil().clamp(xStart + 1, width); + for (var y = 0; y < height; y++) { + final row = y * width; + var hit = false; + for (var x = xStart; x < xEnd; x++) { + if (px[(row + x) * 4 + 3] >= cut) { + hit = true; + break; + } + } + if (hit) { + heights[col] = y.toDouble(); + break; + } + } + } + return CardWaterSurface(heights: heights, columnWidth: columnWidth); +} + +class _Drops { + _Drops(int capacity) + : x = Float32List(capacity), + y = Float32List(capacity), + vx = Float32List(capacity), + vy = Float32List(capacity), + age = Float32List(capacity), + life = Float32List(capacity), + seed = Float32List(capacity), + fx = Float32List(capacity), + fy = Float32List(capacity); + + final Float32List x; + final Float32List y; + final Float32List vx; + final Float32List vy; + final Float32List age; + final Float32List life; + final Float32List seed; + + /// Reaction force SolveCollision stores for the next iteration's SolveForce. + final Float32List fx; + final Float32List fy; + + void copy(int from, int to) { + x[to] = x[from]; + y[to] = y[from]; + vx[to] = vx[from]; + vy[to] = vy[from]; + age[to] = age[from]; + life[to] = life[from]; + seed[to] = seed[from]; + fx[to] = fx[from]; + fy[to] = fy[from]; + } +} + +/// Bakes the per-drop sprite pair from the two source textures. +/// +/// [kernel] is `particle_blurred` (64×64, alpha = coverage) and [normalMap] is +/// `drop_normal` (128×128, a hemisphere normal encoded `n·0.5+0.5`) — both +/// generated by `tool/gen_particle_sprites.py`. The reference samples each at +/// `gl_PointCoord` over the same point quad and writes the **signed** normal +/// with the kernel's alpha; the sign cannot survive an 8-bit unsigned surface, +/// so the pair splits it: +/// +/// pos = (max(nx,0), max(ny,0), nz) — alpha 255 +/// neg = (max(−nx,0), max(−ny,0), kernel·0.784) — alpha 255 +/// +/// and the composite reassembles `Σn = (pos.rg − neg.rg, pos.b)`, with the +/// coverage in neg's blue channel. Alpha stays opaque in both: canvas +/// surfaces premultiply, and a data channel scaled by a varying alpha is how +/// the earlier single-sprite encoding got polluted. +Future<(ui.Image, ui.Image)> bakeParticleSprites({ + required ui.Image kernel, + required ui.Image normalMap, + int size = 64, +}) async { + final kernelBytes = await _rasterize(kernel, size); + final normalBytes = await _rasterize(normalMap, size); + + final pos = Uint8List(size * size * 4); + final neg = Uint8List(size * size * 4); + for (var i = 0; i < size * size; i++) { + final o = i * 4; + final nx = normalBytes[o] / 255.0 * 2.0 - 1.0; + final ny = normalBytes[o + 1] / 255.0 * 2.0 - 1.0; + final nz = (normalBytes[o + 2] / 255.0 * 2.0 - 1.0).clamp(0.0, 1.0); + final a = kernelBytes[o + 3] / 255.0 * 0.784; + + pos[o] = (nx.clamp(0.0, 1.0) * 255).round(); + pos[o + 1] = (ny.clamp(0.0, 1.0) * 255).round(); + pos[o + 2] = (nz * 255).round(); + pos[o + 3] = 255; + neg[o] = ((-nx).clamp(0.0, 1.0) * 255).round(); + neg[o + 1] = ((-ny).clamp(0.0, 1.0) * 255).round(); + neg[o + 2] = (a * 255).round(); + neg[o + 3] = 255; + } + return (await _imageFromRgba(pos, size), await _imageFromRgba(neg, size)); +} + +/// Draws [source] scaled to [size]² and reads the pixels back. +Future _rasterize(ui.Image source, int size) async { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawImageRect( + source, + Rect.fromLTWH(0, 0, source.width.toDouble(), source.height.toDouble()), + Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble()), + Paint()..filterQuality = FilterQuality.medium, + ); + final image = recorder.endRecording().toImageSync(size, size); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + image.dispose(); + return data!.buffer.asUint8List(); +} + +Future _imageFromRgba(Uint8List rgba, int size) { + final completer = Completer(); + ui.decodeImageFromPixels( + rgba, + size, + size, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; +} + +/// Procedural stand-ins for [bakeParticleSprites], matching the generated +/// textures — used until the real assets finish decoding, and in tests. +/// +/// The two functions they stand in for: +/// * `particle_blurred`: alpha flat 1.0 to half the radius, then +/// `cos((r−0.5)/0.5 · π/2)` — within ~3% of every sampled point. +/// * `normal`: a standard unit hemisphere, linear in x/y with +/// `z = √(1−x²−y²)`, encoded `n·0.5+0.5`. +(ui.Image, ui.Image) buildParticleSprites({int size = 16}) { + ui.Image build(bool negative) { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final paint = Paint(); + final half = (size - 1) / 2.0; + for (var y = 0; y < size; y++) { + for (var x = 0; x < size; x++) { + final u = (x - half) / (size / 2); + final v = (half - y) / (size / 2); + final r = math.sqrt(u * u + v * v); + final profile = r <= 0.5 + ? 1.0 + : (r >= 1.0 ? 0.0 : math.cos((r - 0.5) / 0.5 * math.pi / 2)); + final a = profile * 0.784; + final nx = negative ? (-u).clamp(0.0, 1.0) : u.clamp(0.0, 1.0); + final ny = negative ? (-v).clamp(0.0, 1.0) : v.clamp(0.0, 1.0); + final b = negative ? a : math.sqrt(math.max(0.0, 1.0 - u * u - v * v)); + paint.color = Color.fromARGB( + 255, + (nx * 255).round(), + (ny * 255).round(), + (b * 255).round(), + ); + canvas.drawRect(Rect.fromLTWH(x.toDouble(), y.toDouble(), 1, 1), paint); + } + } + return recorder.endRecording().toImageSync(size, size); + } + + return (build(false), build(true)); +} + +/// One particle-particle contact: indices, weight and the A→B unit normal. +class _Contact { + const _Contact(this.a, this.b, this.w, this.nx, this.ny); + + final int a; + final int b; + final double w; + final double nx; + final double ny; +} diff --git a/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart b/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart new file mode 100644 index 000000000..375c8d2db --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart @@ -0,0 +1,264 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +/// A CPU particle field for rain and snow, drawn with `Canvas.drawAtlas`. +/// +/// The reference does not draw its rain with a procedural shader. It runs a GPU compute +/// particle system and draws each drop as an **instanced textured quad** using +/// a motion-blurred streak sprite — which is why a fragment-shader curtain +/// never looks right no matter how it is tuned. Flutter has no compute stage, +/// but `drawAtlas` submits thousands of transformed sprites in a single call, +/// so the same design ports directly with the simulation moved to Dart. +/// +/// The behaviour below is a translation of the reference's own shaders, not an +/// invention — see `particle_rain_line_emitter.comp` and `particle_rain.comp`: +/// +/// * Particles spawn on a **line** across the top, at a uniformly random point. +/// * Depth comes from `easeInCubic(0, 1, rand)`, which despite its name is +/// `rand⁴` — heavily biased toward zero, so most drops are distant and only +/// a few are close. That distribution is what gives the rain its depth. +/// * Motion is **purely linear and perfectly vertical**: no gravity, no +/// acceleration and — for rain — no wind at all. Speed scales by +/// `mix(0.12, 1, depth)`, so near drops fall about eight times faster. +/// * Lifetime is a **constant 3.0 s** (`setLife(3.0f, 3.0f)`), not a range. +/// * Intensity is expressed almost entirely through **particle count**, not +/// speed: the reference's presets run 576 / 1024 / 1472 / 1792 particles for light / +/// moderate / heavy / storm while `speed` moves only -4.5 → -5.2 (±7%). +/// * Drops composite **additively** at very low alpha — peak per-drop alpha is +/// 0.24 and a typical far drop is 0.046. The reference's rain is a faint veil, not an +/// opaque curtain, and drawing it over-strong is the single thing that makes +/// a port look most unlike it. +/// * Opacity scales by `mix(0.3, 2, depth)`, so near drops are ~7x stronger, +/// and each particle fades over its life as `1 - progress²`. +/// * Width scales by `(3 - 2·depth)` — far drops are relatively wider, which +/// keeps them visible at a couple of pixels tall. `drawAtlas` cannot scale +/// non-uniformly, so the atlas carries four width variants (widest first, +/// baked by `tool/build_rain_sprite.py`) and depth indexes them directly. +class PrecipitationField { + /// Sprite atlas: rain has [rainVariants] width variants side by side. + final ui.Image atlas; + + /// Number of width variants packed across the atlas (1 for snow). + final int variants; + + /// Cell size within the atlas, in texels. + final Size cell; + + /// Maximum particles this field can draw. + final int capacity; + + /// Whether particles tumble as they fall (snow) or stay upright (rain). + final bool tumble; + + final _Particle _p; + final math.Random _random; + + PrecipitationField({ + required this.atlas, + required this.capacity, + this.variants = 1, + required this.cell, + this.tumble = false, + int seed = 1, + }) : _p = _Particle(capacity), + _random = math.Random(seed); + + bool _seeded = false; + + /// Slowly moving centre for the gust clustering. + double _gustPhase = 0; + + /// Advances the simulation by [dt] seconds and returns the draw payload. + /// + /// [intensity] 0..1 scales how many particles are live; [fallSpeed] is in + /// widget heights per second at full depth; [wind] tilts the fall (rain + /// passes zero — the reference's rain is perfectly vertical). + ({Float32List transforms, Float32List rects, Int32List colors, int count}) + step({ + required Size size, + required double dt, + required double intensity, + required double fallSpeed, + required double wind, + required double opacity, + required Color tint, + required double sizeMin, + required double sizeMax, + required double life, + }) { + final live = (capacity * intensity.clamp(0.0, 1.0)).round(); + // Drift the gust centre so the clustering moves across the sky. + _gustPhase = (_gustPhase + dt * 0.06) % 1.0; + + if (!_seeded) { + for (var i = 0; i < capacity; i++) { + _spawn(i, size, sizeMin, sizeMax, life); + // Pre-warm: scatter the initial ages so the field starts full rather + // than as one synchronised wave falling from the top. + _p.age[i] = _random.nextDouble() * _p.life[i]; + _p.y[i] = _random.nextDouble() * (size.height + cell.height); + } + _seeded = true; + } + + // Tilt the streak into the wind; the sprite's long axis follows the fall. + final tilt = math.atan(wind * 0.6); + final cosT = math.cos(tilt); + final sinT = math.sin(tilt); + + // Loop-invariant pieces of the per-particle math, hoisted out of the loop — + // with up to 1792 particles a frame the redundant multiplies and the + // `num`-typed `clamp` (which boxes `double`) add up. Each value below is + // computed once and reads identically at every index. + final baseFall = fallSpeed * size.height * dt; + final heightRatio = size.height / cell.height; + final ax = cell.width * 0.5; + final ay = cell.height * 0.5; + // `tint` never varies per particle — only alpha does — so pack its RGB + // once and OR in the 8-bit alpha instead of building a `Color` per drop. + final rgb = tint.toARGB32() & 0x00FFFFFF; + + var n = 0; + for (var i = 0; i < live; i++) { + _p.age[i] += dt; + + final depth = _p.depth[i]; + // The reference's `particle_rain.comp`, verbatim: linear, depth-scaled. + final speedScale = 0.12 + 0.88 * depth; + final fall = baseFall * speedScale; + _p.y[i] += fall; + _p.x[i] += fall * wind * 0.6; + + // Age is the *only* respawn trigger, as in the reference: a drop that has fallen + // off the bottom stays dead for the rest of its 3 s life. Recycling it on + // exit — as an earlier version did — keeps the fastest drops permanently + // on screen and makes the whole field read as far too quick, because the + // near drops cross in well under a second. + if (_p.age[i] >= _p.life[i]) { + _spawn(i, size, sizeMin, sizeMax, life); + } + + // `alpha = mix(1, 0, progress²)` from `updateRunState`. age/life is always + // ≥ 0, so the lower clamp bound never fires — the branch below is exactly + // `clamp(0, 1)` but stays `double` (no `num`-typed return to box). + final ratio = _p.age[i] / _p.life[i]; + final progress = ratio < 1.0 ? ratio : 1.0; + final lifeAlpha = 1.0 - progress * progress; + // `alphaScale = mix(0.3, 2, depth)` from `particle_rain_frag_shader`. + final depthAlpha = 0.3 + 1.7 * depth; + // Same lower-bound argument: every factor is ≥ 0. + final rawAlpha = lifeAlpha * depthAlpha * 0.4 * opacity; + final a = rawAlpha < 1.0 ? rawAlpha : 1.0; + if (a < 0.004) continue; + + // Uniform scale: the sprite's cell height maps to the drop's length. + final scale = _p.size[i] * heightRatio; + + final o = n * 4; + if (tumble) { + final spin = _p.spin[i] * (_p.age[i] + _p.phase[i]); + final c = math.cos(spin) * scale; + final s = math.sin(spin) * scale; + _p.transforms[o] = c; + _p.transforms[o + 1] = s; + } else { + // cosT/sinT are the same for every drop; only scale changes. + final c = cosT * scale; + final s = sinT * scale; + _p.transforms[o] = c; + _p.transforms[o + 1] = s; + } + // Anchor at the cell centre so rotation pivots on the drop. + _p.transforms[o + 2] = + _p.x[i] - (_p.transforms[o] * ax - _p.transforms[o + 1] * ay); + _p.transforms[o + 3] = + _p.y[i] - (_p.transforms[o + 1] * ax + _p.transforms[o] * ay); + + // Depth picks the width variant. `particle_rain.comp` makes the quad's + // width `startSize * 0.4 * (3 - 2*depth)` against a length of + // `startSize * 3.0`, so a *distant* drop is relatively the widest — and + // the atlas is baked widest-first, so the index is depth itself. + final v = variants == 1 + ? 0 + : ((depth * (variants - 1)).round()).clamp(0, variants - 1); + _p.rects[o] = v * cell.width; + _p.rects[o + 1] = 0; + _p.rects[o + 2] = (v + 1) * cell.width; + _p.rects[o + 3] = cell.height; + + // Pack the drop's colour in place of `tint.withValues(alpha: a)`: the + // packed ARGB32 is identical (`withValues` sets alpha verbatim and + // `toARGB32` rounds it the same way) without allocating a `Color` per + // particle every frame. + _p.colors[n] = rgb | ((a * 255.0).round() << 24); + n++; + } + + return ( + transforms: Float32List.sublistView(_p.transforms, 0, n * 4), + rects: Float32List.sublistView(_p.rects, 0, n * 4), + colors: Int32List.sublistView(_p.colors, 0, n), + count: n, + ); + } + + void _spawn(int i, Size size, double sizeMin, double sizeMax, double life) { + // The reference's `easeInCubic(0, 1, r)` is `r⁴` despite the name — most particles + // land near zero, so the field is mostly distant with a few close drops. + final r = _random.nextDouble(); + final depth = r * r * r * r; + + _p.depth[i] = depth; + _p.size[i] = sizeMin + (sizeMax - sizeMin) * depth; + // The reference sets `setLife(3.0f, 3.0f)` — a constant, not a range. + _p.life[i] = life; + _p.age[i] = 0; + // `particle_rain_line_emitter.comp` spawns uniformly along a line exactly + // one screen wide, just above the top edge — no gust clustering. The + // clustering an earlier version added is visible as drifting bands that + // The reference never shows. Snow keeps the wide, clustered line: it is the only + // one with wind, so it does need to enter from off-screen. + if (tumble) { + final u = (_random.nextDouble() + _random.nextDouble()) * 0.5; + final drift = (_gustPhase + _random.nextDouble() * 0.35) % 1.0; + _p.x[i] = ((u + drift) % 1.0 * 1.6 - 0.3) * size.width; + } else { + _p.x[i] = _random.nextDouble() * size.width; + } + _p.y[i] = -0.025 * size.height - 0.5 * _p.size[i] * size.height; + _p.spin[i] = (_random.nextDouble() - 0.5) * 1.6; + _p.phase[i] = _random.nextDouble() * 6.28; + } +} + +/// Struct-of-arrays particle storage — one allocation, reused every frame. +class _Particle { + final Float64List x; + final Float64List y; + final Float64List depth; + final Float64List size; + final Float64List life; + final Float64List age; + final Float64List spin; + final Float64List phase; + + final Float32List transforms; + final Float32List rects; + final Int32List colors; + + _Particle(int n) + : x = Float64List(n), + y = Float64List(n), + depth = Float64List(n), + size = Float64List(n), + life = Float64List(n), + age = Float64List(n), + spin = Float64List(n), + phase = Float64List(n), + transforms = Float32List(n * 4), + rects = Float32List(n * 4), + colors = Int32List(n); +} diff --git a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart new file mode 100644 index 000000000..518b82fd8 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -0,0 +1,700 @@ +import 'dart:ui' as ui; + +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/card_water_field.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/rain_on_glass.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter/scheduler.dart'; + +/// Puts rain on one card: water caught and pooling along its top edge, and — +/// while [RainOnCard.glass] is on — droplets clinging to its face. +/// +/// Both halves come from the reference and both are separate from the falling +/// rain in the backdrop. The face gets a droplet shader as a `RenderEffect` +/// (that is [RainOnGlass]); the edge water is a panel particle system (that is +/// [CardWaterField] plus `shaders/weather/card_water.frag`) and runs +/// regardless of [RainOnCard.glass]. +/// +/// The edge water is drawn the way the engine draws it, in two passes: +/// +/// 1. Drops accumulate **additively** into an offscreen image — coverage +/// only, no colour. The reference uses an 800 px-tall RGBA16F render +/// texture here. +/// 2. That buffer is composited through a shader that applies the alpha +/// threshold and the lighting. Everything that makes it look like water +/// happens in this pass, so drawing the particles straight to the screen — +/// as an earlier version of this did — shows the dots instead. +/// +/// Applied per card rather than to the whole sheet: the reference wets the +/// card, not the page, and a refraction filter over an entire scrolling list +/// costs a full-surface pass every frame and warps text nowhere near the rain. +class RainOnCard extends StatefulWidget { + const RainOnCard({ + super.key, + required this.child, + this.intensity = 0.0, + this.opacity = 1.0, + this.active = true, + this.glass = true, + this.silhouette = false, + this.gated = true, + }); + + /// The card. Water is drawn over it, and — when [glass] is on — it is + /// refracted through the droplets on its face. + final Widget child; + + /// 0 disables both effects; 1 is a downpour on the card. Picks the grade — + /// how much water arrives — as the reference's rain level picks a preset. + final double intensity; + + /// Whether the face gets [RainOnGlass]'s refracting droplets, on top of the + /// edge water every card gets regardless. `false` skips [RainOnGlass] + /// entirely — [child] renders untouched, only the splash along the top edge + /// runs. `true` (the default) still only runs it while the card's own + /// position gate is open — see the position-gate doc below. + final bool glass; + + /// Whether the splash collides with [child]'s own rendered outline instead + /// of a flat line across the top of its bounding box. + /// + /// For [child] shapes that do not fill their own box — text, an icon, + /// anything with real gaps in it — a flat edge pools water in mid-air over + /// every gap. `true` rasterises [child] each time it actually changes (see + /// `_RainOnCardState._scheduleCapture`) and builds a [CardWaterSurface] + /// from its alpha channel, so a drop is caught by the nearest glyph or icon + /// beneath it, or keeps falling if there genuinely is nothing there. + final bool silhouette; + + /// Whether [_RainOnCardState._syncPositionGate]'s scroll-position cut-off + /// applies to this card at all. + /// + /// The gate's fixed threshold (~21 % down the screen) is calibrated for a + /// card that starts *below* it and rises past it mid-scroll — true of the + /// rain-trend card, which sits at the bottom of the hero block. The header + /// sits at the *top* of the same block and is already above that threshold + /// at rest, so the same check would hold it permanently closed instead of + /// ever opening. `false` skips the cut-off — the card relies on the + /// surrounding scroll view culling its paint once it actually scrolls out + /// of view, same as it would for any other off-screen widget. + final bool gated; + + /// The scene alpha — how visible the whole effect is. + /// + /// Separate from [intensity] on purpose: the engine passes its global scene + /// fade here, full strength whenever the scene shows. Folding the rain grade + /// into it — as an earlier version did — ran the water at 0.6 × 0.55 ≈ 33% + /// opacity and washed the whole effect out. + final double opacity; + + /// Whether the water animates. False holds the last frame. + final bool active; + + @override + State createState() => _RainOnCardState(); +} + +class _RainOnCardState extends State + with SingleTickerProviderStateMixin { + static const String _asset = 'shaders/weather/card_water.frag'; + + late final Ticker _ticker = createTicker(_onTick); + + /// Two fields — the reference configures two LiquidFun worlds and always + /// draws both into one buffer. + static final (ui.Image, ui.Image) _fallbackSprites = buildParticleSprites(); + late final CardWaterField _primary = CardWaterField( + spritePos: _fallbackSprites.$1, + spriteNeg: _fallbackSprites.$2, + ); + late final CardWaterField _secondary = CardWaterField( + spritePos: _fallbackSprites.$1, + spriteNeg: _fallbackSprites.$2, + seed: 23, + ); + + /// Repaints the water without rebuilding the card beneath it. + final ValueNotifier _frame = ValueNotifier(0); + + ui.FragmentShader? _shader; + Duration _last = Duration.zero; + Size _size = Size.zero; + double _screenHeight = 0; + Size _screenSize = Size.zero; + + /// Wraps [RainOnCard.child] so [_capture] has something to rasterise — + /// only meaningful while [RainOnCard.silhouette] is on, but cheap enough to + /// hold unconditionally rather than juggling a nullable key. + final GlobalKey _captureKey = GlobalKey(); + + /// The most recently captured outline, or null before the first capture + /// completes — [_onTick] holds emission off until then rather than falling + /// back to a flat edge for a frame or two, which [CardWaterField.update] + /// would otherwise do on its own with a null surface. + CardWaterSurface? _surface; + bool _captureScheduled = false; + + @override + void initState() { + super.initState(); + _load(); + _syncRunning(); + _scheduleCapture(); + } + + /// Queues a rasterise-and-rebuild-the-surface pass for the end of the + /// current frame, unless one is already queued or [RainOnCard.silhouette] + /// is off. Coalescing through [_captureScheduled] matters because + /// [didUpdateWidget] calls this on every rebuild while rain is running — + /// without it, a sheet-drag animation rebuilding this widget every frame + /// would queue a GPU readback every frame right along with it. + void _scheduleCapture() { + if (!widget.silhouette || _captureScheduled) return; + _captureScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) => _capture()); + } + + /// Rasterises [_captureKey]'s subtree — [RainOnCard.child], not whatever + /// [RainOnGlass] does to it, since the boundary sits *inside* that filter + /// and captures only its own descendants — and rebuilds [_surface] from + /// the result. + Future _capture() async { + _captureScheduled = false; + if (!mounted || !widget.silhouette) return; + final boundary = _captureKey.currentContext?.findRenderObject(); + if (boundary is! RenderRepaintBoundary || boundary.debugNeedsPaint) { + // Laid out but not yet painted (common on the very first frame) — try + // again next frame instead of reading a boundary with nothing in it. + _scheduleCapture(); + return; + } + ui.Image? image; + try { + // 1:1 with the widget's own logical pixels, matching the card-local + // coordinates CardWaterField already works in — capturing at the + // device's own pixel ratio would need every returned height rescaled + // before it meant anything to the solver. + image = await boundary.toImage(pixelRatio: 1.0); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + if (!mounted || data == null) return; + _surface = silhouetteSurface(data, image.width, image.height); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'Failed to capture the splash outline'); + } finally { + image?.dispose(); + } + } + + Future _load() async { + try { + final program = await ui.FragmentProgram.fromAsset(_asset); + if (!mounted) return; + setState(() => _shader = program.fragmentShader()); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'Failed to load shader $_asset'); + } + // Swap the procedural stand-in for the baked sprite the moment the two + // source textures decode. Both are generated by `tool/gen_particle_ + // sprites.py` — the kernel is a blurred disc, the normal map an analytic + // hemisphere, so they are the same *functions* the engine samples. + try { + final kernel = await _decodeAsset( + 'assets/weather/particles/particle_blurred.webp', + ); + final normalMap = await _decodeAsset( + 'assets/weather/particles/drop_normal.webp', + ); + final (pos, neg) = await bakeParticleSprites( + kernel: kernel, + normalMap: normalMap, + ); + kernel.dispose(); + normalMap.dispose(); + if (!mounted) { + pos.dispose(); + neg.dispose(); + return; + } + _primary.spritePos = pos; + _primary.spriteNeg = neg; + _secondary.spritePos = pos; + _secondary.spriteNeg = neg; + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'Failed to bake the drop sprite'); + } + } + + static Future _decodeAsset(String key) async { + final data = await rootBundle.load(key); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + final frame = await codec.getNextFrame(); + codec.dispose(); + return frame.image; + } + + /// Set while a gate-resume check is queued, so scroll ticks (which rebuild + /// this card on every pixel) coalesce into one check rather than one + /// post-frame callback each. + bool _resumeScheduled = false; + + @override + void didUpdateWidget(RainOnCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.active != widget.active || + (oldWidget.intensity > 0.001) != (widget.intensity > 0.001) || + (oldWidget.opacity > 0.004) != (widget.opacity > 0.004)) { + _syncRunning(); + } + // Rain about to start is exactly when a fresh outline matters most — the + // capture from last time rain ran could be reading a since-changed + // reading (a new temperature, a switched area). Re-running this on every + // rebuild instead, not just this one transition, would queue a GPU + // readback on every frame of a sheet-drag animation for no benefit. + if (!(oldWidget.intensity > 0.001) && widget.intensity > 0.001) { + _scheduleCapture(); + } + // A scroll tick rebuilding this card may have reopened the position gate + // since the ticker was stopped (a closed gate cuts the water outright). + // If rain should be running but the ticker is idle, re-check the gate + // once this frame settles and restart if it is open. + if (widget.active && + widget.intensity > 0.001 && + !_ticker.isActive && + !_resumeScheduled) { + _resumeScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _resumeScheduled = false; + _resumeIfGateOpen(); + }); + } + } + + void _resumeIfGateOpen() { + if (!mounted) return; + _syncPositionGate(); + if (_gateOpen && widget.active && widget.intensity > 0.001) { + _last = Duration.zero; + _ticker.start(); + } + } + + void _syncRunning() { + final draining = _primary.liveCount > 0 || _secondary.liveCount > 0; + final visible = widget.opacity > 0.004; + if (widget.active && visible && (widget.intensity > 0.001 || draining)) { + if (!_ticker.isActive) { + _last = Duration.zero; + _ticker.start(); + } + } else { + _ticker.stop(); + if (widget.intensity <= 0.001) { + _primary.clear(); + _secondary.clear(); + } + } + } + + /// The position gate: the panel water switches off entirely once the card + /// has travelled [_gateCloseDistance] upward from the lowest point it has + /// been at since appearing — and, alongside `widget.glass`, so does the + /// face's refracting droplets, for the same reason: neither has any + /// business still running on a card that is on its way off the top of the + /// visible sheet. + /// + /// The reference gates on an *absolute* screen fraction instead — the water + /// stops once the card's y drops below 30 % of a 1714/2400 reference + /// height. That fits the reference's own layout, a list of many cards each + /// scrolling past one fixed line, but not this card's: it starts most of a + /// screen's height below that line, so the same check needed almost this + /// card's entire travel before it closed — the problem this constant + /// replaces. Gating on displacement from where the card actually settled + /// closes it after a small, consistent swipe regardless of where that is. + /// + /// Tracking a running *low-water mark* — [_restTopY], the largest + /// [_cardTopY] seen, i.e. the furthest down the screen — rather than the + /// first sample matters for one reason: the sheet's own open animation + /// moves the card by far more than this on its own, before the list is + /// ever scrolled. Pinning the reference to wherever the card has actually + /// settled, and letting it keep tracking that low point for as long as the + /// card is still travelling toward (or resting at) it, keeps that + /// animation from closing the gate on its own; displacement only grows + /// once the card actually rises away from its settled point, which is + /// what a real scroll does. + /// + /// The gate guards the particle draw *and* the accumulate-and-blur, so + /// nothing renders at all — water has no business pooling on a card that is + /// about to slide under the toolbar. + static const double _gateCloseDistance = 64; + + /// Where the card's top edge currently sits, in logical pixels from the top + /// of the window. Null before the first layout. + double? _cardTopY; + + /// Running low-water mark for the gate — see [_gateCloseDistance]. Null + /// before the first layout. + double? _restTopY; + bool _gateOpen = true; + + /// World gravity is multiplied by **1.5 while the card is travelling up the + /// screen**, so water thrown off the lip is pulled back down instead of + /// trailing behind a scroll. + /// + /// The reference compares the card's new position against the last one and + /// switches to the boosted mode when it moved more than 5 px, applying the + /// multiplier on every timer tick's `world.setGravity`. The 5 px is against + /// the same 2400 px reference screen. + double _gravityScale = 1.0; + static const double _scrollGravityScale = 1.5; + static const double _scrollThresholdRatio = 5 / 2400; + + /// Re-reads the card's position and applies the gate. Cheap enough to run + /// per tick: the reference re-evaluates it on every scroll callback. + void _syncPositionGate() { + final box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + final previous = _cardTopY; + _cardTopY = box.localToGlobal(Offset.zero).dy; + final restTopY = _restTopY; + _restTopY = restTopY == null || _cardTopY! > restTopY + ? _cardTopY + : restTopY; + // The reference compares a quantity that grows as the card rises — so the + // boosted mode is the card moving *up*, not any movement at all. + _gravityScale = + previous != null && + previous - _cardTopY! > _scrollThresholdRatio * _screenHeight + ? _scrollGravityScale + : 1.0; + final open = !widget.gated || _restTopY! - _cardTopY! < _gateCloseDistance; + if (open == _gateOpen) return; + setState(() => _gateOpen = open); + } + + void _onTick(Duration elapsed) { + final dt = _last == Duration.zero + ? 0.0 + : (elapsed - _last).inMicroseconds / 1e6; + _last = elapsed; + // Width only. The card sits in a ListView, so its height is unbounded at + // layout time and `Size.isEmpty` was true on every frame — which silently + // skipped the whole simulation and left the edge dry. + if (dt <= 0 || _size.width <= 0) return; + + _syncPositionGate(); + + // Gate closed means the card is leaving the top of the sheet — cut the + // water outright instead of letting it drain invisibly. The painter skips + // it anyway, and every frame of physics on a card that is about to slide + // under the toolbar (or already off past the fold) is wasted; the ticker + // stays stopped until a scroll tick rebuilds this card and reopens the + // gate (see [didUpdateWidget]). + if (!_gateOpen) { + _primary.clear(); + _secondary.clear(); + _frame.value++; + _ticker.stop(); + return; + } + + // While silhouette mode is waiting on its first capture there is no + // surface to catch a drop on, so hold emission off rather than fall back + // to a flat topEdge for a frame or two. + final awaitingSurface = widget.silhouette && _surface == null; + final intensity = awaitingSurface ? 0.0 : widget.intensity.clamp(0.0, 1.0); + final surface = widget.silhouette ? _surface : null; + final grade = _gradeFor(intensity); + _primary.update( + dt, + width: _size.width, + screenHeight: _screenHeight, + intensity: intensity, + preset: grade.$1, + gravityScale: _gravityScale, + surface: surface, + ); + _secondary.update( + dt, + width: _size.width, + screenHeight: _screenHeight, + intensity: intensity, + preset: grade.$2, + gravityScale: _gravityScale, + surface: surface, + ); + _frame.value++; + + if (intensity <= 0.001 && + _primary.liveCount == 0 && + _secondary.liveCount == 0) { + _ticker.stop(); + } + } + + /// Maps our 0→1 intensity onto the reference's rain grades. + /// + /// **Both** worlds run at every grade — the reference's loop configures world 0 and + /// world 1 identically for grades 0/1 and differently only for 2/3, and + /// The reference always draws both. Idling the second world at the lighter grades, + /// as an earlier version did, halves the water. + static (CardWaterPreset, CardWaterPreset) _gradeFor(double intensity) { + if (intensity >= 0.7) { + return (CardWaterPreset.heavy, CardWaterPreset.heavySecondary); + } + if (intensity >= 0.35) { + return (CardWaterPreset.moderate, CardWaterPreset.moderate); + } + return (CardWaterPreset.light, CardWaterPreset.light); + } + + /// `specularIntensity` on the panel branch: 1.2 at night, 1.0 by day. + static const double _specularDay = 1.0; + static const double _specularNight = 1.2; + + /// Drawn diameter of one drop and the metaball's alpha cut, together — + /// The reference picks the pair from the screen aspect: tall screens (h/w ≥ 2, + /// the normal phone) get 5.0 px drops with the 0.6 cut, squarer screens + /// smaller drops with a lower cut. + /// + /// Those are **buffer** pixels, not logical ones: the particle pass renders + /// into an 800-tall render texture that stands for the whole screen, so a + /// drop covers `size/800` of the screen's height whatever the device. + (double, double) get _pointSizeAndThreshold { + final w = _screenSize.width; + final h = _screenSize.height; + if (w <= 0 || h <= 0) return (5.0, 0.6); + final scale = h / CardWaterField.bufferHeight; + if (h / w >= 2.0) return (5.0 * scale, 0.6); + if (h > w) return (3.3 * scale, 0.3); + return (3.7 * scale, 0.3); + } + + @override + void dispose() { + _ticker.dispose(); + _frame.dispose(); + _shader?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + _screenSize = MediaQuery.sizeOf(context); + _screenHeight = _screenSize.height; + // The reference's night flag is `hour >= 15 || hour <= 4` — and that hour is + // the scene's position on its 24-keyframe day ring, where ~15 is dusk and + // ~4 is dawn. DPIP has the same mapping; the theme's brightness (used here + // before) is unrelated to the sun. + final wall = AppTime.utc8; + final key = keyframePosition( + wall.hour + wall.minute / 60.0, + frameCount: 24, + ); + final night = key >= 15.0 || key <= 4.0; + final (pointSize, threshold) = _pointSizeAndThreshold; + return LayoutBuilder( + builder: (context, constraints) { + _size = Size( + constraints.maxWidth, + constraints.hasBoundedHeight ? constraints.maxHeight : 0, + ); + // Height may legitimately be 0 here — see [_onTick]. + // Boundary sits below RainOnGlass so a capture reads widget.child's + // own pixels, not the glass filter's distortion of them — see + // [_capture]'s doc. + final content = RepaintBoundary(key: _captureKey, child: widget.child); + return Stack( + // Water sits above the card's top edge and must not be cut off. + clipBehavior: Clip.none, + children: [ + // Gated the same as the splash below, not just by widget.glass on + // its own: the card sliding up toward the toolbar is exactly when + // its content is about to scroll away, and refracting droplets + // over content that is mid-scroll (or already off past the fold) + // has nothing left to sensibly distort. + widget.glass && _gateOpen + ? RainOnGlass( + intensity: widget.intensity, + opacity: widget.opacity, + // The position gate closes the card's refracting droplets + // along with its splash — a card sliding up toward the + // toolbar has nothing left to sensibly distort, and + // running the shader filter every frame just to skip it + // wastes the GPU. Restarting when the gate reopens is free: + // the droplets resume from their own clock. + active: widget.active && _gateOpen, + child: content, + ) + : content, + Positioned.fill( + child: IgnorePointer( + child: RepaintBoundary( + child: CustomPaint( + painter: _CardWaterPainter( + primary: _primary, + secondary: _secondary, + shader: _shader, + // Also repaints when the sky re-bakes, so the water's + // ambient follows the backdrop it falls out of. + repaint: Listenable.merge([ + _frame, + SkyLutCache.panelAmbient, + ]), + opacity: widget.opacity.clamp(0.0, 1.0), + night: night, + screenHeight: _screenHeight, + pointSize: pointSize, + threshold: threshold, + screenWidth: _screenSize.width, + gateOpen: _gateOpen, + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _CardWaterPainter extends CustomPainter { + _CardWaterPainter({ + required this.primary, + required this.secondary, + required this.shader, + required Listenable repaint, + required this.opacity, + required this.night, + required this.screenHeight, + required this.screenWidth, + required this.gateOpen, + required this.pointSize, + required this.threshold, + }) : super(repaint: repaint); + + final CardWaterField primary; + final CardWaterField secondary; + final ui.FragmentShader? shader; + final double opacity; + final bool night; + final double screenHeight; + final double screenWidth; + + /// The position gate — false once the card is too high on screen; both + /// passes are skipped, not just the draw. + final bool gateOpen; + final double pointSize; + final double threshold; + + /// The separable 5-tap blur the reference runs between the particle pass and + /// the composite, as an equivalent gaussian. + /// + /// Taps sit at ±0.7 and ±0.35 of `uBlurBufferSize` with weights + /// .164/.217/.238, so σ is `sqrt(2·(0.164·0.7² + 0.217·0.35²))` = 0.4625 of + /// that step. The step is the load-bearing part: the reference computes it + /// as `1/((w·355)/h)` **while both fields are still 1**, and only assigns the + /// buffer's width and height afterwards — so the aspect correction is dead + /// code and *both* axes get a flat 1/355 in **UV**. + /// + /// A UV step is not a pixel step. On the 800-tall buffer that is 1.04 px + /// vertically but only `1.04·W/H` horizontally — the blur is 2.2x wider + /// down the screen than across it, and reading it as one isotropic 0.46 px + /// (as an earlier version did) leaves each drop far too sharp and far too + /// much of it above the alpha cut. + static const double _blurStepUv = 1 / 355; + static const double _blurSigmaUv = 0.46253 * _blurStepUv; + + double get _sigmaX => _blurSigmaUv * screenWidth; + double get _sigmaY => _blurSigmaUv * screenHeight; + + /// One signed half of the accumulation — see [CardWaterField.paintCoverage]. + ui.Image _accumulate( + Size fieldSize, + double headroom, { + required bool negative, + }) { + final recorder = ui.PictureRecorder(); + final offscreen = Canvas(recorder); + offscreen.saveLayer( + Offset.zero & fieldSize, + Paint() + ..imageFilter = ui.ImageFilter.blur(sigmaX: _sigmaX, sigmaY: _sigmaY), + ); + offscreen.translate(0, headroom); + primary.paintCoverage(offscreen, dropSize: pointSize, negative: negative); + secondary.paintCoverage(offscreen, dropSize: pointSize, negative: negative); + offscreen.restore(); + return recorder.endRecording().toImageSync( + fieldSize.width.ceil(), + fieldSize.height.ceil(), + ); + } + + @override + void paint(Canvas canvas, Size size) { + final shader = this.shader; + if (shader == null || opacity <= 0.004 || !gateOpen) return; + if (primary.liveCount == 0 && secondary.liveCount == 0) return; + if (size.isEmpty) return; + + // Pass 1: accumulate both signed halves, blurred as the engine blurs its + // buffer. Spans the card plus the whole fall above it. + final headroom = CardWaterField.spawnHeadroom(screenHeight); + final fieldSize = Size(size.width, size.height + headroom); + final pos = _accumulate(fieldSize, headroom, negative: false); + final neg = _accumulate(fieldSize, headroom, negative: true); + + // Pass 2: reassemble the signed sum, light it, hard-cut it. + // `J.s()` passes `Time().hour` — the local wall-clock hour. + final hour = AppTime.utc8.hour.toDouble(); + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(fieldSize.width); + set(fieldSize.height); + // The sky at the panel band — the reference's `uBgLightTex` row. Until the first + // bake lands, a rainy overcast grey-blue stands in. + final ambient = SkyLutCache.panelAmbient.value ?? const Color(0xFF5C6B7E); + set(ambient.r); + set(ambient.g); + set(ambient.b); + set(night ? _specularNight : _specularDay); + // The accumulation is scaled to dodge 8-bit saturation; the cut scales + // with it so the metaball shape is untouched. + set(threshold * CardWaterField.accumulationScale); + set(opacity); + set(hour); + shader.setImageSampler(0, pos); + shader.setImageSampler(1, neg); + + canvas.save(); + canvas.translate(0, -headroom); + canvas.drawRect( + Rect.fromLTWH(0, 0, fieldSize.width, fieldSize.height), + Paint()..shader = shader, + ); + canvas.restore(); + pos.dispose(); + neg.dispose(); + } + + static const double _specularDay = _RainOnCardState._specularDay; + static const double _specularNight = _RainOnCardState._specularNight; + + @override + bool shouldRepaint(covariant _CardWaterPainter oldDelegate) => + oldDelegate.opacity != opacity || + oldDelegate.night != night || + oldDelegate.shader != shader || + oldDelegate.pointSize != pointSize || + oldDelegate.threshold != threshold || + oldDelegate.screenHeight != screenHeight || + oldDelegate.screenWidth != screenWidth || + oldDelegate.gateOpen != gateOpen; +} diff --git a/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart b/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart new file mode 100644 index 000000000..73cf9e1db --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart @@ -0,0 +1,254 @@ +import 'dart:ui' as ui; + +import 'package:dpip/core/logging/log.dart'; +import 'package:flutter/material.dart'; + +/// Puts rain on the *content*: droplets cling to [child] and bend its pixels +/// through each bead. +/// +/// This is a distinct system from the falling rain behind it. The reference applies +/// The reference shader as an Android `RenderEffect` on the card View, +/// so the card's own pixels refract — which is why text visibly warps behind +/// the beads rather than having droplets drawn flat on top. +/// +/// Flutter's equivalent is `ui.ImageFilter.shader`, whose contract matches +/// Android's: the first uniform is a `vec2` the engine fills with the input +/// texture size, and the first sampler receives the filtered content. +/// +/// **Impeller only.** `ImageFilter.shader` throws on the Skia backend, so the +/// filter is dropped and [child] renders untouched — the effect is decoration, +/// and losing it must never cost the content. +class RainOnGlass extends StatefulWidget { + /// The content the droplets sit on. + final Widget child; + + /// 0 disables the effect entirely; 1 is a full downpour on the glass — + /// selects among the reference's three uniform grades. + final double intensity; + + /// The shader's `uAlpha` — how strongly the refraction shows. The reference's + /// controller passes its animation alpha here, 1 once the scene is up. + final double opacity; + + /// Whether the droplets animate. When false the last frame holds. + final bool active; + + const RainOnGlass({ + super.key, + required this.child, + this.intensity = 0.0, + this.opacity = 1.0, + this.active = true, + }); + + @override + State createState() => _RainOnGlassState(); +} + +class _RainOnGlassState extends State + with SingleTickerProviderStateMixin { + static const String _asset = 'shaders/weather/rain_on_glass.frag'; + + final Stopwatch _clock = Stopwatch(); + late final AnimationController _ticker = AnimationController( + duration: const Duration(seconds: 30), + vsync: this, + ); + + ui.FragmentShader? _shader; + + /// Set once `ImageFilter.shader` has been seen to fail, so the throw is not + /// repeated every frame on a Skia device. + bool _filterUnsupported = false; + + @override + void initState() { + super.initState(); + _syncRunning(); + _load(); + } + + Future _load() async { + try { + final program = await ui.FragmentProgram.fromAsset(_asset); + if (!mounted) return; + setState(() => _shader = program.fragmentShader()); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'Failed to load shader $_asset'); + } + } + + void _syncRunning() { + if (widget.active && widget.intensity > 0.01) { + if (!_clock.isRunning) _clock.start(); + _ticker.repeat(); + } else { + _clock.stop(); + _ticker.stop(); + } + } + + @override + void didUpdateWidget(RainOnGlass oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.active != widget.active || + (oldWidget.intensity > 0.01) != (widget.intensity > 0.01)) { + _syncRunning(); + } + } + + @override + void dispose() { + _ticker.dispose(); + _shader?.dispose(); + _clock.stop(); + super.dispose(); + } + + /// Builds the droplet filter, or `null` if shaders-as-filters are not + /// available on this backend. + ui.ImageFilter? _filter(double intensity) { + final shader = _shader; + if (shader == null || _filterUnsupported) return null; + + final grade = _GlassGrade.forIntensity(intensity); + final time = _clock.elapsedMilliseconds / 1000.0; + var i = 0; + void set(double v) => shader.setFloat(i++, v); + // Slots 0..1 are the input size; the engine overwrites them, but they must + // exist for `ImageFilter.shader` to accept the shader. + set(0); + set(0); + // The reference's fixed frame — the reference sets uResolution to 1080×1080 once at + // creation and never per-view, so bead size is absolute, not card-relative. + set(1080); + set(1080); + set(time); + set(grade.staticSize); + set(grade.staticAmount); + set(grade.staticSpeed); + set(grade.runningSize); + set(grade.runningAmount); + set(grade.runningSpeed); + // `uAlpha` is the effect's own strength, not the rain amount. + set(widget.opacity.clamp(0.0, 1.0)); + + try { + return ui.ImageFilter.shader(shader); + } catch (error, stackTrace) { + // Skia backend: no shader image filters. Drop the effect for good. + Log.handle( + error, + stackTrace, + 'ImageFilter.shader unavailable; rain-on-glass disabled', + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _filterUnsupported = true); + }); + return null; + } + } + + @override + Widget build(BuildContext context) { + // The widget tree's SHAPE must not depend on the intensity. `intensity` + // is scaled by the sheet's expansion, so it crosses the on/off threshold + // *while the user is dragging* — and swapping between a wrapped and an + // unwrapped child re-parents the content's element, which silently kills + // the in-flight drag. Taps survive it because they complete within a + // frame; a drag does not. + // + // So the structure is fixed and only `enabled` and the filter change. + return AnimatedBuilder( + animation: _ticker, + builder: (context, child) { + final intensity = widget.intensity.clamp(0.0, 1.0); + final filter = intensity < 0.01 || widget.opacity < 0.004 + ? null + : _filter(intensity); + return ImageFiltered( + // A no-op filter keeps the widget — and so the element tree — + // present even when the effect is off. + imageFilter: + filter ?? ui.ImageFilter.matrix(Matrix4.identity().storage), + enabled: filter != null, + child: child, + ); + }, + child: widget.child, + ); + } +} + +/// One of the three droplet uniform sets for the card effect. +/// +/// The branch key is the weather *type*, not a rain "level". Light rain and +/// sleet take [drizzle]; moderate rain takes [steady]; everything from heavy +/// rain up through storm takes [downpour]. +/// +/// The ladder runs the way intuition does **not**: the lighter the rain, the +/// *smaller and denser* the beads. `uStaticDropSize` is a divisor — +/// `uv *= 40 / uStaticDropSize` — so 0.5 gives a 13.5 device-px lattice against +/// 1.0's 27 px, i.e. four times the beads at half the size. Pinning every rain +/// mode to the downpour set, as an earlier version did, is exactly the "too +/// big, too sparse" that a light-rain capture shows it should not be. +class _GlassGrade { + const _GlassGrade({ + required this.staticSize, + required this.staticAmount, + required this.staticSpeed, + required this.runningSize, + required this.runningAmount, + required this.runningSpeed, + }); + + final double staticSize; + final double staticAmount; + final double staticSpeed; + final double runningSize; + final double runningAmount; + final double runningSpeed; + + /// Light rain and sleet — fine, dense beads. + static const _GlassGrade drizzle = _GlassGrade( + staticSize: 0.5, + staticAmount: 0.5, + staticSpeed: 1.0, + runningSize: 0.85, + runningAmount: 0.6, + runningSpeed: 0.4, + ); + + /// Moderate rain. + /// + /// Unreachable today: [WeatherMode] has only `rain` and `thunderstorm` for + /// wet weather, which sit on the light and storm rungs. Kept because it is + /// the ladder's own middle rung, and the moment a heavy-rain mode exists it + /// is the set it needs — inventing one then would just be guessing again. + static const _GlassGrade steady = _GlassGrade( + staticSize: 0.8, + staticAmount: 0.8, + staticSpeed: 1.5, + runningSize: 1.0, + runningAmount: 0.7, + runningSpeed: 1.0, + ); + + /// Heavy rain through storm. Big, fast, sparse beads. + static const _GlassGrade downpour = _GlassGrade( + staticSize: 1.0, + staticAmount: 1.0, + staticSpeed: 2.0, + runningSize: 1.4, + runningAmount: 1.0, + runningSpeed: 1.3, + ); + + /// Maps our 0→1 intensity onto the ladder. Whole sets are switched by + /// weather type rather than interpolated, so this does the same. + static _GlassGrade forIntensity(double intensity) { + if (intensity >= 0.7) return downpour; + if (intensity >= 0.35) return steady; + return drizzle; + } +} diff --git a/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart b/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart new file mode 100644 index 000000000..35c8e1567 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart @@ -0,0 +1,248 @@ +/// Hermite interpolation between [a] and [b] — GLSL's `smoothstep`. +double smoothstep(double a, double b, double x) { + final t = ((x - a) / (b - a)).clamp(0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +/// One cloud sprite placed in the scene. +/// +/// The reference positions clouds in true 3D — each carries a world x/y/z and +/// a scale, with the camera advancing along -z. A Flutter fragment shader has +/// no geometry stage, so instances are placed in screen-relative terms instead +/// and [depth] stands in for z: it scales the +/// sprite, dims it, and sets how fast it drifts. That reproduces the parallax +/// the 3D layout exists to create without needing the projection. +class CloudInstance { + /// Index into the loaded sprite set. + final int sprite; + + /// Horizontal position, in screen widths. Wraps, so the deck is endless. + final double x; + + /// Vertical position, 0 at the top of the widget to 1 at the bottom. + final double y; + + /// Width as a fraction of the widget's width, before [depth] scaling. + final double scale; + + /// 0 near … 1 far. Near clouds are larger, brighter and drift faster. + final double depth; + + /// Per-instance opacity. + final double opacity; + + const CloudInstance({ + required this.sprite, + required this.x, + required this.y, + required this.scale, + required this.depth, + this.opacity = 1.0, + }); +} + +/// A named arrangement of clouds, mirroring the reference's `skies[]` entries. +class CloudLayout { + final List clouds; + + /// Horizontal drift, in screen widths per second at [CloudInstance.depth] + /// zero. The reference's `cloudSpeed` values are 30 (snow) … 100 (rain); these are + /// the same ordering scaled to screen units. + final double speed; + + const CloudLayout({required this.clouds, required this.speed}); + + /// Scattered fair-weather cumulus — a few small puffs high up. + static const CloudLayout fair = CloudLayout( + speed: 0.010, + clouds: [ + CloudInstance( + sprite: 0, + x: 0.10, + y: 0.30, + scale: 0.52, + depth: 0.75, + opacity: 0.85, + ), + CloudInstance( + sprite: 2, + x: 0.62, + y: 0.22, + scale: 0.44, + depth: 0.85, + opacity: 0.70, + ), + CloudInstance( + sprite: 4, + x: 1.15, + y: 0.36, + scale: 0.60, + depth: 0.65, + opacity: 0.80, + ), + ], + ); + + /// Broken cumulus — the default "some cloud about" sky. + static const CloudLayout scattered = CloudLayout( + speed: 0.012, + clouds: [ + CloudInstance(sprite: 0, x: 0.05, y: 0.34, scale: 0.72, depth: 0.45), + CloudInstance( + sprite: 1, + x: 0.55, + y: 0.22, + scale: 0.58, + depth: 0.70, + opacity: 0.85, + ), + CloudInstance(sprite: 3, x: 1.00, y: 0.40, scale: 0.86, depth: 0.30), + CloudInstance( + sprite: 2, + x: 1.55, + y: 0.26, + scale: 0.50, + depth: 0.80, + opacity: 0.75, + ), + CloudInstance( + sprite: 4, + x: 0.85, + y: 0.52, + scale: 0.64, + depth: 0.55, + opacity: 0.9, + ), + ], + ); + + /// Overcast — a stacked, overlapping deck that fills the frame. + static const CloudLayout overcast = CloudLayout( + speed: 0.008, + clouds: [ + CloudInstance(sprite: 8, x: 0.00, y: 0.30, scale: 1.30, depth: 0.55), + CloudInstance(sprite: 9, x: 0.75, y: 0.20, scale: 1.25, depth: 0.65), + CloudInstance(sprite: 10, x: 1.45, y: 0.34, scale: 1.35, depth: 0.45), + CloudInstance(sprite: 3, x: 0.35, y: 0.46, scale: 1.00, depth: 0.30), + CloudInstance(sprite: 0, x: 1.10, y: 0.50, scale: 0.95, depth: 0.25), + ], + ); + + /// Rain deck — heavy nimbostratus, low and fast. + static const CloudLayout rain = CloudLayout( + speed: 0.030, + clouds: [ + CloudInstance(sprite: 8, x: 0.00, y: 0.26, scale: 1.45, depth: 0.60), + CloudInstance(sprite: 10, x: 0.70, y: 0.16, scale: 1.40, depth: 0.70), + CloudInstance(sprite: 9, x: 1.40, y: 0.30, scale: 1.50, depth: 0.50), + CloudInstance(sprite: 11, x: 0.40, y: 0.44, scale: 1.20, depth: 0.28), + CloudInstance(sprite: 3, x: 1.15, y: 0.48, scale: 1.10, depth: 0.20), + ], + ); +} + +/// Where and how big a cloud instance lands on screen this frame. +typedef PlacedCloud = ({ + double left, + double top, + double width, + double height, + double opacity, + double depth, + int sprite, +}); + +/// Lays out [layout] for a widget of [size], drifted by [time] seconds. +/// +/// Instances are returned far-to-near so a painter can draw them in order and +/// get correct overlap without a depth buffer. Horizontal positions wrap over +/// a span wider than the screen, so clouds enter and leave continuously. +List placeClouds( + CloudLayout layout, { + required double width, + required double height, + required double time, + required double coverage, + double wind = 0.0, + double spriteAspect = 512 / 288, +}) { + final placed = []; + // How many instances the current coverage justifies. Below ~0.08 the sky is + // clear and nothing is drawn at all. + final visible = (layout.clouds.length * coverage.clamp(0.0, 1.0)).ceil(); + + for (var i = 0; i < layout.clouds.length && i < visible; i++) { + final c = layout.clouds[i]; + + // Near clouds drift faster — the parallax the reference gets from its 3D layout. + final speed = layout.speed * (1.0 + wind * 2.0) * (1.6 - c.depth); + // Wrap over two screen widths so a sprite is never popped into view. + final span = 2.4; + var x = (c.x + time * speed) % span; + if (x < 0) x += span; + x -= 0.7; // start off the left edge + + // Distant clouds are smaller and hazier. + final w = width * c.scale * (1.25 - 0.45 * c.depth); + final h = w / spriteAspect; + + placed.add(( + left: x * width, + top: c.y * height - h * 0.5, + width: w, + height: h, + opacity: c.opacity * (1.0 - 0.35 * c.depth), + depth: c.depth, + sprite: c.sprite, + )); + } + + // Far first, so nearer clouds overlap them. + placed.sort((a, b) => b.depth.compareTo(a.depth)); + return placed; +} + +/// The reference's light group: `(picker, multi, intensity)`. +/// +/// `picker` selects which sky elevation the light samples (0 = horizon, +/// 1 = zenith), `multi` is a gain and `intensity` scales the contribution. +typedef LightConfig = (double picker, double multi, double intensity); + +/// Cloud lighting for a given sun height, in the reference's five-group form. +/// +/// The engine authors these per keyframe; the ramps here follow the same +/// shape — as the sun drops, every probe slides toward the horizon, which is +/// what turns the clouds gold at dusk without any colour being named. +({ + LightConfig base, + LightConfig sun, + LightConfig ground, + LightConfig ambient, + double whitePer, +}) +cloudLighting({required double sunAngleY}) { + // 0 at night, 1 at midday. `sunAngleY` runs ~0.01 … 0.57. + final day = ((sunAngleY - 0.02) / 0.42).clamp(0.0, 1.0); + + return ( + // The body reads the sky just above the horizon. + base: (0.05 + 0.10 * day, 0.70 + 0.35 * day, 1.0), + // The lit face reads the sky near the sun's own height. + sun: (0.10 + 0.70 * day, 0.85 + 0.55 * day, 0.55 + 0.75 * day), + // Bounce off the haze below. + ground: (0.02, 0.55 + 0.25 * day, 0.30 + 0.30 * day), + // Dome fill from the zenith. + ambient: (0.80 + 0.15 * day, 0.60 + 0.30 * day, 0.35 + 0.35 * day), + // The reference's real curve peaks at 0.800. It cannot be used yet: `whitePer` + // mixes each light toward warm `vec3(1, 0.74, 0.53)`, and the reference weights + // those lights by the **authored per-keyframe `cloudConfig` intensities** + // while this port still synthesises them from one `day` scalar. Those + // synthesised intensities run high, so the true 0.8 pushes the four-light + // sum past 1 and the sprites clamp to solid white (measured: 0% white + // pixels at 0.1, large saturated blocks at 0.8). + // + // Blocked on consuming the authored cloudConfig; until then this stays at + // a value that does not blow out. + whitePer: 0.1 * smoothstep(0.30, 1.0, day), + ); +} diff --git a/lib/features/home/presentation/widgets/weather_sky/sky_keyframe.dart b/lib/features/home/presentation/widgets/weather_sky/sky_keyframe.dart new file mode 100644 index 000000000..0fde69852 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/sky_keyframe.dart @@ -0,0 +1,239 @@ +import 'dart:math' as math; + +/// One keyframe of the reference scene: the atmosphere, the light rig and +/// the fog/cloud colours at a single point in the day. +/// +/// The reference engine does not simulate a sun. Its sky is a lookup table indexed +/// by `(sunAngleY, view elevation)`, and [sunAngleY] is authored per keyframe +/// — it is literally the u coordinate into that table, not an elevation angle. +/// Time of day is therefore a walk along a ring of keyframes (17 for clear and +/// cloudy, 4 for everything else), not an ephemeris. +/// +/// Values here are already scaled to the units the shaders consume. +class SkyKeyframe { + /// Keyframe index within its weather's ring. + final double time; + + /// Sky-LUT u coordinate. Roughly 0.01 (night) … 0.57 (noon). + final double sunAngleY; + + /// Camera yaw in degrees, which sets the frustum's elevation span. + final double cameraYaw; + + /// Sun radiance multiplier, 3 … 23. + final double sunIntensity; + + /// Ozone layer half-thickness (metres). + final double ozoneThickness; + + /// Tonemap coefficients `(a, b, c, d, e)` applied inside the LUT bake. + final (double, double, double, double, double) postColor; + + /// The medium, as `(dry, wet)` ramps lerped by relative humidity. + /// + /// There is no un-ramped form of any of these: [resolveKeyframe] always + /// takes the humidity lerp, so a scalar alongside each pair would be read by + /// nothing. + final (double, double) humidRayleighHeight; + final (double, double) humidMieScatter; + final (double, double) humidMieAbsorb; + final (double, double) humidMieHeight; + final (double, double) humidMieAsymmetry; + + const SkyKeyframe({ + required this.time, + required this.sunAngleY, + required this.cameraYaw, + required this.sunIntensity, + required this.ozoneThickness, + required this.postColor, + required this.humidRayleighHeight, + required this.humidMieScatter, + required this.humidMieAbsorb, + required this.humidMieHeight, + required this.humidMieAsymmetry, + }); +} + +/// The medium's fixed constants: the standard Earth atmosphere from Hillaire, +/// "A Scalable and Production Ready Sky and Atmosphere Rendering Technique" +/// (EGSR 2020), which is what everything from Bruneton onward uses. +/// +/// Distances are metres and coefficients 1/metre. +abstract final class SkyConstants { + SkyConstants._(); + + /// Rayleigh scattering coefficient (1/m). + static const (double, double, double) rayleighScatter = ( + 5.802e-6, + 1.3558e-5, + 3.31e-5, + ); + + /// Ozone absorption coefficient (1/m). + static const (double, double, double) ozoneAbsorb = ( + 6.5e-7, + 1.881e-6, + 8.5e-8, + ); + + /// Ozone layer centre altitude (m). + static const double ozoneCentre = 25000; + + /// Planet radius (m). + static const double planetRadius = 6360000; + + /// Atmosphere outer radius (m). + static const double atmosphereRadius = 6460000; + + /// Viewer altitude (m). + static const double eyeAltitude = 739.98; + + /// Ray-march sample count in the sky-LUT bake. + static const int marchStepCount = 30; + + /// Transmittance LUT size. + static const int transmittanceWidth = 256; + static const int transmittanceHeight = 256; + + /// Sky-view LUT size. 141 rows = 128 above the horizon plus 13 below. + static const int skyLutWidth = 256; + static const int skyLutHeight = 141; +} + +/// A keyframe resolved for a moment in time: the two neighbouring keyframes +/// blended, then the humidity ramps applied. +class ResolvedSky { + final double sunAngleY; + final double cameraYaw; + final double sunIntensity; + final double rayleighHeight; + final double mieScatter; + final double mieAbsorb; + final double mieHeight; + final double mieAsymmetry; + final double ozoneThickness; + final (double, double, double, double, double) postColor; + + const ResolvedSky({ + required this.sunAngleY, + required this.cameraYaw, + required this.sunIntensity, + required this.rayleighHeight, + required this.mieScatter, + required this.mieAbsorb, + required this.mieHeight, + required this.mieAsymmetry, + required this.ozoneThickness, + required this.postColor, + }); + + /// Whether re-baking the LUTs is needed to move from this to [other]. + /// + /// Only the values the bake actually reads are compared — the fog and cloud + /// colours feed other layers and must not trigger a bake on their own. + bool needsRebake(ResolvedSky other) => + sunAngleY != other.sunAngleY || + cameraYaw != other.cameraYaw || + sunIntensity != other.sunIntensity || + rayleighHeight != other.rayleighHeight || + mieScatter != other.mieScatter || + mieAbsorb != other.mieAbsorb || + mieHeight != other.mieHeight || + mieAsymmetry != other.mieAsymmetry || + ozoneThickness != other.ozoneThickness || + postColor != other.postColor; +} + +double _lerp(double a, double b, double t) => a + (b - a) * t; + +/// Interpolates [frames] at ring position [position] and applies [humidity]. +/// +/// [position] is in keyframe units (0 … frames.length), and wraps — the ring +/// closes from the last keyframe back to the first, which is what carries the +/// scene through midnight without a seam. +ResolvedSky resolveSky( + List frames, { + required double position, + required double humidity, +}) { + assert(frames.isNotEmpty, 'a weather type must have keyframes'); + + final n = frames.length; + final wrapped = position % n; + final i0 = wrapped.floor() % n; + final i1 = (i0 + 1) % n; + final t = wrapped - wrapped.floorToDouble(); + + final a = frames[i0]; + final b = frames[i1]; + final h = humidity.clamp(0.0, 1.0); + + /// Blends a humidity ramp across both keyframes, then across humidity. + double humid((double, double) ra, (double, double) rb) => + _lerp(_lerp(ra.$1, ra.$2, h), _lerp(rb.$1, rb.$2, h), t); + + return ResolvedSky( + sunAngleY: _lerp(a.sunAngleY, b.sunAngleY, t), + cameraYaw: _lerp(a.cameraYaw, b.cameraYaw, t), + sunIntensity: _lerp(a.sunIntensity, b.sunIntensity, t), + // The humidity ramps override the base atmosphere values wherever the + // keyframe supplies one — that is the engine's own precedence. + rayleighHeight: humid(a.humidRayleighHeight, b.humidRayleighHeight), + mieScatter: humid(a.humidMieScatter, b.humidMieScatter), + mieAbsorb: humid(a.humidMieAbsorb, b.humidMieAbsorb), + mieHeight: humid(a.humidMieHeight, b.humidMieHeight), + mieAsymmetry: humid(a.humidMieAsymmetry, b.humidMieAsymmetry), + ozoneThickness: _lerp(a.ozoneThickness, b.ozoneThickness, t), + postColor: ( + _lerp(a.postColor.$1, b.postColor.$1, t), + _lerp(a.postColor.$2, b.postColor.$2, t), + _lerp(a.postColor.$3, b.postColor.$3, t), + _lerp(a.postColor.$4, b.postColor.$4, t), + _lerp(a.postColor.$5, b.postColor.$5, t), + ), + ); +} + +/// Maps a wall-clock hour to a position on a keyframe ring of [frameCount]. +/// +/// The ring is authored as an even split of the day with keyframe 0 at +/// midnight: the 17-frame tables peak at keyframe 8 (`sunAngleY` 0.02 → 0.50 → +/// 0.01), i.e. 11.3 h, solar noon. Sunrise therefore lands near keyframe 4 and +/// sunset near keyframe 13. +/// +/// Rather than assume those hours, the day and night segments are *stretched* +/// between the real [sunrise] and [sunset]. An even split would peg dawn to a +/// fixed 5.6 h year-round, which is over an hour out in a Taiwanese winter +/// (sunrise 06:35 in December against 05:05 in June). +/// +/// Anchoring must map sunrise to the **dawn keyframe**, not to keyframe 0 — +/// keyframe 0 is midnight, and mapping sunrise there renders a night sky at +/// 06:00. +/// +/// [hour], [sunrise] and [sunset] are local decimal hours. +double keyframePosition( + double hour, { + required int frameCount, + double sunrise = 5.6, + double sunset = 18.4, +}) { + final n = frameCount.toDouble(); + // Where dawn and dusk sit on an evenly-split ring. + final dawnKey = n * 5.6 / 24.0; + final duskKey = n * 18.4 / 24.0; + + final wrapped = hour % 24.0; + final h = wrapped < 0 ? wrapped + 24.0 : wrapped; + + if (h >= sunrise && h < sunset) { + final t = (h - sunrise) / math.max(sunset - sunrise, 1e-6); + return dawnKey + t * (duskKey - dawnKey); + } + + // Night: from dusk, through midnight, back around to dawn. + final nightHours = 24.0 - (sunset - sunrise); + final since = h >= sunset ? h - sunset : h + 24.0 - sunset; + final t = since / math.max(nightHours, 1e-6); + return (duskKey + t * (n - duskKey + dawnKey)) % n; +} diff --git a/lib/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart b/lib/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart new file mode 100644 index 000000000..5baa0e297 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart @@ -0,0 +1,1150 @@ +// GENERATED — do not edit by hand. +// +// The scene keyframe table, already rescaled to the GL units the sky shaders +// consume. +// +// Each weather type carries either 17 keyframes (a full day/night ring, +// for clear and cloudy) or 4 (morning/noon/dusk/night). `sunAngleY` is +// not a physical elevation — it is the u coordinate into the sky LUT. + +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; + +/// sunny — 17 keyframes. +const List sunnyKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.02, + cameraYaw: 4, + sunIntensity: 8, + ozoneThickness: 8000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (4000, 2000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.02, + cameraYaw: 4, + sunIntensity: 11, + ozoneThickness: 10000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (4000, 2000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.03, + cameraYaw: 3, + sunIntensity: 13, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 5000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 7000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.05, + cameraYaw: 2, + sunIntensity: 11, + ozoneThickness: 40000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 6000), + humidMieScatter: (8e-06, 1.2e-05), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (2000, 3000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 4, + sunAngleY: 0.06, + cameraYaw: 0, + sunIntensity: 12, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (5000, 3000), + humidMieScatter: (1.3e-05, 1.7e-05), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (2000, 3000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.1, + cameraYaw: 1, + sunIntensity: 13, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 5000), + humidMieScatter: (8e-06, 1.2e-05), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 6, + sunAngleY: 0.2, + cameraYaw: 1, + sunIntensity: 13, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 7, + sunAngleY: 0.4, + cameraYaw: 1, + sunIntensity: 17, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 9000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 8, + sunAngleY: 0.5, + cameraYaw: 2, + sunIntensity: 19, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 9000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 9, + sunAngleY: 0.33, + cameraYaw: 2, + sunIntensity: 15, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 10, + sunAngleY: 0.13, + cameraYaw: 1, + sunIntensity: 14, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10000, 8000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 11, + sunAngleY: 0.05, + cameraYaw: 1, + sunIntensity: 18, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (9000, 7000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.03, + cameraYaw: 1, + sunIntensity: 23, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (9000, 7000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 2000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 13, + sunAngleY: 0.025, + cameraYaw: 0.8, + sunIntensity: 20, + ozoneThickness: 30000, + postColor: (1, 0, 0.3, 0, 1), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 14, + sunAngleY: 0.015, + cameraYaw: 0, + sunIntensity: 15, + ozoneThickness: 20000, + postColor: (1, 0, 0.2, 0, 1), + humidRayleighHeight: (4000, 2000), + humidMieScatter: (8e-06, 1.2e-05), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (1500, 2000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.01, + cameraYaw: 4, + sunIntensity: 20, + ozoneThickness: 17000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (5000, 3000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (1000, 5000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 16, + sunAngleY: 0.02, + cameraYaw: 4, + sunIntensity: 8, + ozoneThickness: 8000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (4000, 2000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// cloudy — 17 keyframes. +const List cloudyKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.02, + cameraYaw: 4, + sunIntensity: 8, + ozoneThickness: 8000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (4000, 2000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.02, + cameraYaw: 4, + sunIntensity: 11, + ozoneThickness: 10000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (4000, 2000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.03, + cameraYaw: 3, + sunIntensity: 13, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 5000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 7000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.05, + cameraYaw: 2, + sunIntensity: 11, + ozoneThickness: 40000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 6000), + humidMieScatter: (8e-06, 1.2e-05), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (2000, 3000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 4, + sunAngleY: 0.06, + cameraYaw: 0, + sunIntensity: 12, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (5000, 3000), + humidMieScatter: (1.3e-05, 1.7e-05), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (2000, 3000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.1, + cameraYaw: 1, + sunIntensity: 13, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 5000), + humidMieScatter: (8e-06, 1.2e-05), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 6, + sunAngleY: 0.2, + cameraYaw: 1, + sunIntensity: 13, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 7, + sunAngleY: 0.4, + cameraYaw: 1, + sunIntensity: 17, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 9000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 8, + sunAngleY: 0.5, + cameraYaw: 2, + sunIntensity: 19, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 9000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 9, + sunAngleY: 0.33, + cameraYaw: 2, + sunIntensity: 15, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 10, + sunAngleY: 0.13, + cameraYaw: 1, + sunIntensity: 14, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10000, 8000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 11, + sunAngleY: 0.05, + cameraYaw: 1, + sunIntensity: 18, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (9000, 7000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.03, + cameraYaw: 1, + sunIntensity: 23, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (9000, 7000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 2000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 13, + sunAngleY: 0.025, + cameraYaw: 0.8, + sunIntensity: 20, + ozoneThickness: 30000, + postColor: (1, 0, 0.3, 0, 1), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 14, + sunAngleY: 0.015, + cameraYaw: 0, + sunIntensity: 15, + ozoneThickness: 20000, + postColor: (1, 0, 0.2, 0, 1), + humidRayleighHeight: (4000, 2000), + humidMieScatter: (8e-06, 1.2e-05), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (1500, 2000), + humidMieAsymmetry: (0, 0.1), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.01, + cameraYaw: 4, + sunIntensity: 20, + ozoneThickness: 17000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (5000, 3000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (1000, 5000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 16, + sunAngleY: 0.02, + cameraYaw: 4, + sunIntensity: 8, + ozoneThickness: 8000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (4000, 2000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// overcast — 4 keyframes. +const List overcastKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 7, + sunIntensity: 4, + ozoneThickness: 39000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10000, 10000), + humidMieScatter: (3.8e-05, 3.8e-05), + humidMieAbsorb: (9e-06, 9e-06), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 7, + sunIntensity: 10, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (12000, 12000), + humidMieScatter: (3e-05, 3e-05), + humidMieAbsorb: (1.2e-05, 1.2e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 7, + sunIntensity: 10, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (12000, 12000), + humidMieScatter: (3e-05, 3e-05), + humidMieAbsorb: (1.2e-05, 1.2e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 7, + sunIntensity: 4, + ozoneThickness: 39000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10000, 10000), + humidMieScatter: (3.8e-05, 3.8e-05), + humidMieAbsorb: (9e-06, 9e-06), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// foggy — 4 keyframes. +const List foggyKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 8, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (2000, 2000), + humidMieScatter: (1.5e-05, 1.5e-05), + humidMieAbsorb: (1e-05, 1e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 10, + sunIntensity: 10, + ozoneThickness: 3000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10000, 10000), + humidMieScatter: (1.2e-05, 1.2e-05), + humidMieAbsorb: (3e-06, 3e-06), + humidMieHeight: (10000, 10000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 10, + sunIntensity: 10, + ozoneThickness: 3000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10000, 10000), + humidMieScatter: (1.2e-05, 1.2e-05), + humidMieAbsorb: (3e-06, 3e-06), + humidMieHeight: (10000, 10000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 8, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (2000, 2000), + humidMieScatter: (1.5e-05, 1.5e-05), + humidMieAbsorb: (1e-05, 1e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// smoggy — 4 keyframes. +const List smoggyKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 0, + sunIntensity: 4, + ozoneThickness: 8000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10000, 10000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (1e-05, 1e-05), + humidMieHeight: (10000, 10000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 0, + sunIntensity: 15, + ozoneThickness: 8000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (16000, 16000), + humidMieScatter: (3.8e-05, 3.8e-05), + humidMieAbsorb: (1e-05, 1e-05), + humidMieHeight: (10000, 10000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 0, + sunIntensity: 15, + ozoneThickness: 8000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (16000, 16000), + humidMieScatter: (3.8e-05, 3.8e-05), + humidMieAbsorb: (1e-05, 1e-05), + humidMieHeight: (10000, 10000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 0, + sunIntensity: 4, + ozoneThickness: 8000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10000, 10000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (1e-05, 1e-05), + humidMieHeight: (10000, 10000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// rainyLight — 4 keyframes. +const List rainyLightKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 4, + sunIntensity: 4, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (9000, 9000), + humidMieScatter: (1.4e-05, 1.4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 10, + sunIntensity: 10, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (17000, 17000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 10, + sunIntensity: 10, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (17000, 17000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 4, + sunIntensity: 4, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (9000, 9000), + humidMieScatter: (1.4e-05, 1.4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// rainyMedium — 4 keyframes. +const List rainyMediumKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 5, + sunIntensity: 4, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 8000), + humidMieScatter: (3e-05, 3e-05), + humidMieAbsorb: (3.8e-05, 3.8e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 10, + sunIntensity: 9, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (15000, 15000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 10, + sunIntensity: 9, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (15000, 15000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 5, + sunIntensity: 4, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 8000), + humidMieScatter: (3e-05, 3e-05), + humidMieAbsorb: (3.8e-05, 3.8e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// rainyHeavy — 4 keyframes. +const List rainyHeavyKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 7, + sunIntensity: 3, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 8000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 10, + sunIntensity: 8, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (15000, 15000), + humidMieScatter: (1.2e-05, 1.2e-05), + humidMieAbsorb: (1.4e-05, 1.4e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 10, + sunIntensity: 8, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (15000, 15000), + humidMieScatter: (1.2e-05, 1.2e-05), + humidMieAbsorb: (1.4e-05, 1.4e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 7, + sunIntensity: 3, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 8000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// rainyExtreme — 4 keyframes. +const List rainyExtremeKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 4, + sunIntensity: 3, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (1e-05, 1e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 6, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (13000, 13000), + humidMieScatter: (0, 0), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 6, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (13000, 13000), + humidMieScatter: (0, 0), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 4, + sunIntensity: 3, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (1e-05, 1e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// snowyLight — 4 keyframes. +const List snowyLightKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 4, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (6000, 6000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 13, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 13, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 4, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (6000, 6000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// snowyMedium — 4 keyframes. +const List snowyMediumKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 5, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 11, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 11, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 5, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// snowyHeavy — 4 keyframes. +const List snowyHeavyKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 5, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 10, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (5000, 5000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 10, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (5000, 5000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 5, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// sandyHeavy — 4 keyframes. +const List sandyHeavyKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.57, + cameraYaw: 0, + sunIntensity: 8, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (50000, 50000), + humidMieScatter: (0.00017, 0.00017), + humidMieAbsorb: (5e-05, 5e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.57, + cameraYaw: 0, + sunIntensity: 20, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (50000, 50000), + humidMieScatter: (0.00017, 0.00017), + humidMieAbsorb: (5e-05, 5e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.57, + cameraYaw: 0, + sunIntensity: 20, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (50000, 50000), + humidMieScatter: (0.00017, 0.00017), + humidMieAbsorb: (5e-05, 5e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.57, + cameraYaw: 0, + sunIntensity: 8, + ozoneThickness: 30000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (50000, 50000), + humidMieScatter: (0.00017, 0.00017), + humidMieAbsorb: (5e-05, 5e-05), + humidMieHeight: (2000, 2000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// sandyLight — 4 keyframes. +const List sandyLightKeyframes = [ + SkyKeyframe( + time: 4, + sunAngleY: 0.2, + cameraYaw: 3, + sunIntensity: 6, + ozoneThickness: 2000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (22000, 22000), + humidMieScatter: (8e-05, 8e-05), + humidMieAbsorb: (8e-06, 8e-06), + humidMieHeight: (10000, 10000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.2, + cameraYaw: 3, + sunIntensity: 15, + ozoneThickness: 1000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (17000, 17000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (3e-06, 3e-06), + humidMieHeight: (10000, 10000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.2, + cameraYaw: 3, + sunIntensity: 15, + ozoneThickness: 1000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (17000, 17000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (3e-06, 3e-06), + humidMieHeight: (10000, 10000), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.2, + cameraYaw: 3, + sunIntensity: 6, + ozoneThickness: 2000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (22000, 22000), + humidMieScatter: (8e-05, 8e-05), + humidMieAbsorb: (8e-06, 8e-06), + humidMieHeight: (10000, 10000), + humidMieAsymmetry: (0, 0), + ), +]; + +/// All weather types, in weather-code order. +const List> weatherKeyframes = [ + sunnyKeyframes, + cloudyKeyframes, + overcastKeyframes, + foggyKeyframes, + smoggyKeyframes, + rainyLightKeyframes, + rainyMediumKeyframes, + rainyHeavyKeyframes, + rainyExtremeKeyframes, + snowyLightKeyframes, + snowyMediumKeyframes, + snowyHeavyKeyframes, + sandyHeavyKeyframes, + sandyLightKeyframes, +]; diff --git a/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart b/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart new file mode 100644 index 000000000..f08ca1f02 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart @@ -0,0 +1,383 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; +import 'package:flutter/material.dart'; + +/// Bakes and caches the two atmospheric lookup tables the sky is drawn from. +/// +/// This is what lets a physically-integrated sky run at 60 fps on a phone, and +/// it is the reference engine's own design: ray-marching scattering per pixel per +/// frame is far too expensive, so the integral is evaluated into small +/// textures and the per-frame shader becomes a lookup. +/// +/// 1. [transmittance] (256×256) — how much sunlight survives to any altitude +/// and sun angle. Depends only on the medium. +/// 2. [skyView] (256×141) — the sky's radiance, with the sun's position along +/// its arc on the u axis. Both sizes are the engine's own. +/// +/// The engine re-bakes only when the weather or time slot changes, gated on a +/// dirty flag; [update] does the same by comparing the resolved keyframe. +class SkyLutCache { + final ui.FragmentShader _transmittanceShader; + final ui.FragmentShader _skyLutShader; + + ui.Image? _transmittance; + ui.Image? _skyView; + ResolvedSky? _baked; + + /// The baked LUT's raw RGBA rows, read back once per bake so pixel- + /// independent probes (the cloud shader's base/haze colours) can be sampled + /// on the CPU instead of the GPU fetching them on every cloud pixel. + Uint8List? _skyViewBytes; + + /// The LUT column at the keyframe's sun angle, CPU-extracted (1×141). The + /// cloud shader samples this instead of the full 2D LUT — its u is the + /// constant sun angle, so horizontal filtering happens once at bake time and + /// every probe costs a 2-tap vertical fetch instead of a 4-tap bilinear. + ui.Image? _skyColumn; + + /// The screen sky gradient (4×1024), rebuilt whenever the keyframe re-bakes. + /// The sky pass is a pure vertical gradient of a fixed LUT column, so it is + /// precomputed here and drawn as one stretched image per frame instead of + /// running a full-screen shader. + ui.Image? _skyGradient; + + /// Takes the stage-1 and stage-2 bake shaders, in pipeline order. They stay + /// owned by the caller; [dispose] only releases the baked images. + SkyLutCache(this._transmittanceShader, this._skyLutShader); + + /// The current sky-view LUT, or `null` before the first bake. + ui.Image? get skyView => _skyView; + + /// The LUT column at the keyframe's sun angle, for the cloud shader's + /// probes — or `null` before the first bake's readback completes. + ui.Image? get skyColumn => _skyColumn; + + /// The screen sky gradient the backdrop draws each frame, or `null` before + /// the first readback completes. + ui.Image? get skyGradient => _skyGradient; + + /// Size of [skyView] in texels, for the samplers' manual bilinear filter. + static const Size skyViewSize = Size(256, 141); + + /// The sky colour the reference's panel water sees. + /// + /// The reference shader's `uBgLightTex` is the 256×141 sky-view LUT (the cloud + /// shader indexes the same texture with the LUT's own `(y·256−115)/141` + /// layout, and the city scene passes the LUT in directly); the water pass + /// samples it at the panel's on-screen `vUv`, i.e. a lower-middle row. + /// Published here as one colour — the LUT row the panel band covers — every + /// time the LUT re-bakes, so the water is lit by the *same sky it falls + /// out of*, experimental overrides included. + static final ValueNotifier panelAmbient = ValueNotifier(null); + + /// Where the panel water samples the LUT: `vUv` ≈ (0.5, 0.7) — the card + /// sits in the lower third of the screen. + static const Offset _ambientSample = Offset(0.5, 0.7); + + /// Re-bakes if [sky] differs from what is cached. Returns whether it did. + bool update(ResolvedSky sky) { + final previous = _baked; + if (previous != null && !previous.needsRebake(sky)) return false; + + // The medium changes with almost every keyframe, so both stages re-bake + // together; splitting them would save little and risk them disagreeing. + _transmittance?.dispose(); + _transmittance = _bakeTransmittance(sky); + + _skyView?.dispose(); + _skyView = _bakeSkyView(sky, _transmittance!); + _baked = sky; + _publishReadback(_skyView!, sky); + return true; + } + + /// Reads the LUT rows back — async and once per bake, never per frame. The + /// buffer is kept so [skyAt] can serve the pixel-independent cloud probes; + /// the panel-water ambient row, the cloud shader's column and the screen + /// gradient are all derived from the same read. + void _publishReadback(ui.Image skyView, ResolvedSky sky) { + skyView + .toByteData(format: ui.ImageByteFormat.rawRgba) + .then((data) { + if (data == null) return; + _skyViewBytes = data.buffer.asUint8List(); + final bytes = _skyViewBytes!; + final x = (_ambientSample.dx * (skyViewSize.width - 1)).round(); + final y = (_ambientSample.dy * (skyViewSize.height - 1)).round(); + final o = (y * skyViewSize.width.toInt() + x) * 4; + panelAmbient.value = Color.fromARGB( + 255, + bytes[o], + bytes[o + 1], + bytes[o + 2], + ); + _bakeSkyColumn(sky); + _bakeSkyGradient(sky); + }) + .catchError((Object _) {}); + } + + /// Extracts the LUT column at the keyframe's sun angle into a 1×141 image. + /// + /// The horizontal tap pair and blend factor are the shaders' own — the same + /// texel-centre convention as `sampleLut`, evaluated once on the CPU. A GPU + /// 2D bilinear at the constant u then reduces to the identical 2-tap + /// vertical fetch, so the cloud lighting is unchanged. + void _bakeSkyColumn(ResolvedSky sky) { + final bytes = _skyViewBytes; + if (bytes == null) return; + const width = SkyConstants.skyLutWidth; + const height = SkyConstants.skyLutHeight; + final sx = sky.sunAngleY.clamp(0.0, 1.0) * width - 0.5; + final x0 = sx.floor(); + final fx = sx - x0; + final xa = x0.clamp(0, width - 1); + final xb = (x0 + 1).clamp(0, width - 1); + + final image = _rasterize(1, height, (canvas) { + for (var r = 0; r < height; r++) { + final o = r * width * 4; + final ca = o + xa * 4; + final cb = o + xb * 4; + canvas.drawRect( + Rect.fromLTWH(0, r.toDouble(), 1, 1), + Paint() + ..color = Color.fromARGB( + 255, + (bytes[ca] + (bytes[cb] - bytes[ca]) * fx).round().clamp(0, 255), + (bytes[ca + 1] + (bytes[cb + 1] - bytes[ca + 1]) * fx) + .round() + .clamp(0, 255), + (bytes[ca + 2] + (bytes[cb + 2] - bytes[ca + 2]) * fx) + .round() + .clamp(0, 255), + ), + ); + } + }); + _skyColumn?.dispose(); + _skyColumn = image; + } + + /// Bakes the screen sky gradient — a 4×1024 image whose rows are the exact + /// colour the old `sky_view.frag` screen pass produced at that screen + /// position, evaluated on the CPU from the frustum and the LUT column. + /// + /// The shader's per-pixel `normalize → asin → sqrt` chain and its 4-tap + /// bilinear are replaced by a piecewise-linear ramp of 1024 exact samples, + /// drawn stretched once per frame. Near the horizon — where the mapping is + /// steepest — a sample every ~2px keeps the error below one 8-bit level. + /// The reference's sub-texel dither is dropped: it existed to hide LUT row + /// seams, and this ramp is smooth by construction. + void _bakeSkyGradient(ResolvedSky sky) { + final bytes = _skyViewBytes; + if (bytes == null) return; + const width = 4; + const rows = 1024; + final fr = buildFrustum(sky.cameraYaw); + final ax = fr.top[0]; + final ay = fr.top[1]; + final bx = fr.bottom[0]; + final by = fr.bottom[1]; + final u = sky.sunAngleY.clamp(0.0, 1.0); + + final image = _rasterize(width, rows, (canvas) { + for (var r = 0; r < rows; r++) { + final t = (r + 0.5) / rows; + final x = ax + (bx - ax) * t; + final y = ay + (by - ay) * t; + final len = math.sqrt(x * x + y * y); + final theta = math.asin((y / len).clamp(-1.0, 1.0)); + final v = + 0.5 + 0.5 * theta.sign * math.sqrt(theta.abs() / (math.pi / 2.0)); + final lutV = ((v * 256.0 - 115.0) / 141.0).clamp(0.0, 1.0); + final color = skyAt(u, lutV) ?? const Color(0xFF3F6DAE); + canvas.drawRect( + Rect.fromLTWH(0, r.toDouble(), width.toDouble(), 1), + Paint()..color = color, + ); + } + }); + _skyGradient?.dispose(); + _skyGradient = image; + } + + /// The baked sky's colour at LUT coordinate ([u], [v]), bilinear — the same + /// manual filtering the shaders' `sampleLut` does (Flutter binds samplers + /// NEAREST), and in the same texel-centre convention: `uv · size − 0.5`, + /// so the first tap lands on a texel's centre and out-of-range indices + /// clamp to the edge texel. `null` before the first readback completes. + /// + /// Lives here because [sky_view.frag]'s u axis *is* the keyframe's + /// `sunAngleY`, so a probe's u is known on the CPU; probes whose picker is + /// pixel-independent (the cloud shader's base and haze colours) are then + /// baked once per keyframe rather than fetched on every cloud pixel. + Color? skyAt(double u, double v) { + final bytes = _skyViewBytes; + if (bytes == null) return null; + const width = SkyConstants.skyLutWidth; + const height = SkyConstants.skyLutHeight; + Color texel(int px, int py) { + final o = (py * width + px) * 4; + return Color.fromARGB(255, bytes[o], bytes[o + 1], bytes[o + 2]); + } + + final sx = u.clamp(0.0, 1.0) * width - 0.5; + final x0 = sx.floor(); + final fx = sx - x0; + final sy = v.clamp(0.0, 1.0) * height - 0.5; + final y0 = sy.floor(); + final fy = sy - y0; + int px(int i) => i.clamp(0, width - 1); + int py(int j) => j.clamp(0, height - 1); + + final c00 = texel(px(x0), py(y0)); + final c10 = texel(px(x0 + 1), py(y0)); + final c01 = texel(px(x0), py(y0 + 1)); + final c11 = texel(px(x0 + 1), py(y0 + 1)); + return Color.lerp(Color.lerp(c00, c10, fx), Color.lerp(c01, c11, fx), fy)!; + } + + ui.Image _bakeTransmittance(ResolvedSky sky) { + final shader = _transmittanceShader; + var i = 0; + void set(double v) => shader.setFloat(i++, v); + + set(SkyConstants.transmittanceWidth.toDouble()); + set(SkyConstants.transmittanceHeight.toDouble()); + set(SkyConstants.rayleighScatter.$1); + set(SkyConstants.rayleighScatter.$2); + set(SkyConstants.rayleighScatter.$3); + set(SkyConstants.ozoneAbsorb.$1); + set(SkyConstants.ozoneAbsorb.$2); + set(SkyConstants.ozoneAbsorb.$3); + set(sky.rayleighHeight); + set(sky.mieScatter); + set(sky.mieAbsorb); + set(sky.mieHeight); + set(SkyConstants.ozoneCentre); + set(sky.ozoneThickness); + set(SkyConstants.planetRadius); + set(SkyConstants.atmosphereRadius); + + return _rasterise( + shader, + SkyConstants.transmittanceWidth, + SkyConstants.transmittanceHeight, + ); + } + + ui.Image _bakeSkyView(ResolvedSky sky, ui.Image transmittance) { + final shader = _skyLutShader; + var i = 0; + void set(double v) => shader.setFloat(i++, v); + + set(SkyConstants.skyLutWidth.toDouble()); + set(SkyConstants.skyLutHeight.toDouble()); + set(SkyConstants.rayleighScatter.$1); + set(SkyConstants.rayleighScatter.$2); + set(SkyConstants.rayleighScatter.$3); + set(SkyConstants.ozoneAbsorb.$1); + set(SkyConstants.ozoneAbsorb.$2); + set(SkyConstants.ozoneAbsorb.$3); + set(sky.sunIntensity); + set(sky.sunIntensity); + set(sky.sunIntensity); + set(sky.postColor.$1); + set(sky.postColor.$2); + set(sky.postColor.$3); + set(sky.postColor.$4); + set(sky.postColor.$5); + set(sky.rayleighHeight); + set(sky.mieScatter); + set(sky.mieAbsorb); + set(sky.mieHeight); + set(sky.mieAsymmetry); + set(SkyConstants.ozoneCentre); + set(sky.ozoneThickness); + set(SkyConstants.planetRadius); + set(SkyConstants.atmosphereRadius); + set(SkyConstants.eyeAltitude); + set(SkyConstants.transmittanceWidth.toDouble()); + set(SkyConstants.transmittanceHeight.toDouble()); + shader.setImageSampler(0, transmittance); + + return _rasterise( + shader, + SkyConstants.skyLutWidth, + SkyConstants.skyLutHeight, + ); + } + + static ui.Image _rasterise(ui.FragmentShader shader, int width, int height) => + _rasterize( + width, + height, + (canvas) => canvas.drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..shader = shader, + ), + ); + + /// Rasterises [draw] into a small GPU-resident image. Synchronous — no CPU + /// round trip, which is what makes an in-frame bake affordable. + static ui.Image _rasterize( + int width, + int height, + void Function(ui.Canvas) draw, + ) { + final recorder = ui.PictureRecorder(); + draw(ui.Canvas(recorder)); + final picture = recorder.endRecording(); + final image = picture.toImageSync(width, height); + picture.dispose(); + return image; + } + + /// Releases the baked images. The shaders are owned by the caller. + void dispose() { + _transmittance?.dispose(); + _skyView?.dispose(); + _skyGradient?.dispose(); + _skyColumn?.dispose(); + _transmittance = null; + _skyView = null; + _skyGradient = null; + _skyColumn = null; + _skyViewBytes = null; + _baked = null; + } +} + +/// The view frustum's top and bottom rays, as the engine builds them. +/// +/// Two properties here are load-bearing for the look and are kept deliberately: +/// +/// • A fixed 20° vertical field of view. The visible sky is a narrow ~19° +/// band, not a dome — widening it is the quickest way to change the look. +/// There is nothing to aspect-correct: the sky has no azimuthal variation, +/// so only the vertical extent means anything. +/// • The camera's 0.017 height offset is baked into the corners and the +/// shader normalises them from the world origin rather than the camera, so +/// that offset acts as a permanent upward pitch bias of about 9.7°. +typedef Frustum = ({List top, List bottom}); + +/// Builds the frustum for a keyframe's `cameraYaw` (degrees). +Frustum buildFrustum(double cameraYawDeg) { + const nearZ = 0.1; + const eyeY = 0.017; + const fovDeg = 20.0; + + final fTan = nearZ * math.tan((fovDeg / 2.0) * math.pi / 180.0); + final p = cameraYawDeg * math.pi / 180.0; + final cp = math.cos(p); + final sp = math.sin(p); + + return ( + top: [nearZ * cp - fTan * sp, eyeY + nearZ * sp + fTan * cp, 0.0], + bottom: [nearZ * cp + fTan * sp, eyeY + nearZ * sp - fTan * cp, 0.0], + ); +} diff --git a/lib/features/home/presentation/widgets/weather_sky/solar_time.dart b/lib/features/home/presentation/widgets/weather_sky/solar_time.dart new file mode 100644 index 000000000..5a47ad136 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/solar_time.dart @@ -0,0 +1,130 @@ +import 'dart:math' as math; + +/// Where the sun is, and when it rises and sets, for a point on the ground. +/// +/// The reference engine has no ephemeris — its sky is a ring of authored keyframes. +/// But the ring still has to be *anchored* to the real day, or dawn keyframes +/// land at the wrong hour and drift by hours across the seasons. In Taiwan +/// daylight runs from about 10h35m in December to 13h40m in June, so anchoring +/// to fixed clock times would be visibly wrong twice a year. +/// +/// So this computes the real sunrise and sunset, and the keyframe ring is +/// stretched between them (see `keyframePosition` in `sky_keyframe.dart`). + +/// [elevation] is radians above the horizon (negative at night); [azimuth] is +/// radians clockwise from true north. +typedef SolarPosition = ({double elevation, double azimuth}); + +/// Taiwan's approximate geographic centre — the fallback when no device +/// location is available. The sun's position varies by well under a degree +/// across the island, so this is visually exact for the whole country. +const double kTaiwanLatitude = 23.75; +const double kTaiwanLongitude = 121.0; + +const double _deg = math.pi / 180.0; + +/// Days since the J2000.0 epoch (2000-01-01 12:00 UTC). +double _julianDays(DateTime utc) => + utc.millisecondsSinceEpoch / 86400000.0 - 10957.5; + +/// The sun's apparent position at [utc], by a low-precision algorithm +/// (accurate to ~0.01°, far beyond what a backdrop needs). +SolarPosition solarPosition( + DateTime utc, { + double latitude = kTaiwanLatitude, + double longitude = kTaiwanLongitude, +}) { + final n = _julianDays(utc); + + final meanLongitude = (280.460 + 0.9856474 * n) % 360.0; + final meanAnomaly = ((357.528 + 0.9856003 * n) % 360.0) * _deg; + + // Equation of centre → apparent ecliptic longitude. + final eclipticLongitude = + (meanLongitude + + 1.915 * math.sin(meanAnomaly) + + 0.020 * math.sin(2 * meanAnomaly)) * + _deg; + + final obliquity = (23.439 - 0.0000004 * n) * _deg; + + final rightAscension = math.atan2( + math.cos(obliquity) * math.sin(eclipticLongitude), + math.cos(eclipticLongitude), + ); + final declination = math.asin( + math.sin(obliquity) * math.sin(eclipticLongitude), + ); + + final gmstHours = (18.697374558 + 24.06570982441908 * n) % 24.0; + final hourAngle = (gmstHours * 15.0 + longitude) * _deg - rightAscension; + + final lat = latitude * _deg; + final sinElevation = + math.sin(lat) * math.sin(declination) + + math.cos(lat) * math.cos(declination) * math.cos(hourAngle); + + return ( + elevation: math.asin(sinElevation.clamp(-1.0, 1.0)), + azimuth: + math.atan2( + -math.sin(hourAngle), + math.tan(declination) * math.cos(lat) - + math.sin(lat) * math.cos(hourAngle), + ) % + (2 * math.pi), + ); +} + +/// Sunrise and sunset for the calendar day of [utc], in local decimal hours +/// at [utcOffsetHours]. +/// +/// Solved from the hour angle at which the sun's centre reaches −0.833° +/// (the standard refraction-and-radius allowance), so it matches published +/// almanac times rather than geometric horizon crossing. +({double sunrise, double sunset}) sunTimes( + DateTime utc, { + double latitude = kTaiwanLatitude, + double longitude = kTaiwanLongitude, + double utcOffsetHours = 8, +}) { + final n = _julianDays(utc); + + final meanLongitude = (280.460 + 0.9856474 * n) % 360.0; + final meanAnomaly = ((357.528 + 0.9856003 * n) % 360.0) * _deg; + final eclipticLongitude = + (meanLongitude + + 1.915 * math.sin(meanAnomaly) + + 0.020 * math.sin(2 * meanAnomaly)) * + _deg; + final obliquity = (23.439 - 0.0000004 * n) * _deg; + final declination = math.asin( + math.sin(obliquity) * math.sin(eclipticLongitude), + ); + + final lat = latitude * _deg; + const altitude = -0.833 * _deg; + + final cosHourAngle = + (math.sin(altitude) - math.sin(lat) * math.sin(declination)) / + (math.cos(lat) * math.cos(declination)); + + // Polar day or night — no crossing exists. + if (cosHourAngle <= -1) return (sunrise: 0.0, sunset: 24.0); + if (cosHourAngle >= 1) return (sunrise: 12.0, sunset: 12.0); + + final hourAngle = math.acos(cosHourAngle) / _deg / 15.0; // hours + + // Equation of time, from the difference between mean and apparent sun. + final rightAscension = math.atan2( + math.cos(obliquity) * math.sin(eclipticLongitude), + math.cos(eclipticLongitude), + ); + var eot = (meanLongitude * _deg - rightAscension) / _deg / 15.0; + // Unwrap: the difference must stay within a few minutes of zero. + eot = (eot + 12.0) % 24.0 - 12.0; + + final solarNoon = 12.0 - longitude / 15.0 + utcOffsetHours - eot; + + return (sunrise: solarNoon - hourAngle, sunset: solarNoon + hourAngle); +} diff --git a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart new file mode 100644 index 000000000..ba6000201 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart @@ -0,0 +1,588 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/core/settings/sky_time_mode.dart'; +import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/precipitation_field.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_clouds.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/solar_time.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; + +/// GPU-rendered layered sky backdrop, ported from the reference engine. +/// +/// The scene is driven the way the original is: a ring of authored keyframes +/// per weather type (`sky_keyframe_data.dart`) supplies the atmosphere, and a three-stage scattering pipeline +/// (`shaders/sky/`) bakes that into two lookup tables the screen pass simply +/// reads. There is no simulated sun — `sunAngleY` *is* the sky LUT's u +/// coordinate. The ring is anchored to the real sunrise and sunset for the +/// viewer's latitude, so dawn keyframes land at dawn year-round. +/// +/// Clouds are authored volumetric sprites with baked normal maps +/// (`assets/weather/clouds/`, generated by `tool/gen_cloud_sprites.py`), lit by +/// sampling the sky LUT — the environment-light model, and the reason +/// cloud colour always agrees with the sky behind it. +/// +/// Cost is controlled by structure: the scattering integral runs only when the +/// resolved keyframe changes (minutes apart, not frames), layers render at +/// [_renderScale] and are upscaled, and each gates itself on its own control. +/// +/// [mode] forces a look; [active] holds the last frame when the backdrop is +/// off-screen to save GPU. +class WeatherSkyBackground extends StatefulWidget { + /// Which weather look to render. + final WeatherMode mode; + + /// Overrides the look's default rain amount (0..1 on the painter's ladder: + /// 0.321 小雨 → 1.0 暴雨) when a live code carries one; `null` keeps the + /// look's own default. + final double? rainIntensity; + + /// Overrides the look's default snow amount (0..1); `null` keeps the look's. + final double? snowIntensity; + + /// Whether the animation should run. + final bool active; + + /// Viewer latitude, for anchoring the keyframe ring to the real day. + final double latitude; + + /// Viewer longitude. + final double longitude; + + /// Relative humidity 0..1, which blends each keyframe's atmosphere ramps. + final double humidity; + + /// Pins the sky to a point in the day, or follows the clock on + /// [SkyTimeMode.auto]. + final SkyTimeMode timeMode; + + const WeatherSkyBackground({ + super.key, + this.mode = WeatherMode.auto, + this.rainIntensity, + this.snowIntensity, + this.active = true, + this.latitude = kTaiwanLatitude, + this.longitude = kTaiwanLongitude, + this.humidity = 0.65, + this.timeMode = SkyTimeMode.auto, + }); + + @override + State createState() => _WeatherSkyBackgroundState(); +} + +class _WeatherSkyBackgroundState extends State + with SingleTickerProviderStateMixin { + /// Internal render resolution as a fraction of the widget's size. + /// + /// The reference rasterises everything before layer 8 — sky, galaxy, clouds + /// — at **half** device resolution and the deferred layers at 0.75, then + /// upscales. This port draws every layer in one pass, so a + /// single scale has to serve both; 0.75 matches the precipitation layers, + /// which are the ones that suffer most from being softened, and leaves the + /// cloud deck only slightly sharper than the original. + static const double _renderScale = 0.75; + + /// Number of cloud sprites in `assets/weather/clouds/`. + static const int _spriteCount = 12; + + static const List _bakeAssets = [ + 'shaders/sky/transmittance.frag', + 'shaders/sky/sky_lut.frag', + ]; + + static const List _layerAssets = [ + WeatherSkyPainter.nightAsset, + WeatherSkyPainter.cloudsAsset, + WeatherSkyPainter.lightningAsset, + WeatherSkyPainter.sunFlareAsset, + WeatherSkyPainter.rainbowAsset, + ]; + + final Stopwatch _clock = Stopwatch(); + late final AnimationController _ticker = AnimationController( + duration: const Duration(seconds: 60), + vsync: this, + ); + + final Map _shaders = {}; + final List _sprites = []; + final List _sunTextures = []; + SkyLutCache? _lutCache; + PrecipitationField? _rain; + PrecipitationField? _snow; + + /// Wall-clock of the previous frame, for the particle integration step. + double _lastFrameSeconds = 0; + + @override + void initState() { + super.initState(); + _syncRunning(); + _load(); + } + + Future _load() async { + final layers = {}; + final bakers = {}; + + for (final asset in [..._bakeAssets, ..._layerAssets]) { + try { + final program = await ui.FragmentProgram.fromAsset(asset); + final shader = program.fragmentShader(); + if (_bakeAssets.contains(asset)) { + bakers[asset] = shader; + } else { + layers[asset] = shader; + } + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'Failed to load shader $asset'); + } + } + + ui.Image? rainAtlas; + ui.Image? snowAtlas; + for (final entry in const [ + ('assets/weather/particles/rain_drop.webp', true), + ('assets/weather/particles/snow_flake.webp', false), + ]) { + try { + final data = await rootBundle.load(entry.$1); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + final image = (await codec.getNextFrame()).image; + if (entry.$2) { + rainAtlas = image; + } else { + snowAtlas = image; + } + } catch (error, stackTrace) { + Log.handle( + error, + stackTrace, + 'Failed to load particle sprite ${entry.$1}', + ); + } + } + + final sunTex = []; + for (final path in const [ + 'assets/weather/sky/sun_profile.webp', + 'assets/weather/sky/sun_rays.webp', + 'assets/weather/sky/annulus.webp', + ]) { + try { + final data = await rootBundle.load(path); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + sunTex.add((await codec.getNextFrame()).image); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'Failed to load sun texture $path'); + } + } + + final sprites = []; + for (var i = 0; i < _spriteCount; i++) { + final path = 'assets/weather/clouds/${i.toString().padLeft(2, '0')}.webp'; + try { + final data = await rootBundle.load(path); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + sprites.add((await codec.getNextFrame()).image); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'Failed to load cloud sprite $path'); + } + } + + if (!mounted) { + for (final s in [...layers.values, ...bakers.values]) { + s.dispose(); + } + for (final image in sprites) { + image.dispose(); + } + rainAtlas?.dispose(); + snowAtlas?.dispose(); + for (final t in sunTex) { + t.dispose(); + } + return; + } + + final transmittance = bakers[_bakeAssets[0]]; + final skyLut = bakers[_bakeAssets[1]]; + + setState(() { + _disposeResources(); + _shaders + ..addAll(layers) + ..addAll(bakers); + _sprites.addAll(sprites); + _sunTextures.addAll(sunTex); + // Without both bake shaders there is no sky; the fallback colour shows. + _lutCache = (transmittance != null && skyLut != null) + ? SkyLutCache(transmittance, skyLut) + : null; + // The rain atlas packs four width variants; snow is a single cell. + _rain = rainAtlas == null + ? null + : PrecipitationField( + atlas: rainAtlas, + // The reference's storm preset pool; lighter rain uses proportionally + // fewer, which is how it expresses intensity. + capacity: 1792, + variants: 4, + cell: const Size(32, 68), + seed: 7, + ); + _snow = snowAtlas == null + ? null + : PrecipitationField( + atlas: snowAtlas, + capacity: 900, + cell: const Size(40, 40), + tumble: true, + seed: 11, + ); + }); + } + + void _disposeResources() { + _lutCache?.dispose(); + _lutCache = null; + for (final shader in _shaders.values) { + shader.dispose(); + } + _shaders.clear(); + for (final image in _sprites) { + image.dispose(); + } + _sprites.clear(); + for (final t in _sunTextures) { + t.dispose(); + } + _sunTextures.clear(); + _rain?.atlas.dispose(); + _snow?.atlas.dispose(); + _rain = null; + _snow = null; + } + + void _syncRunning() { + if (widget.active) { + if (!_clock.isRunning) _clock.start(); + _ticker.repeat(); + } else { + _clock.stop(); + _ticker.stop(); + } + } + + @override + void didUpdateWidget(WeatherSkyBackground oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.active != widget.active) _syncRunning(); + } + + @override + void dispose() { + _ticker.dispose(); + _disposeResources(); + _clock.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cache = _lutCache; + if (cache == null || _shaders.length < _layerAssets.length) { + return ColoredBox(color: _fallbackColour(widget.mode)); + } + + return LayoutBuilder( + builder: (context, constraints) { + final painted = AnimatedBuilder( + animation: _ticker, + builder: (context, _) { + final frame = _buildFrame(); + // Re-bakes only when the resolved keyframe has actually moved. + cache.update(frame.sky); + + // Clamped so a dropped frame or a resume from background does not + // teleport every particle down the screen in one step. + final dt = (frame.time - _lastFrameSeconds).clamp(0.0, 1 / 20); + _lastFrameSeconds = frame.time; + + return CustomPaint( + size: Size.infinite, + painter: WeatherSkyPainter( + shaders: _shaders, + lutCache: cache, + cloudSprites: _sprites, + sunTextures: _sunTextures, + frame: frame, + rainField: _rain, + snowField: _snow, + dt: dt, + ), + ); + }, + ); + + if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { + return painted; + } + + // Rasterise small and let the transform upscale bilinearly. + return ClipRect( + child: Align( + alignment: Alignment.topLeft, + child: Transform.scale( + scale: 1 / _renderScale, + alignment: Alignment.topLeft, + filterQuality: FilterQuality.low, + child: SizedBox( + width: (constraints.maxWidth * _renderScale).ceilToDouble(), + height: (constraints.maxHeight * _renderScale).ceilToDouble(), + child: painted, + ), + ), + ), + ); + }, + ); + } + + SkyFrame _buildFrame() { + final utc = AppTime.utc; + final local = AppTime.utc8; + final time = _clock.elapsedMilliseconds / 1000.0; + + final look = _lookFor(widget.mode); + final frames = look.keyframes; + // A live code's continuous channels win over the look's defaults — e.g. + // 106 有雨 keeps the rain look's keyframes but runs 0.35 instead of the + // fixed 小雨 rung. Snow is a separate field, so a rain-snow mix renders + // both. + final rain = widget.rainIntensity ?? look.rain; + final snow = widget.snowIntensity ?? look.snow; + + // Anchor the ring to the day's real sunrise and sunset, so dawn keyframes + // land at dawn in December as well as June. + final sun = sunTimes( + utc, + latitude: widget.latitude, + longitude: widget.longitude, + ); + final hour = + skyTimeHour(widget.timeMode) ?? + (local.hour + local.minute / 60.0 + local.second / 3600.0); + final position = keyframePosition( + hour, + frameCount: frames.length, + sunrise: sun.sunrise, + sunset: sun.sunset, + ); + + final sky = resolveSky( + frames, + position: position, + humidity: widget.humidity, + ); + + return SkyFrame( + time: time, + sky: sky, + cloudLayout: look.layout, + cloudCoverage: look.coverage, + rain: rain, + snow: snow, + fog: look.fog, + rainbow: look.rainbow, + wind: look.wind, + lightning: look.lightning ? _lightningAt(time) : LightningFrame.none, + moonPhase: _moonPhase(utc), + keyframePosition: position, + ); + } + + Color _fallbackColour(WeatherMode mode) => switch (mode) { + WeatherMode.thunderstorm => const Color(0xFF2A2F3A), + WeatherMode.rain => const Color(0xFF44505F), + WeatherMode.fog => const Color(0xFF8A9099), + _ => const Color(0xFF3F6DAE), + }; +} + +typedef _Look = ({ + List keyframes, + CloudLayout layout, + double coverage, + double rain, + double snow, + double fog, + double rainbow, + double wind, + bool lightning, +}); + +/// Maps a [WeatherMode] onto a the reference weather type and its layer controls. +/// +/// The keyframe tables cover all 14 of the engine's weather types; the five +/// user-facing modes pick from them, so adding a mode later is a table entry +/// rather than new artwork. +_Look _lookFor(WeatherMode mode) => switch (mode) { + // Until a live feed drives it, `auto` is Taiwan's typical humid, lightly + // clouded sky. + WeatherMode.auto => ( + keyframes: cloudyKeyframes, + layout: CloudLayout.scattered, + coverage: 0.55, + rain: 0.0, + snow: 0.0, + fog: 0.0, + rainbow: 0.0, + wind: 0.25, + lightning: false, + ), + WeatherMode.clear => ( + keyframes: sunnyKeyframes, + layout: CloudLayout.fair, + coverage: 0.35, + rain: 0.0, + snow: 0.0, + fog: 0.0, + rainbow: 0.0, + wind: 0.18, + lightning: false, + ), + WeatherMode.cloudy => ( + keyframes: cloudyKeyframes, + layout: CloudLayout.scattered, + coverage: 0.85, + rain: 0.0, + snow: 0.0, + fog: 0.0, + rainbow: 0.0, + wind: 0.30, + lightning: false, + ), + WeatherMode.overcast => ( + keyframes: overcastKeyframes, + layout: CloudLayout.overcast, + coverage: 1.0, + rain: 0.0, + snow: 0.0, + fog: 0.05, + rainbow: 0.0, + wind: 0.20, + lightning: false, + ), + WeatherMode.snow => ( + keyframes: snowyMediumKeyframes, + layout: CloudLayout.overcast, + coverage: 0.95, + rain: 0.0, + snow: 0.7, + fog: 0.10, + rainbow: 0.0, + wind: 0.30, + lightning: false, + ), + // No sand shader yet — the sandy keyframes plus a heavy dust-coloured haze + // stand in, so the mode is at least testable. + WeatherMode.sand => ( + keyframes: sandyHeavyKeyframes, + layout: CloudLayout.overcast, + coverage: 0.65, + rain: 0.0, + snow: 0.0, + fog: 0.9, + rainbow: 0.0, + wind: 0.55, + lightning: false, + ), + WeatherMode.rain => ( + keyframes: rainyMediumKeyframes, + layout: CloudLayout.rain, + coverage: 1.0, + // The reference expresses rain intensity almost entirely through particle *count*, + // and it has four discrete presets: 576 / 1024 / 1472 / 1792 drops for + // 小雨 / 中雨 / 大雨 / 暴雨. Against the storm-sized pool below that is + // 0.321 / 0.571 / 0.821 / 1.0, so the plain rain backdrop sits on the + // 小雨 rung — the same rung the shipped capture runs at. + rain: 0.321, + snow: 0.0, + fog: 0.08, + rainbow: 0.0, + wind: 0.40, + lightning: false, + ), + WeatherMode.fog => ( + keyframes: foggyKeyframes, + layout: CloudLayout.overcast, + coverage: 0.7, + rain: 0.0, + snow: 0.0, + fog: 0.85, + rainbow: 0.0, + wind: 0.10, + lightning: false, + ), + WeatherMode.thunderstorm => ( + keyframes: rainyExtremeKeyframes, + layout: CloudLayout.rain, + coverage: 1.0, + // The 暴雨 rung — the full 1792-drop pool. + rain: 1.0, + snow: 0.0, + fog: 0.12, + rainbow: 0.0, + wind: 0.65, + lightning: true, + ), +}; + +/// Approximate lunar phase, 0 new → 0.5 full → 1 new. +/// +/// A linear count of synodic months since a known new moon. Good to about a +/// day, which for a moon a few dozen pixels across is exact. +double _moonPhase(DateTime utc) { + const synodicMonth = 29.530588853; + final days = + utc.difference(DateTime.utc(2000, 1, 6, 18, 14)).inMinutes / 1440.0; + final phase = (days / synodicMonth) % 1.0; + return phase < 0 ? phase + 1.0 : phase; +} + +/// The lightning envelope at [time] seconds. +/// +/// Strikes are quantised into windows and hash-gated so only some fire, giving +/// an irregular rhythm. The window is deliberately long: a disaster-prevention +/// app must not strobe, and the shader caps flash brightness on top of this. +LightningFrame _lightningAt(double time) { + const period = 4.2; + final window = (time / period).floorToDouble(); + if (_hash01(window) > 0.42) return LightningFrame.none; + + final age = time - window * period; + if (age > 1.2) return LightningFrame.none; + + final strength = 0.55 + 0.45 * _hash01(window + 7.0); + return LightningFrame( + age: age, + flash: strength, + bolt: strength, + seed: window, + ); +} + +double _hash01(double n) { + final x = math.sin(n * 127.1) * 43758.5453; + return x - x.floorToDouble(); +} diff --git a/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart new file mode 100644 index 000000000..0140593a8 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart @@ -0,0 +1,602 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_clouds.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/precipitation_field.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart'; +import 'package:flutter/material.dart'; + +/// The per-frame scene state the painter renders. +/// +/// [sky] is the resolved keyframe — the reference engine drives everything from a +/// keyframe ring rather than a sun position, so `sky.sunAngleY` (the sky LUT's +/// u coordinate) is what every layer derives its time of day from. +class SkyFrame { + /// Seconds since the animation started. + final double time; + + /// The interpolated atmosphere and colours for this moment. + final ResolvedSky sky; + + /// Cloud arrangement and how much of it is shown. + final CloudLayout cloudLayout; + final double cloudCoverage; + + /// Rain intensity 0..1. + final double rain; + + /// Snow heaviness 0..1. + final double snow; + + /// Fog density 0..1. + final double fog; + + /// Rainbow opacity 0..1. + final double rainbow; + + /// Wind, signed; slants precipitation and speeds cloud drift. + final double wind; + + /// Lightning state for this frame. + final LightningFrame lightning; + + /// Moon phase 0 new → 0.5 full → 1 new. + final double moonPhase; + + /// Where the scene sits on its keyframe ring — the sun's arc is a function + /// of this, not of wall-clock time. + final double keyframePosition; + + const SkyFrame({ + required this.time, + required this.sky, + required this.cloudLayout, + required this.cloudCoverage, + required this.rain, + required this.snow, + required this.fog, + required this.rainbow, + required this.wind, + required this.lightning, + required this.moonPhase, + required this.keyframePosition, + }); + + /// How high the sun sits, 0 (night) … 1 (midday), from the keyframe's + /// authored `sunAngleY` rather than any elevation angle. + double get dayAmount => ((sky.sunAngleY - 0.02) / 0.42).clamp(0.0, 1.0); + + /// Night weight — drives the star field and the grade's cool cast. + double get nightAmount => (1.0 - dayAmount / 0.25).clamp(0.0, 1.0); + + /// Golden-hour weight, peaking when the sun is low but present. + double get goldenAmount { + final d = dayAmount; + if (d <= 0.02 || d >= 0.55) return 0.0; + return (1.0 - ((d - 0.18).abs() / 0.30)).clamp(0.0, 1.0); + } + + /// Direct-sun strength reaching the scene, cut by cloud cover. + double get sunIntensity => dayAmount * (1.0 - 0.8 * cloudCoverage); +} + +/// A single lightning strike's state within its own timeline. +class LightningFrame { + final double age; + final double flash; + final double bolt; + final double seed; + + const LightningFrame({ + required this.age, + required this.flash, + required this.bolt, + required this.seed, + }); + + static const LightningFrame none = LightningFrame( + age: 0, + flash: 0, + bolt: 0, + seed: 0, + ); + + bool get isActive => flash > 0.002 || bolt > 0.002; + + @override + bool operator ==(Object other) => + other is LightningFrame && + other.age == age && + other.flash == flash && + other.bolt == bolt && + other.seed == seed; + + @override + int get hashCode => Object.hash(age, flash, bolt, seed); +} + +/// Draws the layer stack in the reference scene order. +/// +/// Order mirrors the original engine: sky → night → clouds → precipitation → +/// lightning → sun flare → rainbow → fog → grade. Each layer gates itself on +/// its own control value, so drawing all of them costs almost nothing when +/// the weather is calm. +class WeatherSkyPainter extends CustomPainter { + final Map shaders; + final List cloudSprites; + + /// The LUT owner — its CPU-side [SkyLutCache.skyAt] readback supplies the + /// cloud shader's pixel-independent base/haze probe colours, [SkyLutCache + /// .skyGradient] is the backdrop's sky pass and [SkyLutCache.skyColumn] + /// feeds the cloud shader's probes. + final SkyLutCache lutCache; + + /// Sun disc ramp, static ray pattern and ring ramp, in that order. + final List sunTextures; + final SkyFrame frame; + + /// Rain and snow particle fields, or `null` before their sprites load. + final PrecipitationField? rainField; + final PrecipitationField? snowField; + + /// Seconds since the previous frame, for the particle integration. + final double dt; + + const WeatherSkyPainter({ + required this.shaders, + required this.lutCache, + required this.cloudSprites, + required this.sunTextures, + required this.frame, + required this.rainField, + required this.snowField, + required this.dt, + }); + + // Asset keys, matching `pubspec.yaml`. + static const String cloudsAsset = 'shaders/cloud/clouds.frag'; + static const String nightAsset = 'shaders/weather/night.frag'; + static const String lightningAsset = 'shaders/weather/lightning.frag'; + static const String sunFlareAsset = 'shaders/weather/sun_flare.frag'; + static const String rainbowAsset = 'shaders/weather/rainbow.frag'; + + @override + void paint(Canvas canvas, Size size) { + _paintSky(canvas, size); + _paintNight(canvas, size); + _paintClouds(canvas, size); + _paintRain(canvas, size); + _paintSnow(canvas, size); + _paintLightning(canvas, size); + _paintSunFlare(canvas, size); + _paintRainbow(canvas, size); + } + + @override + bool shouldRepaint(WeatherSkyPainter old) => + old.frame.time != frame.time || !identical(old.frame, frame); + + void _fill( + Canvas canvas, + Size size, + ui.FragmentShader shader, [ + BlendMode blend = BlendMode.srcOver, + ]) { + canvas.drawRect( + Offset.zero & size, + Paint() + ..shader = shader + ..blendMode = blend, + ); + } + + // --- sky ---------------------------------------------------------------- + // A pure vertical gradient of the fixed LUT column, so it is precomputed on + // the CPU at each re-bake (`SkyLutCache._bakeSkyGradient`) and drawn as one + // stretched image — no full-screen shader, no per-pixel `asin`/`sqrt`/hash. + void _paintSky(Canvas canvas, Size size) { + final gradient = lutCache.skyGradient; + if (gradient == null) { + // The very first frame lands before the readback — a flat sky instead of + // a flash of whatever sits behind the backdrop. + canvas.drawRect( + Offset.zero & size, + Paint()..color = const Color(0xFF3F6DAE), + ); + return; + } + canvas.drawImageRect( + gradient, + Rect.fromLTWH( + 0, + 0, + gradient.width.toDouble(), + gradient.height.toDouble(), + ), + Offset.zero & size, + Paint()..filterQuality = FilterQuality.low, + ); + } + + // --- night -------------------------------------------------------------- + void _paintNight(Canvas canvas, Size size) { + final shader = shaders[nightAsset]; + final night = frame.nightAmount; + if (shader == null || night < 0.01) return; + + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(size.width); + set(size.height); + set(0.62); + set(0.68); + set(0.92); // galaxy tint + set(frame.time); + set(night); + set(0.0); // scroll + set(frame.cloudCoverage); + // The dedicated galaxy layer was removed (it never loaded in production); + // the procedural band stays off. + set(0.0); + + _fill(canvas, size, shader); + } + + // --- clouds ------------------------------------------------------------- + void _paintClouds(Canvas canvas, Size size) { + final shader = shaders[cloudsAsset]; + final skyColumn = lutCache.skyColumn; + if (shader == null || skyColumn == null || cloudSprites.isEmpty) return; + if (frame.cloudCoverage < 0.02) return; + + final lighting = cloudLighting(sunAngleY: frame.sky.sunAngleY); + final placed = placeClouds( + frame.cloudLayout, + width: size.width, + height: size.height, + time: frame.time, + coverage: frame.cloudCoverage, + wind: frame.wind, + ); + + // The sun's direction swings across the sky with its height, so lit faces + // move around the cloud through the day. + final day = frame.dayAmount; + final sunDir = [-0.55 + 1.1 * day, 0.25 + 0.70 * day, 0.45]; + + // The base and haze sky probes are pixel-independent, so their colours are + // read once per frame from the CPU LUT readback rather than fetched on + // every cloud pixel (see clouds.frag's iBaseSky / iHazeSky). The picker + // mapping mirrors the shader's `skyAt` exactly, and the base picker rides + // the same `cloudDepth` lerp the shader applies. + final lutU = frame.sky.sunAngleY.clamp(0.0, 1.0); + final fallback = SkyLutCache.panelAmbient.value ?? const Color(0xFF5C6B7E); + final baseSky = + lutCache.skyAt( + lutU, + _skyAtV(0.05 + (lighting.base.$1 - 0.05) * 0.85), + ) ?? + fallback; + final hazeSky = lutCache.skyAt(lutU, _skyAtV(0.95)) ?? fallback; + + for (final p in placed) { + final sprite = cloudSprites[p.sprite % cloudSprites.length]; + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(p.width); + set(p.height); + set(sunDir[0]); + set(sunDir[1]); + set(sunDir[2]); + set(0.0); + set(-1.0); + set(0.2); // ground bounce, from below + set(0.0); + set(1.0); + set(0.1); // ambient, from above + set(lighting.base.$1); + set(lighting.base.$2); + set(lighting.base.$3); + set(lighting.sun.$1); + set(lighting.sun.$2); + set(lighting.sun.$3); + set(lighting.ground.$1); + set(lighting.ground.$2); + set(lighting.ground.$3); + set(lighting.ambient.$1); + set(lighting.ambient.$2); + set(lighting.ambient.$3); + set(p.opacity); + set(0.05); // start edge + set(0.23); // end edge + set(1.0); // progress — deck fully revealed + set(0.35); // smooth + set(0.85); // cloud depth + set(day); + set(frame.fog * 0.5); + set(frame.lightning.flash * 0.8); + set(lighting.whitePer); + // The LUT sampler is the CPU-extracted column: `.y` is its height (141) + // and `.x` is unused by the shader. + set(SkyLutCache.skyViewSize.height); + set(1.0); + set(sprite.width.toDouble()); + set(sprite.height.toDouble()); + set(baseSky.r); + set(baseSky.g); + set(baseSky.b); + set(hazeSky.r); + set(hazeSky.g); + set(hazeSky.b); + shader.setImageSampler(0, sprite); + shader.setImageSampler(1, skyColumn); + + canvas.save(); + canvas.translate(p.left, p.top); + canvas.drawRect( + Rect.fromLTWH(0, 0, p.width, p.height), + Paint()..shader = shader, + ); + canvas.restore(); + } + } + + /// The cloud shader's `skyAt` picker→LUT-v mapping, mirrored on the CPU for + /// the pre-baked base/haze probes — the shader's `elevation = picker·(π/2)` + /// cancels to `sqrt(picker)` (see clouds.frag). + static double _skyAtV(double picker) => + ((math.sqrt(picker.clamp(0.0, 1.0)) + 0.1015625) / 1.1015625).clamp( + 0.0, + 1.0, + ); + + // --- rain --------------------------------------------------------------- + // + // Drawn as sprites rather than a fragment shader, because that is what the reference + // does: instanced textured quads of a motion-blurred streak. A procedural + // curtain cannot reproduce the sprite's taper and head, which is most of + // what makes rain read as rain. + void _paintRain(Canvas canvas, Size size) { + final field = rainField; + if (field == null || frame.rain < 0.002) return; + + // The reference's streak sprite is WHITE (its texture's RGB is 248/255 wherever + // alpha > 0) and composites additively — night rain dims because the + // *alpha* is tiny over a dark sky, not because the drops are tinted. An + // earlier version tinted them with the sky *and* ran them at 2.5x the reference's + // alpha, which is two compensating errors that never cancel. + final lift = frame.lightning.flash * 0.8; + final white = (248 + lift * 7).clamp(0.0, 255.0).round(); + final tint = Color.fromARGB(255, white, white, white); + + // Where this rain sits on the reference's four-preset ladder (0 = 小雨, 1 = 暴雨). + // `frame.rain` is the particle-count fraction, and 小雨 is 576/1792. + const lightRung = 0.321; + final tier = ((frame.rain - lightRung) / (1 - lightRung)).clamp(0.0, 1.0); + + final batch = field.step( + size: size, + dt: dt, + intensity: frame.rain, + // The reference's speed uniform is -4.5 (小雨) to -5.2 (暴雨) in NDC units per + // second, and the screen spans 2.0 NDC — so 2.25 to 2.60 screen heights + // per second. This is the *near* drop's speed; `speedScale` scales the + // rest of the field down from it. + fallSpeed: 2.25 + 0.35 * tier, + // The reference's rain falls perfectly vertically — the compute shader has no + // wind term at all. + wind: 0.0, + // A flat 0.30, not a ramp: `particle_rain_frag_shader` multiplies this by + // `mix(0.3, 2, depth)` and the life curve, which is where the variation + // comes from. Peak per-drop alpha lands at 0.229 and a median drop at + // 0.047 — a faint veil. + opacity: 0.30, + tint: tint, + // `particle_rain.comp` sets the quad's `scale.y` to `startSize * 3.0`, + // and this field's `size` *is* the drawn length as a fraction of the + // widget height — so the reference's startSize range carries that x3. Light rain + // spans 0.018..0.036 of the height; the storm preset reaches 0.060. + sizeMin: 0.018, + sizeMax: 0.036 + 0.024 * tier, + life: 3.0, + ); + if (batch.count == 0) return; + + // Additive, as the reference sets `setBlendFunc(SRC_ALPHA, ONE)`. + canvas.drawRawAtlas( + field.atlas, + batch.transforms, + batch.rects, + batch.colors, + BlendMode.modulate, + null, + Paint()..blendMode = BlendMode.plus, + ); + } + + // --- snow --------------------------------------------------------------- + void _paintSnow(Canvas canvas, Size size) { + final field = snowField; + if (field == null || frame.snow < 0.002) return; + + // Flakes are lit by the sky, so they grey down at night. + final lit = ((1.0 - 0.5 * frame.nightAmount) * 255).round(); + final tint = Color.fromARGB( + 255, + lit, + lit, + (lit * 1.02).clamp(0, 255).round(), + ); + + final batch = field.step( + size: size, + dt: dt, + intensity: frame.snow, + fallSpeed: 0.10 + 0.10 * frame.snow, + wind: frame.wind * 1.6, + opacity: 0.7 + 0.3 * frame.snow, + tint: tint, + sizeMin: 0.004, + sizeMax: 0.016, + life: 8.0, + ); + if (batch.count == 0) return; + + canvas.drawRawAtlas( + field.atlas, + batch.transforms, + batch.rects, + batch.colors, + BlendMode.modulate, + null, + Paint(), + ); + } + + // --- lightning ---------------------------------------------------------- + void _paintLightning(Canvas canvas, Size size) { + final shader = shaders[lightningAsset]; + if (shader == null || !frame.lightning.isActive) return; + + final l = frame.lightning; + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(size.width); + set(size.height); + set(0.70); + set(0.80); + set(1.00); + set(0.25); + set(0.32); + set(0.55); + set(1.0); + set(1.0); + set(1.0); + set(l.age); + set(l.flash); + set(l.bolt); + set(l.seed); + // The reference's frame-count envelope, converted to seconds at 60 fps. + set(10.0 / 60.0); + set(10.0 / 60.0); + set(30.0 / 60.0); + set(25.0 / 60.0); + + _fill(canvas, size, shader); + } + + // --- sun ------------------------------------------------------------------ + void _paintSunFlare(Canvas canvas, Size size) { + final shader = shaders[sunFlareAsset]; + if (shader == null || sunTextures.length < 3) return; + + final intensity = frame.sunIntensity; + if (intensity < 0.02) return; + + // The reference's sun rides a shallow parametric arc driven by the keyframe ring + // (`SunUniform`'s transition listener), not a linear sweep: + // + // t = (clamp((p - 6) / 4, -0.5, 1.5) - 0.5) * pi/2 + // x = sin(t) * 0.5 + // y = 0.5 - (1 - cos(t)) * 0.15 + // + // Keyframe 4 puts it at the left, 8 at the top, 12 at the right. + final t = (frame.keyframePosition.clamp(6.0 - 2.0, 6.0 + 6.0) - 6.0) / 4.0; + final theta = (t.clamp(-0.5, 1.5) - 0.5) * math.pi / 2.0; + final offsetY = -0.06 + 0.08 * frame.dayAmount; + final sunX = 0.5 + math.sin(theta) * 0.5; + final arcY = 0.5 - (1.0 - math.cos(theta)) * 0.15 + offsetY; + final sunY = 0.5 - arcY; + final golden = frame.goldenAmount; + + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(size.width); + set(size.height); + set(sunX); + set(sunY); + set(1.0); + set(0.80 - 0.14 * golden); + set(0.45 - 0.22 * golden); // ray tint + set(frame.time); + set(intensity); + // The disc survives cloud better than the rays do — it is far brighter. + set((1.0 - 0.55 * frame.cloudCoverage).clamp(0.0, 1.0)); + set((0.35 + 0.5 * golden) * (1.0 - 0.7 * frame.cloudCoverage)); + // `uAnnulusAlpha` is 0.13 while the sun is up. + set(0.13 * (1.0 - frame.cloudCoverage)); + // `uCircleAlpha` 1.03, and `uCircleOffset` -0.5. + set(1.03 * (1.0 - 0.8 * frame.cloudCoverage)); + set(-0.5); + set((frame.time / 9.0).floorToDouble()); + set((0.7 + 0.3 * golden) * (1.0 - 0.5 * frame.cloudCoverage)); + shader.setImageSampler(0, sunTextures[0]); + shader.setImageSampler(1, sunTextures[1]); + shader.setImageSampler(2, sunTextures[2]); + + // The reference renders the sun into a **quarter-resolution** buffer + // (`new C1502b(ctx, w / 4, h / 4, true)`) and blits it back up with + // The reference shader, which is just `texture(uTex, vUv) * uOpacity`. All of the + // softness in its rays and glow comes from that 4x upscale — drawing the + // shader straight to the screen gives a much harsher, more aliased sun. + // + // (the reference shader is the sun's upsample pass, despite the reference notes + // filing it as an aurora effect.) + final quarter = Size( + (size.width / 4).ceilToDouble().clamp(1, double.infinity), + (size.height / 4).ceilToDouble().clamp(1, double.infinity), + ); + // The shader's own resolution uniform must match the buffer it draws into. + shader.setFloat(0, quarter.width); + shader.setFloat(1, quarter.height); + + final recorder = ui.PictureRecorder(); + ui.Canvas( + recorder, + ).drawRect(Offset.zero & quarter, Paint()..shader = shader); + final picture = recorder.endRecording(); + final small = picture.toImageSync( + quarter.width.toInt(), + quarter.height.toInt(), + ); + picture.dispose(); + + canvas.drawImageRect( + small, + Offset.zero & quarter, + Offset.zero & size, + Paint() + // Bilinear on the way up is what softens it. + ..filterQuality = FilterQuality.low + ..blendMode = BlendMode.plus, + ); + small.dispose(); + } + + // --- rainbow ------------------------------------------------------------ + void _paintRainbow(Canvas canvas, Size size) { + final shader = shaders[rainbowAsset]; + if (shader == null || frame.rainbow < 0.002) return; + + final day = frame.dayAmount; + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(size.width); + set(size.height); + set(-0.4 + 0.8 * day); + set(0.2 + 0.6 * day); + set(0.85); // sun direction + set(frame.rainbow); + set(0.5); + set(0.5); + set(0.85); + set(0.22); + set(4.0); + // The rainbow shares the sky's narrow frustum so it sits consistently. + set(20.0 * math.pi / 180.0); + set(frame.sky.cameraYaw * math.pi / 180.0); + + _fill(canvas, size, shader); + } +} diff --git a/lib/features/home/presentation/widgets/weather_sky_background.dart b/lib/features/home/presentation/widgets/weather_sky_background.dart deleted file mode 100644 index b1d3c8dcf..000000000 --- a/lib/features/home/presentation/widgets/weather_sky_background.dart +++ /dev/null @@ -1,244 +0,0 @@ -import 'dart:math' as math; -import 'dart:ui' as ui; - -import 'package:dpip/core/logging/log.dart'; -import 'package:dpip/core/settings/weather_mode.dart'; -import 'package:flutter/material.dart'; - -/// GPU-accelerated procedural sky backdrop. -/// -/// Ported from the `feat/new-home` design: a fragment shader -/// (`shaders/weather_sky.frag`) draws a time-of-day sky with drifting clouds -/// and rain — no image sampling, so it stays cheap to animate. [mode] forces a -/// look (or [WeatherMode.auto] for defaults until a weather feed is wired up); -/// [active] pauses the animation when the backdrop is off-screen (e.g. while the -/// home sheet is collapsed) to save GPU. -class WeatherSkyBackground extends StatefulWidget { - /// Which weather look to render. - final WeatherMode mode; - - /// Whether the animation should run. When `false` the shader holds its last - /// frame instead of ticking. - final bool active; - - const WeatherSkyBackground({ - super.key, - this.mode = WeatherMode.auto, - this.active = true, - }); - - @override - State createState() => _WeatherSkyBackgroundState(); -} - -class _WeatherSkyBackgroundState extends State - with SingleTickerProviderStateMixin { - final Stopwatch _clock = Stopwatch(); - late final AnimationController _ticker = AnimationController( - duration: const Duration(seconds: 60), - vsync: this, - ); - ui.FragmentShader? _shader; - - @override - void initState() { - super.initState(); - _syncRunning(); - _load(); - } - - Future _load() async { - try { - final program = await ui.FragmentProgram.fromAsset( - 'shaders/weather_sky.frag', - ); - if (mounted) setState(() => _shader = program.fragmentShader()); - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'Failed to load weather_sky shader'); - } - } - - /// Runs the clock + ticker only while [WeatherSkyBackground.active]. - void _syncRunning() { - if (widget.active) { - if (!_clock.isRunning) _clock.start(); - _ticker.repeat(); - } else { - _clock.stop(); - _ticker.stop(); - } - } - - @override - void didUpdateWidget(WeatherSkyBackground oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.active != widget.active) _syncRunning(); - } - - @override - void dispose() { - _ticker.dispose(); - _shader?.dispose(); - _clock.stop(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final light = Theme.of(context).brightness == Brightness.light ? 1.0 : 0.0; - final weather = _weightsFor(widget.mode); - final shader = _shader; - if (shader == null) { - return ColoredBox( - color: _fallbackColor(_skyScene(DateTime.now().hour), light), - ); - } - return AnimatedBuilder( - animation: _ticker, - builder: (context, _) { - final now = DateTime.now(); - final time = _clock.elapsedMilliseconds / 1000; - return CustomPaint( - size: Size.infinite, - painter: _SkyShaderPainter( - shader: shader, - scene: _skyScene(now.hour), - cloud: weather.cloud, - rain: weather.rain, - wind: weather.wind, - fog: weather.fog, - sunPhase: _sunPhase(now), - light: light, - time: time, - lightning: widget.mode == WeatherMode.thunderstorm - ? _lightningAt(time) - : 0.0, - ), - ); - }, - ); - } -} - -typedef _Weights = ({double cloud, double rain, double wind, double fog}); - -/// Maps a [WeatherMode] onto the shader's cloud/rain/wind/fog weights. -/// -/// [WeatherMode.clear] keeps `cloud` below the shader's coverage gate so the -/// deck/overcast wash stay off. Until a realtime feed drives -/// [WeatherMode.auto], it renders a calm, lightly clouded sky. -_Weights _weightsFor(WeatherMode mode) => switch (mode) { - WeatherMode.auto => (cloud: 0.28, rain: 0.0, wind: 0.35, fog: 0.0), - WeatherMode.clear => (cloud: 0.0, rain: 0.0, wind: 0.2, fog: 0.0), - WeatherMode.rain => (cloud: 0.85, rain: 0.7, wind: 0.6, fog: 0.1), - WeatherMode.fog => (cloud: 0.45, rain: 0.0, wind: 0.15, fog: 0.9), - WeatherMode.thunderstorm => (cloud: 0.9, rain: 0.85, wind: 0.8, fog: 0.15), -}; - -/// Spatially-uniform lightning flash at [time] (seconds): quantise into windows, -/// hash-gate which fire, then a double-flash exponential-impulse envelope, -/// clamped below 1 for photosensitivity. Fed to the shader's `iLightning`. -double _lightningAt(double time) { - const period = 3.6; - final window = (time / period).floorToDouble(); - if (_hash01(window) > 0.45) return 0.0; - final x = time - window * period; - final env = _expImpulse(x, 9.0) + 0.6 * _expImpulse(x - 0.12, 12.0); - return (env * (0.7 + 0.3 * _hash01(window + 7.0))).clamp(0.0, 0.9); -} - -double _expImpulse(double t, double k) { - if (t < 0) return 0.0; - final h = k * t; - return h * math.exp(1.0 - h); -} - -double _hash01(double n) { - final x = math.sin(n * 127.1) * 43758.5453; - return x - x.floorToDouble(); -} - -/// Time-of-day scene index: `0` day, `1` night, `2` dawn, `3` sunset. -int _skyScene(int hour) { - if (hour < 5 || hour >= 19) return 1; - if (hour < 7) return 2; - if (hour >= 17) return 3; - return 0; -} - -/// Solar phase in `[0, 1]` across the 5am–7pm daylight window. -double _sunPhase(DateTime now) { - final h = now.hour + now.minute / 60.0; - return ((h - 5.0) / 14.0).clamp(0.0, 1.0); -} - -/// Solid colour shown before the shader loads (and as a permanent fallback). -/// -/// Matched to the clear-sky mid band so the hand-off from fallback to GPU sky -/// is a soft crossfade, not a colour jump. -Color _fallbackColor(int scene, double light) { - if (scene == 0 && light > 0.5) return const Color(0xFF7AB8F0); - return switch (scene) { - 1 => const Color(0xFF0A1024), - 2 => const Color(0xFF735284), - 3 => const Color(0xFF8C4D66), - _ => const Color(0xFF387AC7), - }; -} - -/// Feeds the sky parameters to the shader and fills the canvas. -class _SkyShaderPainter extends CustomPainter { - final ui.FragmentShader shader; - final int scene; - final double cloud; - final double rain; - final double wind; - final double fog; - final double sunPhase; - final double light; - final double time; - final double lightning; - - const _SkyShaderPainter({ - required this.shader, - required this.scene, - required this.cloud, - required this.rain, - required this.wind, - required this.fog, - required this.sunPhase, - required this.light, - required this.time, - required this.lightning, - }); - - @override - void paint(Canvas canvas, Size size) { - shader - ..setFloat(0, time) - ..setFloat(1, size.width) - ..setFloat(2, size.height) - ..setFloat(3, scene.toDouble()) - ..setFloat(4, 0) // iScroll — parallax disabled for now - ..setFloat(5, cloud) - ..setFloat(6, rain) - ..setFloat(7, wind) - ..setFloat(8, sunPhase) - ..setFloat(9, light) - ..setFloat(10, fog) - ..setFloat(11, lightning); - canvas.drawRect(Offset.zero & size, Paint()..shader = shader); - } - - @override - bool shouldRepaint(_SkyShaderPainter old) => - old.time != time || - old.scene != scene || - old.cloud != cloud || - old.rain != rain || - old.wind != wind || - old.fog != fog || - old.sunPhase != sunPhase || - old.light != light || - old.lightning != lightning; -} diff --git a/lib/features/map/presentation/layers/admin_outline_chrome.dart b/lib/features/map/presentation/layers/admin_outline_chrome.dart new file mode 100644 index 000000000..70c5daac3 --- /dev/null +++ b/lib/features/map/presentation/layers/admin_outline_chrome.dart @@ -0,0 +1,201 @@ +/// Switchable administrative borders (縣市 / 鄉鎮) for a weather raster that +/// covers the base style's borders, redrawn **over** the raster. +library; + +import 'dart:async'; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/color_hex.dart'; +import 'package:dpip/shared/map/admin_outline.dart'; +import 'package:dpip/shared/map/raster_timeline_layer.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// The reference admin frame a weather raster can sit over. +/// +/// A full-saturation raster (radar echo, QPESUMS forecast, wind forecast) +/// covers the base style's borders exactly where the weather is worth looking +/// at, so the layer draws its own county and township borders **on top** of the +/// raster, each switchable. The two questions are independent — "which county" +/// and "which township" — so they are two toggles, and both ship **on**, because +/// an unidentified county is one you cannot act on. +/// +/// The geometry is [AdminOutline]'s shared casing construction for every +/// consumer; only the ids come from the base style's own source, so a map +/// showing two rasters at once draws two independent border sets. +mixin AdminOutlineChrome on RasterTimelineLayer { + /// Whether 國界 (world country borders) are redrawn above the raster. + /// + /// Off by default: on a Taiwan-focused surface the neighbours' outlines are + /// usually noise, and a reader who wants them can ask. + final ValueNotifier showGlobalOutline = ValueNotifier(false); + + /// Whether 縣市 borders are redrawn above the raster. + final ValueNotifier showCountyOutline = ValueNotifier(true); + + /// Whether 鄉鎮 borders are redrawn above the raster. Separate from the + /// county toggle: they are different questions ("which city" vs "which + /// township"), and the finer mesh is the first thing a reader wants gone when + /// studying a single cell. + final ValueNotifier showTownOutline = ValueNotifier(true); + + /// The live map controller, set on attach and cleared on detach. Shared with + /// chrome mixins layered above this one, so it is protected rather than + /// library-private. + @protected + MapLibreMapController? controller; + final Set _boundariesShown = {}; + + /// All admin-chrome listenables, for a legend that follows the toggles. + Listenable get adminChromeListenable => + Listenable.merge([showGlobalOutline, showCountyOutline, showTownOutline]); + + /// Which boundary sets should currently be on the map. + Set get _wantedBoundaries => { + // Inserted finest → coarsest so the later, coarser frame wins where two + // run together along a coastline. + if (showTownOutline.value) AdminBoundary.town, + if (showCountyOutline.value) AdminBoundary.county, + if (showGlobalOutline.value) AdminBoundary.global, + }; + + /// Turns the 國界 borders on/off, applying it to a live map immediately. + void setShowGlobalOutline(bool value) { + if (showGlobalOutline.value == value) return; + showGlobalOutline.value = value; + unawaited(_syncBoundaries()); + } + + /// Turns the 縣市 borders on/off, applying it to a live map immediately. + void setShowCountyOutline(bool value) { + if (showCountyOutline.value == value) return; + showCountyOutline.value = value; + unawaited(_syncBoundaries()); + } + + /// Turns the 鄉鎮 borders on/off, applying it to a live map immediately. + void setShowTownOutline(bool value) { + if (showTownOutline.value == value) return; + showTownOutline.value = value; + unawaited(_syncBoundaries()); + } + + @override + Future onAttached(MapLibreMapController controller) async { + super.onAttached(controller); + this.controller = controller; + await _syncBoundaries(); + } + + @override + Future onDetached(MapLibreMapController controller) async { + super.onDetached(controller); + this.controller = null; + // Only undo what was actually added — tearing down an overlay that was + // never mounted issues removals the map never asked for. + for (final boundary in _boundariesShown.toList()) { + _boundariesShown.remove(boundary); + await AdminOutline.remove(controller, boundary); + } + } + + @override + void onStyleReset() { + // The style reload dropped every runtime layer, these included; forget them + // so the next attach re-adds instead of no-oping on stale state. + _boundariesShown.clear(); + super.onStyleReset(); + } + + /// Brings the drawn boundary sets in line with the toggles. + Future _syncBoundaries() async { + final controller = this.controller; + if (controller == null) return; + + final wanted = _wantedBoundaries; + try { + for (final boundary in AdminBoundary.values) { + final shown = _boundariesShown.contains(boundary); + if (wanted.contains(boundary) == shown) continue; + if (shown) { + _boundariesShown.remove(boundary); + await AdminOutline.remove(controller, boundary); + } else { + _boundariesShown.add(boundary); + await AdminOutline.add(controller, boundary); + } + } + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'Failed to sync the admin outlines'); + } + } + + /// The admin chrome's legend entries, following the toggles — a legend naming + /// a line that is not on the map is worse than no legend. + List adminLegendItems(BuildContext context) { + final l10n = AppLocalizations.of(context); + return [ + if (showGlobalOutline.value) + SymbolLegendItem( + swatch: LineSwatch( + color: colorFromHexRgb(AdminOutline.lineColor)!, + width: AdminBoundary.global.lineWidth, + opacity: AdminBoundary.global.lineOpacity, + casingColor: const Color(0xFF000000), + casingWidth: AdminBoundary.global.casingWidth, + casingOpacity: AdminBoundary.global.casingOpacity, + ), + label: l10n.radarGlobalOutline, + ), + if (showCountyOutline.value) + SymbolLegendItem( + swatch: LineSwatch( + color: colorFromHexRgb(AdminOutline.lineColor)!, + width: AdminBoundary.county.lineWidth, + opacity: AdminBoundary.county.lineOpacity, + casingColor: const Color(0xFF000000), + casingWidth: AdminBoundary.county.casingWidth, + casingOpacity: AdminBoundary.county.casingOpacity, + ), + label: l10n.radarCountyOutline, + ), + if (showTownOutline.value) + SymbolLegendItem( + swatch: LineSwatch( + color: colorFromHexRgb(AdminOutline.lineColor)!, + width: AdminBoundary.town.lineWidth, + opacity: AdminBoundary.town.lineOpacity, + casingColor: const Color(0xFF000000), + casingWidth: AdminBoundary.town.casingWidth, + casingOpacity: AdminBoundary.town.casingOpacity, + ), + label: l10n.radarTownOutline, + ), + ]; + } + + /// A legend card that follows the admin toggles: the swatches are inserted + /// under the caller's scale block, joined by a divider. + Widget adminLegendSection(BuildContext context) { + final overlays = adminLegendItems(context); + if (overlays.isEmpty) return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: AppSpacing.sm), + Divider( + height: 1, + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: AppSpacing.sm), + SymbolLegend(items: overlays), + ], + ); + } +} diff --git a/lib/features/map/presentation/layers/disaster_map_layer.dart b/lib/features/map/presentation/layers/disaster_map_layer.dart index 244a5abdd..6ff2ef4ee 100644 --- a/lib/features/map/presentation/layers/disaster_map_layer.dart +++ b/lib/features/map/presentation/layers/disaster_map_layer.dart @@ -4,7 +4,6 @@ library; import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:dpip/core/error/result.dart'; @@ -20,6 +19,7 @@ import 'package:dpip/shared/color_hex.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -35,7 +35,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// hits several overlapping markers zooms in to spread them apart instead of /// guessing which one was meant (see [onMapTap]); detail on a single-hit tap /// comes from [DisasterMapRepository]. -class DisasterMapLayer implements MapLayer { +class DisasterMapLayer with MapLayerDefaults implements MapLayer { DisasterMapLayer(this._repository); final DisasterMapRepository _repository; @@ -168,27 +168,11 @@ class DisasterMapLayer implements MapLayer { @override double get bottomChromeFraction => DpmSheet.peekExtent; - @override - double get mapMinZoom => 4; - @override double get mapMaxZoom => 16; /// DPM MVT is added at runtime — never bake into the base style JSON. - @override - Future>> frames() async => const Ok([]); - - @override - Future prepare(MapLibreMapController c, List frames) async {} - - @override - Future show( - MapLibreMapController c, - MapFrame frame, { - bool scrubbing = false, - }) async {} - /// Toggle a sub-layer; clears its selection when turned off and stops /// prefetching when no sub-layer remains visible. void setSubLayerVisible(DpmSubLayerBase sub, bool value) { @@ -623,17 +607,16 @@ class DisasterMapLayer implements MapLayer { } @override - Widget buildTopTrailingChrome(BuildContext context) => - DisasterMapOverlayMenu(layer: this); - - @override - Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); - - @override - void onMapGestureStart() {} - - @override - void onMapGestureEnd() {} + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }) => DisasterMapOverlayMenu( + layer: this, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); @override Future onCameraIdle(MapLibreMapController controller) => @@ -679,9 +662,6 @@ class DisasterMapLayer implements MapLayer { close(); } - @override - void selectFeature(String id) {} - @override void onStyleReset() { // Style reload drops runtime layers; [render] re-adds when active. diff --git a/lib/features/map/presentation/layers/humidity_layer.dart b/lib/features/map/presentation/layers/humidity_layer.dart index f9b9fa4da..b89eb2c5e 100644 --- a/lib/features/map/presentation/layers/humidity_layer.dart +++ b/lib/features/map/presentation/layers/humidity_layer.dart @@ -7,7 +7,9 @@ import 'package:dpip/features/weather/domain/weather_trend.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/material.dart'; -class HumidityMapLayer extends WeatherStationLayer { +class HumidityMapLayer + extends + WeatherStationLayer { HumidityMapLayer(super.repository); @override diff --git a/lib/features/map/presentation/layers/lightning_layer.dart b/lib/features/map/presentation/layers/lightning_layer.dart index c8bc37841..24bdf9c70 100644 --- a/lib/features/map/presentation/layers/lightning_layer.dart +++ b/lib/features/map/presentation/layers/lightning_layer.dart @@ -6,7 +6,6 @@ library; import 'dart:async'; -import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:dpip/core/error/result.dart'; @@ -15,21 +14,20 @@ import 'package:dpip/features/weather/domain/lightning_snapshot.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/color_hex.dart'; -import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class LightningMapLayer implements MapLayer { +class LightningMapLayer with MapLayerDefaults implements MapLayer { LightningMapLayer(this._repository); final MeteorLightningRepository _repository; static const String _sourceId = 'lightning-src'; static const String _layerId = 'lightning-lyr'; - static const String _dotImageId = 'lightning-dot'; - static const String _crossImageId = 'lightning-cross'; + static const String _imagePrefix = 'lightning'; static const Map _empty = { 'type': 'FeatureCollection', @@ -67,43 +65,6 @@ class LightningMapLayer implements MapLayer { @override double get bottomChromeFraction => 0; - @override - double get mapMinZoom => 4; - - @override - double get mapMaxZoom => BaseMap.maxZoom; - - @override - Future render(MapLibreMapController controller) async {} - - @override - Future onMapTap( - LatLng latLng, - MapLibreMapController controller, - ) async {} - - @override - Widget buildSheet(BuildContext context) => const SizedBox.shrink(); - - @override - Widget buildTopTrailingChrome(BuildContext context) => - const SizedBox.shrink(); - - @override - Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); - - @override - void onMapGestureStart() {} - - @override - void onMapGestureEnd() {} - - @override - Future onCameraIdle(MapLibreMapController controller) async {} - - @override - Future onAmbientCacheCleared(MapLibreMapController controller) async {} - @override Widget buildLegend(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -213,9 +174,6 @@ class LightningMapLayer implements MapLayer { _shownFrameId = null; } - @override - void selectFeature(String id) {} - @override void onStyleReset() { _mounted = false; @@ -232,13 +190,13 @@ class LightningMapLayer implements MapLayer { return result.when( ok: (snapshot) { _cache[frameId] = snapshot; - // Bound memory — keep ~40 frames. + // Bound memory — keep ~40 frames: drop the oldest (ids are Unix + // seconds), never the frame that is on screen. if (_cache.length > 40) { - final keys = _cache.keys.toList() - ..sort(); // oldest seconds first as string-sortable? better by int - keys.sort((a, b) => int.parse(a).compareTo(int.parse(b))); - for (final k in keys.take(_cache.length - 40)) { - if (k != _shownFrameId) _cache.remove(k); + 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; @@ -253,8 +211,19 @@ class LightningMapLayer implements MapLayer { Future _ensureImages(MapLibreMapController controller) async { if (_imagesReady) return; try { - await controller.addImage(_dotImageId, await _renderDot(), true); - await controller.addImage(_crossImageId, await _renderCross(), true); + // One pre-coloured PNG per shape × age bucket, black outline baked in — + // the same trick as the wind arrows. MapLibre tints a plain (non-SDF) + // image by *replacing* its RGB, which would erase a baked outline, so the + // colour has to be baked too; the layer picks the image by feature. + for (final kind in const ['dot', 'cross']) { + for (final minutes in const [5, 10, 30, 60]) { + final fill = colorFromHexRgb(_ageHex[minutes]!)!; + final bytes = kind == 'dot' + ? await _renderDot(fill) + : await _renderCross(fill); + await controller.addImage(_imageId(kind, minutes), bytes, false); + } + } _imagesReady = true; } catch (error, stackTrace) { // Style reload may leave images; retry next show. @@ -262,6 +231,9 @@ class LightningMapLayer implements MapLayer { } } + static String _imageId(String kind, int minutes) => + '$_imagePrefix-$kind-$minutes'; + Future _ensureSource(MapLibreMapController controller) async { if (_mounted) return; await _removeFromMap(controller); @@ -273,35 +245,24 @@ class LightningMapLayer implements MapLayer { _sourceId, _layerId, SymbolLayerProperties( - iconImage: [ - 'match', - ['get', 'kind'], - 'cg', - _crossImageId, - _dotImageId, - ], - iconColor: [ - 'match', - ['get', 'age'], - 5, - _ageHex[5]!, - 10, - _ageHex[10]!, - 30, - _ageHex[30]!, - _ageHex[60]!, - ], + // The image is picked by the feature's `icon` property (kind + age), + // carrying the pre-baked colour + black outline — no `iconColor` tint, + // which would replace the baked-in outline on a non-SDF image. + iconImage: ['get', 'icon'], iconOpacity: 0.85, iconAllowOverlap: true, iconIgnorePlacement: true, + // Same visual weight as the wind arrows (~35–110 px on screen at + // Taiwan overview zooms) — strikes have no speed dimension, so this is + // a flat zoom ramp rather than the wind layer's per-speed nested one. iconSize: [ 'interpolate', ['linear'], ['zoom'], 5, - 0.35, + 0.6, 15, - 1.1, + 1.8, ], ), enableInteraction: false, @@ -313,13 +274,18 @@ class LightningMapLayer implements MapLayer { final features = >[]; for (final strike in snapshot.strikes) { final age = _ageBucket(snapshot.time, strike.time); + final kind = strike.type == 1 ? 'cross' : 'dot'; features.add({ 'type': 'Feature', 'geometry': { 'type': 'Point', 'coordinates': [strike.longitude, strike.latitude], }, - 'properties': {'kind': strike.type == 1 ? 'cg' : 'cc', 'age': age}, + 'properties': { + 'kind': strike.type == 1 ? 'cg' : 'cc', + 'age': age, + 'icon': _imageId(kind, age), + }, }); } return {'type': 'FeatureCollection', 'features': features}; @@ -334,14 +300,27 @@ class LightningMapLayer implements MapLayer { return 60; } - Future _renderDot() async { + Future _renderDot(Color fill) async { const size = 64.0; + // Same proportional outline as the wind arrows (5.5 on a 96 px canvas ≈ + // 5.7 %): 4.0 on the smaller 64 px mark keeps the ring visibly as thick + // after the layer scales both glyphs to the same on-screen size. + const halo = 4.0; final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); - final paint = Paint() - ..color = const Color(0xFFFFFFFF) - ..style = PaintingStyle.fill; - canvas.drawCircle(const Offset(size / 2, size / 2), size * 0.28, paint); + // Black offset-outline baked in (like the wind arrows): the PNG carries the + // bucket colour + black ring, and the layer shows it un-tinted so the ring + // survives. + canvas.drawCircle( + const Offset(size / 2, size / 2), + size * 0.28 + halo, + Paint()..color = const Color(0xFF000000), + ); + canvas.drawCircle( + const Offset(size / 2, size / 2), + size * 0.28, + Paint()..color = fill, + ); final image = await recorder.endRecording().toImage( size.toInt(), size.toInt(), @@ -350,37 +329,27 @@ class LightningMapLayer implements MapLayer { return data!.buffer.asUint8List(); } - Future _renderCross() async { + Future _renderCross(Color fill) async { const size = 64.0; const thickness = 10.0; + // Same proportional outline as the wind arrows — see [_renderDot]. + const halo = 4.0; final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); - final paint = Paint() - ..color = const Color(0xFFFFFFFF) - ..style = PaintingStyle.fill; - // Vertical + horizontal bars. - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromCenter( - center: const Offset(size / 2, size / 2), - width: thickness, - height: size * 0.7, - ), - const Radius.circular(2), - ), - paint, - ); - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromCenter( - center: const Offset(size / 2, size / 2), - width: size * 0.7, - height: thickness, - ), - const Radius.circular(2), + // Vertical + horizontal bars, black behind the colour (see [_renderDot]). + RRect bar(double w, double h) => RRect.fromRectAndRadius( + Rect.fromCenter( + center: const Offset(size / 2, size / 2), + width: w, + height: h, ), - paint, + const Radius.circular(2), ); + final black = Paint()..color = const Color(0xFF000000); + canvas.drawRRect(bar(thickness + halo * 2, size * 0.7 + halo * 2), black); + canvas.drawRRect(bar(size * 0.7 + halo * 2, thickness + halo * 2), black); + canvas.drawRRect(bar(thickness, size * 0.7), Paint()..color = fill); + canvas.drawRRect(bar(size * 0.7, thickness), Paint()..color = fill); final image = await recorder.endRecording().toImage( size.toInt(), size.toInt(), @@ -411,7 +380,13 @@ class _LegendMark extends StatelessWidget { return Container( width: 12, height: 12, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + // Same black outline the map icons bake in — pale strikes (yellow / + // blue on the frosted card) need it to read as a mark. + border: Border.all(color: Colors.black, width: 1.2), + ), ); } return SizedBox( @@ -429,13 +404,20 @@ class _CrossPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { + // Black bars first (thicker), the colour on top — the legend cross mirrors + // the map marker's baked black outline. final paint = Paint() - ..color = color - ..strokeWidth = 2.5 + ..color = const Color(0xFF000000) + ..strokeWidth = 5 ..strokeCap = StrokeCap.round; final c = Offset(size.width / 2, size.height / 2); canvas.drawLine(Offset(c.dx, 1), Offset(c.dx, size.height - 1), paint); canvas.drawLine(Offset(1, c.dy), Offset(size.width - 1, c.dy), paint); + paint + ..color = color + ..strokeWidth = 2.5; + canvas.drawLine(Offset(c.dx, 1), Offset(c.dx, size.height - 1), paint); + canvas.drawLine(Offset(1, c.dy), Offset(size.width - 1, c.dy), paint); } @override diff --git a/lib/features/map/presentation/layers/pressure_layer.dart b/lib/features/map/presentation/layers/pressure_layer.dart index 45439cb21..07acaddb6 100644 --- a/lib/features/map/presentation/layers/pressure_layer.dart +++ b/lib/features/map/presentation/layers/pressure_layer.dart @@ -7,7 +7,9 @@ import 'package:dpip/features/weather/domain/weather_trend.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/material.dart'; -class PressureMapLayer extends WeatherStationLayer { +class PressureMapLayer + extends + WeatherStationLayer { PressureMapLayer(super.repository); @override diff --git a/lib/features/map/presentation/layers/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart new file mode 100644 index 000000000..4ed332edb --- /dev/null +++ b/lib/features/map/presentation/layers/qpesums_layer.dart @@ -0,0 +1,117 @@ +import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; +import 'package:dpip/features/map/presentation/layers/scan_range_overlay_chrome.dart'; +import 'package:dpip/features/map/presentation/widgets/scan_range_overlay_menu.dart'; +import 'package:dpip/features/weather/domain/qpesums_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/raster_timeline_layer.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// The QPESUMS next-1-hour precipitation forecast (未來一小時降水預報) raster +/// overlay. +/// +/// Everything about scrubbing lives in [RasterTimelineLayer]; this supplies +/// only the layer's identity, its opacity, and the QPESUMS hourly-rate colour +/// key. Frame ids are Unix milliseconds, which [parseFrameTime] already reads. +/// +/// The forecast covers the same grid the radar composite observes, so it shares +/// the radar's scan-range geometry — and, like radar, it redraws its own +/// scan-range outline plus county/town borders **over** the raster +/// ([ScanRangeOverlayChrome]), switchable from its options chip. +class QpesumsMapLayer extends RasterTimelineLayer + with AdminOutlineChrome, ScanRangeOverlayChrome { + QpesumsMapLayer(QpesumsRepository super.repository); + + /// Distinct from radar's ids: both layers can be on the map at once, and each + /// draws its own outline instead of clashing over one source/layer pair. + @override + String get scanRangeSourceId => 'qpesums-scan-range'; + + @override + String get scanRangeLayerId => 'qpesums-scan-range-outline'; + + @override + String get scanRangeColor => '#78909C'; + + /// The forecast covers the base style's borders, so this layer supplies its + /// own on top — same reasoning as radar, and the same chrome. + @override + String? get rasterBelowLayerId => null; + + @override + String get id => 'qpesums'; + + @override + String label(BuildContext context) => + AppLocalizations.of(context).mapLayerQpesums; + + @override + IconData get icon => Icons.cloud_outlined; + + /// Slightly translucent so terrain and boundaries read through the forecast. + @override + double get opacity => 0.85; + + /// The frame times are forecast valid times, not observations — the timeline + /// caption must say so instead of the shared "observed" default. + @override + String timelineCaption(BuildContext context) => + AppLocalizations.of(context).mapTimelineForecast; + + /// Each frame is a one-hour forecast window: the tile at 21:00 estimates + /// rain for 21:00–22:00, so the timeline shows that range rather than a bare + /// instant the user could read as a point measurement. + @override + Duration? get framePeriod => const Duration(hours: 1); + + @override + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }) => ScanRangeOverlayMenu( + layer: this, + tooltip: AppLocalizations.of(context).qpesumsOverlayMenuTooltip, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); + + @override + Widget buildLegend(BuildContext context) => ListenableBuilder( + listenable: chromeListenable, + builder: (context, _) { + return MapLegendCard( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ColorScaleLegend( + unit: 'mm/h', + stops: [ + (1, '#9CFCFF'), + (2, '#03C8FF'), + (6, '#059BFF'), + (10, '#0165FF'), + (15, '#329400'), + (20, '#38FF00'), + (30, '#FFFB00'), + (40, '#FFC800'), + (50, '#FF9600'), + (70, '#FF0000'), + (90, '#CC0000'), + (110, '#A00000'), + (130, '#96009B'), + (150, '#C900CC'), + (200, '#FF00F0'), + (300, '#FFC8FF'), + ], + ), + chromeLegendSection(context), + ], + ), + ); + }, + ); +} diff --git a/lib/features/map/presentation/layers/radar_layer.dart b/lib/features/map/presentation/layers/radar_layer.dart index 9a7fd2bca..62c9c6db2 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -1,17 +1,61 @@ +import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.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/widgets/radar_overlay_menu.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/raster_timeline_layer.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// The radar echo (雷達回波) raster overlay. /// /// Everything about scrubbing — the preload ring, tile warming, scoped cancels — /// lives in [RasterTimelineLayer]; this supplies only radar's identity, its -/// opacity, and the dBZ colour key. -class RadarMapLayer extends RasterTimelineLayer { +/// opacity, the dBZ colour key, and the three overlays its options chip toggles. +/// +/// The echo is mounted **above** the base style's borders (see +/// [rasterBelowLayerId]) and the borders are put back on top as switchable +/// layers ([ScanRangeOverlayChrome]). That is what makes them switchable at +/// all: while they came through from underneath there was no way to get an +/// uninterrupted raster. +class RadarMapLayer extends RasterTimelineLayer + with AdminOutlineChrome, ScanRangeOverlayChrome { RadarMapLayer(RadarRepository super.repository); + /// The radar composite's own ids — the default geometry and layer naming. + @override + String get scanRangeSourceId => RadarScanRange.sourceId; + + @override + String get scanRangeLayerId => RadarScanRange.outlineLayerId; + + @override + String get scanRangeColor => _rangeColor; + + /// Blue-grey: distinct from every dBZ colour in the scale below, so the + /// outline is never mistaken for an echo. + static const String _rangeColor = '#78909C'; + + /// The echo covers the base style's borders instead of passing under them — + /// this layer supplies its own on top, and showing both would draw every + /// boundary twice at two weights. + @override + String? get rasterBelowLayerId => null; + + @override + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }) => RadarOverlayMenu( + layer: this, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); + @override String get id => 'radar'; @@ -27,25 +71,37 @@ class RadarMapLayer extends RasterTimelineLayer { double get opacity => 0.85; @override - Widget buildLegend(BuildContext context) => const MapLegendCard( - child: ColorScaleLegend( - unit: 'dBZ', - stops: [ - (0, '#00FFFF'), - (5, '#00A3FF'), - (10, '#005BFF'), - (15, '#0000FF'), - (20, '#00D300'), - (25, '#00A000'), - (30, '#CCEA00'), - (35, '#FFD300'), - (40, '#FF8800'), - (45, '#FF1800'), - (50, '#D30000'), - (55, '#A00000'), - (60, '#EA00CC'), - (65, '#9600FF'), - ], - ), + Widget buildLegend(BuildContext context) => ListenableBuilder( + listenable: chromeListenable, + builder: (context, _) { + return MapLegendCard( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ColorScaleLegend( + unit: 'dBZ', + stops: [ + (0, '#00FFFF'), + (5, '#00A3FF'), + (10, '#005BFF'), + (15, '#0000FF'), + (20, '#00D300'), + (25, '#00A000'), + (30, '#CCEA00'), + (35, '#FFD300'), + (40, '#FF8800'), + (45, '#FF1800'), + (50, '#D30000'), + (55, '#A00000'), + (60, '#EA00CC'), + (65, '#9600FF'), + ], + ), + chromeLegendSection(context), + ], + ), + ); + }, ); } diff --git a/lib/features/map/presentation/layers/radar_scan_range.dart b/lib/features/map/presentation/layers/radar_scan_range.dart new file mode 100644 index 000000000..508d0d1ab --- /dev/null +++ b/lib/features/map/presentation/layers/radar_scan_range.dart @@ -0,0 +1,232 @@ +import 'dart:math' as math; + +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// One of the radars whose coverage makes up the composite. +class RadarSite { + const RadarSite(this.lon, this.lat, this.radiusKm); + + final double lon; + final double lat; + + /// Usable range, in kilometres. + final double radiusKm; +} + +/// The area CWA's radar composite (`O-A0059-001`) actually observes. +/// +/// Not the declared grid — that is a plain 115–126.5 × 18–29 box, and most of +/// it is never sampled. The real coverage is the **union of four radar range +/// circles, clipped to the grid**, and the difference matters on a +/// disaster-prevention map: blank outside the circles means *not observed*, not +/// *no rain*. +/// +/// The point test comes from the haversine distance condition +/// +/// ```text +/// 2·Rₑ·asin(√(sin²(Δφ/2) + cosφ·cosφᵢ·sin²(Δλ/2))) ≤ Rᵢ +/// ``` +/// +/// with `sin²(·/2Rₑ)` applied to both sides. `sin` is monotonic on `[0, π/2]`, +/// so that is order-preserving and the condition becomes +/// +/// ```text +/// sin²(Δφ/2) + cosφ·cosφᵢ·sin²(Δλ/2) ≤ kᵢ, kᵢ = sin²(Rᵢ/2Rₑ) +/// ``` +/// +/// `kᵢ` is a constant, so the test carries no `asin`, no `√` and no division. +/// The boundary is the same identity solved for Δλ at a fixed latitude, which +/// is likewise closed-form — no marching, no polygon clipping. +/// +/// Across 17.9–29.1° sampled at 0.005°, the four circles' longitude intervals +/// always merge into a **single** interval, so the union's outline is one ring +/// built from the left edge going up and the right edge coming back down. No +/// boolean polygon operations are needed. +abstract final class RadarScanRange { + RadarScanRange._(); + + /// Earth radius used to fit the ranges, in kilometres. + static const double earthRadiusKm = 6371.0; + + /// The contributing radars. The fourth is inferred: the composite covers + /// ground no Taiwanese site can reach, and Ishigaki is the only radar in + /// range of it. + /// The three domestic sites come first, so [sites] can be sliced by origin. + static const List sites = [ + RadarSite(120.8543, 21.9046, 457.72), // RCKT 墾丁 + RadarSite(120.0808, 23.1472, 460.29), // RCCG 七股 + RadarSite(121.7644, 25.0735, 459.76), // RCWF 五分山 + RadarSite(124.1885, 24.4235, 398.69), // 石垣島 + ]; + + /// How many of [sites] are CWA's own — the rest is inferred coverage. + static const int domesticSiteCount = 3; + + /// The declared grid (cell centres): 921 × 881 at 0.0125°. + static const double gridResolution = 0.0125; + static const double west = 115.0; + static const double south = 18.0; + static const double east = west + (921 - 1) * gridResolution; // 126.5 + static const double north = south + (881 - 1) * gridResolution; // 29.0 + + /// Source and layer ids, kept distinct from the radar raster's own. + static const String sourceId = 'radar-scan-range'; + static const String outlineLayerId = 'radar-scan-range-outline'; + + static const double _deg = math.pi / 180.0; + + /// `x²` — cheaper and cast-free versus `math.pow(x, 2) as double`. + static double _sq(double x) => x * x; + + /// `kᵢ = sin²(Rᵢ / 2Rₑ)` per site, precomputed. + static final List _k = [ + for (final s in sites) _sq(math.sin(s.radiusKm / (2 * earthRadiusKm))), + ]; + + /// `cos φᵢ` per site, precomputed. + static final List _cosLat = [ + for (final s in sites) math.cos(s.lat * _deg), + ]; + + /// Whether a point is observed. Pure comparison — no `asin`, no `√`. + static bool covered(double lon, double lat) { + if (lon < west || lon > east || lat < south || lat > north) return false; + final cosP = math.cos(lat * _deg); + for (var i = 0; i < sites.length; i++) { + final site = sites[i]; + final a = _sq(math.sin((lat - site.lat) * _deg / 2)); + final b = _sq(math.sin((lon - site.lon) * _deg / 2)); + if (a + cosP * _cosLat[i] * b <= _k[i]) return true; + } + return false; + } + + /// The longitude interval one circle spans at [lat], or null if it does not + /// reach that latitude. Closed-form inverse of the condition above. + static (double, double)? _lonSpan(int i, double lat, double cosP) { + final site = sites[i]; + final s = _k[i] - _sq(math.sin((lat - site.lat) * _deg / 2)); + if (s <= 0) return null; + final t = s / (cosP * _cosLat[i]); + if (t <= 0) return null; + final dlon = 2.0 * math.asin(math.sqrt(math.min(t, 1.0))) / _deg; + return (site.lon - dlon, site.lon + dlon); + } + + /// The coverage outline as `[lon, lat]` pairs, closed and wound + /// counter-clockwise (right edge up, left edge back down). + /// + /// [step] is the latitude sampling interval; it defaults to the grid's own + /// resolution, which puts a vertex on every row the data actually has. + static List> ring({double step = gridResolution}) { + final n = ((north - south) / step).round(); + final left = >[]; + final right = >[]; + + for (var i = 0; i <= n; i++) { + final lat = math.min(south + i * step, north); + final cosP = math.cos(lat * _deg); + + double? lo; + double? hi; + for (var j = 0; j < sites.length; j++) { + final span = _lonSpan(j, lat, cosP); + if (span == null) continue; + // The merged interval is always single — see the class doc — so the + // union is just the outermost pair. + lo = lo == null ? span.$1 : math.min(lo, span.$1); + hi = hi == null ? span.$2 : math.max(hi, span.$2); + } + if (lo == null || hi == null) continue; + + final clampedLo = math.max(lo, west); + final clampedHi = math.min(hi, east); + if (clampedHi <= clampedLo) continue; + + left.add([_round6(clampedLo), _round6(lat)]); + right.add([_round6(clampedHi), _round6(lat)]); + } + + final ring = [...right, ...left.reversed]; + if (ring.isNotEmpty) ring.add(ring.first); + return ring; + } + + static double _round6(double v) => (v * 1e6).roundToDouble() / 1e6; + + /// The coverage outline as a GeoJSON polygon. + /// + /// A **map**, never an encoded string: `addSource` hands this straight to + /// `NSJSONSerialization.dataWithJSONObject` on iOS, which throws — crashing + /// the app, not returning an error — on a top-level string. + static Map geoJson() => { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'properties': { + 'name': 'effective_extent', + 'note': '4 radar range circles union, clipped to grid', + }, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring()], + }, + }, + ], + }; + + /// Adds the source and outline if they are not already present. + /// + /// Outline only — no fill. The covered area is where the echo itself is, and + /// a wash over it would tint every dBZ colour on the map. + /// + /// [sourceId]/[layerId] let a second raster (QPESUMS, whose coverage is the + /// same composite) draw its own outline under distinct ids — the defaults are + /// the radar ids, so existing callers pass nothing. + static Future add( + MapLibreMapController controller, { + required String outlineColor, + String? belowLayerId, + String sourceId = RadarScanRange.sourceId, + String layerId = RadarScanRange.outlineLayerId, + }) async { + await controller.addSource( + sourceId, + GeojsonSourceProperties(data: geoJson()), + ); + await controller.addLineLayer( + sourceId, + layerId, + LineLayerProperties( + lineColor: outlineColor, + lineWidth: 1.5, + lineOpacity: 0.7, + // Dashed, so it never reads as a coastline or an administrative border. + lineDasharray: const [3.0, 2.0], + ), + belowLayerId: belowLayerId, + enableInteraction: false, + ); + } + + /// Removes the layer and source, tolerating a partially-added state. + /// + /// The ids must match the [add] call the outline was drawn with. + static Future remove( + MapLibreMapController controller, { + String sourceId = RadarScanRange.sourceId, + String layerId = RadarScanRange.outlineLayerId, + }) async { + try { + await controller.removeLayer(layerId); + } catch (_) { + // Not present — nothing to undo. + } + try { + await controller.removeSource(sourceId); + } catch (_) { + // Not present — nothing to undo. + } + } +} diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index a8a125e27..e9f7fe6f8 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -2,24 +2,16 @@ /// selectable window (`now` / `10m` / `1h` / …), tap → trend sheet. library; -import 'dart:math' as math; - -import 'package:dpip/app/theme/app_spacing.dart'; -import 'package:dpip/core/error/result.dart'; -import 'package:dpip/features/map/presentation/widgets/station_sheet.dart'; -import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; +import 'package:dpip/features/map/presentation/layers/weather_station_layer.dart'; import 'package:dpip/features/weather/domain/rain_interval.dart'; import 'package:dpip/features/weather/domain/rain_snapshot.dart'; -import 'package:dpip/features/weather/domain/weather_station.dart'; +import 'package:dpip/features/weather/domain/rain_trend.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; -import 'package:dpip/shared/color_hex.dart'; -import 'package:dpip/shared/map/base_map.dart'; -import 'package:dpip/shared/map/map_layer.dart'; -import 'package:dpip/shared/map/map_station_labels.dart'; -import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:dpip/shared/map/map_town_labels.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; /// Localised labels for [RainInterval] (domain stays Flutter-free). extension RainIntervalL10n on RainInterval { @@ -36,27 +28,15 @@ extension RainIntervalL10n on RainInterval { }; } -class RainMapLayer implements MapLayer, StationSheetSource { - RainMapLayer(this._repository); - - final MeteorRainRepository _repository; +/// Shares [WeatherStationLayer]'s dots/sheet/trend machinery; only the value +/// source (the accumulation window) and its chrome differ. +class RainMapLayer + extends WeatherStationLayer { + RainMapLayer(super.repository); /// Selected accumulation window — default matches legacy (`now` = 今日). final ValueNotifier interval = ValueNotifier(RainInterval.now); - final ValueNotifier _selected = ValueNotifier(null); - final ValueNotifier _selectionRevision = ValueNotifier(0); - - Map _stations = const {}; - Map _observations = const {}; - bool _loaded = false; - MapLibreMapController? _controller; - - String get _sourceId => 'rain-src'; - String get _circleId => 'rain-circle'; - - String get _labelId => 'rain-label'; - @override String get id => 'rain'; @@ -71,15 +51,16 @@ class RainMapLayer implements MapLayer, StationSheetSource { String get unit => 'mm'; @override - double? get chartMinY => 0; + int get decimals => 1; @override - double? get chartMaxY => null; + double? get chartMinY => 0; @override bool get chartBars => true; /// Legacy precipitation colour ramp (mm). + @override List<(double, String)> get colorStops => const [ (0, '#c2c2c2'), (10, '#9cfcff'), @@ -93,70 +74,16 @@ class RainMapLayer implements MapLayer, StationSheetSource { (2000, '#000000'), ]; - static const int _decimals = 1; - - @override - bool get usesTimeline => false; - - @override - double get bottomChromeFraction => StationSheet.peekExtent; - - @override - double get mapMinZoom => 4; - - @override - double get mapMaxZoom => BaseMap.maxZoom; - - @override - Future>> frames() async => const Ok([]); - @override - Future prepare(MapLibreMapController c, List frames) async {} + double? valueOf(RainObservation observation) => + interval.value.valueOf(observation); @override - Future show( - MapLibreMapController c, - MapFrame frame, { - bool scrubbing = false, - }) async {} + List trendOf(RainTrend trend) => trend.rain; + /// Dry stations (0 mm) clutter the island at overview; reveal past z8. @override - Future render(MapLibreMapController controller) async { - _controller = controller; - await _ensureData(); - await _removeFromMap(controller); - await controller.addSource( - _sourceId, - GeojsonSourceProperties(data: _geoJson()), - ); - await controller.addCircleLayer( - _sourceId, - _circleId, - CircleLayerProperties( - circleColor: _colorExpression(), - circleRadius: 6, - circleStrokeColor: '#FFFFFF', - circleStrokeWidth: 1, - circleOpacity: 0.9, - ), - // Dry stations (0 mm) clutter the island at overview; reveal past z8. - filter: _visibleFilter, - enableInteraction: false, - ); - // Name over reading, pinned under the dot and de-collided by the engine - // (see [stationLabelProps]). - await controller.addSymbolLayer( - _sourceId, - _labelId, - stationLabelProps(textField: const ['get', 'label']), - minzoom: 9, - filter: _visibleFilter, - enableInteraction: false, - ); - } - - /// Non-zero always; zero-mm only when zoomed in past 8. - static const List _visibleFilter = [ + List? get featureFilter => const [ 'any', [ '>', @@ -170,14 +97,36 @@ class RainMapLayer implements MapLayer, StationSheetSource { ], ]; + @override + bool includeInSelection( + RainObservation observation, + double value, + double zoom, + ) => value > 0 || zoom > 8; + + @override + Listenable get chromeListenable => interval; + + @override + Widget? legendHeader(BuildContext context) => Text( + interval.value.label(AppLocalizations.of(context)), + style: Theme.of(context).textTheme.labelMedium, + ); + + @override + String title(BuildContext context) { + final l10n = AppLocalizations.of(context); + return '${l10n.mapLayerRain} · ${interval.value.label(l10n)}'; + } + /// Switches the accumulation window and refreshes dots + labels in place. Future setInterval(RainInterval next) async { if (interval.value == next) return; interval.value = next; - final controller = _controller; - if (controller != null) { + final map = controller; + if (map != null) { try { - await controller.setGeoJsonSource(_sourceId, _geoJson()); + await map.setGeoJsonSource(sourceId, geoJson); } catch (_) { // Source gone (layer torn down) — next [render] rebuilds it. } @@ -185,256 +134,53 @@ class RainMapLayer implements MapLayer, StationSheetSource { } @override - Future onMapTap(LatLng latLng, MapLibreMapController controller) async { - const threshold = 0.18 * 0.18; - final cosLat = math.cos(latLng.latitude * math.pi / 180); - final window = interval.value; - final zoom = controller.cameraPosition?.zoom ?? 0; - final showZero = zoom > 8; - String? best; - var bestDistance = threshold; - for (final entry in _stations.entries) { - final observation = _observations[entry.key]; - final value = observation == null ? null : window.valueOf(observation); - if (value == null) continue; - if (value <= 0 && !showZero) continue; - final station = entry.value; - final dLat = station.latitude - latLng.latitude; - final dLon = (station.longitude - latLng.longitude) * cosLat; - final distance = dLat * dLat + dLon * dLon; - if (distance < bestDistance) { - bestDistance = distance; - best = entry.key; - } - } - if (best != null) { - _selected.value = best; - _selectionRevision.value++; - } - } - - @override - Widget buildTopTrailingChrome(BuildContext context) { + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).colorScheme; return ListenableBuilder( - listenable: interval, + listenable: Listenable.merge([interval, showTownLabels]), builder: (context, _) { final current = interval.value; return MenuAnchor( - builder: (context, controller, child) { - return IconButton.filledTonal( - tooltip: l10n.rainIntervalMenu, - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - icon: Icon( - Icons.timelapse_outlined, - color: colors.onSecondaryContainer, - ), - ); - }, + alignmentOffset: const Offset(0, 4), + style: MapChipButton.menuStyle(context), + builder: (context, controller, _) => MapChipButton( + icon: Icons.timelapse_outlined, + tooltip: l10n.rainIntervalMenu, + // Same chip affordance and height as every other layer's menu, so + // the compass (parked under the chip band) lines up across layers. + active: current != RainInterval.now, + onTap: () => + controller.isOpen ? controller.close() : controller.open(), + ), menuChildren: [ - for (final option in RainInterval.values) - MenuItemButton( - onPressed: () => setInterval(option), - trailingIcon: option == current - ? Icon(Icons.check, size: 18, color: colors.primary) - : null, - child: Text(option.label(l10n)), - ), + MapMenuScrollView( + children: [ + SectionHeader(l10n.rainIntervalSection), + for (final option in RainInterval.values) + MenuItemButton( + onPressed: () => setInterval(option), + trailingIcon: option == current + ? Icon(Icons.check, size: 18, color: colors.primary) + : null, + child: Text(option.label(l10n)), + ), + const MapMenuDivider(), + SectionHeader(l10n.mapOverlaySectionMap), + MapTownLabelsRow( + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ), + ], + ), ], ); }, ); } - - @override - Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); - - @override - void onMapGestureStart() {} - - @override - void onMapGestureEnd() {} - - @override - Future onCameraIdle(MapLibreMapController controller) async {} - - @override - Future onAmbientCacheCleared(MapLibreMapController controller) async {} - - @override - Widget buildSheet(BuildContext context) => ListenableBuilder( - listenable: interval, - builder: (context, _) => StationSheet(key: ValueKey(id), source: this), - ); - - @override - Widget buildLegend(BuildContext context) { - final l10n = AppLocalizations.of(context); - return ListenableBuilder( - listenable: interval, - builder: (context, _) => MapLegendCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - interval.value.label(l10n), - style: Theme.of(context).textTheme.labelMedium, - ), - const SizedBox(height: AppSpacing.xs), - ColorScaleLegend(stops: colorStops, unit: unit, appendUnit: true), - ], - ), - ), - ); - } - - @override - Future clear(MapLibreMapController controller) async { - await _removeFromMap(controller); - _controller = null; - _selected.value = null; - } - - @override - void selectFeature(String id) => select(id); - - @override - void onStyleReset() {} - - @override - ValueListenable get selection => _selected; - - @override - ValueListenable get selectionRevision => _selectionRevision; - - @override - String title(BuildContext context) { - final l10n = AppLocalizations.of(context); - return '${l10n.mapLayerRain} · ${interval.value.label(l10n)}'; - } - - @override - String stationName(String id) => _stations[id]?.name ?? id; - - @override - String? stationSubtitle(String id) { - final station = _stations[id]; - return station == null ? null : '${station.county} · ${station.town}'; - } - - @override - Color? valueColor(String id) { - final observation = _observations[id]; - final value = observation == null - ? null - : interval.value.valueOf(observation); - return value == null ? null : _rampColor(value); - } - - @override - String? reading(String id) { - final observation = _observations[id]; - if (observation == null) return null; - final value = interval.value.valueOf(observation); - return value == null ? null : '${value.toStringAsFixed(_decimals)} $unit'; - } - - @override - Future> trend(String id, String range) async { - final result = await _repository.trend(id, range: range); - return result.map((t) => TrendSeries(times: t.times, values: t.rain)); - } - - @override - void close() => _selected.value = null; - - @override - void select(String id) { - _selected.value = id; - _selectionRevision.value++; - } - - Future _ensureData() async { - if (_loaded) return; - final stations = (await _repository.stations()).valueOrNull; - final snapshot = (await _repository.latest()).valueOrNull; - if (stations != null) _stations = stations; - if (snapshot != null) { - _observations = {for (final o in snapshot.stations) o.id: o}; - } - _loaded = stations != null && snapshot != null; - } - - Map _geoJson() { - final window = interval.value; - final features = >[]; - for (final entry in _stations.entries) { - final observation = _observations[entry.key]; - if (observation == null) continue; - final value = window.valueOf(observation); - if (value == null) continue; - final station = entry.value; - features.add({ - 'type': 'Feature', - 'geometry': { - 'type': 'Point', - 'coordinates': [station.longitude, station.latitude], - }, - 'properties': { - 'id': entry.key, - 'value': value, - 'label': '${station.name}\n${value.toStringAsFixed(_decimals)} $unit', - }, - }); - } - return {'type': 'FeatureCollection', 'features': features}; - } - - List _colorExpression() => [ - 'interpolate', - ['linear'], - ['get', 'value'], - for (final (at, color) in colorStops) ...[at, color], - ]; - - Color? _rampColor(double value) { - final stops = colorStops; - if (stops.isEmpty) return null; - if (value <= stops.first.$1) return colorFromHexRgb(stops.first.$2); - if (value >= stops.last.$1) return colorFromHexRgb(stops.last.$2); - for (var i = 0; i < stops.length - 1; i++) { - final (lowAt, lowHex) = stops[i]; - final (highAt, highHex) = stops[i + 1]; - if (value < lowAt || value > highAt) continue; - final low = colorFromHexRgb(lowHex); - final high = colorFromHexRgb(highHex); - if (low == null || high == null) return low ?? high; - final span = highAt - lowAt; - return Color.lerp(low, high, span == 0 ? 0 : (value - lowAt) / span); - } - return colorFromHexRgb(stops.last.$2); - } - - Future _removeFromMap(MapLibreMapController controller) async { - for (final layerId in [_circleId, _labelId]) { - try { - await controller.removeLayer(layerId); - } catch (_) { - // Expected when the layer isn't on the map yet. - } - } - try { - await controller.removeSource(_sourceId); - } catch (_) { - // Expected when the source isn't on the map yet. - } - } } diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 2659e9853..160fecd99 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -11,13 +11,11 @@ import 'package:dpip/features/earthquake/domain/seismic_station.dart'; import 'package:dpip/features/earthquake/domain/trem_station_repository.dart'; import 'package:dpip/features/map/presentation/widgets/rts_monitor_panel.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; -import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_station_labels.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/widgets/intensity_legend.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; -import 'package:dpip/core/error/result.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -25,7 +23,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// station dots (coloured by raw intensity `i`) on every ~1 Hz snapshot. Not /// tap- or timeline-driven; its "sheet" is a compact monitor panel showing the /// feed's freshness, and [buildLegend] shows the intensity scale. -class RtsMapLayer implements MapLayer { +class RtsMapLayer with MapLayerDefaults implements MapLayer { RtsMapLayer(this._feed, this._stationRepository); final RealtimeNotifier _feed; @@ -71,25 +69,6 @@ class RtsMapLayer implements MapLayer { @override double get bottomChromeFraction => RtsMonitorPanel.bottomStripFraction; - @override - double get mapMinZoom => 4; - - @override - double get mapMaxZoom => BaseMap.maxZoom; - - @override - Future>> frames() async => const Ok([]); - - @override - Future prepare(MapLibreMapController c, List frames) async {} - - @override - Future show( - MapLibreMapController c, - MapFrame frame, { - bool scrubbing = false, - }) async {} - @override Future render(MapLibreMapController controller) async { _controller = controller; @@ -173,34 +152,9 @@ class RtsMapLayer implements MapLayer { sortKey: _labelSortKey, ); - @override - Future onMapTap( - LatLng latLng, - MapLibreMapController controller, - ) async {} - @override Widget buildSheet(BuildContext context) => RtsMonitorPanel(feed: _feed); - @override - Widget buildTopTrailingChrome(BuildContext context) => - const SizedBox.shrink(); - - @override - Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); - - @override - void onMapGestureStart() {} - - @override - void onMapGestureEnd() {} - - @override - Future onCameraIdle(MapLibreMapController controller) async {} - - @override - Future onAmbientCacheCleared(MapLibreMapController controller) async {} - @override Widget buildLegend(BuildContext context) => const MapLegendCard( child: IntensityLegend(mode: IntensityLegendMode.rts), @@ -216,9 +170,6 @@ class RtsMapLayer implements MapLayer { _controller = null; } - @override - void selectFeature(String id) {} - @override void onStyleReset() => _added = false; diff --git a/lib/features/map/presentation/layers/satellite_layer.dart b/lib/features/map/presentation/layers/satellite_layer.dart index 1bcd340d6..2670317e6 100644 --- a/lib/features/map/presentation/layers/satellite_layer.dart +++ b/lib/features/map/presentation/layers/satellite_layer.dart @@ -1,25 +1,120 @@ +import 'dart:async'; + +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/map/presentation/widgets/satellite_legend.dart'; +import 'package:dpip/features/map/presentation/widgets/satellite_style_menu.dart'; +import 'package:dpip/features/weather/domain/satellite_channel.dart'; import 'package:dpip/features/weather/domain/satellite_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:dpip/shared/map/raster_timeline_layer.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -/// The satellite IR cloud (衛星雲圖) raster overlay. +/// The satellite cloud (衛星雲圖) raster overlay for one [SatelliteChannel] — +/// a raw AHI band or a derived product, keyed by the channel's `?channel=`. /// /// Scrubbing behaviour is [RasterTimelineLayer]'s; what is specific here is the -/// black boundary outline. The imagery is fully opaque and mostly white, so the -/// base style's light borders vanish underneath it — this layer draws its own -/// dark ones while it is active. +/// bright-yellow boundary outline and — for raw bands — a live colour [style] +/// switch (`?style=`), re-mounted through the scaffold's reload when it changes. class SatelliteMapLayer extends RasterTimelineLayer { - SatelliteMapLayer(SatelliteRepository super.repository); + SatelliteMapLayer( + SatelliteRepository super.repository, { + required this.channel, + }); + + /// Which view of Himawari this layer renders — sets both the tile URL's + /// `?channel=` and the picker label. + final SatelliteChannel channel; + + /// Colour rendering of a raw band (JMA grayscale default); ignored for named + /// products, which carry their own palette. Non-default ⇒ the settings chip + /// shows its marker dot. + final ValueNotifier style = ValueNotifier( + SatelliteStyle.gray, + ); + + /// Whether 國界 (world country borders) are redrawn above the imagery. Off + /// by default: on a Taiwan-focused surface the neighbours' outlines are + /// usually noise, and a reader who wants them can ask — the same default as + /// the radar / wind frame ([AdminOutlineChrome.showGlobalOutline]). + final ValueNotifier showGlobalOutline = ValueNotifier(false); + + bool _globalShown = false; + + /// The live map controller, set on attach and cleared on detach — needed to + /// sync the 國界 toggle straight onto a mounted map. + @protected + MapLibreMapController? controller; + + /// Turns the 國界 borders on/off, applying it to a live map immediately. + void setShowGlobalOutline(bool value) { + if (showGlobalOutline.value == value) return; + showGlobalOutline.value = value; + unawaited(_syncGlobalOutline()); + } + + /// Adds or removes the 國界 line to match [showGlobalOutline]. + Future _syncGlobalOutline() async { + final controller = this.controller; + if (controller == null) return; + final wanted = showGlobalOutline.value; + if (wanted == _globalShown) return; + _globalShown = wanted; + try { + if (wanted) { + await controller.addLineLayer( + 'exptech', + satelliteGlobalOutlineLayerId, + LineLayerProperties(lineColor: satelliteOutlineColor, lineWidth: 1.0), + sourceLayer: 'global', + enableInteraction: false, + ); + } else { + await controller.removeLayer(satelliteGlobalOutlineLayerId); + } + } catch (error, stackTrace) { + // A style reload can race the toggle; the next sync recovers. + _globalShown = !wanted; + Log.handle(error, stackTrace, 'Failed to sync the satellite outlines'); + } + } + /// Switches [style] and re-mounts the layer so the map picks up the new + /// `?style=` tiles. The reload is the scaffold's job ([onReloadActive]); a + /// no-op for named-product channels, and for bands whose only rendering is + /// grayscale ([SatelliteChannel.isThermal] bands alone take the temperature + /// styles). + void setStyle( + SatelliteStyle value, { + required Future Function() onReloadActive, + }) { + if (!channel.isBand || + style.value == value || + (!channel.isThermal && value != SatelliteStyle.gray)) { + return; + } + style.value = value; + (source as SatelliteRepository).setStyle(value.key); + unawaited(onReloadActive()); + } + + /// The plain IR (B13) layer keeps the historical `satellite` id — the + /// default-layer preference and any saved layer order predate the + /// multi-channel split and name this exact layer. Every other channel gets a + /// `satellite-` id namespaced off it. @override - String get id => 'satellite'; + String get id => channel == SatelliteChannel.irClean + ? 'satellite' + : 'satellite-${channel.key}'; @override String label(BuildContext context) => - AppLocalizations.of(context).mapLayerSatellite; + satelliteChannelLabel(channel, AppLocalizations.of(context)); + + @override + String? subtitle(BuildContext context) => channel.subtitle; @override IconData get icon => Icons.satellite_alt_outlined; @@ -29,24 +124,58 @@ class SatelliteMapLayer extends RasterTimelineLayer { @override double get opacity => 1; + /// Thermal bands offer the colour-style menu (and the township-label + /// toggle); everything else — named products and the reflectance bands + /// (B01–B06, whose only rendering is grayscale) — has no style to pick and + /// gets the reference menu (國界 toggle + township labels) instead. + @override + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }) { + if (channel.isBand && channel.isThermal) { + return SatelliteStyleMenu( + layer: this, + onReloadActive: onReloadActive, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); + } + return SatelliteReferenceMenu( + layer: this, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); + } + @override Future onAttached(MapLibreMapController controller) async { + this.controller = controller; try { - for (final (layerId, sourceLayer) in const [ - (satelliteGlobalOutlineLayerId, 'global'), - (satelliteCountyOutlineLayerId, 'city'), - ]) { - await controller.addLineLayer( - 'exptech', - layerId, - const LineLayerProperties( - lineColor: satelliteOutlineColor, - lineWidth: 1.0, - ), - sourceLayer: sourceLayer, - enableInteraction: false, - ); - } + // County frame in bright yellow, township mesh in dark yellow — the hue + // set that stays legible on imagery which is overall dark. The 國界 + // border is the [showGlobalOutline] toggle (off by default), so only the + // two admin frames are drawn unconditionally. + await controller.addLineLayer( + 'exptech', + satelliteCountyOutlineLayerId, + LineLayerProperties(lineColor: satelliteOutlineColor, lineWidth: 1.0), + sourceLayer: 'city', + enableInteraction: false, + ); + await controller.addLineLayer( + 'exptech', + satelliteTownOutlineLayerId, + LineLayerProperties( + lineColor: satelliteTownOutlineColor, + lineWidth: 0.7, + ), + sourceLayer: 'town', + enableInteraction: false, + ); + await _syncGlobalOutline(); } catch (_) { // Half-added outlines are worse than none — roll back and carry on. await onDetached(controller); @@ -55,9 +184,12 @@ class SatelliteMapLayer extends RasterTimelineLayer { @override Future onDetached(MapLibreMapController controller) async { + this.controller = null; + _globalShown = false; for (final layerId in const [ - satelliteCountyOutlineLayerId, satelliteGlobalOutlineLayerId, + satelliteCountyOutlineLayerId, + satelliteTownOutlineLayerId, ]) { try { await controller.removeLayer(layerId); @@ -66,5 +198,51 @@ class SatelliteMapLayer extends RasterTimelineLayer { } @override - Widget buildLegend(BuildContext context) => const SizedBox.shrink(); + Widget buildLegend(BuildContext context) => SatelliteLegend( + channel: channel, + style: style, + showGlobal: showGlobalOutline, + ); } + +/// Localised picker label for a satellite [channel] — the `ひまわり `(B##) +/// convention the layer names share. +String satelliteChannelLabel(SatelliteChannel channel, AppLocalizations l10n) => + switch (channel) { + SatelliteChannel.visibleBlue => l10n.mapLayerSatelliteB01, + SatelliteChannel.visibleGreen => l10n.mapLayerSatelliteB02, + SatelliteChannel.visibleRed => l10n.mapLayerSatelliteB03, + SatelliteChannel.nir => l10n.mapLayerSatelliteB04, + SatelliteChannel.nirPhase => l10n.mapLayerSatelliteB05, + SatelliteChannel.nirCloud => l10n.mapLayerSatelliteB06, + SatelliteChannel.swir => l10n.mapLayerSatelliteB07, + SatelliteChannel.wvUpper => l10n.mapLayerSatelliteB08, + SatelliteChannel.wvMid => l10n.mapLayerSatelliteB09, + SatelliteChannel.wvLow => l10n.mapLayerSatelliteB10, + SatelliteChannel.so2 => l10n.mapLayerSatelliteB11, + SatelliteChannel.ozone => l10n.mapLayerSatelliteB12, + SatelliteChannel.irClean => l10n.mapLayerSatelliteB13, + SatelliteChannel.irLong => l10n.mapLayerSatelliteB14, + SatelliteChannel.irLong2 => l10n.mapLayerSatelliteB15, + SatelliteChannel.co2 => l10n.mapLayerSatelliteB16, + SatelliteChannel.truecolor => l10n.mapLayerSatelliteTruecolor, + SatelliteChannel.naturalcolor => l10n.mapLayerSatelliteNaturalcolor, + SatelliteChannel.ash => l10n.mapLayerSatelliteAsh, + SatelliteChannel.dust => l10n.mapLayerSatelliteDust, + SatelliteChannel.airmass => l10n.mapLayerSatelliteAirmass, + SatelliteChannel.nightmicrophysics => + l10n.mapLayerSatelliteNightmicrophysics, + SatelliteChannel.watervapor => l10n.mapLayerSatelliteWatervapor, + SatelliteChannel.btdSplit => l10n.mapLayerSatelliteBtdSplit, + SatelliteChannel.btdFog => l10n.mapLayerSatelliteBtdFog, + SatelliteChannel.btdWvirw => l10n.mapLayerSatelliteBtdWvirw, + SatelliteChannel.btdSo2 => l10n.mapLayerSatelliteBtdSo2, + SatelliteChannel.btdCo2 => l10n.mapLayerSatelliteBtdCo2, + SatelliteChannel.btdOzone => l10n.mapLayerSatelliteBtdOzone, + SatelliteChannel.cloudtop => l10n.mapLayerSatelliteCloudtop, + SatelliteChannel.cloudmask => l10n.mapLayerSatelliteCloudmask, + SatelliteChannel.sst => l10n.mapLayerSatelliteSst, + SatelliteChannel.ndvi => l10n.mapLayerSatelliteNdvi, + SatelliteChannel.ndwi => l10n.mapLayerSatelliteNdwi, + SatelliteChannel.mndwi => l10n.mapLayerSatelliteMndwi, + }; diff --git a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart new file mode 100644 index 000000000..73e5ef8be --- /dev/null +++ b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart @@ -0,0 +1,161 @@ +/// Switchable reference chrome for a weather raster: the scan-range outline +/// plus the county and township borders, all redrawn **over** the raster. +library; + +import 'dart:async'; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; +import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_style.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// The reference chrome a weather raster can sit over. +/// +/// A full-saturation raster (radar echo, QPESUMS forecast) covers the base +/// style's borders exactly where the weather is worth looking at, so the layer +/// draws its own scan-range outline and admin borders **on top** of the raster, +/// each switchable. The three questions are independent — "where is this +/// observed", "which county", "which township" — so they are three toggles, and +/// all three ship **on**, because a blank area means *not observed*, not *no +/// rain*, and an unidentified county is one you cannot act on. +/// +/// The county / township half of that chrome is [AdminOutlineChrome], which +/// the wind forecast layer shares; this adds the radar scan-range outline on +/// top of it. The geometry is the radar composite's ([RadarScanRange]) for +/// every consumer: QPESUMS forecasts the same grid the radars observe, so its +/// coverage is the same union of range circles. Only the ids differ, so a map +/// showing radar and QPESUMS at once draws two outlines instead of clashing +/// over one. +mixin ScanRangeOverlayChrome on AdminOutlineChrome { + /// Whether the observed area is outlined. On by default — see the class doc. + final ValueNotifier showScanRange = ValueNotifier(true); + + bool _rangeShown = false; + + /// Source id this layer's scan-range outline is drawn under — distinct per + /// layer, so two rasters sharing the composite geometry never clash. + String get scanRangeSourceId; + + /// Line-layer id for this layer's scan-range outline. + String get scanRangeLayerId; + + /// Blue-grey: distinct from every dBZ/mm/h colour in the scales, so the + /// outline is never mistaken for precipitation. + String get scanRangeColor; + + /// All chrome listenables, for a legend that follows the toggles. + Listenable get chromeListenable => + Listenable.merge([showScanRange, adminChromeListenable]); + + /// Turns the coverage outline on/off, applying it to a live map immediately. + void setShowScanRange(bool value) { + if (showScanRange.value == value) return; + showScanRange.value = value; + unawaited(_syncRange()); + } + + @override + Future onAttached(MapLibreMapController controller) async { + super.onAttached(controller); + await _syncRange(); + } + + @override + Future onDetached(MapLibreMapController controller) async { + super.onDetached(controller); + if (_rangeShown) { + _rangeShown = false; + await RadarScanRange.remove( + controller, + sourceId: scanRangeSourceId, + layerId: scanRangeLayerId, + ); + } + } + + @override + void onStyleReset() { + // The style reload dropped every runtime layer, these included; forget them + // so the next attach re-adds instead of no-oping on stale state. + _rangeShown = false; + super.onStyleReset(); + } + + /// Adds or removes the coverage outline to match [showScanRange]. + Future _syncRange() async { + final controller = this.controller; + if (controller == null) return; + + final wanted = showScanRange.value; + if (wanted == _rangeShown) return; + _rangeShown = wanted; + + try { + if (wanted) { + await RadarScanRange.add( + controller, + outlineColor: scanRangeColor, + belowLayerId: outlineLayerId, + sourceId: scanRangeSourceId, + layerId: scanRangeLayerId, + ); + } else { + await RadarScanRange.remove( + controller, + sourceId: scanRangeSourceId, + layerId: scanRangeLayerId, + ); + } + } catch (error, stackTrace) { + // A style reload can race the toggle; the next sync recovers. + _rangeShown = !wanted; + Log.handle(error, stackTrace, 'Failed to sync the scan range'); + } + } + + /// The chrome's legend entries, following the toggles — a legend naming a + /// line that is not on the map is worse than no legend. + List chromeLegendItems(BuildContext context) { + final l10n = AppLocalizations.of(context); + return [ + if (showScanRange.value) + SymbolLegendItem( + swatch: const LineSwatch( + color: Color(0xFF78909C), + width: 1.5, + opacity: 0.7, + dash: [3, 2], + ), + label: l10n.radarScanRange, + ), + ...adminLegendItems(context), + ]; + } + + /// A legend card that follows the chrome toggles: the swatches are inserted + /// under the caller's scale block, joined by a divider. + Widget chromeLegendSection(BuildContext context) { + final overlays = chromeLegendItems(context); + if (overlays.isEmpty) return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: AppSpacing.sm), + Divider( + height: 1, + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: AppSpacing.sm), + SymbolLegend(items: overlays), + ], + ); + } +} diff --git a/lib/features/map/presentation/layers/temperature_layer.dart b/lib/features/map/presentation/layers/temperature_layer.dart index dac2206dc..a9c7b4aae 100644 --- a/lib/features/map/presentation/layers/temperature_layer.dart +++ b/lib/features/map/presentation/layers/temperature_layer.dart @@ -7,7 +7,9 @@ import 'package:dpip/features/weather/domain/weather_trend.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/material.dart'; -class TemperatureMapLayer extends WeatherStationLayer { +class TemperatureMapLayer + extends + WeatherStationLayer { TemperatureMapLayer(super.repository); @override diff --git a/lib/features/map/presentation/layers/typhoon_layer.dart b/lib/features/map/presentation/layers/typhoon_layer.dart index 0bc78e382..8d1f98a76 100644 --- a/lib/features/map/presentation/layers/typhoon_layer.dart +++ b/lib/features/map/presentation/layers/typhoon_layer.dart @@ -6,7 +6,10 @@ import 'dart:async'; import 'dart:math' as math; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/geo_math.dart'; import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/shared/map/admin_outline.dart'; +import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; import 'package:dpip/features/map/presentation/layers/typhoon_storm_band.dart'; import 'package:dpip/features/map/presentation/layers/typhoon_weather_overlay.dart'; import 'package:dpip/features/map/presentation/widgets/typhoon_forecast_callouts.dart'; @@ -26,11 +29,13 @@ import 'package:dpip/features/typhoon/domain/typhoon_warning.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:dpip/features/weather/domain/satellite_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/color_hex.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/camera_fit.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -46,7 +51,7 @@ typedef _StormPoint = ({ /// Sheet-type [MapLayer]: renders every typhoon dataset on the map and drives /// [TyphoonPanel] (summary, warning) via notifiers. Meteor satellite PNG and /// optional radar/IR underlays are aligned to the live bulletin time. -class TyphoonMapLayer implements MapLayer { +class TyphoonMapLayer with MapLayerDefaults implements MapLayer { TyphoonMapLayer( this._repository, { required this.radar, @@ -91,6 +96,31 @@ class TyphoonMapLayer implements MapLayer { TyphoonWeatherOverlay.radar, ); + /// Whether the radar composite's observed area is outlined under the track. + /// + /// Radar-only, and gone the moment the underlay is not radar: a coverage + /// boundary drawn over Himawari IR would be bounding an instrument that is + /// not on screen. Defaults on, as on the radar surface — outside it, blank + /// means *not observed*, not *no rain*. + final ValueNotifier showScanRange = ValueNotifier(true); + + /// Whether 縣市 borders are redrawn over the radar underlay. Also radar-only: + /// the echo is what buries the base style's borders. + final ValueNotifier showCountyOutline = ValueNotifier(true); + + /// Whether 鄉鎮 borders are redrawn over the radar underlay, switchable apart + /// from the county set — over a storm track the fine mesh is usually the + /// first thing worth dropping. + final ValueNotifier showTownOutline = ValueNotifier(true); + + bool _rangeShown = false; + bool _globalShown = false; + final Set _boundariesShown = {}; + + /// Blue-grey, matching the radar surface — distinct from every dBZ colour so + /// the coverage outline is never mistaken for an echo. + static const String _rangeColor = '#78909C'; + /// Radar / IR tile ids (Unix seconds), ascending — for bulletin alignment. List _radarSecs = const []; List _satSecs = const []; @@ -163,22 +193,6 @@ class TyphoonMapLayer implements MapLayer { @override double get mapMinZoom => 3; - @override - double get mapMaxZoom => BaseMap.maxZoom; - - @override - Future>> frames() async => const Ok([]); - - @override - Future prepare(MapLibreMapController c, List frames) async {} - - @override - Future show( - MapLibreMapController c, - MapFrame frame, { - bool scrubbing = false, - }) async {} - @override Future render(MapLibreMapController controller) async { _controller = controller; @@ -390,12 +404,6 @@ class TyphoonMapLayer implements MapLayer { @override void onMapGestureEnd() => suppressCallouts.value = false; - @override - Future onCameraIdle(MapLibreMapController controller) async {} - - @override - Future onAmbientCacheCleared(MapLibreMapController controller) async {} - /// Active MapLibre controller (for Flutter screen-space callouts). MapLibreMapController? get mapController => _controller; @@ -778,6 +786,128 @@ class TyphoonMapLayer implements MapLayer { _queue(() => _syncWeatherOverlay(controller)); } + /// Turns the radar coverage outline on/off (radar underlay only). + void setShowScanRange(bool value) { + if (showScanRange.value == value) return; + showScanRange.value = value; + final controller = _controller; + if (controller == null || !_added) return; + _queue(() => _syncRadarChrome(controller)); + } + + /// Turns the 縣市 borders on/off (radar and satellite underlays). + void setShowCountyOutline(bool value) { + if (showCountyOutline.value == value) return; + showCountyOutline.value = value; + final controller = _controller; + if (controller == null || !_added) return; + _queue(() => _syncRadarChrome(controller)); + } + + /// Turns the 鄉鎮 borders on/off (radar and satellite underlays). + void setShowTownOutline(bool value) { + if (showTownOutline.value == value) return; + showTownOutline.value = value; + final controller = _controller; + if (controller == null || !_added) return; + _queue(() => _syncRadarChrome(controller)); + } + + /// Adds or removes the weather underlay's chrome — the scan-range outline + /// (radar only), the country/global border (satellite only), and the county + /// and township borders (both underlays) — to match the toggles. + /// + /// Anchored under the lowest typhoon vector, not on top as on the radar + /// surface: here the track and the storm bands are the subject, and a white + /// border crossing them would compete with the thing being read. Below the + /// vectors it still clears the echo, which is the whole problem. + Future _syncRadarChrome(MapLibreMapController controller) async { + final kind = weatherOverlay.value; + final isRadar = kind == TyphoonWeatherOverlay.radar; + final hasUnderlay = kind != TyphoonWeatherOverlay.none; + final wantRange = isRadar && showScanRange.value; + // Both underlays are opaque weather rasters, so the admin frame is drawn + // over each. Satellite is fully opaque and covers the country edges too, + // so its country border is drawn as well — the frame a satellite viewer + // navigates by. + final wantBoundaries = { + if (hasUnderlay && showTownOutline.value) AdminBoundary.town, + if (hasUnderlay && showCountyOutline.value) AdminBoundary.county, + }; + final wantGlobal = hasUnderlay && !isRadar && showCountyOutline.value; + + final below = _warningNames.isNotEmpty ? _warnLyr : _probLyr; + try { + if (wantRange != _rangeShown) { + _rangeShown = wantRange; + if (wantRange) { + await RadarScanRange.add( + controller, + outlineColor: _rangeColor, + belowLayerId: below, + ); + } else { + await RadarScanRange.remove(controller); + } + } + if (wantGlobal != _globalShown) { + _globalShown = wantGlobal; + if (wantGlobal) { + await controller.addLineLayer( + AdminOutline.sourceId, + satelliteGlobalOutlineLayerId, + LineLayerProperties( + lineColor: satelliteOutlineColor, + lineWidth: 1.0, + ), + sourceLayer: 'global', + belowLayerId: below, + enableInteraction: false, + ); + } else { + try { + await controller.removeLayer(satelliteGlobalOutlineLayerId); + } catch (_) {} + } + } + // Borders after the range/global so they insert above it: the dashed + // range is a hint, the borders are the reference frame. Satellite's + // frame is bright yellow — the hue that survives fully-opaque imagery; + // radar keeps the default white core. + for (final boundary in AdminBoundary.values) { + final shown = _boundariesShown.contains(boundary); + if (wantBoundaries.contains(boundary) == shown) continue; + if (shown) { + _boundariesShown.remove(boundary); + await AdminOutline.remove(controller, boundary); + } else { + _boundariesShown.add(boundary); + await AdminOutline.add( + controller, + boundary, + lineColor: isRadar + ? AdminOutline.lineColor + : boundary == AdminBoundary.town + ? satelliteTownOutlineColor + : satelliteOutlineColor, + belowLayerId: below, + ); + } + } + } catch (error, stackTrace) { + // A style reload or an overlay switch can race this; the next sync + // rebuilds from a known-clean state. + _rangeShown = false; + _globalShown = false; + _boundariesShown.clear(); + Log.handle( + error, + stackTrace, + 'Failed to sync the typhoon weather chrome', + ); + } + } + Future _syncWeatherOverlay(MapLibreMapController controller) async { await _removeWeatherRaster(controller); final kind = weatherOverlay.value; @@ -843,9 +973,29 @@ class TyphoonMapLayer implements MapLayer { belowLayerId: below, ); Log.info('Typhoon ${kind.name} overlay @ $frame (bulletin $bulletin)'); + // Only now — the chrome belongs to the radar underlay, so it is built once + // the underlay actually exists, and every early return above leaves it off. + await _syncRadarChrome(controller); } Future _removeWeatherRaster(MapLibreMapController controller) async { + // The chrome outlives nothing: it exists only while the radar underlay + // does, so it comes down with it rather than being left over an IR image + // or a bare map. + if (_rangeShown) { + _rangeShown = false; + await RadarScanRange.remove(controller); + } + if (_globalShown) { + _globalShown = false; + try { + await controller.removeLayer(satelliteGlobalOutlineLayerId); + } catch (_) {} + } + for (final boundary in _boundariesShown.toList()) { + _boundariesShown.remove(boundary); + await AdminOutline.remove(controller, boundary); + } try { await controller.removeLayer(_wxLyr); } catch (_) {} @@ -900,7 +1050,7 @@ class TyphoonMapLayer implements MapLayer { @override Future onMapTap(LatLng latLng, MapLibreMapController controller) async { const threshold = 0.5 * 0.5; - final cosLat = math.cos(latLng.latitude * math.pi / 180); + final cosLat = math.cos(degToRad(latLng.latitude)); _StormPoint? best; var bestDistance = threshold; for (final point in _points) { @@ -970,8 +1120,16 @@ class TyphoonMapLayer implements MapLayer { TyphoonForecastCalloutOverlay(layer: this); @override - Widget buildTopTrailingChrome(BuildContext context) => - TyphoonOverlayMenu(layer: this); + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }) => TyphoonOverlayMenu( + layer: this, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); @override Widget buildLegend(BuildContext context) { @@ -981,10 +1139,20 @@ class TyphoonMapLayer implements MapLayer { showProbability, showWarningAreas, stormBand, + weatherOverlay, + showScanRange, + showCountyOutline, + showTownOutline, ]), builder: (context, _) { final showProb = showProbability.value; final showL7 = stormBand.value == TyphoonStormBand.level7; + // The radar chrome is switchable *and* underlay-dependent, so the key + // has to follow it: naming a line that is not on the map is worse than + // saying nothing. + final isRadar = weatherOverlay.value == TyphoonWeatherOverlay.radar; + final isSatellite = + weatherOverlay.value == TyphoonWeatherOverlay.satellite; return MapLegendCard( child: SymbolLegend( items: [ @@ -1024,11 +1192,18 @@ class TyphoonMapLayer implements MapLayer { label: l10n.typhoonLegendForecast, ), SymbolLegendItem( - swatch: const _DotSwatch(color: Color(0xFFFF7043)), + swatch: const LegendDot( + color: Color(0xFFFF7043), + borderWidth: 1, + ), label: l10n.typhoonLegendForecastPoint, ), SymbolLegendItem( - swatch: const _DotSwatch(color: Color(0xFFD32F2F), size: 10), + swatch: const LegendDot( + color: Color(0xFFD32F2F), + size: 10, + borderWidth: 1, + ), label: l10n.typhoonLegendCurrent, ), if (!showProb) @@ -1113,6 +1288,54 @@ class TyphoonMapLayer implements MapLayer { ), label: l10n.typhoonLegendWarningAreas, ), + if (isRadar && showScanRange.value) + SymbolLegendItem( + swatch: const LineSwatch( + color: Color(0xFF78909C), + width: 1.5, + opacity: 0.7, + dash: [3, 2], + ), + label: l10n.radarScanRange, + ), + if (isSatellite && showCountyOutline.value) + SymbolLegendItem( + swatch: const LineSwatch( + color: Color(0xFFFFD400), + width: 1.0, + ), + label: l10n.mapLayerSatelliteGlobalOutline, + ), + if ((isRadar || isSatellite) && showCountyOutline.value) + SymbolLegendItem( + swatch: LineSwatch( + color: colorFromHexRgb( + isRadar ? AdminOutline.lineColor : satelliteOutlineColor, + )!, + width: AdminBoundary.county.lineWidth, + opacity: AdminBoundary.county.lineOpacity, + casingColor: const Color(0xFF000000), + casingWidth: AdminBoundary.county.casingWidth, + casingOpacity: AdminBoundary.county.casingOpacity, + ), + label: l10n.radarCountyOutline, + ), + if ((isRadar || isSatellite) && showTownOutline.value) + SymbolLegendItem( + swatch: LineSwatch( + color: colorFromHexRgb( + isRadar + ? AdminOutline.lineColor + : satelliteTownOutlineColor, + )!, + width: AdminBoundary.town.lineWidth, + opacity: AdminBoundary.town.lineOpacity, + casingColor: const Color(0xFF000000), + casingWidth: AdminBoundary.town.casingWidth, + casingOpacity: AdminBoundary.town.casingOpacity, + ), + label: l10n.radarTownOutline, + ), ], ), ); @@ -1128,10 +1351,14 @@ class TyphoonMapLayer implements MapLayer { } @override - void selectFeature(String id) {} - - @override - void onStyleReset() => _added = false; + void onStyleReset() { + // The reload dropped every runtime layer, the chrome included; forget it so + // the next attach re-adds instead of no-oping on stale state. + _added = false; + _rangeShown = false; + _globalShown = false; + _boundariesShown.clear(); + } void _parsePoints(Map geo) { _points.clear(); @@ -1226,45 +1453,6 @@ class _TrackPainter extends CustomPainter { oldDelegate.color != color || oldDelegate.dashed != dashed; } -class _DotSwatch extends StatelessWidget { - const _DotSwatch({required this.color, this.size = 8}); - - final Color color; - final double size; - - @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 1), - ), - ); - } -} - -/// Axis-aligned bounds of [points], or `null` when empty / non-finite. -LatLngBounds? boundsFromPoints(Iterable points) { - double? minLat, maxLat, minLng, maxLng; - for (final p in points) { - final lat = p.latitude; - final lng = p.longitude; - if (!lat.isFinite || !lng.isFinite) continue; - minLat = minLat == null ? lat : math.min(minLat, lat); - maxLat = maxLat == null ? lat : math.max(maxLat, lat); - minLng = minLng == null ? lng : math.min(minLng, lng); - maxLng = maxLng == null ? lng : math.max(maxLng, lng); - } - if (minLat == null) return null; - return LatLngBounds( - southwest: LatLng(minLat, minLng!), - northeast: LatLng(maxLat!, maxLng!), - ); -} - /// Bounding box of every coordinate in a typhoon [geo] `FeatureCollection`. LatLngBounds? typhoonGeojsonBounds(Map geo) { final features = geo['features']; diff --git a/lib/features/map/presentation/layers/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart index cdad6da00..895f1c0ad 100644 --- a/lib/features/map/presentation/layers/weather_station_layer.dart +++ b/lib/features/map/presentation/layers/weather_station_layer.dart @@ -1,17 +1,20 @@ -/// Base for the weather-family station value layers (temperature, humidity, -/// pressure, wind) — station dots coloured by value, tap → trend sheet. +/// Base for the station-value map layers (temperature, humidity, pressure, +/// wind, rain): station dots coloured by value, tap → trend sheet. +/// +/// Generic over the snapshot/observation/trend types so the weather family and +/// the rainfall layer share one implementation; concrete layers supply only +/// which value to read ([valueOf], [trendOf]), the unit, and the colour ramp. library; import 'dart:math' as math; +import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/geo_math.dart'; import 'package:dpip/features/map/presentation/widgets/station_sheet.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/station_value_repository.dart'; import 'package:dpip/features/weather/domain/weather_station.dart'; -import 'package:dpip/features/weather/domain/weather_trend.dart'; import 'package:dpip/shared/color_hex.dart'; -import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_station_labels.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; @@ -23,21 +26,22 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// current value (a MapLibre `interpolate` on the `value` property), and a tap /// selects the nearest station, opening the shared [StationSheet] with its /// reading and 24h/7d trend chart. -/// -/// Concrete layers (temperature, humidity, …) supply only which value to read -/// from an observation ([valueOf]) and from the trend series ([trendOf]), plus -/// the unit and the colour ramp. All share one [MeteorWeatherRepository]. -abstract class WeatherStationLayer implements MapLayer, StationSheetSource { +abstract class WeatherStationLayer< + S extends StationSnapshot, + O extends StationObservation, + T extends TrendTimeAxis +> + with MapLayerDefaults + implements MapLayer, StationSheetSource { WeatherStationLayer(this._repository); - final MeteorWeatherRepository _repository; + final StationValueRepository _repository; - /// The observation value this layer plots (°C, %, hPa, m/s), or null if - /// missing for a station. - double? valueOf(WeatherObservation observation); + /// The observation value this layer plots, or null if missing for a station. + double? valueOf(O observation); /// The matching series from a station's trend payload. - List trendOf(WeatherTrend trend); + List trendOf(T trend); /// Decimal places for the reading text. int get decimals; @@ -53,8 +57,7 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { /// Extra per-feature GeoJSON properties a subclass needs on the map — merged /// into each feature's `properties`. @protected - Map extraProperties(WeatherObservation observation) => - const {}; + Map extraProperties(O observation) => const {}; /// Hook for a subclass to add its own layer(s) on [sourceId] after the base /// dot + labels. Any layer id added here must be listed in [extraLayerIds] so @@ -69,6 +72,25 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { @protected List get extraLayerIds => const []; + /// Optional MapLibre filter for the dot + label layers — e.g. rain hides + /// dry stations until the map is zoomed in. + @protected + List? get featureFilter => null; + + /// Whether a station with [value] is a tap target at [zoom] — e.g. rain + /// ignores ~0 readings when zoomed out. + @protected + bool includeInSelection(O observation, double value, double zoom) => true; + + /// Optional listenable whose change rebuilds the sheet + legend chrome + /// (rain listens to its accumulation-window switch). + @protected + Listenable? get chromeListenable => null; + + /// Optional header rendered above the colour scale in the legend. + @protected + Widget? legendHeader(BuildContext context) => null; + final ValueNotifier _selected = ValueNotifier(null); /// Bumped on every tap that (re-)selects a station — even the same one — so the @@ -76,37 +98,33 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { /// wouldn't notify on its own). final ValueNotifier _selectionRevision = ValueNotifier(0); Map _stations = const {}; - Map _observations = const {}; + Map _observations = const {}; bool _loaded = false; + MapLibreMapController? _controller; + + /// The controller from the last [render], for a subclass to poke the map + /// (e.g. rain's window switch re-pushes the source). + @protected + MapLibreMapController? get controller => _controller; String get _sourceId => 'wx-$id-src'; String get _circleId => 'wx-$id-circle'; String get _labelId => 'wx-$id-label'; - @override - bool get usesTimeline => false; - - @override - double get bottomChromeFraction => StationSheet.peekExtent; - - @override - double get mapMinZoom => 4; - - @override - double get mapMaxZoom => BaseMap.maxZoom; + /// The source id this layer's dots + labels live on. + @protected + String get sourceId => _sourceId; - @override - Future>> frames() async => const Ok([]); + /// The current dot/label GeoJSON, re-pushable by a subclass that mutates its + /// value source in place (e.g. rain's accumulation-window switch). + @protected + Map get geoJson => _geoJson(); @override - Future prepare(MapLibreMapController c, List frames) async {} + bool get usesTimeline => false; @override - Future show( - MapLibreMapController c, - MapFrame frame, { - bool scrubbing = false, - }) async {} + double get bottomChromeFraction => StationSheet.peekExtent; @override Future render(MapLibreMapController controller) async { @@ -130,6 +148,7 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { // Non-interactive: we do our own nearest-station math in onMapTap and // want EVERY tap via map#onMapClick — an interactive layer would eat an // on-dot tap as feature#onTap (unhandled) so the station never selects. + filter: featureFilter, enableInteraction: false, ); } @@ -140,8 +159,10 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { _labelId, stationLabelProps(textField: const ['get', 'label']), minzoom: 9, + filter: featureFilter, enableInteraction: false, ); + _controller = controller; // Let a subclass add its own symbology (e.g. wind arrows) on the source. await decorate(controller, _sourceId); } @@ -151,12 +172,17 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { // Nearest station within ~0.18° (lon scaled by latitude), so a tap in empty // sea doesn't select anything. const threshold = 0.18 * 0.18; - final cosLat = math.cos(latLng.latitude * math.pi / 180); + final cosLat = math.cos(degToRad(latLng.latitude)); + final zoom = controller.cameraPosition?.zoom ?? 0; String? best; var bestDistance = threshold; for (final entry in _stations.entries) { final observation = _observations[entry.key]; - if (observation == null || valueOf(observation) == null) continue; + if (observation == null) continue; + final value = valueOf(observation); + if (value == null || !includeInSelection(observation, value, zoom)) { + continue; + } final station = entry.value; final dLat = station.latitude - latLng.latitude; final dLon = (station.longitude - latLng.longitude) * cosLat; @@ -173,38 +199,41 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { } @override - Widget buildTopTrailingChrome(BuildContext context) => - const SizedBox.shrink(); - - @override - Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); - - @override - void onMapGestureStart() {} - - @override - void onMapGestureEnd() {} - - @override - Future onCameraIdle(MapLibreMapController controller) async {} - - @override - Future onAmbientCacheCleared(MapLibreMapController controller) async {} - - @override - Widget buildSheet(BuildContext context) => - StationSheet(key: ValueKey(id), source: this); + Widget buildSheet(BuildContext context) { + final sheet = StationSheet(key: ValueKey(id), source: this); + final listenable = chromeListenable; + return listenable == null + ? sheet + : ListenableBuilder(listenable: listenable, builder: (_, _) => sheet); + } - /// Colour scale from [colorStops] + [unit] — one legend for every station - /// value layer (temperature / humidity / pressure); wind overrides. - @override - Widget buildLegend(BuildContext context) => MapLegendCard( - child: ColorScaleLegend(stops: colorStops, unit: unit, appendUnit: true), - ); + /// Colour scale from [colorStops] + [unit], with an optional [legendHeader]. + @override + Widget buildLegend(BuildContext context) { + final header = legendHeader(context); + final scale = ColorScaleLegend(stops: colorStops, unit: unit); + final child = header == null + ? scale + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + header, + const SizedBox(height: AppSpacing.xs), + scale, + ], + ); + final card = MapLegendCard(child: child); + final listenable = chromeListenable; + return listenable == null + ? card + : ListenableBuilder(listenable: listenable, builder: (_, _) => card); + } @override Future clear(MapLibreMapController controller) async { await _removeFromMap(controller); + _controller = null; _selected.value = null; } @@ -212,10 +241,7 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { void selectFeature(String id) => select(id); @override - void onStyleReset() {} - // --- StationSheetSource --- - @override ValueListenable get selection => _selected; @@ -237,35 +263,13 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { /// The station's latest observation — for a subclass to read extra fields /// (e.g. wind direction) in an overridden [reading]. @protected - WeatherObservation? observationOf(String id) => _observations[id]; + O? observationOf(String id) => _observations[id]; @override Color? valueColor(String id) { final observation = observationOf(id); final value = observation == null ? null : valueOf(observation); - return value == null ? null : rampColor(value); - } - - /// [value] interpolated on [colorStops] — the Dart twin of the `interpolate` - /// expression handed to MapLibre, so the dot and the sheet agree by - /// construction rather than by two hand-kept colour tables. - @protected - Color? rampColor(double value) { - final stops = colorStops; - if (stops.isEmpty) return null; - if (value <= stops.first.$1) return colorFromHexRgb(stops.first.$2); - if (value >= stops.last.$1) return colorFromHexRgb(stops.last.$2); - for (var i = 0; i < stops.length - 1; i++) { - final (lowAt, lowHex) = stops[i]; - final (highAt, highHex) = stops[i + 1]; - if (value < lowAt || value > highAt) continue; - final low = colorFromHexRgb(lowHex); - final high = colorFromHexRgb(highHex); - if (low == null || high == null) return low ?? high; - final span = highAt - lowAt; - return Color.lerp(low, high, span == 0 ? 0 : (value - lowAt) / span); - } - return colorFromHexRgb(stops.last.$2); + return value == null ? null : rampColor(colorStops, value); } @override @@ -276,6 +280,9 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { return value == null ? null : '${value.toStringAsFixed(decimals)} $unit'; } + @override + Widget? readingIcon(String id) => null; + @override double? get chartMinY => null; @@ -291,10 +298,10 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { return result.map(seriesOf); } - /// Builds the sheet's trend payload from a decoded [WeatherTrend]. Wind - /// overrides to attach the parallel direction series. + /// Builds the sheet's trend payload from a decoded trend. Wind overrides to + /// attach the parallel direction series. @protected - TrendSeries seriesOf(WeatherTrend trend) => + TrendSeries seriesOf(T trend) => TrendSeries(times: trend.times, values: trendOf(trend)); @override @@ -313,12 +320,16 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { final stations = (await _repository.stations()).valueOrNull; final snapshot = (await _repository.latest()).valueOrNull; if (stations != null) _stations = stations; - if (snapshot != null) { - _observations = {for (final o in snapshot.stations) o.id: o}; - } + if (snapshot != null) _observations = observationsOf(snapshot); _loaded = stations != null && snapshot != null; } + /// Latest snapshot → per-station observations keyed by station id. + @protected + Map observationsOf(S snapshot) => { + for (final o in snapshot.stations) o.id: o, + }; + Map _geoJson() { final features = >[]; for (final entry in _stations.entries) { diff --git a/lib/features/map/presentation/layers/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart new file mode 100644 index 000000000..3cd6576df --- /dev/null +++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart @@ -0,0 +1,196 @@ +/// A numerical weather prediction (數值預報) wind-field raster overlay — the +/// ECMWF or GFS model rendered as a coloured speed field on the map. +library; + +import 'dart:async'; + +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; +import 'package:dpip/features/map/presentation/widgets/forecast_overlay_menu.dart'; +import 'package:dpip/features/map/presentation/widgets/wind_particle_overlay.dart'; +import 'package:dpip/features/weather/domain/wind_field.dart'; +import 'package:dpip/features/weather/domain/wind_forecast_model.dart'; +import 'package:dpip/features/weather/domain/wind_forecast_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_layer.dart'; +import 'package:dpip/shared/map/raster_timeline_layer.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// The wind forecast (風場) raster overlay for one model. +/// +/// Everything about scrubbing lives in [RasterTimelineLayer]; this supplies the +/// model's identity, its opacity, the shared wind-speed colour key, the two +/// admin-border overlays its options chip toggles ([AdminOutlineChrome]), and +/// the particle animation ([WindParticleOverlay]) that rides the loaded +/// [field]. +/// +/// The tiles are a semi-transparent speed wash, so the layer draws its own +/// county / township borders **over** the field the same way radar does over +/// its echo. The 0.25° grids stop publishing tiles at zoom 7, so the view is +/// capped there too — past it there is nothing further to see, only the same +/// cell drawn larger ([satellite-tiles-go/web/index.html]). +class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { + WindForecastMapLayer( + WindForecastRepository super.repository, { + required this.model, + }); + + final WindForecastModel model; + + /// The wind grid backing the overlay's particles, reloaded on every shown + /// frame. Null until the first frame's field arrives; the overlay starts its + /// animation only once this is set. + final ValueNotifier field = ValueNotifier(null); + + String? _loadedFieldFrame; + + /// The live map controller — shared with the particle overlay, which reads + /// the camera from it every frame. + MapLibreMapController? get mapController => controller; + + @override + Future show( + MapLibreMapController controller, + MapFrame frame, { + bool scrubbing = false, + }) async { + await super.show(controller, frame, scrubbing: scrubbing); + await _loadField(frame.id); + } + + /// This layer's wind repository — [RasterTimelineLayer] only knows it as a + /// [RasterFrameSource], so the call site narrows it back to the declared + /// [WindForecastRepository] type. + WindForecastRepository get _wind => source as WindForecastRepository; + + /// Fetches the WND1 grid for [frameId] and publishes it to [field]. A failed + /// fetch keeps the previous field — scrubbing through a gap shows the last + /// known wind rather than a blank — and rolls the guard back so the next + /// show of this frame retries instead of skipping it forever. + Future _loadField(String frameId) async { + if (frameId == _loadedFieldFrame) return; + _loadedFieldFrame = frameId; + final result = await _wind.fetchWindField(frameId); + result.when( + ok: (windField) => field.value = windField, + err: (failure) { + Log.warning('Wind field for $frameId failed: ${failure.message}'); + _loadedFieldFrame = null; + }, + ); + } + + @override + Future onDetached(MapLibreMapController controller) async { + await super.onDetached(controller); + // The overlay is gone with the layer; stop advertising a field so a + // re-attach starts clean rather than animating yesterday's grid. + field.value = null; + _loadedFieldFrame = null; + } + + @override + Widget buildMapOverlay(BuildContext context) => + WindParticleOverlay(layer: this); + + @override + String get id => 'wind-${model.key}'; + + @override + IconData get icon => Icons.air; + + @override + String label(BuildContext context) { + final l10n = AppLocalizations.of(context); + return switch (model) { + WindForecastModel.ecmwf => l10n.mapLayerWindForecastEcmwf, + WindForecastModel.gfs => l10n.mapLayerWindForecastGfs, + }; + } + + @override + String? subtitle(BuildContext context) => model.subtitle; + + /// The tile colours carry their own alpha — the field is a semi-transparent + /// wash, not an opaque sheet. + @override + double get opacity => 1.0; + + /// The field covers the base style's borders, so this layer supplies its own + /// on top — same reasoning as radar. + @override + String? get rasterBelowLayerId => null; + + /// The frame times are forecast valid times, not observations — the timeline + /// caption must say so instead of the shared "observed" default. + @override + String timelineCaption(BuildContext context) => + AppLocalizations.of(context).mapTimelineForecast; + + /// The wind endpoints serve one model run at a time (older runs' frames 404), + /// so the oldest frame is the run's issue time — the timeline can name when + /// this forecast was actually produced. + @override + bool get framesAreOneRun => true; + + /// Basin-wide framing — the wind view starts a level below radar's floor so + /// a whole typhoon-scale system fits. + @override + double get mapMinZoom => 3; + + /// The 0.25° grids stop publishing tiles at zoom 7; capping the view keeps + /// the user from zooming into a detail the model never had. + @override + double get mapMaxZoom => 7; + + @override + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }) => ForecastOverlayMenu( + layer: this, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); + + @override + Widget buildLegend(BuildContext context) => ListenableBuilder( + listenable: adminChromeListenable, + builder: (context, _) { + return MapLegendCard( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The ramp is fixed in m/s, so the legend is drawn once and stays + // true for every frame and both models — the same decision as + // `satellite-tiles-go/web/wind.js` (SPEED_COLORS / SPEED_SCALE). + const ColorScaleLegend( + unit: 'm/s', + stops: [ + (0, '#282C6E'), + (2, '#3056A8'), + (4, '#348CBA'), + (6, '#40B296'), + (8, '#68C45E'), + (11, '#B0D048'), + (14, '#E2BE4A'), + (17, '#E28C42'), + (21, '#D6543C'), + (25, '#BA3478'), + (28, '#9E3CB4'), + (32, '#EED6FF'), + ], + ), + adminLegendSection(context), + ], + ), + ); + }, + ); +} diff --git a/lib/features/map/presentation/layers/wind_layer.dart b/lib/features/map/presentation/layers/wind_layer.dart index 3f85971af..27c009ea1 100644 --- a/lib/features/map/presentation/layers/wind_layer.dart +++ b/lib/features/map/presentation/layers/wind_layer.dart @@ -1,12 +1,14 @@ /// The wind layer — rotated arrows pointing where the wind blows toward, /// coloured by wind speed (legacy look). The tap reading carries the exact -/// degrees + an 8-point arrow. +/// degrees and the sheet chart colours the curve by the same speed ramp. library; import 'dart:typed_data'; import 'dart:ui' as ui; +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/wind_speed.dart'; import 'package:dpip/features/map/presentation/widgets/station_sheet.dart'; import 'package:dpip/features/weather/domain/weather_snapshot.dart'; import 'package:dpip/features/weather/domain/weather_trend.dart'; @@ -16,16 +18,23 @@ import 'package:dpip/shared/widgets/map_color_legend.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class WindMapLayer extends WeatherStationLayer { +class WindMapLayer + extends + WeatherStationLayer { WindMapLayer(super.repository); - /// Shared arrow image id (registered once per render) and the arrow layer id. + /// 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'; String get _arrowLayerId => 'wx-$id-arrow'; - /// The rendered arrow PNG, cached — the glyph never changes; its colour and - /// rotation are applied at runtime by the layer, not baked into the image. - Uint8List? _arrowBytes; + /// The rendered arrow PNGs, cached — the glyph never changes; each bucket's + /// colour + black outline are baked in at render time. + List? _arrowBytes; + + String _arrowImageFor(int bucket) => '$_arrowImageId-$bucket'; @override String get id => 'wind'; @@ -84,15 +93,20 @@ class WindMapLayer extends WeatherStationLayer { String sourceId, ) async { final bytes = _arrowBytes ??= await _renderArrow(); - // sdf: true so `iconColor` can tint the white glyph by wind speed. - await controller.addImage(_arrowImageId, bytes, true); + // 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 controller.addSymbolLayer( sourceId, _arrowLayerId, SymbolLayerProperties( - iconImage: _arrowImageId, + // Pick the pre-coloured glyph by speed; rotation is still applied at + // 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(), iconRotate: ['get', 'blow_to'], - iconColor: _colorExpression(), // 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. @@ -147,32 +161,33 @@ class WindMapLayer extends WeatherStationLayer { ); } - /// Discrete speed → colour, matching legacy's 5 wind buckets (white / cyan / - /// blue / purple / pink) on the same 3.4 / 8.0 / 13.9 / 32.7 m/s thresholds — - /// a `step`, not a continuous ramp. - List _colorExpression() => [ + /// 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'], - '#FFFFFF', - 3.4, - '#00FFF0', - 8.0, - '#0085FF', - 13.9, - '#8000FF', - 32.7, - '#FF006B', + _arrowImageFor(0), + for (var i = 1; i < windBuckets.length; i++) ...[ + windBuckets[i].$1, + _arrowImageFor(i), + ], ]; - /// Renders [Icons.navigation] (points north at 0°) to a white PNG for use as - /// an SDF icon: MapLibre tints it via `iconColor` and spins it via - /// `iconRotate`, so one image serves every speed and bearing. - Future _renderArrow() async { + /// 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; - final painter = TextPainter( + // 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), @@ -180,22 +195,65 @@ class WindMapLayer extends WeatherStationLayer { fontSize: 80, fontFamily: icon.fontFamily, package: icon.fontPackage, - color: const Color(0xFFFFFFFF), + color: const Color(0xFF000000), + ), + ), + )..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); - painter.paint( - canvas, - Offset((size - painter.width) / 2, (size - painter.height) / 2), - ); + 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 + 8-point arrow). + /// 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 String? reading(String id) { final observation = observationOf(id); @@ -204,27 +262,42 @@ class WindMapLayer extends WeatherStationLayer { final text = '${speed.toStringAsFixed(decimals)} $unit'; final direction = observation!.windDirection; if (direction == null) return text; - return '$text $direction° ${_arrow(direction)}'; + return '$text $direction°'; } - /// The arrow the wind blows *towards* (meteorological direction is where it - /// comes from, so + 180°), snapped to 8 points. - static String _arrow(int fromDegrees) { - const arrows = ['↓', '↙', '←', '↖', '↑', '↗', '→', '↘']; - final index = ((fromDegrees % 360) / 45).round() % 8; - return arrows[index]; + /// A navigation glyph rotated to where the wind blows *towards*, tinted by + /// the same speed ramp as the map arrows (with a baked-on black outline so a + /// calm white arrow stays visible on the sheet). + @override + Widget? readingIcon(String id) { + final observation = observationOf(id); + final speed = observation?.windSpeed; + final from = observation?.windDirection; + if (speed == null || from == null) return null; + return Transform.rotate( + angle: degToRad(from + 180), + child: WindArrowIcon( + size: 26, + outline: 1.5, + color: windSpeedColor(speed), + ), + ); + } + + /// The hero dot beside the name uses the discrete ramp colour — the same the + /// arrow, the curve, and the X-axis glyphs carry, so the sheet reads one + /// consistent speed colour rather than an interpolation between buckets. + @override + Color? valueColor(String id) { + final speed = observationOf(id)?.windSpeed; + return speed == null ? null : windSpeedColor(speed); } - // Legacy's 5 discrete wind-speed colours (white → pink). Not used for the - // arrow tint (that's the [step] in _colorExpression), but kept as the layer's - // declared ramp for the base contract. + // The shared discrete ramp (white → pink), declared here so the base + // contract's dot/legend machinery reads the same colours the arrows use. @override - List<(double, String)> get colorStops => const [ - (0, '#FFFFFF'), - (3.4, '#00FFF0'), - (8.0, '#0085FF'), - (13.9, '#8000FF'), - (32.7, '#FF006B'), + List<(double, String)> get colorStops => [ + for (final (at, color) in windBuckets) (at, color.toHexRgb()), ]; /// Discrete speed buckets (strongest first) — same thresholds / colours as @@ -245,8 +318,9 @@ class WindMapLayer extends WeatherStationLayer { items: [ for (final (label, hex) in rows) SymbolLegendItem( - // Dark disc behind pale / white glyphs so they stay readable on - // the frosted card (map arrows sit on tiles, not surface). + // 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, @@ -255,9 +329,9 @@ class WindMapLayer extends WeatherStationLayer { color: outline.withValues(alpha: 0.35), shape: BoxShape.circle, ), - child: Icon( - Icons.navigation, + child: WindArrowIcon( size: 14, + outline: 1.5, color: colorFromHexRgb(hex) ?? Colors.white, ), ), diff --git a/lib/features/map/presentation/layers/wind_particle_sim.dart b/lib/features/map/presentation/layers/wind_particle_sim.dart new file mode 100644 index 000000000..e407b7495 --- /dev/null +++ b/lib/features/map/presentation/layers/wind_particle_sim.dart @@ -0,0 +1,467 @@ +/// Pure particle-advection math for the wind-forecast overlay — no Flutter +/// widgets, so every projection and step is unit-testable in isolation. +/// +/// The numbers below are the web renderer's, ported rather than reinvented: +/// `satellite-tiles-go/web/index.html` carries a tuning panel whose sliders +/// were dragged until the flow looked right, and `DEFAULTS` is where those +/// values were written down. Anything tuned by eye cannot be re-derived, so the +/// only way for two renderers to agree is for one of them to copy. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:dpip/features/weather/domain/wind_field.dart'; + +/// Wind speed (m/s) that saturates the visual ramp — fixed across every frame +/// and both models so a streak means the same thing everywhere +/// (`web/wind.js`, `SPEED_SCALE`). +const double kWindSpeedScale = 32; + +/// The zooms the tuned values are pinned at (`web/index.html`, `ZOOM_STOPS`), +/// which are also this layer's own limits. +const double _kZoomLo = 3; +const double _kZoomHi = 7; + +// Each pair is (value at z3, value at z7). Which ones interpolate +// geometrically and which linearly is not a free choice either — it is what +// `TUNE` declares, and a count that steps 6400 → 4096 → 2601 is a visibly +// different field from one that steps 6400 → 5056 → 3712. +const (double, double) _kParticles = (6400, 1024); // log +const (double, double) _kPointSize = (1.5, 1.8); // lin, logical px +const (double, double) _kSpeedFactor = (0.2, 0.0151); // log +const (double, double) _kFadeOpacity = (0.95, 0.945); // lin + +/// Chance per frame that a particle in good standing is recycled anyway. +const double _kDropRate = 0.011; + +/// Relative density of particles in still air and in strong wind. +/// +/// Strong by eleven to one, which is the opposite of what the same pair used +/// to say. See the note in `web/index.html`: once the colour underneath +/// carries the speed, thinning the streaks where the weather is spends the one +/// channel still describing direction. +const double _kDensityCalm = 0.5; +const double _kDensityStrong = 5.5; + +/// Where a zoom sits between the two tuned stops. +/// +/// Clamped rather than extrapolated, matching the web's `effective()`: outside +/// the stops there is no judgement behind the number, only arithmetic. +double _stopFraction(double zoom) => + ((zoom - _kZoomLo) / (_kZoomHi - _kZoomLo)).clamp(0.0, 1.0); + +double _lerpStops((double, double) v, double zoom) { + final f = _stopFraction(zoom); + return v.$1 * (1 - f) + v.$2 * f; +} + +double _logLerpStops((double, double) v, double zoom) { + final f = _stopFraction(zoom); + return math.exp(math.log(v.$1) * (1 - f) + math.log(v.$2) * f); +} + +/// How many particles the field holds at [zoom]. +/// +/// Rounded to a square because the web renderer allocates a square texture of +/// particle state and rounds to one; matching the count matters more than the +/// squareness, but rounding the same way keeps the two exactly equal. +int particleCountFor(double zoom) { + final edge = math.max(1, math.sqrt(_logLerpStops(_kParticles, zoom)).round()); + return edge * edge; +} + +/// Diameter of a particle in logical pixels. The web sets `gl_PointSize` in +/// device pixels and multiplies by the device pixel ratio to get there, so the +/// tuned number is already the logical one. +double pointSizeFor(double zoom) => _lerpStops(_kPointSize, zoom); + +/// What fraction of the trail buffer survives each frame. +/// +/// This is the whole trail mechanism: nothing records where a particle has +/// been. Every frame the previous picture is redrawn at this opacity and the +/// current dots stamped on top, so a streak is the exponential decay of the +/// dots that came before — length falls out of the fade, and costs one draw +/// instead of one per remembered position. +/// +/// Never 1: a fade that does not fade accumulates for ever and the screen +/// saturates to white. +double fadeOpacityFor(double zoom) => _lerpStops(_kFadeOpacity, zoom); + +/// Field-space distance a particle rides per (m/s · frame) at [zoom]. +/// +/// It has to fall with zoom: a field-space step is a fraction of the *world*, +/// so the pixels it covers double with every zoom level in. Held constant at +/// the value that suits z3, particles at z7 move 23× too fast. +double fieldStepFor(double zoom) => 0.0001 * _logLerpStops(_kSpeedFactor, zoom); + +/// The web's `densityWeight`: how many particles a place should hold relative +/// to spreading them evenly, by how hard the wind is blowing there. +double densityWeight(double speed) { + final t = (speed / (0.6 * kWindSpeedScale)).clamp(0.0, 1.0); + final s = t * t * (3 - 2 * t); // smoothstep + return _kDensityCalm + (_kDensityStrong - _kDensityCalm) * s; +} + +/// The camera a wind overlay is drawn under — enough to project a lat/lng to +/// screen pixels on a tilt-free, bearing-rotated Web Mercator view. +class WindCamera { + const WindCamera({ + required this.centerLat, + required this.centerLng, + required this.zoom, + required this.bearing, + }); + + final double centerLat; + final double centerLng; + final double zoom; + + /// Camera heading, degrees clockwise from north. + final double bearing; + + @override + bool operator ==(Object other) => + other is WindCamera && + other.centerLat == centerLat && + other.centerLng == centerLng && + other.zoom == zoom && + other.bearing == bearing; + + @override + int get hashCode => Object.hash(centerLat, centerLng, zoom, bearing); +} + +/// The rectangle of the world a [Size] screen shows, as a lat/lng span. +class WindViewport { + const WindViewport({ + required this.westLng, + required this.eastLng, + required this.northLat, + required this.southLat, + }); + + final double westLng; + final double eastLng; + final double northLat; + final double southLat; +} + +/// Web Mercator northing in `[0, 1]`, 0 at the north edge — the projection +/// every map in this app uses, kept identical to the web demo's `mercY`. +double mercatorY(double lat) { + final phi = lat.clamp(-85.051129, 85.051129) * math.pi / 180; + return (1 - math.log(math.tan(math.pi / 4 + phi / 2)) / math.pi) / 2; +} + +/// Inverse of [mercatorY]. +double mercatorLat(double y) => + (2 * math.atan(math.exp(math.pi * (1 - 2 * y))) - math.pi / 2) * + 180 / + math.pi; + +/// Project a geographic point into the map view. The camera's target sits at +/// the viewport centre and the view rotates clockwise with [WindCamera.bearing]; +/// tilt is always zero in this app (the map disables it), so no perspective. +Offset projectLatLng(WindCamera cam, double lat, double lng, Size size) { + final world = _worldSize(cam.zoom); + final wx = (lng + 180) / 360 * world; + final wy = mercatorY(lat) * world; + final cx = (cam.centerLng + 180) / 360 * world; + final cy = mercatorY(cam.centerLat) * world; + // World x is cyclic, so fold the offset into the half-world nearest the + // camera. Callers do not always hand over a longitude already inside + // [-180, 180]: a particle's longitude is `lon0 + x · 360`, and ECMWF's grid + // starts at 180, which puts every one of its particles in [180, 540). Taking + // that difference literally throws them tens of thousands of pixels off + // screen, where they are judged out of view and recycled before they can draw + // anything. Folding also makes a point behind the antimeridian project onto + // the near edge rather than across the whole world, which is what a map + // centred at 121°E should do with 175°W. + final dx = _wrapWorld(wx - cx, world); + final dy = wy - cy; + final r = cam.bearing * math.pi / 180; + final cosR = math.cos(r); + final sinR = math.sin(r); + return Offset( + size.width / 2 + dx * cosR - dy * sinR, + size.height / 2 + dx * sinR + dy * cosR, + ); +} + +/// The axis-aligned world rectangle a [Size] viewport shows under [cam] — +/// the bounds the simulation respawns particles into. Rotating the view makes +/// this a super-set of the visible area, which only wastes a few particles. +WindViewport viewportBounds(WindCamera cam, Size size) { + final world = _worldSize(cam.zoom); + final cx = (cam.centerLng + 180) / 360 * world; + final cy = mercatorY(cam.centerLat) * world; + final r = cam.bearing * math.pi / 180; + final cosR = math.cos(r); + final sinR = math.sin(r); + var minX = double.infinity, maxX = -double.infinity; + var minY = double.infinity, maxY = -double.infinity; + // The four corners of the screen, un-rotated back into world space. + for (final corner in const [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]) { + final dx = (corner.$1 - 0.5) * size.width; + final dy = (corner.$2 - 0.5) * size.height; + final wx = cx + dx * cosR + dy * sinR; + final wy = cy - dx * sinR + dy * cosR; + minX = math.min(minX, wx); + maxX = math.max(maxX, wx); + minY = math.min(minY, wy); + maxY = math.max(maxY, wy); + } + return WindViewport( + westLng: minX / world * 360 - 180, + eastLng: maxX / world * 360 - 180, + northLat: mercatorLat(minY / world), + southLat: mercatorLat(maxY / world), + ); +} + +double _worldSize(double zoom) => 512 * math.pow(2.0, zoom).toDouble(); + +/// Folds a world-space x offset into `(-world/2, world/2]`. +double _wrapWorld(double dx, double world) => + dx - world * (dx / world + 0.5).floorToDouble(); + +/// One particle: where it is in the field, how fast the air there is moving, +/// and where that puts it on screen. +/// +/// Positions live in field space (x along the field's longitude span, y along +/// its latitude span) so advection is projection-free. Nothing remembers where +/// the particle has been — the streak behind it is the trail buffer's business, +/// not the particle's. +class WindParticle { + WindParticle(this.x, this.y); + + double x; + double y; + + /// Speed of the air the particle last sampled, m/s — sets how bright its dot + /// is stamped, so faster air reads as a brighter streak. + double speed = 0; + + /// Where the particle is on screen, or null when it is off the field or out + /// of view and so has nothing to stamp this frame. + Offset? screen; +} + +/// The animation state — a population advected through a [WindField]. +class WindParticleSim { + /// [count] is only the starting population: [step] resizes it to whatever + /// the current zoom asks for, because the web renderer varies it from 6400 + /// at z3 down to 1024 at z7 and a fixed number matches neither end. + WindParticleSim(this.field, {int count = 6400, math.Random? random}) + : _random = random ?? math.Random(), + particles = [for (var i = 0; i < count; i++) WindParticle(0, 0)]; + + final WindField field; + final math.Random _random; + final List particles; + + bool _seeded = false; + + /// Moves every particle one frame: sample the wind, step, work out where + /// that lands on screen, and recycle the ones that have left. + /// + /// The view's projection is shared by the whole population, so its rotation + /// and origin are computed here once and the loop inlines the same arithmetic + /// [projectLatLng] does per point — six thousand `sin`/`cos` calls a frame + /// become two. + void step(WindCamera cam, Size size) { + final vp = viewportBounds(cam, size); + final fieldSpace = _fieldRect(vp); + final fieldStep = fieldStepFor(cam.zoom); + if (!_seeded) { + _seedIn(fieldSpace); + _seeded = true; + } + _resize(particleCountFor(cam.zoom), fieldSpace); + + final world = _worldSize(cam.zoom); + final cx = (cam.centerLng + 180) / 360 * world; + final cy = mercatorY(cam.centerLat) * world; + final r = cam.bearing * math.pi / 180; + final cosR = math.cos(r); + final sinR = math.sin(r); + final halfWidth = size.width / 2; + final halfHeight = size.height / 2; + final latScale = field.dLat * field.height; + const degToRad = math.pi / 180; + + for (final p in particles) { + final (u, v) = _sampleUV(p.x, p.y); + p.speed = math.sqrt(u * u + v * v); + final lat = field.lat0 + p.y * latScale; + p.x = (p.x + u / math.cos(lat * degToRad) * fieldStep) % 1.0; + p.y -= v * fieldStep; + + final wx = (field.lon0 + p.x * 360 + 180) / 360 * world; + final wy = mercatorY(field.lat0 + p.y * latScale) * world; + final dx = _wrapWorld(wx - cx, world); + final dy = wy - cy; + final screen = Offset( + halfWidth + dx * cosR - dy * sinR, + halfHeight + dx * sinR + dy * cosR, + ); + final onField = p.y >= 0 && p.y <= 1; + final inView = onField && _inView(screen, size); + p.screen = inView ? screen : null; + + // Recycle a particle that has left, and occasionally a healthy one — the + // field would otherwise empty out of wherever the density weighting is + // not putting anything back. + if (!inView || _random.nextDouble() < _kDropRate) { + _respawn(p, fieldSpace); + } + } + } + + /// Grows or shrinks the population to [count], seeding anything new into the + /// current view so a zoom does not open a hole in the field. + void _resize(int count, _FieldRect r) { + if (!_seeded || count == particles.length) return; + if (count < particles.length) { + particles.removeRange(count, particles.length); + return; + } + while (particles.length < count) { + particles.add( + WindParticle( + _lerpX(r, _random.nextDouble()), + _lerp(r.y0, r.y1, _random.nextDouble()), + ), + ); + } + } + + bool _inView(Offset screen, Size size) => + screen.dx >= -0.1 * size.width && + screen.dx <= 1.1 * size.width && + screen.dy >= -0.1 * size.height && + screen.dy <= 1.1 * size.height; + + /// The viewport's rectangle in field space (fractions of the field's + /// longitude / latitude span), for seeding and respawning. + _FieldRect _fieldRect(WindViewport vp) { + final latSpan = field.dLat * field.height; + final x0 = ((vp.westLng - field.lon0) / 360) % 1.0; + var x1 = ((vp.eastLng - field.lon0) / 360) % 1.0; + // Wrapping the two edges independently is what turns a viewport straddling + // the grid's seam into its own complement — west 0.98, east 0.02 describes + // the 96% of the world the viewer cannot see. Carrying the east edge past + // the seam keeps the interval the one that was meant; [_lerpX] folds the + // result back afterwards. ECMWF's seam is the antimeridian, so this is the + // difference between working and not for anyone looking at the Pacific. + if (x1 < x0) x1 += 1.0; + return _FieldRect( + x0: x0, + x1: x1, + y0: (vp.northLat - field.lat0) / latSpan, + y1: (vp.southLat - field.lat0) / latSpan, + ); + } + + /// A point drawn from a longitude interval that may reach past the seam, + /// folded back into the field's own `[0, 1)`. + double _lerpX(_FieldRect r, double t) => _lerp(r.x0, r.x1, t) % 1.0; + + void _seedIn(_FieldRect r) { + for (final p in particles) { + p.x = _lerpX(r, _random.nextDouble()); + p.y = _lerp(r.y0, r.y1, _random.nextDouble()); + } + } + + /// Rejection sampling by wind speed, weighted by [densityWeight]. + /// + /// One candidate, accepted with probability proportional to its weight, and + /// on rejection the particle simply stays where it is and tries again next + /// frame. Not best-of-N: when every candidate lands in calm air, best-of-N + /// returns calm whatever the weights say, which caps the reachable contrast + /// near three to one — nowhere near the eleven asked for here. Rejection has + /// no such ceiling, because the equilibrium density *is* the acceptance + /// probability: inflow to a region is its area times that probability, and + /// the lifetime is the same everywhere. + /// + /// A particle that had already left the view just stays invisible a few more + /// frames, which at sixty a second is nothing. + void _respawn(WindParticle p, _FieldRect r) { + final x = _lerpX(r, _random.nextDouble()); + final y = _lerp(r.y0, r.y1, _random.nextDouble()); + final (u, v) = _sampleUV(x, y); + final weight = densityWeight(math.sqrt(u * u + v * v)); + if (_random.nextDouble() < + weight / math.max(_kDensityCalm, _kDensityStrong)) { + p.x = x; + p.y = y; + } + } + + /// Bilinear sample of both wind components at field-space [x], [y] in one + /// pass — the four-cell geometry (which cells, how much of each) is shared by + /// u and v, so two separate lookups would redo every floor, wrap, and lerp + /// once per component. This is the hand-rolled texture sample the web shader + /// does (`lookup_wind`), reading the two quantised planes directly. + (double, double) _sampleUV(double x, double y) { + final fx = x * field.width; + final fy = y * field.height; + // Columns wrap — the grid's last column neighbours its first, and clamping + // there flattens the wind along the whole seam. Rows do not: there is no + // cell north of the north pole. + final i0 = fx.floor() % field.width; + final i1 = (i0 + 1) % field.width; + var j0 = fy.floor(); + if (j0 < 0) j0 = 0; + if (j0 >= field.height) j0 = field.height - 1; + final j1 = j0 + 1 < field.height ? j0 + 1 : j0; + final tx = fx - fx.floorToDouble(); + final ty = (fy - j0).clamp(0.0, 1.0); + final row0 = j0 * field.width; + final row1 = j1 * field.width; + return ( + _lerpPlane(field.u, row0, row1, i0, i1, tx, ty, field.uMin, field.uMax), + _lerpPlane(field.v, row0, row1, i0, i1, tx, ty, field.vMin, field.vMax), + ); + } + + /// One plane's bilinear interpolation over the shared cell geometry. + double _lerpPlane( + Uint8List plane, + int row0, + int row1, + int i0, + int i1, + double tx, + double ty, + double lo, + double hi, + ) { + final a = plane[row0 + i0]; + final b = plane[row0 + i1]; + final c = plane[row1 + i0]; + final d = plane[row1 + i1]; + final top = a + (b - a) * tx; + final bottom = c + (d - c) * tx; + return lo + (top + (bottom - top) * ty) / 255 * (hi - lo); + } + + double _lerp(double a, double b, double t) => a + (b - a) * t; +} + +class _FieldRect { + const _FieldRect({ + required this.x0, + required this.x1, + required this.y0, + required this.y1, + }); + + final double x0; + final double x1; + final double y0; + final double y1; +} diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index 752a293c7..157f75647 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -10,19 +10,25 @@ import 'package:dpip/features/map/presentation/layers/disaster_map_layer.dart'; import 'package:dpip/features/map/presentation/layers/humidity_layer.dart'; import 'package:dpip/features/map/presentation/layers/lightning_layer.dart'; import 'package:dpip/features/map/presentation/layers/pressure_layer.dart'; +import 'package:dpip/features/map/presentation/layers/qpesums_layer.dart'; import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; import 'package:dpip/features/map/presentation/layers/rain_layer.dart'; import 'package:dpip/features/map/presentation/layers/rts_layer.dart'; import 'package:dpip/features/map/presentation/layers/satellite_layer.dart'; import 'package:dpip/features/map/presentation/layers/temperature_layer.dart'; import 'package:dpip/features/map/presentation/layers/typhoon_layer.dart'; +import 'package:dpip/features/map/presentation/layers/wind_forecast_layer.dart'; import 'package:dpip/features/map/presentation/layers/wind_layer.dart'; import 'package:dpip/features/typhoon/domain/meteor_typhoon_repository.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/qpesums_repository.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; +import 'package:dpip/features/weather/domain/satellite_channel.dart'; import 'package:dpip/features/weather/domain/satellite_repository.dart'; +import 'package:dpip/features/weather/domain/wind_forecast_model.dart'; +import 'package:dpip/features/weather/domain/wind_forecast_repository.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_scaffold.dart'; import 'package:flutter/material.dart'; @@ -47,7 +53,22 @@ class _MapPageState extends State { // Built once so each layer keeps its own MapLibre state across rebuilds. late final List _layers = [ RadarMapLayer(context.read()), - SatelliteMapLayer(context.read()), + // The wind-forecast block sits right after radar: the picker groups by + // category in declared order, and the numerical-forecast group (QPESUMS + // then ECMWF then GFS) is what radar hands off to. + QpesumsMapLayer(context.read()), + for (final model in WindForecastModel.values) + WindForecastMapLayer( + context.read>()[model]!, + model: model, + ), + // One layer per satellite channel — each fetches its own frame list and + // serves its own `?channel=` tiles. + for (final channel in SatelliteChannel.values) + SatelliteMapLayer( + context.read>()[channel]!, + channel: channel, + ), LightningMapLayer(context.read()), TyphoonMapLayer( context.read(), 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 e3c9ad119..701b27a6b 100644 --- a/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/disaster_map_overlay_menu.dart @@ -6,15 +6,24 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/features/map/presentation/layers/disaster_map_layer.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/color_hex.dart'; +import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// Compact icon chip that opens the DPM overlay menu (same chrome as typhoon). class DisasterMapOverlayMenu extends StatelessWidget { - const DisasterMapOverlayMenu({super.key, required this.layer}); + const DisasterMapOverlayMenu({ + super.key, + required this.layer, + required this.showTownLabels, + required this.onShowTownLabelsChanged, + }); final DisasterMapLayer layer; + final ValueListenable showTownLabels; + final ValueChanged onShowTownLabelsChanged; @override Widget build(BuildContext context) { @@ -22,10 +31,13 @@ class DisasterMapOverlayMenu extends StatelessWidget { return ListenableBuilder( listenable: Listenable.merge([ for (final s in layer.subLayers) s.visible, + showTownLabels, ]), builder: (context, _) { // Highlight the chip when the default set is altered (a layer off). - final active = layer.subLayers.any((s) => !s.visible.value); + final active = + layer.subLayers.any((s) => !s.visible.value) || + !showTownLabels.value; return MenuAnchor( alignmentOffset: const Offset(0, 4), style: MapChipButton.menuStyle(context), @@ -44,16 +56,27 @@ class DisasterMapOverlayMenu extends StatelessWidget { ); }, menuChildren: [ - SectionHeader(l10n.disasterMapOverlaySectionLayers), - for (final sub in layer.subLayers) - _ToggleRow( - selected: sub.visible.value, - icon: sub.icon, - color: colorFromHexRgb(sub.color), - title: DisasterMapLayer.layerLabel(l10n, sub.id), - tooltip: DisasterMapLayer.layerTooltip(l10n, sub.id), - onTap: () => layer.setSubLayerVisible(sub, !sub.visible.value), - ), + MapMenuScrollView( + children: [ + SectionHeader(l10n.disasterMapOverlaySectionLayers), + for (final sub in layer.subLayers) + _ToggleRow( + selected: sub.visible.value, + icon: sub.icon, + color: colorFromHexRgb(sub.color), + title: DisasterMapLayer.layerLabel(l10n, sub.id), + tooltip: DisasterMapLayer.layerTooltip(l10n, sub.id), + onTap: () => + layer.setSubLayerVisible(sub, !sub.visible.value), + ), + const MapMenuDivider(), + SectionHeader(l10n.mapOverlaySectionMap), + MapTownLabelsRow( + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ), + ], + ), ], ); }, diff --git a/lib/features/map/presentation/widgets/dpm_sheet.dart b/lib/features/map/presentation/widgets/dpm_sheet.dart index 853e88eaa..c8522618f 100644 --- a/lib/features/map/presentation/widgets/dpm_sheet.dart +++ b/lib/features/map/presentation/widgets/dpm_sheet.dart @@ -3,17 +3,18 @@ /// panels. The body adapts to whichever sub-layer is selected. library; -import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/features/disaster_map/domain/aed_detail.dart'; -import 'package:dpip/features/disaster_map/domain/dpm_categories.dart'; import 'package:dpip/features/disaster_map/domain/restroom_detail.dart'; import 'package:dpip/features/disaster_map/domain/shelter_detail.dart'; import 'package:dpip/features/map/presentation/layers/disaster_map_layer.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/color_hex.dart'; +import 'package:dpip/shared/map/maps_launcher.dart'; +import 'package:dpip/shared/widgets/loading_view.dart'; +import 'package:dpip/shared/widgets/sheet_extent.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; /// Peek / rest / full sheet for DPM detail. class DpmSheet extends StatefulWidget { @@ -33,9 +34,6 @@ class DpmSheet extends StatefulWidget { static const double peekExtent = 0.24; static const double _rest = 0.42; static const double _expanded = 1.0; - static const double _atTopEpsilon = 0.02; - - static bool _isAtTop(double extent) => extent >= _expanded - _atTopEpsilon; @override State createState() => _DpmSheetState(); @@ -61,8 +59,6 @@ class _DpmSheetState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; final allState = Listenable.merge([ for (final s in _subs) Listenable.merge([ @@ -96,85 +92,34 @@ class _DpmSheetState extends State { if (mounted) _extent.value = initial; }); } - return NotificationListener( - onNotification: (notification) { - _extent.value = notification.extent; - return false; - }, - child: Align( - alignment: Alignment.bottomCenter, - child: DraggableScrollableSheet( - key: ValueKey( - selected ? 'dpm-${active.id}-$revision' : 'dpm-peek', - ), - expand: false, - initialChildSize: initial, - minChildSize: DpmSheet.peekExtent, - maxChildSize: DpmSheet._expanded, - snap: true, - snapSizes: const [DpmSheet._rest], - builder: (context, scrollController) { - // Built once — only `atTop` below needs the live extent; - // handing the list down via the `ValueListenableBuilder`'s - // `child` keeps it out of the per-frame rebuild that would - // otherwise redo it on every pixel of the drag. - final content = ListView( - controller: scrollController, - padding: EdgeInsets.only( - bottom: - MediaQuery.viewPaddingOf(context).bottom + - AppSpacing.md, - ), - children: [ - _Grip(extent: _extent), - if (!selected) ...[ - _Hint(), - _Filters( - restroom: widget.restroom, - shelter: widget.shelter, - ), - ] else - _dispatchBody(active!), - ], - ); - return ValueListenableBuilder( - valueListenable: _extent, - child: content, - builder: (context, extent, content) { - final atTop = DpmSheet._isAtTop(extent); - final topInset = MediaQuery.viewPaddingOf(context).top; - return AnnotatedRegion( - value: SystemUiOverlayStyle( - statusBarColor: atTop - ? colors.surface - : Colors.transparent, - statusBarIconBrightness: - theme.brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, - statusBarBrightness: theme.brightness, - ), - child: _Surface( - flushTop: atTop, - child: Padding( - padding: EdgeInsets.only(top: atTop ? topInset : 0), - child: content!, - ), - ), - ); - }, - ); - }, + return ExtentSheetChrome( + extent: _extent, + keyValue: selected ? 'dpm-${active.id}-$revision' : 'dpm-peek', + initial: initial, + min: DpmSheet.peekExtent, + max: DpmSheet._expanded, + snapSizes: const [DpmSheet._rest], + content: (context, scrollController) => ListView( + controller: scrollController, + padding: EdgeInsets.only( + bottom: MediaQuery.viewPaddingOf(context).bottom + AppSpacing.md, ), + children: [ + SheetGrip(extent: _extent), + if (!selected) _Hint() else _dispatchBody(context, active!), + ], ), ); }, ); } - Widget _dispatchBody(DpmSubLayerBase sub) { + Widget _dispatchBody(BuildContext context, DpmSubLayerBase sub) { + final accent = + colorFromHexRgb(sub.color) ?? Theme.of(context).colorScheme.primary; if (identical(sub, widget.aed)) { return _AedBody( + accent: accent, detail: widget.aed.detail, previewName: widget.aed.previewName, previewPlace: widget.aed.previewPlace, @@ -183,6 +128,7 @@ class _DpmSheetState extends State { } if (identical(sub, widget.restroom)) { return _RestroomBody( + accent: accent, detail: widget.restroom.detail, previewName: widget.restroom.previewName, previewPlace: widget.restroom.previewPlace, @@ -190,6 +136,7 @@ class _DpmSheetState extends State { ); } return _ShelterBody( + accent: accent, detail: widget.shelter.detail, previewName: widget.shelter.previewName, previewPlace: widget.shelter.previewPlace, @@ -198,97 +145,6 @@ class _DpmSheetState extends State { } } -class _Surface extends StatelessWidget { - const _Surface({required this.child, this.flushTop = false}); - - final Widget child; - final bool flushTop; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final radius = flushTop ? BorderRadius.zero : AppRadius.topSheet; - return DecoratedBox( - decoration: BoxDecoration( - color: colors.surface, - borderRadius: radius, - boxShadow: flushTop - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 16, - offset: const Offset(0, -2), - ), - ], - ), - child: ClipRRect(borderRadius: radius, child: child), - ); - } -} - -class _Grip extends StatefulWidget { - const _Grip({required this.extent}); - - final ValueListenable extent; - - @override - State<_Grip> createState() => _GripState(); -} - -class _GripState extends State<_Grip> { - late bool _show = !DpmSheet._isAtTop(widget.extent.value); - - @override - void initState() { - super.initState(); - widget.extent.addListener(_onExtent); - } - - @override - void didUpdateWidget(_Grip old) { - super.didUpdateWidget(old); - if (old.extent != widget.extent) { - old.extent.removeListener(_onExtent); - widget.extent.addListener(_onExtent); - _onExtent(); - } - } - - @override - void dispose() { - widget.extent.removeListener(_onExtent); - super.dispose(); - } - - void _onExtent() { - final next = !DpmSheet._isAtTop(widget.extent.value); - if (next == _show) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final show = !DpmSheet._isAtTop(widget.extent.value); - if (show != _show) setState(() => _show = show); - }); - } - - @override - Widget build(BuildContext context) { - if (!_show) return const SizedBox.shrink(); - final colors = Theme.of(context).colorScheme; - return Center( - child: Container( - margin: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - width: 40, - height: 4, - decoration: BoxDecoration( - color: colors.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - ); - } -} - class _Hint extends StatelessWidget { @override Widget build(BuildContext context) { @@ -311,139 +167,6 @@ class _Hint extends StatelessWidget { } } -/// Category filters for the restroom / shelter sub-layers — a chip row per -/// filterable dimension ([DpmSubLayerBase.filters], which the map layer turns -/// into a MapLibre layer filter). Shown at peek, next to the hint; a section -/// hides with its layer. -class _Filters extends StatelessWidget { - const _Filters({required this.restroom, required this.shelter}); - - final DpmSubLayer restroom; - final DpmSubLayer shelter; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final restroomVenue = restroom.filters['type2']!; - final restroomType = restroom.filters['type']!; - return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - 0, - AppSpacing.lg, - AppSpacing.md, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (restroom.visible.value) ...[ - _FilterSection( - title: l10n.dpmFilterSectionRestroom, - chips: [ - for (final value in DpmCategories.restroom) - _categoryChip( - context, - active: restroomVenue.values, - value: value, - label: _restroomCategoryLabel(l10n, value), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - _FilterSection( - title: l10n.dpmFilterSectionRestroomType, - chips: [ - for (final value in DpmCategories.restroomType) - _categoryChip( - context, - active: restroomType.values, - value: value, - label: DisasterMapLayer.restroomTypeLabel(l10n, value), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - ], - if (shelter.visible.value) - _FilterSection( - title: l10n.dpmFilterSectionShelter, - chips: [ - for (var i = 0; i < DpmCategories.shelter.length; i++) - _categoryChip( - context, - active: shelter.filters['category']!.values, - value: DpmCategories.shelter[i], - label: _shelterDisasterLabel(l10n, i), - ), - ], - ), - ], - ), - ); - } - - Widget _categoryChip( - BuildContext context, { - required ValueNotifier> active, - required Object value, - required String label, - }) { - return FilterChip( - selected: active.value.contains(value), - label: Text(label), - visualDensity: VisualDensity.compact, - onSelected: (on) { - final next = Set.from(active.value); - if (on) { - next.add(value); - } else { - next.remove(value); - } - active.value = next; - }, - ); - } -} - -/// Labelled, horizontally scrollable chip row for one filter group. -class _FilterSection extends StatelessWidget { - const _FilterSection({required this.title, required this.chips}); - - final String title; - final List chips; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 0.4, - ), - ), - const SizedBox(height: AppSpacing.xs), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var i = 0; i < chips.length; i++) ...[ - if (i > 0) const SizedBox(width: AppSpacing.xs), - chips[i], - ], - ], - ), - ), - ], - ); - } -} - /// Restroom venue-category label for a `type2` code. String _restroomCategoryLabel(AppLocalizations l10n, int type2) => switch (type2) { @@ -460,26 +183,16 @@ String _restroomCategoryLabel(AppLocalizations l10n, int type2) => _ => '', }; -/// Shelter disaster-type label for a [DpmCategories.shelter] index. -String _shelterDisasterLabel(AppLocalizations l10n, int index) => - switch (index) { - 0 => l10n.dpmDisasterFlood, - 1 => l10n.dpmDisasterEarthquake, - 2 => l10n.dpmDisasterLandslide, - 3 => l10n.dpmDisasterTsunami, - 4 => l10n.dpmDisasterSlope, - 5 => l10n.dpmDisasterNuclear, - _ => '', - }; - class _AedBody extends StatelessWidget { const _AedBody({ + required this.accent, required this.detail, required this.previewName, required this.previewPlace, required this.onClose, }); + final Color accent; final ValueListenable detail; final ValueListenable previewName; final ValueListenable previewPlace; @@ -494,45 +207,97 @@ class _AedBody extends StatelessWidget { final name = d?.name ?? previewName.value ?? l10n.mapLayerAed; final place = d?.place ?? previewPlace.value ?? ''; return _Header( + accent: accent, icon: Icons.medical_services, name: name, place: place, onClose: onClose, + mapsTarget: d == null + ? null + : MapLaunchTarget(lat: d.lat, lng: d.lng, label: d.name), body: d == null ? [_Loading()] : [ - _Row(label: l10n.aedAddress, value: d.address), _Row( + icon: Icons.place_outlined, + label: l10n.aedAddress, + value: d.address, + ), + _Row( + icon: Icons.map_outlined, label: l10n.aedRegion, value: [ d.city, d.district, ].where((s) => s.isNotEmpty).join(' '), ), - _Row(label: l10n.aedCategory, value: d.category), - _Row(label: l10n.aedType, value: d.type), - _Row(label: l10n.aedPlaceDesc, value: d.placeDesc), - _Row(label: l10n.aedDescription, value: d.description), + _Divider(), + _Row( + icon: Icons.category_outlined, + label: l10n.aedCategory, + value: d.category, + ), _Row( + icon: Icons.label_outline, + label: l10n.aedType, + value: d.type, + ), + _Row( + icon: Icons.description_outlined, + label: l10n.aedPlaceDesc, + value: d.placeDesc, + ), + _Row( + icon: Icons.notes_outlined, + label: l10n.aedDescription, + value: d.description, + ), + _Divider(), + _Row( + icon: Icons.schedule_outlined, label: l10n.aedHoursWeekday, value: _hours(d.weekdayStart, d.weekdayEnd), ), _Row( + icon: Icons.schedule_outlined, label: l10n.aedHoursSaturday, value: _hours(d.saturdayStart, d.saturdayEnd), ), _Row( + icon: Icons.schedule_outlined, label: l10n.aedHoursSunday, value: _hours(d.sundayStart, d.sundayEnd), ), - _Row(label: l10n.aedOpenRemark, value: d.openRemark), - _Row(label: l10n.aedEmergencyPhone, value: d.emergencyPhone), + _Row( + icon: Icons.info_outline, + label: l10n.aedOpenRemark, + value: d.openRemark, + ), + _Divider(), + _Row( + icon: Icons.phone_outlined, + label: l10n.aedEmergencyPhone, + value: d.emergencyPhone, + emphasized: true, + onTap: () => _dialPhone(context, d.emergencyPhone), + ), ], ); }, ); } + static void _dialPhone(BuildContext context, String phone) { + callPhoneNumber(phone).then((opened) { + if (!opened && context.mounted) { + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.mapAppCallFailed))); + } + }); + } + static String _hours(String start, String end) { if (start.isEmpty && end.isEmpty) return ''; if (start.isEmpty) return end; @@ -543,12 +308,14 @@ class _AedBody extends StatelessWidget { class _RestroomBody extends StatelessWidget { const _RestroomBody({ + required this.accent, required this.detail, required this.previewName, required this.previewPlace, required this.onClose, }); + final Color accent; final ValueListenable detail; final ValueListenable previewName; final ValueListenable previewPlace; @@ -563,25 +330,37 @@ class _RestroomBody extends StatelessWidget { final name = d?.name ?? previewName.value ?? l10n.mapLayerRestroom; final place = d?.address ?? previewPlace.value ?? ''; return _Header( + accent: accent, icon: Icons.wc, name: name, place: place, onClose: onClose, + mapsTarget: d == null + ? null + : MapLaunchTarget( + lat: d.latitude, + lng: d.longitude, + label: d.name, + ), body: d == null ? [_Loading()] : [ - _Row(label: l10n.dpmAddress, value: d.address), _Row( + icon: Icons.wc_outlined, label: l10n.restroomTypeLabel, value: DisasterMapLayer.restroomTypeLabel(l10n, d.type), ), _Row( + icon: Icons.category_outlined, label: l10n.restroomCategoryLabel, value: _restroomCategoryLabel(l10n, d.type2), ), + _Divider(), _Row( + icon: Icons.star_outline, label: l10n.restroomGradeLabel, value: _restroomGrade(l10n, d.typegrade), + emphasized: true, ), ], ); @@ -601,12 +380,14 @@ class _RestroomBody extends StatelessWidget { class _ShelterBody extends StatelessWidget { const _ShelterBody({ + required this.accent, required this.detail, required this.previewName, required this.previewPlace, required this.onClose, }); + final Color accent; final ValueListenable detail; final ValueListenable previewName; final ValueListenable previewPlace; @@ -621,31 +402,41 @@ class _ShelterBody extends StatelessWidget { final name = d?.name ?? previewName.value ?? l10n.mapLayerShelter; final place = d?.address ?? previewPlace.value ?? ''; return _Header( + accent: accent, icon: Icons.home_work, name: name, place: place, onClose: onClose, + mapsTarget: d == null + ? null + : MapLaunchTarget(lat: d.lat, lng: d.lng, label: d.name), body: d == null ? [_Loading()] : [ - _Row(label: l10n.shelterAddressLabel, value: d.address), _Row( + icon: Icons.groups_outlined, label: l10n.shelterCapacityLabel, value: l10n.shelterCapacityValue(d.capacity), + emphasized: true, ), + _Divider(), _Row( + icon: Icons.category_outlined, label: l10n.shelterCategoryLabel, value: d.category.join(', '), ), _Row( + icon: Icons.home_outlined, label: l10n.shelterIndoorLabel, value: d.indoor ? l10n.dpmYes : l10n.dpmNo, ), _Row( + icon: Icons.park_outlined, label: l10n.shelterOutdoorLabel, value: d.outdoor ? l10n.dpmYes : l10n.dpmNo, ), _Row( + icon: Icons.favorite_outline, label: l10n.shelterVulnerableOkLabel, value: d.vulnerableOk ? l10n.dpmYes : l10n.dpmNo, ), @@ -657,42 +448,84 @@ class _ShelterBody extends StatelessWidget { } /// Title row + optional subtitle + detail rows for a DPM point. +/// +/// The badge echoes the sub-layer's marker colour, so a sheet about an AED +/// reads as red, a restroom blue, a shelter orange — the header is the one +/// place on the sheet that names which of the three the point is. class _Header extends StatelessWidget { const _Header({ + required this.accent, required this.icon, required this.name, required this.place, required this.onClose, + this.mapsTarget, required this.body, }); + final Color accent; final IconData icon; final String name; final String place; final VoidCallback onClose; + final MapLaunchTarget? mapsTarget; final List body; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; + final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, color: colors.primary), - const SizedBox(width: AppSpacing.sm), + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + ), + child: Icon(icon, color: Colors.white, size: 24), + ), + const SizedBox(width: AppSpacing.md), Expanded( - child: Text( - name, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + if (place.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + place, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ], ), ), ), + if (mapsTarget != null) + IconButton.filledTonal( + tooltip: l10n.dpmOpenInMaps, + onPressed: () => pickAndOpenMapApp(context, mapsTarget!), + icon: const Icon(Icons.directions_outlined), + ), IconButton( tooltip: MaterialLocalizations.of(context).closeButtonTooltip, onPressed: onClose, @@ -700,15 +533,6 @@ class _Header extends StatelessWidget { ), ], ), - if (place.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.xs), - Text( - place, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ], const SizedBox(height: AppSpacing.md), ...body, ], @@ -717,54 +541,111 @@ class _Header extends StatelessWidget { } } +/// A thin rule grouping related detail rows — the only structure the sheets +/// body gets, so a long AED list reads as address / category / hours / contact +/// instead of one undifferentiated wall. +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Divider( + height: 1, + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ); + } +} + class _Loading extends StatelessWidget { @override Widget build(BuildContext context) { return const Padding( padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), + child: Center(child: InlineLoading()), ); } } class _Row extends StatelessWidget { - const _Row({required this.label, required this.value}); + const _Row({ + required this.label, + required this.value, + this.icon, + this.emphasized = false, + this.onTap, + }); final String label; final String value; + /// Leading glyph for the row, so a long list scans by picture before text. + final IconData? icon; + + /// Draws the value in the theme's primary colour — for the one number a + /// reader is actually here for (capacity, grade, emergency phone). + final bool emphasized; + + /// Makes the whole row tappable (e.g. dialling the emergency phone) and + /// hints at it with a trailing chevron. + final VoidCallback? onTap; + @override Widget build(BuildContext context) { if (value.trim().isEmpty) return const SizedBox.shrink(); final theme = Theme.of(context); final colors = theme.colorScheme; + final icon = this.icon; + final onTap = this.onTap; + final row = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) ...[ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(icon, size: 18, color: colors.onSurfaceVariant), + ), + const SizedBox(width: AppSpacing.sm), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: emphasized ? FontWeight.w700 : FontWeight.w600, + color: emphasized ? colors.primary : null, + ), + ), + ], + ), + ), + if (onTap != null) + Icon(Icons.chevron_right, size: 20, color: colors.onSurfaceVariant), + ], + ); return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onSurfaceVariant, - letterSpacing: 0.4, + child: onTap == null + ? row + : InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppSpacing.xs), + child: row, ), - ), - const SizedBox(height: 2), - Text( - value, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ), ); } } diff --git a/lib/features/map/presentation/widgets/forecast_overlay_menu.dart b/lib/features/map/presentation/widgets/forecast_overlay_menu.dart new file mode 100644 index 000000000..14951208b --- /dev/null +++ b/lib/features/map/presentation/widgets/forecast_overlay_menu.dart @@ -0,0 +1,104 @@ +/// Frosted dropdown beside the layer switcher — a numerical-forecast wind +/// layer's reference-chrome toggles (county borders, township borders) plus the +/// shared township-name setting. +library; + +import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_town_labels.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Compact icon chip that opens [layer]'s overlay options — the same chrome and +/// position as the radar/QPESUMS menus, so "layer options" is one affordance +/// across the map rather than one per layer. The township-name toggle rides +/// along because it is a base-map property, not a layer one: [MapScaffold] +/// owns its state and injects it here so every settings menu offers the same +/// switch without a second chip. +/// +/// Unlike the radar menu there is no scan-range row: a forecast model is not an +/// instrument with a coverage footprint, so the two admin-border toggles and +/// the map toggle are the whole menu. +class ForecastOverlayMenu extends StatelessWidget { + const ForecastOverlayMenu({ + super.key, + required this.layer, + required this.showTownLabels, + required this.onShowTownLabelsChanged, + }); + + final AdminOutlineChrome layer; + + final ValueListenable showTownLabels; + final ValueChanged onShowTownLabelsChanged; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListenableBuilder( + listenable: Listenable.merge([ + layer.adminChromeListenable, + showTownLabels, + ]), + builder: (context, _) { + final showGlobal = layer.showGlobalOutline.value; + final showCounty = layer.showCountyOutline.value; + final showTown = layer.showTownOutline.value; + final showLabels = showTownLabels.value; + return MenuAnchor( + alignmentOffset: const Offset(0, 4), + style: MapChipButton.menuStyle(context), + builder: (context, controller, _) => MapChipButton( + icon: Icons.tune, + tooltip: l10n.windForecastOverlayMenuTooltip, + // The dot marks "not the defaults". County and town ship on, 國界 + // and labels off, so it lights up when one has moved. + active: showGlobal || !showCounty || !showTown || !showLabels, + onTap: () => + controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + MapMenuScrollView( + children: [ + SectionHeader(l10n.mapOverlaySectionReference), + MapMenuToggleRow( + selected: showGlobal, + icon: Icons.public_outlined, + title: l10n.radarGlobalOutline, + subtitle: l10n.windForecastGlobalOutlineHint, + tooltip: l10n.windForecastGlobalOutlineHint, + onTap: () => layer.setShowGlobalOutline(!showGlobal), + ), + MapMenuToggleRow( + selected: showCounty, + icon: Icons.map_outlined, + title: l10n.radarCountyOutline, + subtitle: l10n.windForecastCountyOutlineHint, + tooltip: l10n.windForecastCountyOutlineHint, + onTap: () => layer.setShowCountyOutline(!showCounty), + ), + MapMenuToggleRow( + selected: showTown, + icon: Icons.grid_on_outlined, + title: l10n.radarTownOutline, + subtitle: l10n.windForecastTownOutlineHint, + tooltip: l10n.windForecastTownOutlineHint, + onTap: () => layer.setShowTownOutline(!showTown), + ), + const MapMenuDivider(), + SectionHeader(l10n.mapOverlaySectionMap), + MapTownLabelsRow( + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ), + ], + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/map/presentation/widgets/radar_overlay_menu.dart b/lib/features/map/presentation/widgets/radar_overlay_menu.dart new file mode 100644 index 000000000..1448ff06c --- /dev/null +++ b/lib/features/map/presentation/widgets/radar_overlay_menu.dart @@ -0,0 +1,32 @@ +/// Frosted dropdown beside the layer switcher — radar overlay toggles. +library; + +import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; +import 'package:dpip/features/map/presentation/widgets/scan_range_overlay_menu.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// The radar layer's own options chip — the shared chrome menu, titled for +/// radar. Kept as a separate type so callers read "radar options", not "some +/// overlay menu". +class RadarOverlayMenu extends StatelessWidget { + const RadarOverlayMenu({ + super.key, + required this.layer, + required this.showTownLabels, + required this.onShowTownLabelsChanged, + }); + + final RadarMapLayer layer; + final ValueListenable showTownLabels; + final ValueChanged onShowTownLabelsChanged; + + @override + Widget build(BuildContext context) => ScanRangeOverlayMenu( + layer: layer, + tooltip: AppLocalizations.of(context).radarOverlayMenuTooltip, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); +} diff --git a/lib/features/map/presentation/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index a7de9469d..afa2011e8 100644 --- a/lib/features/map/presentation/widgets/rts_monitor_panel.dart +++ b/lib/features/map/presentation/widgets/rts_monitor_panel.dart @@ -10,6 +10,7 @@ import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -65,10 +66,9 @@ class _StatusBar extends StatelessWidget { // immune. Latency floored at 0 against sub-sync jitter. final dataTime = hasData ? DateFormat('HH:mm:ss').format( - DateTime.fromMillisecondsSinceEpoch( - time, - isUtc: true, - ).add(const Duration(hours: 8)), + AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch(time, isUtc: true), + ), ) : null; final raw = AppTime.utc.millisecondsSinceEpoch - time; @@ -115,7 +115,7 @@ class _StatusBar extends StatelessWidget { ), child: Row( children: [ - _StatusDot(color: dot), + LegendDot(color: dot), const SizedBox(width: AppSpacing.sm), // Yields to the trailing readouts so a long locale or large text scale // ellipsises the title instead of overflowing the row. @@ -164,19 +164,3 @@ class _StatusBar extends StatelessWidget { return Colors.red; } } - -/// A freshness dot in the [color] chosen for the feed status. -class _StatusDot extends StatelessWidget { - const _StatusDot({required this.color}); - - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ); - } -} diff --git a/lib/features/map/presentation/widgets/satellite_legend.dart b/lib/features/map/presentation/widgets/satellite_legend.dart new file mode 100644 index 000000000..c6d41914b --- /dev/null +++ b/lib/features/map/presentation/widgets/satellite_legend.dart @@ -0,0 +1,375 @@ +/// Legend for one satellite channel — the channel's own colour key, followed +/// by the border reference (bright-yellow country/county, dark-yellow +/// township) that every channel shares. +/// +/// The scales mirror the tile service's rendering (`satellite-tiles-go`): JMA +/// grayscale (colder = whiter) for the window channels, the −40 °C cloud-top +/// enhancement and the Dvorak BD ladder for the temperature styles, a +/// diverging ramp for the brightness-temperature differences, the categorical +/// cloud mask, the oceanographic SST ramp, and the vegetation/water index +/// ramps. RGB composites carry no single numerical scale — they are a colour +/// recipe — so they get a compositing note instead. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/features/weather/domain/satellite_channel.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// The satellite layer's legend card, rebuilt when the band's [style] changes +/// (grayscale vs cloud-top vs Dvorak are three different keys) or the 國界 +/// toggle flips (its legend row follows the map line). +class SatelliteLegend extends StatelessWidget { + const SatelliteLegend({ + super.key, + required this.channel, + required this.style, + this.showGlobal, + }); + + final SatelliteChannel channel; + final ValueListenable style; + + /// Whether 國界 borders are currently on the map — null keeps the row shown + /// (a standalone legend with no live toggle). + final ValueListenable? showGlobal; + + static const Color _county = Color(0xFFFFD400); + static const Color _town = Color(0xFFB79A00); + + @override + Widget build(BuildContext context) { + final showGlobal = this.showGlobal; + return ListenableBuilder( + listenable: Listenable.merge([style, ?showGlobal]), + builder: (context, _) { + final transparency = _transparencyNote(context); + return MapLegendCard( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _channelKey(context), + if (transparency != null) ...[ + const SizedBox(height: AppSpacing.xs), + transparency, + ], + const SizedBox(height: AppSpacing.sm), + Divider( + height: 1, + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: AppSpacing.sm), + _boundaries(context), + ], + ), + ); + }, + ); + } + + /// What transparent means for this channel — every product that draws pixels + /// transparently explains the *why* here (the basemap showing through, or a + /// specific no-signal case), so a reader never wonders what the empty parts + /// of the image are. null when the product is effectively always opaque. + Widget? _transparencyNote(BuildContext context) { + final l10n = AppLocalizations.of(context); + final String? text = switch (channel) { + // Reflective bands: reflectance ≈ 0 (dark surfaces, night) is transparent. + SatelliteChannel.visibleBlue || + SatelliteChannel.visibleGreen || + SatelliteChannel.visibleRed || + SatelliteChannel.nir || + SatelliteChannel.nirPhase || + SatelliteChannel.nirCloud => l10n.mapLayerSatelliteTransparentReflectance, + // Thermal bands: the warm (clear-sky) end of every style is transparent. + SatelliteChannel.swir || + SatelliteChannel.wvUpper || + SatelliteChannel.wvMid || + SatelliteChannel.wvLow || + SatelliteChannel.so2 || + SatelliteChannel.ozone || + SatelliteChannel.irClean || + SatelliteChannel.irLong || + SatelliteChannel.irLong2 || + SatelliteChannel.co2 => l10n.mapLayerSatelliteTransparentWarm, + // The daytime RGBs fade out across the terminator. + SatelliteChannel.truecolor || + SatelliteChannel.naturalcolor => l10n.mapLayerSatelliteTransparentNight, + // Thermal RGB recipes cover the whole scene — no transparency. + SatelliteChannel.ash || + SatelliteChannel.dust || + SatelliteChannel.airmass || + SatelliteChannel.nightmicrophysics => null, + SatelliteChannel.watervapor => l10n.mapLayerSatelliteTransparentWarm, + // Near-zero difference means no absorber — transparent, basemap shows. + SatelliteChannel.btdSplit || + SatelliteChannel.btdFog || + SatelliteChannel.btdWvirw || + SatelliteChannel.btdSo2 || + SatelliteChannel.btdCo2 || + SatelliteChannel.btdOzone => l10n.mapLayerSatelliteTransparentZero, + // Clear sky has no cloud-top value. + SatelliteChannel.cloudtop => l10n.mapLayerSatelliteTransparentWarm, + SatelliteChannel.cloudmask => l10n.mapLayerSatelliteTransparentClear, + // The retrieval has no value over land. + SatelliteChannel.sst => l10n.mapLayerSatelliteTransparentNoData, + SatelliteChannel.ndvi => l10n.mapLayerSatelliteTransparentNoVegetation, + SatelliteChannel.ndwi || + SatelliteChannel.mndwi => l10n.mapLayerSatelliteTransparentNoWater, + }; + if (text == null) return null; + return SymbolLegend( + items: [ + SymbolLegendItem( + swatch: const _SwatchBox(Color(0x00000000), ring: true), + label: text, + ), + ], + ); + } + + /// The value key this channel displays — a scale, a categorical list, or the + /// RGB-compositing note for the colour recipes. + Widget _channelKey(BuildContext context) { + final l10n = AppLocalizations.of(context); + return switch (channel) { + // Reflective bands (B01–B06): JMA grayscale on reflectance %, brighter + // = more reflective. + SatelliteChannel.visibleBlue || + SatelliteChannel.visibleGreen || + SatelliteChannel.visibleRed || + SatelliteChannel.nir || + SatelliteChannel.nirPhase || + SatelliteChannel.nirCloud => const ColorScaleLegend( + unit: '%', + stops: [(0, '#000000'), (50, '#808080'), (100, '#FFFFFF')], + ), + // Thermal bands (B07–B16) take the selected [style]. + SatelliteChannel.swir || + SatelliteChannel.wvUpper || + SatelliteChannel.wvMid || + SatelliteChannel.wvLow || + SatelliteChannel.so2 || + SatelliteChannel.ozone || + SatelliteChannel.irClean || + SatelliteChannel.irLong || + SatelliteChannel.irLong2 || + SatelliteChannel.co2 => _thermalKey(context), + // Colour recipes — no single number to scale against. + SatelliteChannel.truecolor || + SatelliteChannel.naturalcolor || + SatelliteChannel.ash || + SatelliteChannel.dust || + SatelliteChannel.airmass || + SatelliteChannel.nightmicrophysics => SymbolLegend( + items: [ + SymbolLegendItem( + swatch: const _RgbSwatch(), + label: l10n.mapLayerSatelliteRgbComposite, + ), + ], + ), + SatelliteChannel.watervapor => const ColorScaleLegend( + unit: 'K', + stops: [(190, '#FFFFFF'), (235, '#7F7F7F'), (280, '#000000')], + ), + // Brightness-temperature differences: negative blue, zero transparent + // (the basemap shows through), positive red. + SatelliteChannel.btdSplit => _diverging(-3.0, 7.5), + SatelliteChannel.btdFog => _diverging(-7.0, 2.9), + SatelliteChannel.btdWvirw => _diverging(-40.0, 4.0), + SatelliteChannel.btdSo2 => _diverging(-6.0, 6.0), + SatelliteChannel.btdCo2 => _diverging(-40.0, 2.0), + SatelliteChannel.btdOzone => _diverging(-41.5, 4.3), + SatelliteChannel.cloudtop => const ColorScaleLegend( + unit: 'K', + stops: [(190, '#FFFFFF'), (245, '#7F7F7F'), (300, '#000000')], + ), + SatelliteChannel.cloudmask => _cloudMask(l10n), + SatelliteChannel.sst => const ColorScaleLegend( + unit: '°C', + stops: [ + (-2, '#3C1478'), + (3, '#2846C8'), + (8, '#28A0DC'), + (13, '#3CC8A0'), + (18, '#AADC50'), + (23, '#F5C832'), + (28, '#F07828'), + (32, '#C81E28'), + ], + ), + SatelliteChannel.ndvi => const ColorScaleLegend( + unit: 'NDVI', + stops: [(0, '#C8AF6E'), (0.5, '#5F8A3A'), (1, '#196A23')], + ), + SatelliteChannel.ndwi => const ColorScaleLegend( + unit: 'NDWI', + stops: [(0, '#78BEE8'), (0.5, '#3C7FD0'), (1, '#0A3CB8')], + ), + SatelliteChannel.mndwi => const ColorScaleLegend( + unit: 'MNDWI', + stops: [(0, '#78BEE8'), (0.5, '#3C7FD0'), (1, '#0A3CB8')], + ), + }; + } + + /// The three thermal renderings: JMA grayscale, cloud-top enhancement, and + /// the Dvorak BD ladder. + Widget _thermalKey(BuildContext context) { + return switch (style.value) { + SatelliteStyle.gray => const ColorScaleLegend( + unit: 'K', + stops: [(190, '#FFFFFF'), (255, '#7F7F7F'), (320, '#000000')], + ), + SatelliteStyle.jma => const ColorScaleLegend( + unit: '°C', + stops: [ + (-45, '#7891D2'), + (-52, '#2D55CD'), + (-60, '#28A5DC'), + (-68, '#46C86E'), + (-76, '#F5E646'), + (-84, '#F5962D'), + (-92, '#D72D2D'), + ], + ), + SatelliteStyle.bd => const SymbolLegend( + items: [ + SymbolLegendItem( + swatch: _SwatchBox(Color(0xFFE6E6E6)), + label: '> −30 °C', + ), + SymbolLegendItem( + swatch: _SwatchBox(Color(0xFF4B4B4B)), + label: '−41…−53 °C', + ), + SymbolLegendItem( + swatch: _SwatchBox(Color(0xFF000000)), + label: '−63…−69 °C', + ), + SymbolLegendItem( + swatch: _SwatchBox(Color(0xFFFFFFFF), ring: true), + label: '−69…−75 °C', + ), + SymbolLegendItem( + swatch: _SwatchBox(Color(0xFF828282)), + label: '< −81 °C', + ), + ], + ), + }; + } + + /// Diverging difference scale on the tile service's [lo, hi] range — blue + /// negative, near-zero the basemap, red positive. + ColorScaleLegend _diverging(double lo, double hi) => ColorScaleLegend( + unit: 'K', + stops: [(lo, '#1E5AEB'), (0, '#E8ECF2'), (hi, '#E61E14')], + ); + + /// The four cloud-mask categories — clear is transparent on the map. + Widget _cloudMask(AppLocalizations l10n) => SymbolLegend( + items: [ + SymbolLegendItem( + swatch: const _SwatchBox(Color(0x00000000), ring: true), + label: l10n.mapLayerSatelliteCloudClear, + ), + SymbolLegendItem( + swatch: const _SwatchBox(Color(0xFF78C8FF)), + label: l10n.mapLayerSatelliteCloudProbablyClear, + ), + SymbolLegendItem( + swatch: const _SwatchBox(Color(0xFFDCEBF5)), + label: l10n.mapLayerSatelliteCloudProbablyCloudy, + ), + SymbolLegendItem( + swatch: const _SwatchBox(Color(0xFFFFFFFF), ring: true), + label: l10n.mapLayerSatelliteCloudCloudy, + ), + ], + ); + + /// The border reference every channel shares — bright-yellow country and + /// county frame, dark-yellow township mesh, matching [SatelliteMapLayer]'s + /// runtime line layers exactly. The 國界 row hides when the toggle is off, so + /// the legend never names a line that is not on the map. + Widget _boundaries(BuildContext context) { + final l10n = AppLocalizations.of(context); + return SymbolLegend( + items: [ + if (showGlobal?.value ?? true) + SymbolLegendItem( + swatch: const LineSwatch(color: _county, width: 1.0), + label: l10n.mapLayerSatelliteGlobalOutline, + ), + SymbolLegendItem( + swatch: const LineSwatch(color: _county, width: 1.0), + label: l10n.radarCountyOutline, + ), + SymbolLegendItem( + swatch: const LineSwatch(color: _town, width: 0.7), + label: l10n.radarTownOutline, + ), + ], + ); + } +} + +/// R/G/B swatch marking an RGB-recipe composite. +class _RgbSwatch extends StatelessWidget { + const _RgbSwatch(); + + @override + Widget build(BuildContext context) => const Row( + mainAxisSize: MainAxisSize.min, + children: [ + _RgbBar(Color(0xFFE61E14)), + _RgbBar(Color(0xFF1E8A2E)), + _RgbBar(Color(0xFF1E5AEB)), + ], + ); +} + +class _RgbBar extends StatelessWidget { + const _RgbBar(this.color); + + final Color color; + + @override + Widget build(BuildContext context) => Container( + width: 7, + height: 12, + decoration: BoxDecoration( + color: color, + border: Border.all(color: const Color(0x22000000), width: 0.5), + ), + ); +} + +/// Small square swatch for a categorical (non-line) legend row; [ring] draws a +/// grey border so white or transparent fills stay visible on the card. +class _SwatchBox extends StatelessWidget { + const _SwatchBox(this.color, {this.ring = false}); + + final Color color; + final bool ring; + + @override + Widget build(BuildContext context) => Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + border: ring + ? Border.all(color: const Color(0xFF9E9E9E), width: 1) + : null, + ), + ); +} diff --git a/lib/features/map/presentation/widgets/satellite_style_menu.dart b/lib/features/map/presentation/widgets/satellite_style_menu.dart new file mode 100644 index 000000000..93b15fdff --- /dev/null +++ b/lib/features/map/presentation/widgets/satellite_style_menu.dart @@ -0,0 +1,255 @@ +/// Frosted dropdown beside the layer switcher — a raw band's colour style, +/// plus the shared township-label toggle. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/features/map/presentation/layers/satellite_layer.dart'; +import 'package:dpip/features/weather/domain/satellite_channel.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_town_labels.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Compact icon chip that opens the satellite band's colour-style menu. +/// +/// Shown only for [SatelliteChannel.isBand] layers (named products carry their +/// own palette). Picking a style re-mounts the layer through [onReloadActive] +/// so the already-fetched tiles re-render in the new colours. +class SatelliteStyleMenu extends StatelessWidget { + const SatelliteStyleMenu({ + super.key, + required this.layer, + required this.onReloadActive, + required this.showTownLabels, + required this.onShowTownLabelsChanged, + }); + + final SatelliteMapLayer layer; + final Future Function() onReloadActive; + final ValueListenable showTownLabels; + final ValueChanged onShowTownLabelsChanged; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListenableBuilder( + listenable: Listenable.merge([ + layer.style, + layer.showGlobalOutline, + showTownLabels, + ]), + builder: (context, _) { + final style = layer.style.value; + final showGlobal = layer.showGlobalOutline.value; + final showLabels = showTownLabels.value; + final active = + style != SatelliteStyle.gray || showGlobal || !showLabels; + return MenuAnchor( + alignmentOffset: const Offset(0, 4), + style: MapChipButton.menuStyle(context), + builder: (context, controller, _) { + return MapChipButton( + icon: Icons.palette_outlined, + tooltip: l10n.mapLayerStyleTooltip, + active: active, + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + ); + }, + menuChildren: [ + MapMenuScrollView( + children: [ + SectionHeader(l10n.mapLayerStyleSection), + _StyleRow( + selected: style == SatelliteStyle.gray, + icon: Icons.filter_b_and_w_outlined, + title: l10n.mapLayerStyleGray, + tooltip: l10n.mapLayerStyleGrayTooltip, + onTap: () => layer.setStyle( + SatelliteStyle.gray, + onReloadActive: onReloadActive, + ), + ), + _StyleRow( + selected: style == SatelliteStyle.jma, + icon: Icons.palette_outlined, + title: l10n.mapLayerStyleJma, + tooltip: l10n.mapLayerStyleJmaTooltip, + onTap: () => layer.setStyle( + SatelliteStyle.jma, + onReloadActive: onReloadActive, + ), + ), + _StyleRow( + selected: style == SatelliteStyle.bd, + icon: Icons.insert_chart_outlined, + title: l10n.mapLayerStyleBd, + tooltip: l10n.mapLayerStyleBdTooltip, + onTap: () => layer.setStyle( + SatelliteStyle.bd, + onReloadActive: onReloadActive, + ), + ), + const MapMenuDivider(), + SectionHeader(l10n.mapOverlaySectionReference), + MapMenuToggleRow( + selected: showGlobal, + icon: Icons.public_outlined, + title: l10n.radarGlobalOutline, + subtitle: l10n.radarGlobalOutlineHint, + tooltip: l10n.radarGlobalOutlineHint, + onTap: () => layer.setShowGlobalOutline(!showGlobal), + ), + const MapMenuDivider(), + SectionHeader(l10n.mapOverlaySectionMap), + MapTownLabelsRow( + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ), + ], + ), + ], + ); + }, + ); + } +} + +/// The satellite layer's settings dropdown for channels with no colour style +/// (named products and the reflectance bands, whose only rendering is +/// grayscale): the 國界 toggle plus the shared township-name setting — the +/// same "overlay options" affordance as every other layer, so B01 needs no +/// special-casing in the scaffold. +class SatelliteReferenceMenu extends StatelessWidget { + const SatelliteReferenceMenu({ + super.key, + required this.layer, + required this.showTownLabels, + required this.onShowTownLabelsChanged, + }); + + final SatelliteMapLayer layer; + final ValueListenable showTownLabels; + final ValueChanged onShowTownLabelsChanged; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListenableBuilder( + listenable: Listenable.merge([layer.showGlobalOutline, showTownLabels]), + builder: (context, _) { + final showGlobal = layer.showGlobalOutline.value; + final showLabels = showTownLabels.value; + return MenuAnchor( + alignmentOffset: const Offset(0, 4), + style: MapChipButton.menuStyle(context), + builder: (context, controller, _) => MapChipButton( + icon: Icons.tune, + tooltip: l10n.mapOverlaySectionReference, + // The dot marks "not the defaults". 國界 ships off and labels on, + // so it lights up when either has moved. + active: showGlobal || !showLabels, + onTap: () => + controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + MapMenuScrollView( + children: [ + SectionHeader(l10n.mapOverlaySectionReference), + MapMenuToggleRow( + selected: showGlobal, + icon: Icons.public_outlined, + title: l10n.radarGlobalOutline, + subtitle: l10n.radarGlobalOutlineHint, + tooltip: l10n.radarGlobalOutlineHint, + onTap: () => layer.setShowGlobalOutline(!showGlobal), + ), + const MapMenuDivider(), + SectionHeader(l10n.mapOverlaySectionMap), + MapTownLabelsRow( + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ), + ], + ), + ], + ); + }, + ); + } +} + +/// Radio row for one colour style. +class _StyleRow extends StatelessWidget { + const _StyleRow({ + required this.selected, + required this.icon, + required this.title, + required this.tooltip, + required this.onTap, + }); + + final bool selected; + final IconData icon; + final String title; + final String tooltip; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final theme = Theme.of(context); + return Tooltip( + message: tooltip, + child: MenuItemButton( + onPressed: onTap, + style: MapChipButton.rowStyle( + selected + ? colors.primaryContainer.withValues(alpha: 0.45) + : Colors.transparent, + ), + child: SizedBox( + width: MapMenuToggleRow.width, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + Icon( + icon, + size: 22, + color: selected ? colors.primary : colors.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurface, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + Icon( + selected ? Icons.check_circle : Icons.circle_outlined, + size: 20, + color: selected ? colors.primary : colors.outlineVariant, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart new file mode 100644 index 000000000..dd2dcf59d --- /dev/null +++ b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart @@ -0,0 +1,117 @@ +/// Frosted dropdown beside the layer switcher — a raster's reference-chrome +/// toggles (scan range, county borders, township borders) plus the shared +/// township-name setting. +library; + +import 'package:dpip/features/map/presentation/layers/scan_range_overlay_chrome.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_town_labels.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Compact icon chip that opens [layer]'s overlay options — the same chrome and +/// position as the radar/typhoon menus, so "layer options" is one affordance +/// across the map rather than one per layer. The township-name toggle rides +/// along because it is a base-map property, not a layer one: [MapScaffold] +/// owns its state and injects it here so every settings menu offers the same +/// switch without a second chip. +class ScanRangeOverlayMenu extends StatelessWidget { + const ScanRangeOverlayMenu({ + super.key, + required this.layer, + required this.tooltip, + required this.showTownLabels, + required this.onShowTownLabelsChanged, + }); + + final ScanRangeOverlayChrome layer; + + /// Tooltip for the options chip (layer-specific, so each raster says what it + /// is configuring). + final String tooltip; + + final ValueListenable showTownLabels; + final ValueChanged onShowTownLabelsChanged; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListenableBuilder( + listenable: Listenable.merge([layer.chromeListenable, showTownLabels]), + builder: (context, _) { + final showRange = layer.showScanRange.value; + final showGlobal = layer.showGlobalOutline.value; + final showCounty = layer.showCountyOutline.value; + final showTown = layer.showTownOutline.value; + final showLabels = showTownLabels.value; + return MenuAnchor( + alignmentOffset: const Offset(0, 4), + style: MapChipButton.menuStyle(context), + builder: (context, controller, _) => MapChipButton( + icon: Icons.tune, + tooltip: tooltip, + // The dot marks "not the defaults". The reference toggles ship on + // and 國界 ships off, so it lights up when one has moved from its + // default. + active: + !showRange || + showGlobal || + !showCounty || + !showTown || + !showLabels, + onTap: () => + controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + MapMenuScrollView( + children: [ + SectionHeader(l10n.mapOverlaySectionReference), + MapMenuToggleRow( + selected: showGlobal, + icon: Icons.public_outlined, + title: l10n.radarGlobalOutline, + subtitle: l10n.radarGlobalOutlineHint, + tooltip: l10n.radarGlobalOutlineHint, + onTap: () => layer.setShowGlobalOutline(!showGlobal), + ), + MapMenuToggleRow( + selected: showRange, + icon: Icons.crop_free_outlined, + title: l10n.radarScanRange, + subtitle: l10n.radarScanRangeHint, + tooltip: l10n.radarScanRangeSubtitle, + onTap: () => layer.setShowScanRange(!showRange), + ), + MapMenuToggleRow( + selected: showCounty, + icon: Icons.map_outlined, + title: l10n.radarCountyOutline, + subtitle: l10n.radarCountyOutlineHint, + tooltip: l10n.radarCountyOutlineSubtitle, + onTap: () => layer.setShowCountyOutline(!showCounty), + ), + MapMenuToggleRow( + selected: showTown, + icon: Icons.grid_on_outlined, + title: l10n.radarTownOutline, + subtitle: l10n.radarTownOutlineHint, + tooltip: l10n.radarTownOutlineSubtitle, + onTap: () => layer.setShowTownOutline(!showTown), + ), + const MapMenuDivider(), + SectionHeader(l10n.mapOverlaySectionMap), + MapTownLabelsRow( + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ), + ], + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/map/presentation/widgets/station_sheet.dart b/lib/features/map/presentation/widgets/station_sheet.dart index 6fec6b76f..2a2515904 100644 --- a/lib/features/map/presentation/widgets/station_sheet.dart +++ b/lib/features/map/presentation/widgets/station_sheet.dart @@ -8,13 +8,17 @@ import 'package:dpip/app/theme/app_motion.dart'; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/format.dart'; +import 'package:dpip/core/geo/geo_math.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/features/map/presentation/wind_speed.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/error_view.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; +import 'package:dpip/shared/widgets/sheet_extent.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; /// A trend series to plot: absolute Unix-second [times] aligned index-wise to @@ -55,6 +59,10 @@ abstract interface class StationSheetSource { /// The current reading text for [id] (e.g. "27.8°C"), or null if unknown. String? reading(String id); + /// An icon rendered beside [reading] (e.g. a rotated wind arrow tinted by the + /// station's current speed), or null for layers without one. + Widget? readingIcon(String id); + /// The colour this station's value has on the map, or null when it has no /// reading. Shown as a small mark beside the name so the sheet is visibly the /// dot the user just tapped — identity carried by a mark, not by tinting text. @@ -106,9 +114,6 @@ class StationSheet extends StatefulWidget { static const double peekExtent = 0.14; static const double _rest = 0.42; static const double _expanded = 1.0; - static const double _atTopEpsilon = 0.02; - - static bool _isAtTop(double extent) => extent >= _expanded - _atTopEpsilon; @override State createState() => _StationSheetState(); @@ -144,8 +149,6 @@ class _StationSheetState extends State { @override Widget build(BuildContext context) { final source = widget.source; - final theme = Theme.of(context); - final colors = theme.colorScheme; return ValueListenableBuilder( valueListenable: source.selectionRevision, builder: (context, revision, _) => ValueListenableBuilder( @@ -164,68 +167,26 @@ class _StationSheetState extends State { if (mounted) _extent.value = initial; }); } - return NotificationListener( - onNotification: (notification) { - _extent.value = notification.extent; - return false; - }, - child: Align( - alignment: Alignment.bottomCenter, - child: DraggableScrollableSheet( - key: ValueKey(selected ? 'sel-$revision' : 'peek'), - expand: false, - initialChildSize: initial, - minChildSize: StationSheet.peekExtent, - maxChildSize: StationSheet._expanded, - snap: true, - snapSizes: const [StationSheet._rest], - builder: (context, scrollController) { - // Body is the VLB child — extent ticks only restyle chrome. - final body = stationId == null - ? _EmptyBody( - scrollController: scrollController, - extent: _extent, - ) - : _SheetBody( - source: source, - stationId: stationId, - scrollController: scrollController, - loadTrend: _trend, - invalidateTrend: _invalidateTrend, - extent: _extent, - ); - return ValueListenableBuilder( - valueListenable: _extent, - child: body, - builder: (context, extent, child) { - final atTop = StationSheet._isAtTop(extent); - final topInset = MediaQuery.paddingOf(context).top; - return AnnotatedRegion( - value: SystemUiOverlayStyle( - statusBarColor: atTop - ? colors.surface - : Colors.transparent, - statusBarIconBrightness: - theme.brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, - statusBarBrightness: theme.brightness, - ), - child: _SheetSurface( - flushTop: atTop, - // Sheet paints under the status bar; pad content so - // titles clear the island. Mid-height: no top gap. - child: Padding( - padding: EdgeInsets.only(top: atTop ? topInset : 0), - child: child!, - ), - ), - ); - }, - ); - }, - ), - ), + return ExtentSheetChrome( + extent: _extent, + keyValue: selected ? 'sel-$revision' : 'peek', + initial: initial, + min: StationSheet.peekExtent, + max: StationSheet._expanded, + snapSizes: const [StationSheet._rest], + content: (context, scrollController) => stationId == null + ? _EmptyBody( + scrollController: scrollController, + extent: _extent, + ) + : _SheetBody( + source: source, + stationId: stationId, + scrollController: scrollController, + loadTrend: _trend, + invalidateTrend: _invalidateTrend, + extent: _extent, + ), ); }, ), @@ -251,7 +212,7 @@ class _EmptyBody extends StatelessWidget { // status-bar-sized gap above the grip even with no SafeArea wrapper. padding: EdgeInsets.zero, children: [ - _SheetGrip(extent: extent), + SheetGrip(extent: extent), Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.lg, @@ -279,103 +240,6 @@ class _EmptyBody extends StatelessWidget { } } -/// The sheet's grab handle — hidden once the sheet is flush with the top. -class _SheetGrip extends StatefulWidget { - const _SheetGrip({required this.extent}); - - final ValueListenable extent; - - @override - State<_SheetGrip> createState() => _SheetGripState(); -} - -class _SheetGripState extends State<_SheetGrip> { - late bool _show = !StationSheet._isAtTop(widget.extent.value); - - @override - void initState() { - super.initState(); - widget.extent.addListener(_onExtent); - } - - @override - void didUpdateWidget(_SheetGrip old) { - super.didUpdateWidget(old); - if (old.extent != widget.extent) { - old.extent.removeListener(_onExtent); - widget.extent.addListener(_onExtent); - _onExtent(); - } - } - - @override - void dispose() { - widget.extent.removeListener(_onExtent); - super.dispose(); - } - - void _onExtent() { - final next = !StationSheet._isAtTop(widget.extent.value); - if (next == _show) return; - // Defer — flipping grip mid-DSS-layout dirties parentData for semantics. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final show = !StationSheet._isAtTop(widget.extent.value); - if (show != _show) setState(() => _show = show); - }); - } - - @override - Widget build(BuildContext context) { - if (!_show) return const SizedBox.shrink(); - final colors = Theme.of(context).colorScheme; - return Center( - child: Container( - margin: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - width: 40, - height: 4, - decoration: BoxDecoration( - color: colors.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - ); - } -} - -/// The frosted, rounded sheet panel. -class _SheetSurface extends StatelessWidget { - const _SheetSurface({required this.child, this.flushTop = false}); - - final Widget child; - - /// Full-bleed under the status bar — drop top radius / shadow so it reads as - /// one panel with the safe-area band, not a floating card. - final bool flushTop; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final radius = flushTop ? BorderRadius.zero : AppRadius.topSheet; - return DecoratedBox( - decoration: BoxDecoration( - color: colors.surface, - borderRadius: radius, - boxShadow: flushTop - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 16, - offset: const Offset(0, -2), - ), - ], - ), - child: ClipRRect(borderRadius: radius, child: child), - ); - } -} - class _SheetBody extends StatefulWidget { const _SheetBody({ required this.source, @@ -441,12 +305,13 @@ class _SheetBodyState extends State<_SheetBody> { controller: widget.scrollController, padding: EdgeInsets.zero, children: [ - _SheetGrip(extent: widget.extent), + SheetGrip(extent: widget.extent), _StationHero( extent: widget.extent, name: widget.source.stationName(id), subtitle: widget.source.stationSubtitle(id), reading: reading, + readingIcon: widget.source.readingIcon(id), valueColor: widget.source.valueColor(id), onClose: widget.source.close, ), @@ -535,6 +400,7 @@ class _StationHero extends StatefulWidget { required this.onClose, this.subtitle, this.reading, + this.readingIcon, this.valueColor, }); @@ -542,6 +408,7 @@ class _StationHero extends StatefulWidget { final String name; final String? subtitle; final String? reading; + final Widget? readingIcon; final Color? valueColor; final VoidCallback onClose; @@ -549,53 +416,26 @@ class _StationHero extends StatefulWidget { State<_StationHero> createState() => _StationHeroState(); } -class _StationHeroState extends State<_StationHero> { - late bool _expanded = StationSheet._isAtTop(widget.extent.value); - - @override - void initState() { - super.initState(); - widget.extent.addListener(_onExtent); - } - +class _StationHeroState extends State<_StationHero> with SheetExtentFlag { @override - void didUpdateWidget(_StationHero old) { - super.didUpdateWidget(old); - if (old.extent != widget.extent) { - old.extent.removeListener(_onExtent); - widget.extent.addListener(_onExtent); - _onExtent(); - } - } + ValueListenable get sheetExtent => widget.extent; @override - void dispose() { - widget.extent.removeListener(_onExtent); - super.dispose(); - } - - void _onExtent() { - final next = StationSheet._isAtTop(widget.extent.value); - if (next == _expanded) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final expanded = StationSheet._isAtTop(widget.extent.value); - if (expanded != _expanded) setState(() => _expanded = expanded); - }); - } + bool sheetFlagFrom(double extent) => sheetExtentIsAtTop(extent); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - final nameStyle = _expanded + final expanded = sheetFlag; + final nameStyle = expanded ? theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700) : theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); final subStyle = - (_expanded ? theme.textTheme.bodyMedium : theme.textTheme.bodySmall) + (expanded ? theme.textTheme.bodyMedium : theme.textTheme.bodySmall) ?.copyWith(color: colors.onSurfaceVariant); - final readingStyle = _expanded + final readingStyle = expanded ? theme.textTheme.displayMedium?.copyWith( color: colors.onSurface, fontWeight: FontWeight.w600, @@ -606,15 +446,15 @@ class _StationHeroState extends State<_StationHero> { fontWeight: FontWeight.w600, height: 1, ); - final dotSize = _expanded ? 14.0 : 10.0; + final dotSize = expanded ? 14.0 : 10.0; // No AnimatedSize here — it + DSS resize trips semantics.parentDataDirty. return Padding( padding: EdgeInsets.fromLTRB( AppSpacing.lg, - _expanded ? AppSpacing.md : 0, + expanded ? AppSpacing.md : 0, AppSpacing.sm, - _expanded ? AppSpacing.md : AppSpacing.sm, + expanded ? AppSpacing.md : AppSpacing.sm, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -625,7 +465,7 @@ class _StationHeroState extends State<_StationHero> { if (widget.valueColor case final dot?) ...[ Padding( padding: EdgeInsets.only( - top: _expanded ? 8 : 5, + top: expanded ? 8 : 5, right: AppSpacing.sm, ), child: AnimatedContainer( @@ -670,12 +510,23 @@ class _StationHeroState extends State<_StationHero> { ], ), if (widget.reading case final reading?) ...[ - SizedBox(height: _expanded ? AppSpacing.md : AppSpacing.xs), - AnimatedDefaultTextStyle( - duration: AppMotion.medium, - curve: Curves.easeOutCubic, - style: readingStyle ?? const TextStyle(), - child: Text(reading), + SizedBox(height: expanded ? AppSpacing.md : AppSpacing.xs), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (widget.readingIcon case final icon?) ...[ + icon, + const SizedBox(width: AppSpacing.sm), + ], + Flexible( + child: AnimatedDefaultTextStyle( + duration: AppMotion.medium, + curve: Curves.easeOutCubic, + style: readingStyle ?? const TextStyle(), + child: Text(reading), + ), + ), + ], ), ], ], @@ -896,10 +747,9 @@ class _TrendChart extends StatelessWidget { if (maxY <= minY) maxY = minY + yInterval; String timeLabel(double x) { - final t = DateTime.fromMillisecondsSinceEpoch( - x.toInt() * 1000, - isUtc: true, - ).add(const Duration(hours: 8)); + final t = AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch(x.toInt() * 1000, isUtc: true), + ); final hourMark = l10n.chartHourLabel(t.hour); // Daily-or-wider: date only. Sub-daily 7d: date + hour. 24h: compact hour. if (labelStep >= 24 * hourSec) return DateFormat('M/d').format(t); @@ -913,32 +763,46 @@ class _TrendChart extends StatelessWidget { bool onLabelStep(double value) => _isOnStep(value, labelAnchor, labelStep); - int? directionForSpot(int spotIndex) { - final directions = series.directions; - if (directions == null || spotIndex < 0 || spotIndex >= sampleAt.length) { - return null; + /// Sample index (into [series.values]/[directions]) nearest chart-x [x] — + /// works across the per-bucket segment bars, whose `spotIndex` is relative + /// to each segment's own list and so can't index back into the series. + /// + /// Spots are ordered by time, so this is a binary search — a 7 d series is + /// ~170 points, and this runs for every axis tick and tooltip. + int nearestSample(double x) { + var lo = 0, hi = spots.length; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (spots[mid].x < x) { + lo = mid + 1; + } else { + hi = mid; + } } - final sample = sampleAt[spotIndex]; - if (sample >= directions.length) return null; - return directions[sample]; + if (lo == 0) return sampleAt[0]; + if (lo == spots.length) return sampleAt[lo - 1]; + // Tie → the earlier index, matching the old scan's strict `<`. + return (spots[lo].x - x).abs() < (x - spots[lo - 1].x).abs() + ? sampleAt[lo] + : sampleAt[lo - 1]; } /// Nearest sample's meteorological "from" at chart-x [x] (for axis ticks). - int? directionNear(double x) { + int? nearestDirection(double x) { final directions = series.directions; - if (directions == null || spots.isEmpty) return null; - var best = 0; - var bestDist = (spots.first.x - x).abs(); - for (var i = 1; i < spots.length; i++) { - final d = (spots[i].x - x).abs(); - if (d < bestDist) { - bestDist = d; - best = i; - } - } - return directionForSpot(best); + if (directions == null) return null; + return directions[nearestSample(x)]; } + /// Nearest sample's wind speed at chart-x [x], or null when the series has + /// no value there. + double? nearestSpeed(double x) => series.values[nearestSample(x)]; + + /// The ramp colour at chart-x [x] — used for the tooltip, so a tooltip over + /// a strong reading is as coloured as the curve. + Color speedColorAt(double x) => + isWind ? windSpeedColor(series.values[nearestSample(x)]!) : lineColor; + // Bottom axis: wind needs a second row for arrows above the clock labels. final bottomReserved = isWind ? 40.0 : 22.0; @@ -1010,28 +874,26 @@ class _TrendChart extends StatelessWidget { child: UnconstrainedBox(child: time), ); } - final from = directionNear(x); + final from = nearestDirection(x); + final speed = nearestSpeed(x); + final Widget arrow; + if (from != null && speed != null) { + arrow = Transform.rotate( + angle: degToRad(from + 180), + child: WindArrowIcon(size: 12, color: windSpeedColor(speed)), + ); + } else { + // Matches the arrow box height (glyph + outline) so the clock + // labels stay aligned whether or not this tick has an arrow. + arrow = const SizedBox(height: 16); + } return SideTitleWidget( meta: meta, space: 2, child: UnconstrainedBox( child: Column( mainAxisSize: MainAxisSize.min, - children: [ - if (from != null) - Transform.rotate( - angle: (from + 180) * math.pi / 180, - child: Icon( - Icons.navigation, - size: 12, - color: lineColor, - ), - ) - else - const SizedBox(height: 12), - const SizedBox(height: 2), - time, - ], + children: [arrow, const SizedBox(height: 2), time], ), ), ); @@ -1300,6 +1162,55 @@ class _TrendChart extends StatelessWidget { ); } + // Wind: the speed curve is drawn in per-bucket runs (white → pink, the same + // ramp as the map arrows), so crossing a threshold visibly changes colour — + // one colour for the whole line hides how strong the wind is. + final windSegments = isWind ? _windSegments(spots, sampleAt, series) : null; + LineChartBarData lineBar(List barSpots, Color color) { + final isWholeSeries = identical(barSpots, spots); + final isSeriesEnd = + windSegments != null && + barSpots.isNotEmpty && + identical(barSpots.last, spots.last); + return LineChartBarData( + spots: barSpots, + isCurved: true, + preventCurveOverShooting: true, + color: color, + barWidth: 2.5, + isStrokeCapRound: true, + isStrokeJoinRound: true, + shadow: Shadow( + color: color.withValues(alpha: 0.28), + blurRadius: 6, + offset: const Offset(0, 2), + ), + // End marker for the whole series only — a per-segment marker would + // dot every threshold crossing. + dotData: FlDotData( + checkToShowDot: (spot, bar) => + (isWholeSeries || isSeriesEnd) && spot == bar.spots.last, + getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter( + radius: 4.5, + color: color, + strokeColor: colors.surface, + strokeWidth: 2, + ), + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + color.withValues(alpha: 0.22), + color.withValues(alpha: 0.02), + ], + ), + ), + ); + } + return LineChart( LineChartData( minX: minX, @@ -1307,41 +1218,11 @@ class _TrendChart extends StatelessWidget { minY: minY, maxY: maxY, lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: true, - preventCurveOverShooting: true, - color: lineColor, - barWidth: 2.5, - isStrokeCapRound: true, - isStrokeJoinRound: true, - shadow: Shadow( - color: lineColor.withValues(alpha: 0.28), - blurRadius: 6, - offset: const Offset(0, 2), - ), - // End marker only — wind direction lives on the bottom axis. - dotData: FlDotData( - checkToShowDot: (spot, bar) => spot == bar.spots.last, - getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter( - radius: 4.5, - color: lineColor, - strokeColor: colors.surface, - strokeWidth: 2, - ), - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - lineColor.withValues(alpha: 0.22), - lineColor.withValues(alpha: 0.02), - ], - ), - ), - ), + if (windSegments != null) + for (final segment in windSegments) + lineBar(segment.spots, segment.color) + else + lineBar(spots, lineColor), ], gridData: FlGridData( drawVerticalLine: true, @@ -1374,7 +1255,7 @@ class _TrendChart extends StatelessWidget { for (final _ in indices) TouchedSpotIndicatorData( FlLine( - color: lineColor.withValues(alpha: 0.35), + color: (bar.color ?? lineColor).withValues(alpha: 0.35), strokeWidth: 1, dashArray: const [4, 3], ), @@ -1382,7 +1263,7 @@ class _TrendChart extends StatelessWidget { getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter( radius: 5, - color: lineColor, + color: bar.color ?? lineColor, strokeColor: colors.surface, strokeWidth: 2, ), @@ -1404,18 +1285,15 @@ class _TrendChart extends StatelessWidget { color: lineColor.withValues(alpha: 0.55), width: 1, ), - getTooltipItems: (touched) => [ - for (final spot in touched) - _tooltipItem( - spot: spot, - unit: unit, - time: timeLabel(spot.x), - direction: directionForSpot(spot.spotIndex), - colors: colors, - textTheme: theme.textTheme, - accent: lineColor, - ), - ], + getTooltipItems: (touched) => windTooltipItems( + touched: touched, + unit: unit, + timeLabel: timeLabel, + directionAt: nearestDirection, + accentAt: speedColorAt, + colors: colors, + textTheme: theme.textTheme, + ), ), ), ), @@ -1426,6 +1304,73 @@ class _TrendChart extends StatelessWidget { } } +/// Splits [spots] into contiguous same-bucket runs of the wind ramp, so the +/// chart can draw each run in its bucket colour. +/// +/// Each run after the first starts at the previous run's last spot (a shared +/// endpoint), so the curve never gaps at a threshold crossing — the shared +/// point renders in the new bucket's colour, which reads as the step happening +/// exactly there. A leading run shorter than two spots is dropped: the next +/// run already draws it (it starts at that spot), and fl_chart can't stroke a +/// one-point bar. +List<({List spots, Color color})> _windSegments( + List spots, + List sampleAt, + TrendSeries series, +) { + final buckets = [ + for (final i in sampleAt) windSpeedBucket(series.values[i]!), + ]; + final segments = <({List spots, Color color})>[]; + var start = 0; + while (start < spots.length) { + final bucket = buckets[start]; + var end = start + 1; + while (end < spots.length && buckets[end] == bucket) { + end++; + } + final from = segments.isEmpty ? start : start - 1; + final run = [for (var i = from; i < end; i++) spots[i]]; + if (run.length >= 2) { + segments.add((spots: run, color: windBuckets[bucket].$2)); + } + start = end; + } + return segments; +} + +/// Builds the wind-chart tooltip items for [touched]. +/// +/// fl_chart hard-requires **one item per touched spot** (`drawTouchTooltip` +/// throws on a length mismatch). The wind curve is drawn as per-bucket segment +/// bars that share their boundary spots, so a single touch near a threshold +/// crossing can land on two bars at the same x — every item must be returned, +/// and the duplicates (same value, same time) render perfectly overlapping, so +/// a boundary touch still reads as a single card. Do **not** dedupe here. +@visibleForTesting +List windTooltipItems({ + required List touched, + required String unit, + required String Function(double x) timeLabel, + required int? Function(double x) directionAt, + required Color Function(double x) accentAt, + required ColorScheme colors, + required TextTheme textTheme, +}) { + return [ + for (final spot in touched) + _tooltipItem( + spot: spot, + unit: unit, + time: timeLabel(spot.x), + direction: directionAt(spot.x), + colors: colors, + textTheme: textTheme, + accent: accentAt(spot.x), + ), + ]; +} + /// Chart touch tooltip: value emphasized, meta (direction / time) muted. LineTooltipItem _tooltipItem({ required FlSpot spot, @@ -1520,8 +1465,7 @@ double niceAxisStep(double span, int targetTicks) { /// "1007.0" stays "1007" and fits the left gutter. String _axisLabel(double value, double step) { if (step >= 1) return value.toStringAsFixed(0); - final label = value.toStringAsFixed(1); - return label.endsWith('.0') ? label.substring(0, label.length - 2) : label; + return trimTrailingZero(value); } /// fl_chart reserves a shown side's `reservedSize` as canvas margin diff --git a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart index c0c6d3c42..293ef7259 100644 --- a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart @@ -7,15 +7,25 @@ import 'package:dpip/features/map/presentation/layers/typhoon_layer.dart'; import 'package:dpip/features/map/presentation/layers/typhoon_storm_band.dart'; import 'package:dpip/features/map/presentation/layers/typhoon_weather_overlay.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// Compact icon chip that opens a categorised overlay menu. class TyphoonOverlayMenu extends StatelessWidget { - const TyphoonOverlayMenu({super.key, required this.layer}); + const TyphoonOverlayMenu({ + super.key, + required this.layer, + required this.showTownLabels, + required this.onShowTownLabelsChanged, + }); final TyphoonMapLayer layer; + final ValueListenable showTownLabels; + final ValueChanged onShowTownLabelsChanged; static const Color _l7 = Color(0xFF9C27B0); static const Color _l10 = Color(0xFFFFC107); @@ -23,7 +33,6 @@ class TyphoonOverlayMenu extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final colors = Theme.of(context).colorScheme; return ListenableBuilder( listenable: Listenable.merge([ layer.showProbability, @@ -31,6 +40,10 @@ class TyphoonOverlayMenu extends StatelessWidget { layer.showWarningAreas, layer.stormBand, layer.weatherOverlay, + layer.showScanRange, + layer.showCountyOutline, + layer.showTownOutline, + showTownLabels, ]), builder: (context, _) { final band = layer.stormBand.value; @@ -38,11 +51,21 @@ class TyphoonOverlayMenu extends StatelessWidget { final showCallouts = layer.showForecastCallouts.value; final showWarn = layer.showWarningAreas.value; final weather = layer.weatherOverlay.value; + final isRadar = weather == TyphoonWeatherOverlay.radar; + final showRange = layer.showScanRange.value; + final showCounty = layer.showCountyOutline.value; + final showTown = layer.showTownOutline.value; + final showLabels = showTownLabels.value; final active = showProb || !showCallouts || showWarn || + !showLabels || band != TyphoonStormBand.level7 || + // Any underlay at all is already a deviation, and the radar chrome + // only exists while there is one — so the marker is on for every + // state those toggles can be seen in, and testing them here would + // never change the answer. weather != TyphoonWeatherOverlay.none; return MenuAnchor( alignmentOffset: const Offset(0, 4), @@ -62,89 +85,120 @@ class TyphoonOverlayMenu extends StatelessWidget { ); }, menuChildren: [ - SectionHeader(l10n.typhoonOverlaySectionStorm), - _StormBandRow( - selected: band == TyphoonStormBand.level7, - accent: _l7, - title: l10n.typhoonLegendCircle15, - subtitle: l10n.typhoonOverlayStormBandSubtitle, - tooltip: l10n.typhoonOverlayStormL7Tooltip, - onTap: () => layer.setStormBand(TyphoonStormBand.level7), - ), - _StormBandRow( - selected: band == TyphoonStormBand.level10, - accent: _l10, - title: l10n.typhoonLegendCircle25, - subtitle: l10n.typhoonOverlayStormBandSubtitle, - tooltip: l10n.typhoonOverlayStormL10Tooltip, - onTap: () => layer.setStormBand(TyphoonStormBand.level10), - ), - _menuDivider(colors), - SectionHeader(l10n.typhoonOverlaySectionWeather), - _WeatherRow( - selected: weather == TyphoonWeatherOverlay.none, - icon: Icons.layers_clear_outlined, - title: l10n.typhoonOverlayWeatherNone, - tooltip: l10n.typhoonOverlayWeatherNoneTooltip, - onTap: () => layer.setWeatherOverlay(TyphoonWeatherOverlay.none), - ), - _WeatherRow( - selected: weather == TyphoonWeatherOverlay.radar, - icon: Icons.radar_outlined, - title: l10n.mapLayerRadar, - subtitle: l10n.typhoonOverlayWeatherHint, - tooltip: l10n.typhoonOverlayWeatherRadarTooltip, - onTap: () => layer.setWeatherOverlay(TyphoonWeatherOverlay.radar), - ), - _WeatherRow( - selected: weather == TyphoonWeatherOverlay.satellite, - icon: Icons.satellite_alt_outlined, - title: l10n.mapLayerSatellite, - subtitle: l10n.typhoonOverlayWeatherHint, - tooltip: l10n.typhoonOverlayWeatherSatelliteTooltip, - onTap: () => - layer.setWeatherOverlay(TyphoonWeatherOverlay.satellite), - ), - _menuDivider(colors), - SectionHeader(l10n.typhoonOverlaySectionExtra), - _ToggleRow( - selected: showCallouts, - icon: Icons.info_outline, - title: l10n.typhoonOverlayForecastCallouts, - tooltip: l10n.typhoonOverlayForecastCalloutsTooltip, - onTap: () => layer.setShowForecastCallouts(!showCallouts), - ), - _ToggleRow( - selected: showProb, - icon: Icons.bubble_chart_outlined, - title: l10n.typhoonLegendProbability, - subtitle: l10n.typhoonOverlayProbabilityHint, - tooltip: l10n.typhoonOverlayProbabilityTooltip, - onTap: () => layer.setShowProbability(!showProb), - ), - _ToggleRow( - selected: showWarn, - icon: Icons.warning_amber_outlined, - title: l10n.typhoonLegendWarningAreas, - tooltip: l10n.typhoonOverlayWarningTooltip, - onTap: () => layer.setShowWarningAreas(!showWarn), + MapMenuScrollView( + children: [ + SectionHeader(l10n.typhoonOverlaySectionStorm), + _StormBandRow( + selected: band == TyphoonStormBand.level7, + accent: _l7, + title: l10n.typhoonLegendCircle15, + subtitle: l10n.typhoonOverlayStormBandSubtitle, + tooltip: l10n.typhoonOverlayStormL7Tooltip, + onTap: () => layer.setStormBand(TyphoonStormBand.level7), + ), + _StormBandRow( + selected: band == TyphoonStormBand.level10, + accent: _l10, + title: l10n.typhoonLegendCircle25, + subtitle: l10n.typhoonOverlayStormBandSubtitle, + tooltip: l10n.typhoonOverlayStormL10Tooltip, + onTap: () => layer.setStormBand(TyphoonStormBand.level10), + ), + const MapMenuDivider(), + SectionHeader(l10n.typhoonOverlaySectionWeather), + _WeatherRow( + selected: weather == TyphoonWeatherOverlay.none, + icon: Icons.layers_clear_outlined, + title: l10n.typhoonOverlayWeatherNone, + tooltip: l10n.typhoonOverlayWeatherNoneTooltip, + onTap: () => + layer.setWeatherOverlay(TyphoonWeatherOverlay.none), + ), + _WeatherRow( + selected: weather == TyphoonWeatherOverlay.radar, + icon: Icons.radar_outlined, + title: l10n.mapLayerRadar, + subtitle: l10n.typhoonOverlayWeatherHint, + tooltip: l10n.typhoonOverlayWeatherRadarTooltip, + onTap: () => + layer.setWeatherOverlay(TyphoonWeatherOverlay.radar), + ), + _WeatherRow( + selected: weather == TyphoonWeatherOverlay.satellite, + icon: Icons.satellite_alt_outlined, + title: l10n.mapLayerSatellite, + subtitle: l10n.typhoonOverlayWeatherHint, + tooltip: l10n.typhoonOverlayWeatherSatelliteTooltip, + onTap: () => + layer.setWeatherOverlay(TyphoonWeatherOverlay.satellite), + ), + // Radar's own chrome, offered only while radar is the underlay — + // a coverage boundary over IR would bound an instrument that is + // not on screen, and the base style's borders only disappear + // under the echo. + if (isRadar) ...[ + MapMenuToggleRow( + selected: showRange, + icon: Icons.crop_free_outlined, + title: l10n.radarScanRange, + subtitle: l10n.radarScanRangeHint, + tooltip: l10n.radarScanRangeSubtitle, + onTap: () => layer.setShowScanRange(!showRange), + ), + MapMenuToggleRow( + selected: showCounty, + icon: Icons.map_outlined, + title: l10n.radarCountyOutline, + subtitle: l10n.radarCountyOutlineHint, + tooltip: l10n.radarCountyOutlineSubtitle, + onTap: () => layer.setShowCountyOutline(!showCounty), + ), + MapMenuToggleRow( + selected: showTown, + icon: Icons.grid_on_outlined, + title: l10n.radarTownOutline, + subtitle: l10n.radarTownOutlineHint, + tooltip: l10n.radarTownOutlineSubtitle, + onTap: () => layer.setShowTownOutline(!showTown), + ), + ], + const MapMenuDivider(), + SectionHeader(l10n.typhoonOverlaySectionExtra), + MapMenuToggleRow( + selected: showCallouts, + icon: Icons.info_outline, + title: l10n.typhoonOverlayForecastCallouts, + tooltip: l10n.typhoonOverlayForecastCalloutsTooltip, + onTap: () => layer.setShowForecastCallouts(!showCallouts), + ), + MapMenuToggleRow( + selected: showProb, + icon: Icons.bubble_chart_outlined, + title: l10n.typhoonLegendProbability, + subtitle: l10n.typhoonOverlayProbabilityHint, + tooltip: l10n.typhoonOverlayProbabilityTooltip, + onTap: () => layer.setShowProbability(!showProb), + ), + MapMenuToggleRow( + selected: showWarn, + icon: Icons.warning_amber_outlined, + title: l10n.typhoonLegendWarningAreas, + tooltip: l10n.typhoonOverlayWarningTooltip, + onTap: () => layer.setShowWarningAreas(!showWarn), + ), + const MapMenuDivider(), + SectionHeader(l10n.mapOverlaySectionMap), + MapTownLabelsRow( + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ), + ], ), ], ); }, ); } - - static Widget _menuDivider(ColorScheme colors) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.xs, - ), - child: Divider( - height: 1, - color: colors.outlineVariant.withValues(alpha: 0.5), - ), - ); } class _StormBandRow extends StatelessWidget { @@ -375,87 +429,3 @@ class _WeatherRow extends StatelessWidget { ); } } - -/// Checkbox-style row for optional overlays. -class _ToggleRow extends StatelessWidget { - const _ToggleRow({ - required this.selected, - required this.icon, - required this.title, - required this.tooltip, - required this.onTap, - this.subtitle, - }); - - final bool selected; - final IconData icon; - final String title; - final String? subtitle; - final String tooltip; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final theme = Theme.of(context); - return Tooltip( - message: tooltip, - child: MenuItemButton( - onPressed: onTap, - style: MapChipButton.rowStyle( - selected - ? colors.primaryContainer.withValues(alpha: 0.45) - : Colors.transparent, - ), - child: SizedBox( - width: 228, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - child: Row( - children: [ - Icon( - icon, - size: 22, - color: selected ? colors.primary : colors.onSurfaceVariant, - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurface, - fontWeight: selected - ? FontWeight.w600 - : FontWeight.w400, - ), - ), - if (subtitle != null) - Text( - subtitle!, - style: theme.textTheme.labelSmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ], - ), - ), - Icon( - selected ? Icons.check_box : Icons.check_box_outline_blank, - size: 20, - color: selected ? colors.primary : colors.outlineVariant, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/map/presentation/widgets/typhoon_panel.dart b/lib/features/map/presentation/widgets/typhoon_panel.dart index 065a7ec32..ff6815bdd 100644 --- a/lib/features/map/presentation/widgets/typhoon_panel.dart +++ b/lib/features/map/presentation/widgets/typhoon_panel.dart @@ -7,6 +7,8 @@ library; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/format.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/features/map/presentation/layers/typhoon_layer.dart'; import 'package:dpip/features/typhoon/domain/compass_direction.dart'; import 'package:dpip/features/typhoon/domain/cyclone_identity.dart'; @@ -14,9 +16,9 @@ import 'package:dpip/features/typhoon/domain/storm_circle.dart'; import 'package:dpip/features/typhoon/domain/typhoon_intensity.dart'; import 'package:dpip/features/typhoon/domain/typhoon_track.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/sheet_extent.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; class TyphoonPanel extends StatefulWidget { const TyphoonPanel({super.key, required this.layer}); @@ -27,9 +29,6 @@ class TyphoonPanel extends StatefulWidget { static const double peekExtent = 0.22; static const double _rest = 0.55; static const double _expanded = 1.0; - static const double _atTopEpsilon = 0.02; - - static bool _isAtTop(double extent) => extent >= _expanded - _atTopEpsilon; @override State createState() => _TyphoonPanelState(); @@ -49,8 +48,6 @@ class _TyphoonPanelState extends State { @override Widget build(BuildContext context) { final layer = widget.layer; - final theme = Theme.of(context); - final colors = theme.colorScheme; return ValueListenableBuilder( valueListenable: layer.tapRevision, builder: (context, revision, _) => ValueListenableBuilder( @@ -65,64 +62,24 @@ class _TyphoonPanelState extends State { if (mounted) _extent.value = initial; }); } - return NotificationListener( - onNotification: (notification) { - _extent.value = notification.extent; - return false; - }, - child: Align( - alignment: Alignment.bottomCenter, - child: DraggableScrollableSheet( - key: ValueKey(popped ? 'wp-$revision' : 'peek'), - expand: false, - initialChildSize: initial, - minChildSize: TyphoonPanel.peekExtent, - maxChildSize: TyphoonPanel._expanded, - snap: true, - snapSizes: const [TyphoonPanel._rest], - builder: (context, scrollController) { - final body = ListView( - controller: scrollController, - padding: EdgeInsets.only( - bottom: - MediaQuery.paddingOf(context).bottom + AppSpacing.md, - ), - children: [ - _Grip(extent: _extent), - _Bulletin(layer: layer, extent: _extent), - _WarningBlock(layer: layer), - _TappedWaypoint(layer: layer), - ], - ); - return ValueListenableBuilder( - valueListenable: _extent, - child: body, - builder: (context, extent, child) { - final atTop = TyphoonPanel._isAtTop(extent); - final topInset = MediaQuery.paddingOf(context).top; - return AnnotatedRegion( - value: SystemUiOverlayStyle( - statusBarColor: atTop - ? colors.surface - : Colors.transparent, - statusBarIconBrightness: - theme.brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, - statusBarBrightness: theme.brightness, - ), - child: _Surface( - flushTop: atTop, - child: Padding( - padding: EdgeInsets.only(top: atTop ? topInset : 0), - child: child!, - ), - ), - ); - }, - ); - }, + return ExtentSheetChrome( + extent: _extent, + keyValue: popped ? 'wp-$revision' : 'peek', + initial: initial, + min: TyphoonPanel.peekExtent, + max: TyphoonPanel._expanded, + snapSizes: const [TyphoonPanel._rest], + content: (context, scrollController) => ListView( + controller: scrollController, + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom + AppSpacing.md, ), + children: [ + SheetGrip(extent: _extent), + _Bulletin(layer: layer, extent: _extent), + _WarningBlock(layer: layer), + _TappedWaypoint(layer: layer), + ], ), ); }, @@ -131,98 +88,6 @@ class _TyphoonPanelState extends State { } } -class _Surface extends StatelessWidget { - const _Surface({required this.child, this.flushTop = false}); - - final Widget child; - final bool flushTop; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final radius = flushTop ? BorderRadius.zero : AppRadius.topSheet; - return DecoratedBox( - decoration: BoxDecoration( - color: colors.surface, - borderRadius: radius, - boxShadow: flushTop - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 16, - offset: const Offset(0, -2), - ), - ], - ), - child: ClipRRect(borderRadius: radius, child: child), - ); - } -} - -/// Grab handle — hidden once the sheet is flush with the top. -class _Grip extends StatefulWidget { - const _Grip({required this.extent}); - - final ValueListenable extent; - - @override - State<_Grip> createState() => _GripState(); -} - -class _GripState extends State<_Grip> { - late bool _show = !TyphoonPanel._isAtTop(widget.extent.value); - - @override - void initState() { - super.initState(); - widget.extent.addListener(_onExtent); - } - - @override - void didUpdateWidget(_Grip old) { - super.didUpdateWidget(old); - if (old.extent != widget.extent) { - old.extent.removeListener(_onExtent); - widget.extent.addListener(_onExtent); - _onExtent(); - } - } - - @override - void dispose() { - widget.extent.removeListener(_onExtent); - super.dispose(); - } - - void _onExtent() { - final next = !TyphoonPanel._isAtTop(widget.extent.value); - if (next == _show) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final show = !TyphoonPanel._isAtTop(widget.extent.value); - if (show != _show) setState(() => _show = show); - }); - } - - @override - Widget build(BuildContext context) { - if (!_show) return const SizedBox.shrink(); - final colors = Theme.of(context).colorScheme; - return Center( - child: Container( - margin: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - width: 40, - height: 4, - decoration: BoxDecoration( - color: colors.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - ); - } -} - /// Header + CWA-worded fact sheet (position / motion / intensity / radii). /// Formats the sheet hero / picker title for one cyclone. String cycloneSheetTitle( @@ -266,42 +131,12 @@ class _Bulletin extends StatefulWidget { /// actually read `atTop`. `setState` only runs when the derived boolean /// changes, so the bulletin now rebuilds on data updates plus the (rare) top /// threshold crossing — not on every drag tick. -class _BulletinState extends State<_Bulletin> { - late bool _atTop = TyphoonPanel._isAtTop(widget.extent.value); - +class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { @override - void initState() { - super.initState(); - widget.extent.addListener(_onExtent); - } + ValueListenable get sheetExtent => widget.extent; @override - void didUpdateWidget(_Bulletin old) { - super.didUpdateWidget(old); - if (old.extent != widget.extent) { - old.extent.removeListener(_onExtent); - widget.extent.addListener(_onExtent); - _onExtent(); - } - } - - @override - void dispose() { - widget.extent.removeListener(_onExtent); - super.dispose(); - } - - void _onExtent() { - final next = TyphoonPanel._isAtTop(widget.extent.value); - if (next == _atTop) return; - // Defer — flipping mid-DSS-layout dirties parentData for semantics (see - // `_Grip`'s identical guard above). - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final atTop = TyphoonPanel._isAtTop(widget.extent.value); - if (atTop != _atTop) setState(() => _atTop = atTop); - }); - } + bool sheetFlagFrom(double extent) => sheetExtentIsAtTop(extent); @override Widget build(BuildContext context) { @@ -336,7 +171,7 @@ class _BulletinState extends State<_Bulletin> { ? track.analysis.last : null; final now = track?.now; - final atTop = _atTop; + final atTop = sheetFlag; if (track == null && summary == null) { return Padding( @@ -355,24 +190,37 @@ class _BulletinState extends State<_Bulletin> { ); } - final title = cycloneSheetTitle( - l10n, - name: track?.name ?? summary?.name, - cwaName: track?.cwaName ?? summary?.cwaName, - tyNo: track?.tyNo ?? summary?.tyNo, - tdNo: track?.tdNo ?? summary?.tdNo, + final name = track?.name ?? summary?.name; + final cwaName = track?.cwaName ?? summary?.cwaName; + final tyNo = track?.tyNo ?? summary?.tyNo; + final tdNo = track?.tdNo ?? summary?.tdNo; + final spec = cycloneTitleSpec( + name: name, + cwaName: cwaName, + tyNo: tyNo, + tdNo: tdNo, ); - final engName = presentText(track?.name ?? summary?.name); - // Hide the English line when the hero already embeds that name, or when - // the picker title is the TD form (no separate international name). + // The CWA name is the hero; the TY/TD serial is secondary and sits in + // its own badge, never appended to the name. A TD has no name of its + // own, so the hero falls back to the bare "tropical depression" class — + // the serial (TD 19) already lives in the badge. + final title = + spec.displayName ?? + (spec.isTyphoon + ? cycloneSheetTitle(l10n, name: name) + : l10n.typhoonIntensityTd); + final serialBadge = switch ((spec.isTyphoon, spec.number)) { + (true, final n?) => l10n.typhoonTyNo(n), + (false, final n?) => l10n.typhoonTdNo(n), + _ => null, + }; + final engName = presentText(name); + // The English line shows when it adds information the hero lacks (it + // has no name of its own, or carries a different one). final showEng = engName != null && - !title.contains(engName) && - cycloneDisplayName( - cwaName: track?.cwaName ?? summary?.cwaName, - name: track?.name ?? summary?.name, - ) != - null; + engName != title && + cycloneDisplayName(cwaName: cwaName, name: name) != null; final lat = fix?.latitude ?? summary?.latitude; final lon = fix?.longitude ?? summary?.longitude; final wind = fix?.wind ?? summary?.wind; @@ -386,51 +234,63 @@ class _BulletinState extends State<_Bulletin> { : (useZh ? compass.zh : compass.en); final intensity = typhoonIntensityFromWind(wind); - // Name is the hero — large at peek, larger when full-bleed. - final nameStyle = - (atTop - ? theme.textTheme.displayMedium - : theme.textTheme.displaySmall) - ?.copyWith( - fontWeight: FontWeight.w800, - height: 1.05, - letterSpacing: -0.5, - ); + // The name is the sheet's title — bold and compact, never display-grade: + // a long CWA name must not wrap the hero or squeeze the badges out. + final nameStyle = theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + height: 1.15, + letterSpacing: -0.3, + ); final dataSec = layer.bulletinSecond; final dataTime = dataSec == null ? null - : DateTime.fromMillisecondsSinceEpoch( - dataSec * 1000, - isUtc: true, - ).add(const Duration(hours: 8)); + : AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch( + dataSec * 1000, + isUtc: true, + ), + ); + + ({String key, String label}) optionOf({ + String? name, + String? cwaName, + String? tyNo, + String? tdNo, + }) { + final spec = cycloneTitleSpec( + name: name, + cwaName: cwaName, + tyNo: tyNo, + tdNo: tdNo, + ); + final label = + spec.displayName ?? + (spec.number == null + ? l10n.typhoonIntensityTd + : l10n.typhoonTdNo(spec.number!)); + return ( + key: cycloneKeyOf(name: name, cwaName: cwaName, tdNo: tdNo), + label: label, + ); + } - final options = <({String key, String title})>[ + final options = <({String key, String label})>[ for (final t in layer.track.value?.cyclones ?? const []) - ( - key: cycloneKeyOf(name: t.name, cwaName: t.cwaName, tdNo: t.tdNo), - title: cycloneSheetTitle( - l10n, - name: t.name, - cwaName: t.cwaName, - tyNo: t.tyNo, - tdNo: t.tdNo, - ), + optionOf( + name: t.name, + cwaName: t.cwaName, + tyNo: t.tyNo, + tdNo: t.tdNo, ), - ]; - if (options.isEmpty) { - for (final c in layer.activeCyclones) { - options.add(( - key: cycloneKey(c), - title: cycloneSheetTitle( - l10n, + if ((layer.track.value?.cyclones ?? const []).isEmpty) + for (final c in layer.activeCyclones) + optionOf( name: c.name, cwaName: c.cwaName, tyNo: c.tyNo, tdNo: c.tdNo, ), - )); - } - } + ]; final selectedKey = layer.selectedCycloneKey.value; final multi = options.length > 1; @@ -447,100 +307,99 @@ class _BulletinState extends State<_Bulletin> { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: colors.primaryContainer.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: Icon(Icons.cyclone, color: colors.primary, size: 24), + ), + const SizedBox(width: AppSpacing.md), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (multi) - PopupMenuButton( - initialValue: selectedKey, - tooltip: title, - padding: EdgeInsets.zero, - onSelected: layer.selectCyclone, - itemBuilder: (context) => [ - for (final o in options) - PopupMenuItem( - value: o.key, - child: Text( - o.title, - style: theme.textTheme.titleMedium - ?.copyWith( - fontWeight: o.key == selectedKey - ? FontWeight.w700 - : FontWeight.w500, - ), - ), - ), - ], - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: Text(title, style: nameStyle)), - Padding( - padding: const EdgeInsets.only( - top: AppSpacing.sm, - ), - child: Icon( - Icons.arrow_drop_down, - color: colors.onSurface, - size: atTop ? 36 : 32, - ), + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: nameStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (showEng) + Padding( + padding: const EdgeInsets.only( + top: AppSpacing.xs, + ), + child: Text( + engName, + style: theme.textTheme.titleSmall?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: 0.4, + fontWeight: FontWeight.w500, ), - ], + ), ), - ) - else - Text(title, style: nameStyle), - if (showEng) - Padding( - padding: const EdgeInsets.only(top: AppSpacing.xs), - child: Text( - engName, - style: - (atTop - ? theme.textTheme.titleMedium - : theme.textTheme.titleSmall) - ?.copyWith( - color: colors.onSurfaceVariant, - letterSpacing: 0.4, - fontWeight: FontWeight.w500, - ), - ), - ), - ], + ], + ), ), ), const SizedBox(width: AppSpacing.sm), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ + if (serialBadge != null) ...[ + _SerialBadge(label: serialBadge), + if (intensity != null) + const SizedBox(height: AppSpacing.xs), + ], if (intensity != null) _IntensityChip(intensity: intensity), - if (dataTime != null) - Padding( - padding: EdgeInsets.only( - top: intensity != null ? AppSpacing.xs : 0, - ), - child: Text( - l10n.typhoonDataTime( - '${dataTime.month.toString().padLeft(2, '0')}/' - '${dataTime.day.toString().padLeft(2, '0')} ' - '${dataTime.hour.toString().padLeft(2, '0')}:' - '${dataTime.minute.toString().padLeft(2, '0')}', - ), - style: theme.textTheme.labelSmall?.copyWith( - color: colors.onSurfaceVariant, - fontFeatures: const [ - FontFeature.tabularFigures(), - ], - ), - textAlign: TextAlign.end, - ), - ), ], ), ], ), + if (dataTime != null || multi) ...[ + const SizedBox(height: AppSpacing.sm), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: multi + ? Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.xs, + children: [ + for (final o in options) + _CyclonePill( + label: o.label, + selected: o.key == selectedKey, + onTap: () => layer.selectCyclone(o.key), + ), + ], + ) + : const SizedBox.shrink(), + ), + if (dataTime != null) + Text( + l10n.typhoonDataTime( + '${dataTime.month.toString().padLeft(2, '0')}/' + '${dataTime.day.toString().padLeft(2, '0')} ' + '${dataTime.hour.toString().padLeft(2, '0')}:' + '${dataTime.minute.toString().padLeft(2, '0')}', + ), + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + textAlign: TextAlign.end, + ), + ], + ), + ], const SizedBox(height: AppSpacing.md), DecoratedBox( decoration: BoxDecoration( @@ -554,6 +413,7 @@ class _BulletinState extends State<_Bulletin> { children: [ if (lat != null && lon != null) _TableRow( + icon: Icons.place_outlined, label: l10n.typhoonLabelPosition, value: Column( crossAxisAlignment: CrossAxisAlignment.end, @@ -571,11 +431,13 @@ class _BulletinState extends State<_Bulletin> { ), if (dirLabel != null && dirLabel.isNotEmpty) _TableRow( + icon: Icons.explore_outlined, label: l10n.typhoonLabelDirection, value: Text(dirLabel, style: _valueStyle(theme)), ), if (speed != null) _TableRow( + icon: Icons.navigation_outlined, label: l10n.typhoonLabelSpeed, value: Text( l10n.typhoonValueKm(speed.toStringAsFixed(0)), @@ -584,6 +446,7 @@ class _BulletinState extends State<_Bulletin> { ), if (pressure != null) _TableRow( + icon: Icons.compress_outlined, label: l10n.typhoonLabelPressure, value: Text( l10n.typhoonValueHpa(pressure.toStringAsFixed(0)), @@ -592,6 +455,7 @@ class _BulletinState extends State<_Bulletin> { ), if (wind != null) _TableRow( + icon: Icons.air_outlined, label: l10n.typhoonLabelWind, value: Text( l10n.typhoonValueMs(wind.toStringAsFixed(0)), @@ -600,6 +464,7 @@ class _BulletinState extends State<_Bulletin> { ), if (gust != null) _TableRow( + icon: Icons.air, label: l10n.typhoonLabelGust, value: Text( l10n.typhoonValueMs(gust.toStringAsFixed(0)), @@ -630,10 +495,7 @@ class _BulletinState extends State<_Bulletin> { ); } - static String _fmt1(double v) { - final s = v.toStringAsFixed(1); - return s.endsWith('.0') ? v.toStringAsFixed(0) : s; - } + static String _fmt1(double v) => trimTrailingZero(v); static TextStyle? _valueStyle(ThemeData theme) => theme.textTheme.bodyMedium?.copyWith( @@ -642,22 +504,123 @@ class _BulletinState extends State<_Bulletin> { ); } +/// One storm in the sheet's cyclone selector — a quiet pill that marks the +/// focused storm and switches on tap. Only rendered when more than one storm +/// is active, so a single-storm season shows no chrome at all. +class _CyclonePill extends StatelessWidget { + const _CyclonePill({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppSpacing.lg), + child: DecoratedBox( + decoration: BoxDecoration( + color: selected + ? colors.primaryContainer.withValues(alpha: 0.55) + : Colors.transparent, + borderRadius: BorderRadius.circular(AppSpacing.lg), + border: Border.all( + color: selected + ? Colors.transparent + : colors.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs + 1, + ), + child: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: selected + ? colors.onPrimaryContainer + : colors.onSurfaceVariant, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ), + ); + } +} + +/// Low-key CWA serial badge (TY 4 / TD 14) on the sheet hero — the storm's +/// catalogue number is secondary to its name, so it renders as a quiet pill +/// rather than part of the display typography. +class _SerialBadge extends StatelessWidget { + const _SerialBadge({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppSpacing.lg), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.45), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + child: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ); + } +} + /// Label | value row with a hairline divider — table-like bulletin layout. class _TableRow extends StatelessWidget { const _TableRow({ required this.label, required this.value, + this.icon, this.showDivider = true, }); final String label; final Widget value; + + /// Leading glyph, so the fact sheet scans by picture (position → place pin, + /// motion → heading, pressure → gauge) instead of by caption. + final IconData? icon; + final bool showDivider; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; + final icon = this.icon; return DecoratedBox( decoration: BoxDecoration( border: showDivider @@ -674,8 +637,12 @@ class _TableRow extends StatelessWidget { vertical: AppSpacing.sm + 2, ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ + if (icon != null) ...[ + Icon(icon, size: 16, color: colors.onSurfaceVariant), + const SizedBox(width: AppSpacing.sm), + ], Expanded( flex: 5, child: Text( @@ -925,18 +892,33 @@ class _WarningBlock extends StatelessWidget { decoration: BoxDecoration( color: colors.errorContainer.withValues(alpha: 0.45), borderRadius: AppRadius.small, + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.45), + ), ), child: Padding( padding: const EdgeInsets.all(AppSpacing.md), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - l10n.typhoonWarningTitle, - style: theme.textTheme.labelLarge?.copyWith( - color: colors.onErrorContainer, - fontWeight: FontWeight.w700, - ), + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + size: 20, + color: colors.onErrorContainer, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + l10n.typhoonWarningTitle, + style: theme.textTheme.labelLarge?.copyWith( + color: colors.onErrorContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + ], ), const SizedBox(height: AppSpacing.xs), Text( @@ -1017,102 +999,158 @@ class _TappedWaypoint extends StatelessWidget { padding: const EdgeInsets.fromLTRB( AppSpacing.lg, 0, - AppSpacing.sm, + AppSpacing.lg, AppSpacing.md, ), child: DecoratedBox( decoration: BoxDecoration( color: colors.primaryContainer.withValues(alpha: 0.35), borderRadius: AppRadius.small, + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.45), + ), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.md, AppSpacing.md, - 0, - AppSpacing.md, + AppSpacing.xs, + AppSpacing.sm, ), - child: Icon( - Icons.place_outlined, - size: 20, - color: colors.primary, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.place, + color: Colors.white, + size: 22, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + if (forecast != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + l10n.typhoonForecastLead( + forecast.tau.toString(), + ), + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ), + IconButton( + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + icon: const Icon(Icons.close, size: 18), + onPressed: layer.clearForecastSelection, + ), + ], ), ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), + if (forecast != null) ...[ + const _WaypointDivider(), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.md, + ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - if (forecast != null) ...[ - const SizedBox(height: AppSpacing.xs), - Text( - l10n.typhoonForecastLead(forecast.tau.toString()), - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, + if (forecast.pressure != null) + _WaypointRow( + icon: Icons.compress_outlined, + label: l10n.typhoonLabelPressure, + value: l10n.typhoonValueHpa( + forecast.pressure!.toStringAsFixed(0), ), ), - if (forecast.pressure != null) - Text( - '${l10n.typhoonLabelPressure} ' - '${l10n.typhoonValueHpa(forecast.pressure!.toStringAsFixed(0))}', - style: theme.textTheme.bodySmall, - ), - if (forecast.wind != null) - Text( - '${l10n.typhoonLabelWind} ' - '${l10n.typhoonValueMs(forecast.wind!.toStringAsFixed(0))}', - style: theme.textTheme.bodySmall, - ), - if (forecast.gust != null) - Text( - '${l10n.typhoonLabelGust} ' - '${l10n.typhoonValueMs(forecast.gust!.toStringAsFixed(0))}', - style: theme.textTheme.bodySmall, + if (forecast.wind != null) + _WaypointRow( + icon: Icons.air_outlined, + label: l10n.typhoonLabelWind, + value: l10n.typhoonValueMs( + forecast.wind!.toStringAsFixed(0), ), - if (forecast.speed != null) - Text( - '${l10n.typhoonLabelSpeed} ' - '${l10n.typhoonValueKm(forecast.speed!.toStringAsFixed(0))}', - style: theme.textTheme.bodySmall, + ), + if (forecast.gust != null) + _WaypointRow( + icon: Icons.air, + label: l10n.typhoonLabelGust, + value: l10n.typhoonValueMs( + forecast.gust!.toStringAsFixed(0), ), - if (dirLabel != null && dirLabel.isNotEmpty) - Text( - '${l10n.typhoonLabelDirection} $dirLabel', - style: theme.textTheme.bodySmall, + ), + if (forecast.speed != null) + _WaypointRow( + icon: Icons.navigation_outlined, + label: l10n.typhoonLabelSpeed, + value: l10n.typhoonValueKm( + forecast.speed!.toStringAsFixed(0), ), - if (forecast.r15 != null) - Text( - '${l10n.typhoonLabelGaleAvg} ' - '${l10n.typhoonValueKm(forecast.r15!.toStringAsFixed(0))}', - style: theme.textTheme.bodySmall, + ), + if (dirLabel != null && dirLabel.isNotEmpty) + _WaypointRow( + icon: Icons.explore_outlined, + label: l10n.typhoonLabelDirection, + value: dirLabel, + ), + if (forecast.r15 != null || forecast.r70 != null) + const _WaypointDivider(indented: true), + if (forecast.r15 != null) + _WaypointRow( + icon: Icons.radio_button_unchecked, + label: l10n.typhoonLabelGaleAvg, + value: l10n.typhoonValueKm( + forecast.r15!.toStringAsFixed(0), ), - if (forecast.r70 != null) - Text( - '${l10n.typhoonLabelProbCircle} ' - '${l10n.typhoonValueKm(forecast.r70!.toStringAsFixed(0))}', - style: theme.textTheme.bodySmall, + ), + if (forecast.r70 != null) + _WaypointRow( + icon: Icons.radio_button_checked, + label: l10n.typhoonLabelProbCircle, + value: l10n.typhoonValueKm( + forecast.r70!.toStringAsFixed(0), ), - if (stateNote != null && stateNote.isNotEmpty) - Text(stateNote, style: theme.textTheme.bodySmall), + ), + if (stateNote != null && stateNote.isNotEmpty) ...[ + const _WaypointDivider(indented: true), + _WaypointNote(text: stateNote), ], ], ), ), - ), - IconButton( - icon: const Icon(Icons.close, size: 18), - onPressed: layer.clearForecastSelection, - ), + ], ], ), ), @@ -1121,3 +1159,120 @@ class _TappedWaypoint extends StatelessWidget { ); } } + +/// A thin rule grouping the waypoint rows; [indented] aligns it under the text +/// column (right of the leading icon), so the groups read as subdivisions. +class _WaypointDivider extends StatelessWidget { + const _WaypointDivider({this.indented = false}); + + final bool indented; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: indented ? AppSpacing.lg + AppSpacing.md : 0, + top: AppSpacing.sm, + bottom: AppSpacing.sm, + ), + child: Divider( + height: 1, + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ); + } +} + +/// Label | value row for a forecast metric — same icon hierarchy as the DPM +/// sheet: small grey caption over a weighted value, scanning by picture. +class _WaypointRow extends StatelessWidget { + const _WaypointRow({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(icon, size: 18, color: colors.onSurfaceVariant), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Free-form note (the CWA forecast-state sentence) under the metric rows. +class _WaypointNote extends StatelessWidget { + const _WaypointNote({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 18, + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + text, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/map/presentation/widgets/wind_particle_overlay.dart b/lib/features/map/presentation/widgets/wind_particle_overlay.dart new file mode 100644 index 000000000..609dfe66d --- /dev/null +++ b/lib/features/map/presentation/widgets/wind_particle_overlay.dart @@ -0,0 +1,272 @@ +/// The wind-forecast particle animation — a Flutter overlay that advects a +/// cloud of particles through the frame's wind field, mirroring the web demo's +/// GPU pass on the CPU. +library; + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:dpip/features/map/presentation/layers/wind_forecast_layer.dart'; +import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +/// Renders the ECMWF / GFS wind field as flowing streaks, not just a colour +/// wash. +/// +/// The map's overlay slot gives the layer a plain widget; this one owns the +/// animation — a [Ticker] steps the simulation every frame and a repaint +/// notifier redraws the [CustomPainter] — while the geometry (projection, +/// advection, recycling) lives in [WindParticleSim] where it is testable +/// without a widget tree. +/// +/// The camera comes from the live map controller every tick, so the streaks +/// track pan/zoom/rotate exactly; the trail buffer is screen-space, so it is +/// dropped on any camera change — a streak drawn for one view is wrong for the +/// next, and the web renderer's alternative is to smear the old one across the +/// pan. The ticker only runs while a wind field is loaded, so an empty layer +/// costs nothing. +class WindParticleOverlay extends StatefulWidget { + const WindParticleOverlay({super.key, required this.layer}); + + final WindForecastMapLayer layer; + + @override + State createState() => _WindParticleOverlayState(); +} + +class _WindParticleOverlayState extends State + with SingleTickerProviderStateMixin { + /// Marks the painter for redraw each frame without rebuilding the widget + /// tree — the per-frame path of this overlay. + final _OverlayRepaint _repaint = _OverlayRepaint(); + + late final Ticker _ticker = createTicker(_onTick); + + WindParticleSim? _sim; + WindCamera? _lastCamera; + final _TrailBuffer _trails = _TrailBuffer(); + + @override + void initState() { + super.initState(); + widget.layer.field.addListener(_onField); + // Straight assignment: this runs inside the first build, where asking for + // another one throws. + _adoptField(); + } + + /// The layer loaded a new field (frame scrub or first show) — swap the + /// simulation and start (or stop) the animation accordingly. + /// + /// This has to rebuild, because [_WindParticlePainter] is handed the + /// simulation by value when [build] runs. The field arrives asynchronously + /// and is therefore still null at [initState], so a painter built then holds + /// null and paints nothing however busily the ticker steps behind it. What + /// broke the illusion was that the first pan repainted the map surface and + /// rebuilt this overlay with it — so the animation appeared to need a nudge + /// to start, when what it needed was a build. + void _onField() { + if (!mounted) return; + setState(_adoptField); + } + + void _adoptField() { + final field = widget.layer.field.value; + _sim = field == null ? null : WindParticleSim(field); + // A new frame's streaks must not grow out of the previous frame's. + _trails.clear(); + if (field == null) { + _ticker.stop(); + } else if (!_ticker.isActive) { + _ticker.start(); + } + } + + void _onTick(Duration _) { + final controller = widget.layer.mapController; + final sim = _sim; + if (!mounted || controller == null || sim == null) return; + final position = controller.cameraPosition; + final size = context.size; + if (position == null || size == null || size.isEmpty) return; + + final camera = WindCamera( + centerLat: position.target.latitude, + centerLng: position.target.longitude, + zoom: position.zoom, + bearing: position.bearing, + ); + final previous = _lastCamera; + if (previous != null && previous != camera) { + _trails.clear(); + } + _lastCamera = camera; + + // The painter needs these at paint time and the widget does not rebuild + // when the map zooms, so they travel on the buffer rather than as props. + _trails.zoom = camera.zoom; + _trails.devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + + sim.step(camera, size); + _repaint.mark(); + } + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WindParticlePainter( + sim: _sim, + trails: _trails, + repaint: _repaint, + ), + size: Size.infinite, + ); + } + + @override + void dispose() { + widget.layer.field.removeListener(_onField); + _ticker.dispose(); + _repaint.dispose(); + _trails.dispose(); + super.dispose(); + } +} + +/// The screen-space accumulation buffer the streaks live in. +/// +/// This is the whole trail mechanism, and it is the web renderer's: nothing +/// records where a particle has been. Each frame the previous picture is drawn +/// back at [fadeOpacity] and the current dots are stamped on top, so a streak +/// is the exponential decay of the dots that came before it. The cost is one +/// composite per frame regardless of how long the streaks are — which is what +/// makes six thousand particles affordable, where remembering fifty positions +/// each would be three hundred thousand line segments a frame. +/// +/// Held by the [State] rather than the painter, because a painter is rebuilt +/// whenever the widget is and the buffer has to survive that. +class _TrailBuffer { + ui.Image? _image; + + /// Speed buckets for [_stamp], reused across frames — the stamp path runs + /// once a frame, so allocating fresh lists for it (16 × up to 6400 points) + /// is garbage the collector pays for on the hottest path in the app. + final List> _buckets = List.generate( + 16, + (_) => [], + growable: false, + ); + + /// Live camera zoom and device pixel ratio, written by the ticker. The + /// painter needs both at paint time and neither is known at build time — + /// the widget does not rebuild when the map zooms. + double zoom = + 3; // the wind layer's own floor, until the ticker says otherwise + double devicePixelRatio = 1; + + void clear() { + _image?.dispose(); + _image = null; + } + + void dispose() => clear(); + + /// Fades what is there, stamps [particles] over it, and returns the result. + ui.Image advance(Iterable particles, Size size) { + final dpr = devicePixelRatio; + final w = math.max(1, (size.width * dpr).round()); + final h = math.max(1, (size.height * dpr).round()); + final previous = _image; + if (previous != null && (previous.width != w || previous.height != h)) { + clear(); + } + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final old = _image; + if (old != null) { + canvas.drawImage( + old, + Offset.zero, + Paint() + ..color = const Color( + 0xFFFFFFFF, + ).withValues(alpha: fadeOpacityFor(zoom)), + ); + } + _stamp(canvas, particles, dpr); + + final next = recorder.endRecording().toImageSync(w, h); + old?.dispose(); + _image = next; + return next; + } + + /// Stamps one dot per visible particle, in device pixels. + /// + /// The web gives every point its own alpha from a fragment shader; a + /// [Canvas] carries one colour per call, so the field is bucketed by speed + /// and drawn a bucket at a time. Sixteen steps across an alpha range of 0.55 + /// is a third of a level apart in 8-bit terms — below what the fade's own + /// rounding does to it anyway. + void _stamp(Canvas canvas, Iterable particles, double dpr) { + const buckets = 16; + final points = _buckets; + for (final bucket in points) { + bucket.clear(); + } + for (final p in particles) { + final screen = p.screen; + if (screen == null) continue; + final t = (p.speed / kWindSpeedScale).clamp(0.0, 1.0); + points[math.min(buckets - 1, (t * buckets).floor())].add(screen * dpr); + } + final paint = Paint() + ..strokeWidth = pointSizeFor(zoom) * dpr + ..strokeCap = StrokeCap.round; + for (var i = 0; i < buckets; i++) { + if (points[i].isEmpty) continue; + // The bucket's midpoint, on the web's ramp: brighter where it is faster. + final t = (i + 0.5) / buckets; + paint.color = Color.fromRGBO(255, 255, 255, 0.35 + 0.55 * t); + canvas.drawPoints(ui.PointMode.points, points[i], paint); + } + } +} + +/// Composites the trail buffer onto the map. +class _WindParticlePainter extends CustomPainter { + _WindParticlePainter({ + required this.sim, + required this.trails, + required Listenable repaint, + }) : super(repaint: repaint); + + final WindParticleSim? sim; + final _TrailBuffer trails; + + @override + void paint(Canvas canvas, Size size) { + final sim = this.sim; + if (sim == null || size.isEmpty) return; + final image = trails.advance(sim.particles, size); + canvas.drawImageRect( + image, + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + Offset.zero & size, + Paint()..filterQuality = FilterQuality.none, + ); + } + + @override + bool shouldRepaint(covariant _WindParticlePainter oldDelegate) => + oldDelegate.sim != sim; +} + +/// A [ChangeNotifier] whose only job is to mark the painter dirty — a private +/// subclass so the state can call [ChangeNotifier.notifyListeners] without the +/// "visible for testing" lint. +class _OverlayRepaint extends ChangeNotifier { + void mark() => notifyListeners(); +} diff --git a/lib/features/map/presentation/wind_speed.dart b/lib/features/map/presentation/wind_speed.dart new file mode 100644 index 000000000..c408976f3 --- /dev/null +++ b/lib/features/map/presentation/wind_speed.dart @@ -0,0 +1,83 @@ +/// The wind-speed colour ramp and arrow glyph, shared by the map arrows and +/// the station sheet charts — one threshold table so the legend, the map, and +/// the trend plot agree by construction. +library; + +import 'package:flutter/material.dart'; + +/// The five speed buckets, weakest first — the same thresholds / colours as the +/// legacy wind layer. +const windBuckets = <(double threshold, Color color)>[ + (0, Color(0xFFFFFFFF)), // 白 — calm + (3.4, Color(0xFF00FFF0)), // 青 + (8.0, Color(0xFF0085FF)), // 藍 + (13.9, Color(0xFF8000FF)), // 紫 + (32.7, Color(0xFFFF006B)), // 粉 — strongest +]; + +/// Bucket index for [value] (0 = calm … 4 = strongest). +int windSpeedBucket(double value) { + for (var i = windBuckets.length - 1; i >= 0; i--) { + if (value >= windBuckets[i].$1) return i; + } + return 0; +} + +/// The discrete ramp colour for [value]. +Color windSpeedColor(double value) => windBuckets[windSpeedBucket(value)].$2; + +/// The 8 offset directions (cardinal + diagonal) that bake a black outline +/// around the arrow glyph — used by the pre-rendered map PNGs +/// ([WindMapLayer._renderArrow]) and the Flutter [WindArrowIcon], so both +/// silhouettes share the same edge. +const windOutlineDirs = <(double, double)>[ + (1, 0), + (-1, 0), + (0, 1), + (0, -1), + (0.7071, 0.7071), + (0.7071, -0.7071), + (-0.7071, 0.7071), + (-0.7071, -0.7071), +]; + +/// A [Icons.navigation] glyph with a black offset-outline baked on, tinted +/// [color] — the same silhouette as the map arrows, so a calm white arrow +/// stays readable on pale cards. +class WindArrowIcon extends StatelessWidget { + const WindArrowIcon({ + super.key, + required this.color, + this.size = 12, + this.outline = 1, + }); + + final Color color; + + /// Glyph size in logical pixels (the box grows by the outline so it isn't + /// clipped by the rotation). + final double size; + + /// Outline offset distance, in logical pixels. + final double outline; + + @override + Widget build(BuildContext context) { + final pad = outline + 1; + return SizedBox( + width: size + pad * 2, + height: size + pad * 2, + child: Stack( + alignment: Alignment.center, + children: [ + for (final (dx, dy) in windOutlineDirs) + Transform.translate( + offset: Offset(dx * outline, dy * outline), + child: Icon(Icons.navigation, size: size, color: Colors.black), + ), + Icon(Icons.navigation, size: size, color: color), + ], + ), + ); + } +} diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 63f9f4fab..8003836d7 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -3,6 +3,7 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; +import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/default_map_layer_ui.dart'; @@ -79,11 +80,14 @@ class MorePage extends StatelessWidget { SectionHeader(l10n.moreSectionAdvanced), _MoreGroup( children: [ - _MoreTile( - icon: Icons.science_outlined, - title: l10n.experimentalFeatures, - onTap: () => context.pushNamed(AppRoutes.experimental), - ), + // Hidden until ten taps on the Developer page's version row + // (ExperimentalSettings.unlocked). + if (context.watch().unlocked) + _MoreTile( + icon: Icons.science_outlined, + title: l10n.experimentalFeatures, + onTap: () => context.pushNamed(AppRoutes.experimental), + ), _MoreTile( icon: Icons.history_outlined, title: l10n.changelogTitle, diff --git a/lib/features/notification/data/notify_api.dart b/lib/features/notification/data/notify_api.dart index d72e5a218..e3df1f269 100644 --- a/lib/features/notification/data/notify_api.dart +++ b/lib/features/notification/data/notify_api.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/api_region.dart'; /// The `notify` endpoints, migrated from the legacy `api-1` host to @@ -16,7 +17,7 @@ class NotifyApi { /// /// `GET https://api.core-tnn1.exptech.dev/api/v2/notify/{token}` Future> getNotify(String token) async => - (await _client.get(ApiTier.coreExclusiveApi, '/api/v2/notify/$token')) + (await _client.get(ApiTier.coreExclusiveApi, '${ApiPaths.notify}$token')) as List; /// Sets one [channel] to option [status]; the server returns the full updated @@ -30,7 +31,7 @@ class NotifyApi { ) async => (await _client.get( ApiTier.coreExclusiveApi, - '/api/v2/notify/$token/$channel/$status', + '${ApiPaths.notify}$token/$channel/$status', )) as List; } diff --git a/lib/features/notification/presentation/pages/notify_page.dart b/lib/features/notification/presentation/pages/notify_page.dart index c45ab6c93..673550021 100644 --- a/lib/features/notification/presentation/pages/notify_page.dart +++ b/lib/features/notification/presentation/pages/notify_page.dart @@ -137,11 +137,7 @@ class _ChannelTile extends StatelessWidget { title: Text(notifyChannelTitle(channel, l10n)), subtitle: Text(notifyOptionLabel(settings.kindOf(channel), l10n)), trailing: saving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const InlineLoading(size: 20) : const Icon(Icons.chevron_right), enabled: !busy, onTap: () => _edit(context), diff --git a/lib/features/settings/presentation/pages/default_map_layer_page.dart b/lib/features/settings/presentation/pages/default_map_layer_page.dart index 6358df536..5fa404757 100644 --- a/lib/features/settings/presentation/pages/default_map_layer_page.dart +++ b/lib/features/settings/presentation/pages/default_map_layer_page.dart @@ -6,6 +6,8 @@ import 'package:dpip/core/settings/default_map_layer.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/default_map_layer_ui.dart'; +import 'package:dpip/shared/map/map_layer_category.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -41,18 +43,22 @@ class DefaultMapLayerPage extends StatelessWidget { ), ), ), - for (final layer in DefaultMapLayer.values) - ListTile( - leading: Icon(layer.icon), - title: Text(layer.label(l10n)), - trailing: current == layer - ? Icon( - Icons.check, - color: Theme.of(context).colorScheme.primary, - ) - : null, - onTap: () => controller.setLayer(layer), - ), + for (final category in MapLayerCategory.values) ...[ + SectionHeader(categoryLabel(category, l10n)), + for (final layer in DefaultMapLayer.values) + if (categoryOf(layer.id) == category) + ListTile( + leading: Icon(layer.icon), + title: Text(layer.label(l10n)), + trailing: current == layer + ? Icon( + Icons.check, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: () => controller.setLayer(layer), + ), + ], ], ), ); diff --git a/lib/features/settings/presentation/pages/developer_page.dart b/lib/features/settings/presentation/pages/developer_page.dart index e8e0f6751..d0290fc42 100644 --- a/lib/features/settings/presentation/pages/developer_page.dart +++ b/lib/features/settings/presentation/pages/developer_page.dart @@ -17,6 +17,8 @@ import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; import 'package:dpip/core/notifications/notification_service.dart'; import 'package:dpip/core/platform/device_info.dart'; +import 'package:dpip/core/settings/experimental_settings.dart'; +import 'package:dpip/features/settings/presentation/widgets/network_usage_chart.dart'; import 'package:dpip/shared/map/map_tile_cache.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:dpip/shared/widgets/section_header.dart'; @@ -61,8 +63,15 @@ class DeveloperPage extends StatefulWidget { class _DeveloperPageState extends State { List<({String title, List<_Field> fields})>? _sections; + List? _usageHistory; + List? _usageWeek; bool _clearing = false; + /// Version-row taps toward the experimental unlock. Deliberately not + /// persisted — a fresh app start re-arms the easter egg. + static const int _unlockVersionTaps = 10; + int _versionTaps = 0; + @override void initState() { super.initState(); @@ -78,6 +87,11 @@ class _DeveloperPageState extends State { final info = await PackageInfo.fromPlatform(); final cacheStats = await etagCache?.stats(); final usage = await networkUsage?.stats(); + final usageHistory = await networkUsage?.history(); + final usageWeek = await networkUsage?.history( + hours: 24 * 7, + bucketHours: 6, + ); final device = await DeviceInfoService.load(); // Track the build by the git commit it was built from (kGitCommit is kept // current by the .githooks generator — see tool/setup.sh), falling back to @@ -172,7 +186,13 @@ class _DeveloperPageState extends State { ], ), ]; - if (mounted) setState(() => _sections = sections); + if (mounted) { + setState(() { + _sections = sections; + _usageHistory = usageHistory; + _usageWeek = usageWeek; + }); + } } String get _osName => Platform.isIOS @@ -278,6 +298,36 @@ class _DeveloperPageState extends State { ..showSnackBar(const SnackBar(content: Text('Copied to clipboard'))); } + /// Version-row taps: count to [_unlockVersionTaps], then unlock the + /// experimental-features menu (see `ExperimentalSettings.unlock`). Shows the + /// remaining count so the easter egg is discoverable, and confirms once it + /// flips. + void _onVersionTap() { + final settings = context.read(); + if (settings.unlocked) { + _showHint('Experimental features are already unlocked'); + return; + } + _versionTaps++; + final remaining = _unlockVersionTaps - _versionTaps; + if (remaining > 0) { + _showHint( + '$remaining more tap${remaining == 1 ? '' : 's'} to unlock ' + 'experimental features', + ); + return; + } + _versionTaps = 0; + settings.unlock(); + _showHint('Experimental features unlocked'); + } + + void _showHint(String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + void _copyAll() { final sections = _sections; if (sections == null) return; @@ -316,15 +366,23 @@ class _DeveloperPageState extends State { : ListView( padding: const EdgeInsets.only(bottom: AppSpacing.xl), children: [ - for (final section in sections) ...[ - SectionHeader(section.title), - for (final field in section.fields) + for (var i = 0; i < sections.length; i++) ...[ + SectionHeader(sections[i].title), + for (final field in sections[i].fields) _DiagRow( field: field, + onTap: field.label == 'Version' ? _onVersionTap : null, onCopy: _individuallyCopyableLabels.contains(field.label) ? _copy : null, ), + if (sections[i].title == 'Network usage' && + _usageHistory != null && + _usageWeek != null) + NetworkUsageChart( + history: _usageHistory!, + week: _usageWeek!, + ), ], const SectionHeader('Maintenance'), ListTile( @@ -341,12 +399,7 @@ class _DeveloperPageState extends State { subtitle: const Text( 'Removes stored tiles, API responses, and usage stats', ), - trailing: _clearing - ? const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : null, + trailing: _clearing ? const InlineLoading(size: 18) : null, onTap: _clearing ? null : _confirmClearCache, ), ], @@ -359,17 +412,21 @@ class _DeveloperPageState extends State { /// app-bar's "Copy all"); [onCopy] adds a per-row copy button for the few that /// are worth lifting out on their own (see [_DeveloperPageState._individuallyCopyableLabels]). class _DiagRow extends StatelessWidget { - const _DiagRow({required this.field, this.onCopy}); + const _DiagRow({required this.field, this.onCopy, this.onTap}); final _Field field; final ValueChanged? onCopy; + /// Tap handler — used by the version row to arm the experimental unlock. + final VoidCallback? onTap; + @override Widget build(BuildContext context) { final theme = Theme.of(context); final value = field.value; final hasValue = value != null && value.isNotEmpty; return ListTile( + onTap: onTap, title: Text(field.label, style: theme.textTheme.bodyMedium), subtitle: Text( hasValue ? value : '—', diff --git a/lib/features/settings/presentation/pages/experimental_page.dart b/lib/features/settings/presentation/pages/experimental_page.dart index cdcf506f6..4335c09b5 100644 --- a/lib/features/settings/presentation/pages/experimental_page.dart +++ b/lib/features/settings/presentation/pages/experimental_page.dart @@ -1,4 +1,6 @@ import 'package:dpip/core/settings/experimental_settings.dart'; +import 'package:dpip/core/weather/weather_icons.dart'; +import 'package:dpip/core/settings/sky_time_mode.dart'; import 'package:dpip/core/settings/weather_mode.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/section_header.dart'; @@ -7,8 +9,9 @@ import 'package:provider/provider.dart'; /// Experimental / in-progress features, reachable from the More tab. /// -/// Currently hosts the weather-animation override that drives the home -/// backdrop. +/// Hosts the two overrides that drive the home backdrop. They are separate +/// axes on purpose: the look is the product of the weather *and* where the +/// keyframe ring sits, so testing needs both without squaring the entries. class ExperimentalPage extends StatelessWidget { const ExperimentalPage({super.key}); @@ -33,6 +36,17 @@ class ExperimentalPage extends StatelessWidget { onTap: () => context.read().weatherMode = mode, ), + SectionHeader(l10n.skyTime), + for (final mode in SkyTimeMode.values) + ListTile( + leading: Icon(_timeIconFor(mode)), + title: Text(_timeLabelFor(mode, l10n)), + trailing: settings.skyTimeMode == mode + ? Icon(Icons.check, color: colors.primary) + : null, + onTap: () => + context.read().skyTimeMode = mode, + ), ], ), ); @@ -41,7 +55,11 @@ class ExperimentalPage extends StatelessWidget { IconData _iconFor(WeatherMode mode) => switch (mode) { WeatherMode.auto => Icons.auto_mode, WeatherMode.clear => Icons.wb_sunny_outlined, - WeatherMode.rain => Icons.water_drop_outlined, + WeatherMode.cloudy => Icons.filter_drama_outlined, + WeatherMode.overcast => Icons.cloud_outlined, + WeatherMode.rain => rainy, + WeatherMode.snow => Icons.ac_unit_outlined, + WeatherMode.sand => Icons.blur_on_outlined, WeatherMode.fog => Icons.foggy, WeatherMode.thunderstorm => Icons.thunderstorm_outlined, }; @@ -49,8 +67,39 @@ class ExperimentalPage extends StatelessWidget { String _labelFor(WeatherMode mode, AppLocalizations l10n) => switch (mode) { WeatherMode.auto => l10n.weatherModeAuto, WeatherMode.clear => l10n.weatherModeClear, + WeatherMode.cloudy => l10n.weatherModeCloudy, + WeatherMode.overcast => l10n.weatherModeOvercast, WeatherMode.rain => l10n.weatherModeRain, + WeatherMode.snow => l10n.weatherModeSnow, + WeatherMode.sand => l10n.weatherModeSand, WeatherMode.fog => l10n.weatherModeFog, WeatherMode.thunderstorm => l10n.weatherModeThunderstorm, }; + + IconData _timeIconFor(SkyTimeMode mode) => switch (mode) { + SkyTimeMode.auto => Icons.auto_mode, + SkyTimeMode.dawn => Icons.nights_stay_outlined, + SkyTimeMode.sunrise => Icons.wb_twilight_outlined, + SkyTimeMode.morning => Icons.wb_sunny_outlined, + SkyTimeMode.noon => Icons.light_mode_outlined, + SkyTimeMode.afternoon => Icons.wb_sunny_outlined, + SkyTimeMode.golden => Icons.wb_twilight_outlined, + SkyTimeMode.sunset => Icons.wb_twilight_outlined, + SkyTimeMode.dusk => Icons.nights_stay_outlined, + SkyTimeMode.night => Icons.dark_mode_outlined, + }; + + String _timeLabelFor(SkyTimeMode mode, AppLocalizations l10n) => + switch (mode) { + SkyTimeMode.auto => l10n.skyTimeAuto, + SkyTimeMode.dawn => l10n.skyTimeDawn, + SkyTimeMode.sunrise => l10n.skyTimeSunrise, + SkyTimeMode.morning => l10n.skyTimeMorning, + SkyTimeMode.noon => l10n.skyTimeNoon, + SkyTimeMode.afternoon => l10n.skyTimeAfternoon, + SkyTimeMode.golden => l10n.skyTimeGolden, + SkyTimeMode.sunset => l10n.skyTimeSunset, + SkyTimeMode.dusk => l10n.skyTimeDusk, + SkyTimeMode.night => l10n.skyTimeNight, + }; } diff --git a/lib/features/settings/presentation/widgets/network_usage_chart.dart b/lib/features/settings/presentation/widgets/network_usage_chart.dart new file mode 100644 index 000000000..6882e8d31 --- /dev/null +++ b/lib/features/settings/presentation/widgets/network_usage_chart.dart @@ -0,0 +1,358 @@ +/// Hourly downloaded-vs-saved trend for the Debug page's Network usage section. +library; + +import 'dart:math' as math; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/network/network_usage_store.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +/// Which series the chart plots: both stacked, or one on its own. +enum NetworkChartMode { + /// Download (red) and Saved (green) as stacked bars. + both, + + /// Download bars only. + download, + + /// Saved bars only. + saved, +} + +/// Which time window the chart shows. +enum NetworkChartWindow { + /// Trailing 24 hours, one point per hour. + day, + + /// Trailing 7 days, one point per 6 hours. + week, +} + +/// Trend for the Network usage section, switchable between the last 24 hours +/// (hourly) and the last 7 days (6-hour buckets). +/// +/// One bar per point: **red** is what the app actually downloaded, **green** +/// what the cache saved on top — so the shape of the day and the cache payoff +/// read together. A [SegmentedButton] switches the window (24h / 7d), another +/// the series (stacked, download-only, saved-only). The x-axis is labelled in +/// Taipei wall-clock (`11日13時`). +/// +/// Deliberately English-only, matching the Debug page's convention. +class NetworkUsageChart extends StatefulWidget { + const NetworkUsageChart({ + super.key, + required this.history, + required this.week, + }); + + /// Trailing 24 hours, one [HourUsage] per hour. + final List history; + + /// Trailing 7 days, one [HourUsage] per 6-hour bucket. + final List week; + + @override + State createState() => _NetworkUsageChartState(); +} + +class _NetworkUsageChartState extends State { + static const Color _downloadColor = Color(0xFFE53935); // red 600 + static const Color _savedColor = Color(0xFF66BB6A); // green 400 + + NetworkChartMode _mode = NetworkChartMode.both; + NetworkChartWindow _window = NetworkChartWindow.day; + + /// The samples the active window plots. + List get _samples => + _window == NetworkChartWindow.day ? widget.history : widget.week; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final samples = _samples; + final showDownload = + _mode == NetworkChartMode.both || _mode == NetworkChartMode.download; + final showSaved = + _mode == NetworkChartMode.both || _mode == NetworkChartMode.saved; + + // Scale the max to what this mode actually plots. + final maxBytes = samples.fold( + 0, + (m, h) => + math.max(m, (showDownload ? h.down : 0) + (showSaved ? h.saved : 0)), + ); + if (maxBytes == 0) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: Text( + 'No traffic recorded yet — use the app and come back.', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ); + } + + // Plot in the smaller unit that still fits the largest point, so a light + // usage day isn't a sub-one-MB sliver. + final (String unit, double divisor) = maxBytes >= 1024 * 1024 + ? ('MB', 1024 * 1024) + : ('KB', 1024.0); + final maxY = (maxBytes / divisor).ceilToDouble(); + final yInterval = maxY / 4; + + final groups = [ + for (var i = 0; i < samples.length; i++) + BarChartGroupData( + x: i, + barRods: [_rod(samples[i], divisor, showDownload, showSaved)], + ), + ]; + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.xs, + AppSpacing.lg, + AppSpacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showDownload) ...[ + _legendDot(_downloadColor), + const SizedBox(width: AppSpacing.sm), + const Text('Downloaded'), + ], + if (showDownload && showSaved) ...[ + const SizedBox(width: AppSpacing.md), + ], + if (showSaved) ...[ + _legendDot(_savedColor), + const SizedBox(width: AppSpacing.sm), + const Text('Saved'), + ], + ], + ), + const SizedBox(height: AppSpacing.sm), + _ModeToggle( + mode: _mode, + onChanged: (mode) => setState(() => _mode = mode), + ), + const SizedBox(height: AppSpacing.sm), + SegmentedButton( + segments: const [ + ButtonSegment(value: NetworkChartWindow.day, label: Text('24h')), + ButtonSegment(value: NetworkChartWindow.week, label: Text('7d')), + ], + selected: {_window}, + onSelectionChanged: (selection) => + setState(() => _window = selection.first), + showSelectedIcon: false, + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: 150, + child: BarChart( + BarChartData( + maxY: maxY, + alignment: BarChartAlignment.spaceBetween, + groupsSpace: _window == NetworkChartWindow.day ? 2 : 6, + barGroups: groups, + gridData: FlGridData( + drawVerticalLine: false, + horizontalInterval: yInterval, + getDrawingHorizontalLine: (_) => FlLine( + color: colors.outlineVariant.withValues(alpha: 0.4), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: yInterval, + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + value == 0 ? '0' : value.toStringAsFixed(0), + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 22, + // BarChart's x-axis is discrete slots, so `interval` is + // ignored and getTitlesWidget runs for every bar — the + // step has to be applied here. A full-width label + // (11日13時 ≈ 66 px) is wider than a 4-bar gap (~52 px on + // a phone), so labels sit every 6 h (24h) / 42 h (7d). + getTitlesWidget: (value, meta) { + final index = value.toInt(); + final step = _window == NetworkChartWindow.day ? 6 : 7; + if (index < 0 || + index >= samples.length || + index % step != 0) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + meta: meta, + child: Text( + _taipeiLabel(samples[index].hour), + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ); + }, + ), + ), + ), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipColor: (_) => colors.surfaceContainerHigh, + tooltipBorderRadius: BorderRadius.circular(8), + tooltipPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + tooltipMargin: AppSpacing.sm, + // Edge bars sit under the screen edge — clamp the bubble + // inside the chart so it never spills off the phone. + maxContentWidth: 200, + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final sample = samples[group.x]; + final lines = StringBuffer(_taipeiLabel(sample.hour)); + if (showDownload) { + lines.writeln( + '↓ ${_formatNumber(sample.down / divisor)} $unit', + ); + } + if (showSaved) { + lines.writeln( + 'Saved ${_formatNumber(sample.saved / divisor)} $unit', + ); + } + return BarTooltipItem( + lines.toString().trimRight(), + theme.textTheme.labelSmall!.copyWith( + color: colors.onSurface, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + }, + ), + ), + ), + ), + ), + ], + ), + ); + } + + BarChartRodData _rod( + HourUsage sample, + double divisor, + bool showDownload, + bool showSaved, + ) { + final down = sample.down / divisor; + final saved = sample.saved / divisor; + return BarChartRodData( + toY: (showDownload ? down : 0) + (showSaved ? saved : 0), + width: _window == NetworkChartWindow.day ? 6 : 8, + borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), + rodStackItems: switch ((showDownload, showSaved)) { + (true, true) => [ + BarChartRodStackItem(0, down, _downloadColor), + BarChartRodStackItem(down, down + saved, _savedColor), + ], + (true, false) => [BarChartRodStackItem(0, down, _downloadColor)], + (false, true) => [BarChartRodStackItem(0, saved, _savedColor)], + (false, false) => const [], + }, + ); + } + + static Widget _legendDot(Color color) => Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + + static String _formatNumber(double value) { + if (value == value.roundToDouble()) return value.toInt().toString(); + return value.toStringAsFixed(1); + } + + /// Taipei wall-clock label of a UTC epoch hour, e.g. `11日13時`. + static String _taipeiLabel(int utcHour) { + final time = AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch(utcHour * 3600000, isUtc: true), + ); + // l10n-ignore: owner-specified zh-TW time format on the English-only debug page. + return '${time.day}日${time.hour}時'; + } +} + +/// The series selector — stacked, download-only, or saved-only. +class _ModeToggle extends StatelessWidget { + const _ModeToggle({required this.mode, required this.onChanged}); + + final NetworkChartMode mode; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: const [ + ButtonSegment(value: NetworkChartMode.both, label: Text('Both')), + ButtonSegment( + value: NetworkChartMode.download, + label: Text('Download'), + ), + ButtonSegment(value: NetworkChartMode.saved, label: Text('Saved')), + ], + selected: {mode}, + onSelectionChanged: (selection) => onChanged(selection.first), + showSelectedIcon: false, + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } +} diff --git a/lib/features/sponsor/presentation/pages/sponsor_page.dart b/lib/features/sponsor/presentation/pages/sponsor_page.dart index 34f495f82..145d41e65 100644 --- a/lib/features/sponsor/presentation/pages/sponsor_page.dart +++ b/lib/features/sponsor/presentation/pages/sponsor_page.dart @@ -294,11 +294,7 @@ class _Trailing extends StatelessWidget { return Icon(Icons.check_circle, color: colors.primary); } if (purchasing) { - return const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ); + return const InlineLoading(size: 20); } return Container( padding: const EdgeInsets.symmetric( diff --git a/lib/features/typhoon/domain/typhoon_decode.dart b/lib/features/typhoon/domain/typhoon_decode.dart new file mode 100644 index 000000000..6c8247eeb --- /dev/null +++ b/lib/features/typhoon/domain/typhoon_decode.dart @@ -0,0 +1,20 @@ +/// Shared decode helpers for the typhoon feed payloads. +library; + +/// Decodes the `{ updated, cyclones: [...] }` shape shared by the potential / +/// probability / warning endpoints: a missing or malformed `cyclones` degrades +/// to an empty list rather than throwing, and `updated` falls back to 0. +T decodeCyclonesPayload( + Map json, + T Function(int updated, List raw) build, +) { + final updated = (json['updated'] as num?)?.toInt() ?? 0; + final raw = json['cyclones']; + return build(updated, raw is List ? raw : const []); +} + +/// Trims [value]; a blank result becomes null (the API's empty-string sentinel). +String? trimToNull(String? value) { + final t = value?.trim(); + return (t == null || t.isEmpty) ? null : t; +} diff --git a/lib/features/typhoon/domain/typhoon_overlay.dart b/lib/features/typhoon/domain/typhoon_overlay.dart index a679c81cb..dcd7091ed 100644 --- a/lib/features/typhoon/domain/typhoon_overlay.dart +++ b/lib/features/typhoon/domain/typhoon_overlay.dart @@ -16,6 +16,7 @@ library; import 'dart:math' as math; +import 'package:dpip/core/geo/geo_math.dart'; import 'package:dpip/core/models/lat_lng.dart'; import 'package:dpip/features/typhoon/domain/cyclone_identity.dart'; import 'package:dpip/features/typhoon/domain/storm_circle.dart'; @@ -401,9 +402,9 @@ List _convexHull(List points) { LatLng _destination(LatLng from, double bearingDeg, double distanceKm) { const earthKm = 6371.0; final angular = distanceKm / earthKm; - final bearing = bearingDeg * math.pi / 180; - final lat1 = from.latitude * math.pi / 180; - final lon1 = from.longitude * math.pi / 180; + final bearing = degToRad(bearingDeg); + final lat1 = degToRad(from.latitude); + final lon1 = degToRad(from.longitude); final sinLat1 = math.sin(lat1); final cosLat1 = math.cos(lat1); final sinAng = math.sin(angular); diff --git a/lib/features/typhoon/domain/typhoon_potential.dart b/lib/features/typhoon/domain/typhoon_potential.dart index 38acb87e4..cc3393a6b 100644 --- a/lib/features/typhoon/domain/typhoon_potential.dart +++ b/lib/features/typhoon/domain/typhoon_potential.dart @@ -3,6 +3,7 @@ library; import 'package:dpip/core/models/lat_lng.dart'; +import 'package:dpip/features/typhoon/domain/typhoon_decode.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'typhoon_potential.freezed.dart'; @@ -59,15 +60,17 @@ abstract class TyphoonPotential with _$TyphoonPotential { /// Decodes one cyclone object, mapping every `[lng, lat]` pair to a [LatLng]. factory TyphoonPotential.decode(Map json) => TyphoonPotential( - tdNo: _blankToNull(json['tdNo'] as String?), - name: _blankToNull(json['name'] as String?), - past: _coords(json['past']), - forecast: _coords(json['forecast']), - cone: _coords(json['cone']), - circle: json['circle'] == null ? null : _coords(json['circle']), + tdNo: trimToNull(json['tdNo'] as String?), + name: trimToNull(json['name'] as String?), + past: latLngsFromPairs(json['past']), + forecast: latLngsFromPairs(json['forecast']), + cone: latLngsFromPairs(json['cone']), + circle: json['circle'] == null + ? null + : latLngsFromPairs(json['circle']), current: json['current'] == null ? null - : _coord(json['current'] as List), + : latLngFromPair(json['current'] as List), points: [ for (final p in (json['points'] as List? ?? const [])) ForecastPoint.fromJson(p as Map), @@ -84,32 +87,15 @@ abstract class PotentialPayload with _$PotentialPayload { }) = _PotentialPayload; /// Decodes `{ updated, cyclones: [...] }`. Missing/empty `cyclones` → []. - factory PotentialPayload.decode(Map json) { - final updated = (json['updated'] as num?)?.toInt() ?? 0; - final raw = json['cyclones']; - if (raw is! List) { - return PotentialPayload(updated: updated, cyclones: const []); - } - return PotentialPayload( - updated: updated, - cyclones: [ - for (final c in raw) - if (c is Map) TyphoonPotential.decode(c), - ], - ); - } -} - -String? _blankToNull(String? value) { - final t = value?.trim(); - return (t == null || t.isEmpty) ? null : t; + factory PotentialPayload.decode(Map json) => + decodeCyclonesPayload( + json, + (updated, raw) => PotentialPayload( + updated: updated, + cyclones: [ + for (final c in raw) + if (c is Map) TyphoonPotential.decode(c), + ], + ), + ); } - -/// Maps a single `[lng, lat]` pair to a [LatLng] (mind the GeoJSON axis order). -LatLng _coord(List pair) => - LatLng((pair[1] as num).toDouble(), (pair[0] as num).toDouble()); - -/// Maps a list of `[lng, lat]` pairs to [LatLng]s; empty/absent in → empty out. -List _coords(Object? raw) => [ - for (final p in (raw as List? ?? const [])) _coord(p as List), -]; diff --git a/lib/features/typhoon/domain/typhoon_probability.dart b/lib/features/typhoon/domain/typhoon_probability.dart index 5df7713ff..07a1c1c7d 100644 --- a/lib/features/typhoon/domain/typhoon_probability.dart +++ b/lib/features/typhoon/domain/typhoon_probability.dart @@ -4,6 +4,7 @@ library; import 'package:dpip/core/models/lat_lng.dart'; +import 'package:dpip/features/typhoon/domain/typhoon_decode.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'typhoon_probability.freezed.dart'; @@ -25,7 +26,7 @@ abstract class ProbabilityLevel with _$ProbabilityLevel { factory ProbabilityLevel.decode(Map json) => ProbabilityLevel( p: (json['p'] as num).toInt(), - coords: _coords(json['coords']), + coords: latLngsFromPairs(json['coords']), ); } @@ -40,7 +41,7 @@ abstract class CycloneProbability with _$CycloneProbability { factory CycloneProbability.decode(Map json) => CycloneProbability( - tdNo: _blankToNull(json['tdNo'] as String?), + tdNo: trimToNull(json['tdNo'] as String?), levels: [ for (final level in (json['levels'] as List? ?? const [])) ProbabilityLevel.decode(level as Map), @@ -57,30 +58,15 @@ abstract class TyphoonProbability with _$TyphoonProbability { }) = _TyphoonProbability; /// Decodes `{ updated, cyclones: [{ tdNo, levels }] }`. - factory TyphoonProbability.decode(Map json) { - final updated = (json['updated'] as num?)?.toInt() ?? 0; - final raw = json['cyclones']; - if (raw is! List) { - return TyphoonProbability(updated: updated, cyclones: const []); - } - return TyphoonProbability( - updated: updated, - cyclones: [ - for (final c in raw) - if (c is Map) CycloneProbability.decode(c), - ], - ); - } -} - -String? _blankToNull(String? value) { - final t = value?.trim(); - return (t == null || t.isEmpty) ? null : t; + factory TyphoonProbability.decode(Map json) => + decodeCyclonesPayload( + json, + (updated, raw) => TyphoonProbability( + updated: updated, + cyclones: [ + for (final c in raw) + if (c is Map) CycloneProbability.decode(c), + ], + ), + ); } - -/// Maps a list of `[lng, lat]` pairs to [LatLng]s (mind the GeoJSON axis order); -/// empty/absent in → empty out. -List _coords(Object? raw) => [ - for (final p in (raw as List? ?? const [])) - LatLng(((p as List)[1] as num).toDouble(), (p[0] as num).toDouble()), -]; diff --git a/lib/features/typhoon/domain/typhoon_warning.dart b/lib/features/typhoon/domain/typhoon_warning.dart index 5fedf73ee..43355ab75 100644 --- a/lib/features/typhoon/domain/typhoon_warning.dart +++ b/lib/features/typhoon/domain/typhoon_warning.dart @@ -2,6 +2,7 @@ /// (dataset 001). library; +import 'package:dpip/features/typhoon/domain/typhoon_decode.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'typhoon_warning.freezed.dart'; @@ -133,20 +134,15 @@ abstract class WarningPayload with _$WarningPayload { }) = _WarningPayload; /// Decodes `{ updated, cyclones: [...] }`. - factory WarningPayload.decode(Map json) { - final raw = json['cyclones']; - if (raw is! List) { - return WarningPayload( - updated: (json['updated'] as num?)?.toInt() ?? 0, - cyclones: const [], + factory WarningPayload.decode(Map json) => + decodeCyclonesPayload( + json, + (updated, raw) => WarningPayload( + updated: updated, + cyclones: [ + for (final c in raw) + if (c is Map) TyphoonWarning.fromJson(c), + ], + ), ); - } - return WarningPayload( - updated: (json['updated'] as num?)?.toInt() ?? 0, - cyclones: [ - for (final c in raw) - if (c is Map) TyphoonWarning.fromJson(c), - ], - ); - } } diff --git a/lib/features/weather/data/frame_tile_api.dart b/lib/features/weather/data/frame_tile_api.dart new file mode 100644 index 000000000..f6c7bc140 --- /dev/null +++ b/lib/features/weather/data/frame_tile_api.dart @@ -0,0 +1,94 @@ +/// Shared frame-keyed tile API for the v2 raster overlays (radar, satellite, +/// QPESUMS, wind forecast). +library; + +import 'dart:typed_data'; + +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_paths.dart'; +import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/core/network/meteor_decode.dart'; + +/// Tile endpoints for one v2 raster overlay — radar / satellite / QPESUMS / +/// wind all share the same shape, differing only in the URL path segment +/// ([path]). +/// +/// Everything is keyed by a Unix timestamp (seconds for radar/satellite, +/// milliseconds for QPESUMS); a frame id *is* the timestamp. The frame list is +/// delta-encoded (`[base, Δ, Δ, …]`) and served from `api.core-tnn1` with +/// ETag/304. Tiles are WebP on `static.core-tnn1` +/// ([ApiTier.coreStaticExclusive]); [tileUrl] feeds MapLibre. Prefetch warms +/// SQLite + ambient under the same origin URL. Caching is **ETag-only**. +class FrameTileApi { + FrameTileApi(this._client, this.path, {this.channel, this.style, this.model}); + + final ApiClient _client; + + /// The overlay's URL path segment: `radar`, `satellite`, `qpesums`, or + /// `wind`. + final String path; + + /// Optional `?channel=` for the satellite overlay — selects which band or + /// derived product a frame renders. Null keeps the server default (B13) and + /// the URL channel-free; radar / qpesums never set it. + final String? channel; + + /// Optional `?style=` for a **single-band** satellite channel — which colour + /// rendering to use (`gray` / `jma` / `bd`). Mutable because the map's style + /// menu switches it live (via `SatelliteRepository.setStyle`); named-product + /// channels ignore it. + String? style; + + /// Optional `?model=` for the wind forecast overlay — which numerical + /// weather prediction model (`gfs` / `ecmwf`) a frame renders. Null keeps the + /// URL model-free; radar / satellite / qpesums never set it. + final String? model; + + /// The v2 frame list lives on `api.core-tnn1` (no region failover). + static const ApiTier _listTier = ApiTier.coreExclusiveApi; + + /// v2 tiles live on `static.core-tnn1` (no region failover). + static const ApiTier _tileTier = ApiTier.coreStaticExclusive; + + /// Available frame timestamps, **newest first**. + /// + /// `https://api.core-tnn1.exptech.dev/api/v2/tiles//list[?channel=…]` + Future> getFrames() async => framesFromList( + await _client.get(_listTier, '${ApiPaths.tiles}/$path/list$_query') as List, + ); + + /// XYZ WebP raster tile URL template for a [frame] (a Unix timestamp). + /// + /// `https://static.core-tnn1.exptech.dev/api/v2/tiles///{z}/{x}/{y}.webp[?channel=…][&style=…]` + String tileUrl(String frame) => + '${_client.hostsFor(_tileTier).first}' + '${ApiPaths.tiles}/$path/$frame/{z}/{x}/{y}.webp$_query'; + + String get _query { + final channel = this.channel; + final style = this.style; + final model = this.model; + if (channel == null && style == null && model == null) return ''; + final parts = [ + if (channel != null) 'channel=$channel', + if (style != null) 'style=$style', + if (model != null) 'model=$model', + ]; + return '?${parts.join('&')}'; + } + + /// Restores the delta-encoded list `[base, Δ, Δ, …]` to absolute timestamps + /// and returns them **newest first** as strings (the frame id used by + /// [tileUrl]). Empty in → empty out. Exposed for unit testing. + static List framesFromList(List deltas) => [ + for (final v in MeteorDecode.deltaSeconds(deltas).reversed) v.toString(), + ]; + + /// The raw WND1 wind-field bytes for [frame] — the v1 endpoint parallels the + /// v2 tiles (`/api/v1/wind//.bin` on the same static host), + /// keyed by the model [tileUrl] renders. + Future fetchWindBin(String frame) async => (await _client.getBytes( + _tileTier, + '${ApiPaths.windV1}/$model/$frame.bin', + )).bytes; +} diff --git a/lib/features/weather/data/frame_tile_repository.dart b/lib/features/weather/data/frame_tile_repository.dart index 99062d7b1..96964c815 100644 --- a/lib/features/weather/data/frame_tile_repository.dart +++ b/lib/features/weather/data/frame_tile_repository.dart @@ -1,15 +1,26 @@ -/// Shared tile plumbing for the frame-keyed raster overlays (radar, satellite). +/// Shared tile plumbing for the frame-keyed raster overlays (radar, satellite, +/// QPESUMS). library; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/network/api_exception.dart'; +import 'package:dpip/core/network/api_paths.dart'; +import 'package:dpip/features/weather/data/frame_tile_api.dart'; +import 'package:dpip/features/weather/domain/qpesums_repository.dart'; +import 'package:dpip/features/weather/domain/radar_repository.dart'; +import 'package:dpip/features/weather/domain/satellite_repository.dart'; +import 'package:dpip/features/weather/domain/wind_field.dart'; +import 'package:dpip/features/weather/domain/wind_forecast_repository.dart'; import 'package:dpip/shared/map/map_tile_warmer.dart'; import 'package:dpip/shared/map/raster_frame_source.dart'; import 'package:flutter/foundation.dart'; /// Implements the tile half of [RasterFrameSource] once. /// -/// Radar and satellite differ only in endpoint and zoom ceiling: both serve -/// `…//{z}/{x}/{y}.webp`, so warming, abandoning, and releasing are -/// identical. Subclasses supply [frames], [tileUrl], and the two constants. +/// Radar, satellite, and QPESUMS differ only in endpoint and zoom ceiling: +/// all serve `…//{z}/{x}/{y}.webp`, so warming, abandoning, and +/// releasing are identical. Subclasses supply [frames], [tileUrl], and the two +/// constants. /// /// Every URL here is built from [tileUrl] itself rather than reassembled from /// parts — a warmed key that differs from what MapLibre requests by so much as @@ -30,6 +41,11 @@ abstract base class FrameTileRepository implements RasterFrameSource { @protected String get tilePathPrefix; + /// How close to native's mirror cap a fill warm fills to — a little under the + /// cap (native trims only beyond it), so the mirror stays full but never + /// churns. + static const double _fillTarget = 0.85; + @override Future warmFrameTiles({ required List frames, @@ -38,6 +54,7 @@ abstract base class FrameTileRepository implements RasterFrameSource { required double north, required double east, required double zoom, + bool fill = false, }) { if (frames.isEmpty) return Future.value(); final tiles = viewportTiles( @@ -49,14 +66,21 @@ abstract base class FrameTileRepository implements RasterFrameSource { maxZoom: maxZoom, ); if (tiles.isEmpty) return Future.value(); - return warmer.warmUrls([ - for (final frame in frames) - for (final tile in tiles) - tileUrl(frame) - .replaceFirst('{z}', '${tile.z}') - .replaceFirst('{x}', '${tile.x}') - .replaceFirst('{y}', '${tile.y}'), - ], logLabel: '$tilePathPrefix×${frames.length}'); + return warmer.warmUrls( + [ + // Frame order is preserved, so a fill warm injects nearest-the-finger + // frames' tiles first and only the most distant stay cold when the + // mirror fills. + for (final frame in frames) + for (final tile in tiles) + tileUrl(frame) + .replaceFirst('{z}', '${tile.z}') + .replaceFirst('{x}', '${tile.x}') + .replaceFirst('{y}', '${tile.y}'), + ], + logLabel: '$tilePathPrefix×${frames.length}', + fillUntil: fill ? _fillTarget : 0, + ); } @override @@ -69,3 +93,45 @@ abstract base class FrameTileRepository implements RasterFrameSource { /// The URL prefix every tile of [frame] shares — the template up to `{z}`. String _framePrefix(String frame) => tileUrl(frame).split('{z}').first; } + +/// [RasterFrameSource] for one v2 tile overlay, backed by [FrameTileApi]. +/// +/// The concrete overlay is selected by the API's [FrameTileApi.path]. The four +/// per-overlay domain interfaces are empty aliases of [RasterFrameSource], so a +/// single parameterised impl satisfies them all; the provider layer still +/// injects each overlay under its own interface. +final class FrameTileRepositoryImpl extends FrameTileRepository + implements + RadarRepository, + SatelliteRepository, + QpesumsRepository, + WindForecastRepository { + FrameTileRepositoryImpl(this._api, super.warmer, {this.maxZoom = 11}); + + final FrameTileApi _api; + + /// Highest zoom this overlay publishes tiles for. Radar / satellite / + /// QPESUMS reach 11; the 0.25° wind forecast grids stop at 7 (any deeper is + /// upsampled to nothing new). + @override + final int maxZoom; + + @override + String get tilePathPrefix => '${ApiPaths.tiles}/${_api.path}/'; + + @override + Future>> frames() => guardResult(_api.getFrames); + + @override + String tileUrl(String frame) => _api.tileUrl(frame); + + @override + Future> fetchWindField(String frame) => + guardResult(() async { + final bytes = await _api.fetchWindBin(frame); + return WindField.fromWnd1(bytes); + }); + + @override + void setStyle(String? style) => _api.style = style; +} diff --git a/lib/features/weather/data/meteor_lightning_api.dart b/lib/features/weather/data/meteor_lightning_api.dart deleted file mode 100644 index a807d4342..000000000 --- a/lib/features/weather/data/meteor_lightning_api.dart +++ /dev/null @@ -1,39 +0,0 @@ -/// v5 meteor **lightning** endpoints on the region-aware [ApiClient]. -library; - -import 'package:dpip/core/network/api_client.dart'; -import 'package:dpip/core/network/api_region.dart'; - -/// Lightning-strike snapshots — **API v5** (`/api/v5/meteor/lightning`). -/// -/// **Cache-split dual host** (both on `core-tnn1`): a history URL ending in a -/// 10-digit second is immutable and served from `static.core-tnn1` -/// ([ApiTier.coreStaticExclusive], 1-year cache); everything else (latest, list) -/// is served from `api.core-tnn1` ([ApiTier.coreExclusiveApi], 1-second cache -/// with ETag/304). Lightning has no station directory; returns raw decoded JSON, -/// and the repository aligns the parallel arrays into domain models. -class MeteorLightningApi { - const MeteorLightningApi(this._client); - - final ApiClient _client; - - /// Latest / list (mutable) live on `api.core-tnn1`. - static const ApiTier _api = ApiTier.coreExclusiveApi; - - /// Timestamped history (immutable) lives on `static.core-tnn1`. - static const ApiTier _static = ApiTier.coreStaticExclusive; - - static const String _base = '/api/v5/meteor/lightning'; - - /// Latest field-array snapshot of recent strikes. - Future> getLatest() async => - (await _client.get(_api, _base)) as Map; - - /// History time list, delta-encoded seconds (`[base, Δ, …]`). - Future> getList() async => - (await _client.get(_api, '$_base/list')) as List; - - /// Historical snapshot at [second] (10-digit Unix seconds) — from `static`. - Future> getAt(int second) async => - (await _client.get(_static, '$_base/$second')) as Map; -} diff --git a/lib/features/weather/data/meteor_lightning_repository_impl.dart b/lib/features/weather/data/meteor_lightning_repository_impl.dart index 9be92411d..932412012 100644 --- a/lib/features/weather/data/meteor_lightning_repository_impl.dart +++ b/lib/features/weather/data/meteor_lightning_repository_impl.dart @@ -1,30 +1,18 @@ -/// [MeteorLightningRepository] backed by [MeteorLightningApi]. +/// [MeteorLightningRepository] backed by [MeteorSnapshotApi]. library; -import 'package:dpip/core/error/result.dart'; -import 'package:dpip/core/network/api_exception.dart'; -import 'package:dpip/core/network/meteor_decode.dart'; -import 'package:dpip/features/weather/data/meteor_lightning_api.dart'; +import 'package:dpip/features/weather/data/meteor_snapshot_repository_impl.dart'; import 'package:dpip/features/weather/domain/lightning_snapshot.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; /// Maps the datasource's raw JSON to domain models, converting transport/decode /// errors to typed failures via [guardResult]. -class MeteorLightningRepositoryImpl implements MeteorLightningRepository { - const MeteorLightningRepositoryImpl(this._api); - - final MeteorLightningApi _api; - - @override - Future> latest() => - guardResult(() async => LightningSnapshot.decode(await _api.getLatest())); - - @override - Future>> history() => - guardResult(() async => MeteorDecode.deltaSeconds(await _api.getList())); +class MeteorLightningRepositoryImpl + extends MeteorSnapshotRepositoryImpl + implements MeteorLightningRepository { + const MeteorLightningRepositoryImpl(super.api); @override - Future> at(int second) => guardResult( - () async => LightningSnapshot.decode(await _api.getAt(second)), - ); + LightningSnapshot decodeSnapshot(Map json) => + LightningSnapshot.decode(json); } diff --git a/lib/features/weather/data/meteor_rain_repository_impl.dart b/lib/features/weather/data/meteor_rain_repository_impl.dart index 255135abe..7f5fad2c6 100644 --- a/lib/features/weather/data/meteor_rain_repository_impl.dart +++ b/lib/features/weather/data/meteor_rain_repository_impl.dart @@ -1,10 +1,10 @@ -/// [MeteorRainRepository] backed by [MeteorRainApi]. +/// [MeteorRainRepository] backed by [MeteorSnapshotApi]. library; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/network/api_exception.dart'; -import 'package:dpip/core/network/meteor_decode.dart'; -import 'package:dpip/features/weather/data/meteor_rain_api.dart'; +import 'package:dpip/features/weather/data/meteor_snapshot_repository_impl.dart'; +import 'package:dpip/features/weather/data/meteor_station_decode.dart'; import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; import 'package:dpip/features/weather/domain/rain_snapshot.dart'; import 'package:dpip/features/weather/domain/rain_trend.dart'; @@ -12,36 +12,20 @@ import 'package:dpip/features/weather/domain/weather_station.dart'; /// Maps the datasource's raw JSON to domain models, converting transport/decode /// errors to typed failures via [guardResult]. -class MeteorRainRepositoryImpl implements MeteorRainRepository { - const MeteorRainRepositoryImpl(this._api); - - final MeteorRainApi _api; - - @override - Future>> stations() => - guardResult(() async { - final raw = await _api.getStation(); - return raw.map( - (code, value) => MapEntry( - code, - WeatherStation.fromJson(value as Map), - ), - ); - }); +class MeteorRainRepositoryImpl + extends MeteorSnapshotRepositoryImpl + implements MeteorRainRepository { + const MeteorRainRepositoryImpl(super.api); @override - Future> latest() => - guardResult(() async => RainSnapshot.decode(await _api.getLatest())); + RainSnapshot decodeSnapshot(Map json) => + RainSnapshot.decode(json); @override - Future>> history() => - guardResult(() async => MeteorDecode.deltaSeconds(await _api.getList())); - - @override - Future> at(int second) => - guardResult(() async => RainSnapshot.decode(await _api.getAt(second))); + Future>> stations() => + fetchStations(api.getStation); @override Future> trend(String id, {String range = '24h'}) => - guardResult(() async => RainTrend.decode(await _api.getTrend(id, range))); + guardResult(() async => RainTrend.decode(await api.getTrend(id, range))); } diff --git a/lib/features/weather/data/meteor_rain_api.dart b/lib/features/weather/data/meteor_snapshot_api.dart similarity index 74% rename from lib/features/weather/data/meteor_rain_api.dart rename to lib/features/weather/data/meteor_snapshot_api.dart index 35cdfb013..81851623e 100644 --- a/lib/features/weather/data/meteor_rain_api.dart +++ b/lib/features/weather/data/meteor_snapshot_api.dart @@ -1,11 +1,13 @@ -/// v5 meteor **rain** endpoints on the region-aware [ApiClient]. +/// v5 meteor snapshot endpoints shared by the weather / rain / lightning +/// repositories on the region-aware [ApiClient]. library; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/network/api_region.dart'; -/// Rain station directory + accumulation snapshots + per-station trend — **API -/// v5** (`/api/v5/meteor/rain`). +/// The snapshot surface every meteor dataset shares: a station directory +/// (weather / rain only), latest + historical field-array snapshots, the delta- +/// encoded history time list, and a per-station trend (weather / rain only). /// /// **Cache-split dual host** (both on `core-tnn1`): a history URL ending in a /// 10-digit second is immutable and served from `static.core-tnn1` @@ -13,21 +15,22 @@ import 'package:dpip/core/network/api_region.dart'; /// trend, station) is served from `api.core-tnn1` ([ApiTier.coreExclusiveApi], /// 1-second cache with ETag/304). Returns raw decoded JSON; the repository /// decodes the field-arrays / delta-seconds into domain models. -class MeteorRainApi { - const MeteorRainApi(this._client); +class MeteorSnapshotApi { + const MeteorSnapshotApi(this._client, this._base); final ApiClient _client; + /// The dataset root path (e.g. `/api/v5/meteor/weather`). + final String _base; + /// Latest / list / directory / trend (mutable) live on `api.core-tnn1`. static const ApiTier _api = ApiTier.coreExclusiveApi; /// Timestamped history (immutable) lives on `static.core-tnn1`. static const ApiTier _static = ApiTier.coreStaticExclusive; - static const String _base = '/api/v5/meteor/rain'; - /// Static station directory `{ code: {n,c,t,alt,lat,lon} }` (ETag-cached) — - /// same shape as weather's, so the domain reuses `WeatherStation`. + /// same shape for weather and rain. Future> getStation() async => (await _client.get(_api, '$_base/station')) as Map; @@ -43,7 +46,7 @@ class MeteorRainApi { Future> getAt(int second) async => (await _client.get(_static, '$_base/$second')) as Map; - /// Per-station rainfall trend for [id] over [range] (`24h` | `7d`). + /// Per-station trend for [id] over [range] (`24h` | `7d`). Future> getTrend(String id, String range) async => (await _client.get(_api, '$_base/trend/$id', query: {'range': range})) as Map; diff --git a/lib/features/weather/data/meteor_snapshot_repository_impl.dart b/lib/features/weather/data/meteor_snapshot_repository_impl.dart new file mode 100644 index 000000000..dd3fd023d --- /dev/null +++ b/lib/features/weather/data/meteor_snapshot_repository_impl.dart @@ -0,0 +1,38 @@ +/// Base impl for the meteor snapshot repositories — the latest / history / at +/// trio is identical across weather, rain, and lightning; only the snapshot +/// type's decode differs. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/network/api_exception.dart'; +import 'package:dpip/features/weather/data/meteor_snapshot_api.dart'; +import 'package:dpip/features/weather/data/meteor_station_decode.dart'; +import 'package:flutter/foundation.dart'; + +/// Maps the datasource's raw JSON to [S], converting transport/decode errors to +/// typed failures via [guardResult]. Subclasses supply [decodeSnapshot] plus +/// any endpoints the dataset doesn't share (stations / trend). +abstract class MeteorSnapshotRepositoryImpl { + const MeteorSnapshotRepositoryImpl(this._api); + + final MeteorSnapshotApi _api; + + /// The shared snapshot API — for subclasses exposing the dataset's station + /// directory or trends. + @protected + MeteorSnapshotApi get api => _api; + + /// Decodes the raw field-array snapshot payload into the domain model. + S decodeSnapshot(Map json); + + /// The latest snapshot. + Future> latest() => + guardResult(() async => decodeSnapshot(await _api.getLatest())); + + /// Available snapshot times (Unix seconds, ascending); `Ok([])` when none. + Future>> history() => fetchHistory(_api.getList); + + /// The historical snapshot at [second]. + Future> at(int second) => + guardResult(() async => decodeSnapshot(await _api.getAt(second))); +} diff --git a/lib/features/weather/data/meteor_station_decode.dart b/lib/features/weather/data/meteor_station_decode.dart new file mode 100644 index 000000000..94c6c1d25 --- /dev/null +++ b/lib/features/weather/data/meteor_station_decode.dart @@ -0,0 +1,24 @@ +/// Guarded decodes shared by the meteor repo family (weather / rain / +/// lightning): the `code → station` map and the delta-seconds history list. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/network/api_exception.dart'; +import 'package:dpip/core/network/meteor_decode.dart'; +import 'package:dpip/features/weather/domain/weather_station.dart'; + +/// The station catalogue as `code → model`, from any meteor `getStation()`. +Future>> fetchStations( + Future> Function() fetch, +) => guardResult(() async { + final raw = await fetch(); + return raw.map( + (code, value) => + MapEntry(code, WeatherStation.fromJson(value as Map)), + ); +}); + +/// The delta-seconds history list, from any meteor `getList()`. +Future>> fetchHistory( + Future> Function() fetch, +) => guardResult(() async => MeteorDecode.deltaSeconds(await fetch())); diff --git a/lib/features/weather/data/meteor_weather_api.dart b/lib/features/weather/data/meteor_weather_api.dart index f83306263..04e6dd7ef 100644 --- a/lib/features/weather/data/meteor_weather_api.dart +++ b/lib/features/weather/data/meteor_weather_api.dart @@ -3,51 +3,24 @@ library; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/features/weather/data/meteor_snapshot_api.dart'; /// Weather station directory + observation snapshots — **API v5** /// (`/api/v5/meteor/weather`). /// -/// **Cache-split dual host** (both on `core-tnn1`): a history URL ending in a -/// 10-digit second is immutable and served from `static.core-tnn1` -/// ([ApiTier.coreStaticExclusive], 1-year cache); everything else (latest, list, -/// trend, realtime, forecast, station) is served from `api.core-tnn1` -/// ([ApiTier.coreExclusiveApi], 1-second cache -/// with ETag/304). Returns raw decoded JSON; the repository decodes the -/// field-arrays / delta-seconds into domain models. +/// The shared station / latest / list / history / trend surface lives in +/// [MeteorSnapshotApi] (same cache-split dual host contract); this class adds +/// the weather-only [getRealtime] and [getForecast] endpoints. Returns raw +/// decoded JSON; the repository decodes the field-arrays / delta-seconds into +/// domain models. class MeteorWeatherApi { - const MeteorWeatherApi(this._client); + MeteorWeatherApi(this._client) + : snapshots = MeteorSnapshotApi(_client, '/api/v5/meteor/weather'); final ApiClient _client; - /// Latest / list / directory / trend / realtime / forecast (mutable) live on - /// `api.core-tnn1`. - static const ApiTier _api = ApiTier.coreExclusiveApi; - - /// Timestamped history (immutable) lives on `static.core-tnn1`. - static const ApiTier _static = ApiTier.coreStaticExclusive; - - static const String _base = '/api/v5/meteor/weather'; - - /// Static station directory `{ code: {n,c,t,alt,lat,lon} }` (ETag-cached). - Future> getStation() async => - (await _client.get(_api, '$_base/station')) as Map; - - /// Latest field-array snapshot. - Future> getLatest() async => - (await _client.get(_api, _base)) as Map; - - /// History time list, delta-encoded seconds (`[base, Δ, …]`). - Future> getList() async => - (await _client.get(_api, '$_base/list')) as List; - - /// Historical snapshot at [second] (10-digit Unix seconds) — from `static`. - Future> getAt(int second) async => - (await _client.get(_static, '$_base/$second')) as Map; - - /// Per-station weather trend for [id] over [range] (`24h` | `7d`). - Future> getTrend(String id, String range) async => - (await _client.get(_api, '$_base/trend/$id', query: {'range': range})) - as Map; + /// The weather dataset's shared snapshot endpoints. + final MeteorSnapshotApi snapshots; /// Nearest station to [latitude],[longitude] (the `:coords` = `lat,lng` path). /// Returns `{}` when the coordinate is outside Taiwan. @@ -55,11 +28,16 @@ class MeteorWeatherApi { double latitude, double longitude, ) async => - (await _client.get(_api, '$_base/realtime/$latitude,$longitude')) + (await _client.get( + _api, + '/api/v5/meteor/weather/realtime/$latitude,$longitude', + )) as Map; /// Township forecast for the 3-digit [code]. Future> getForecast(String code) async => - (await _client.get(_api, '$_base/forecast/$code')) + (await _client.get(_api, '/api/v5/meteor/weather/forecast/$code')) as Map; + + static const ApiTier _api = ApiTier.coreExclusiveApi; } diff --git a/lib/features/weather/data/meteor_weather_repository_impl.dart b/lib/features/weather/data/meteor_weather_repository_impl.dart index f9d3fa809..904bc1ba8 100644 --- a/lib/features/weather/data/meteor_weather_repository_impl.dart +++ b/lib/features/weather/data/meteor_weather_repository_impl.dart @@ -3,7 +3,8 @@ library; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/network/api_exception.dart'; -import 'package:dpip/core/network/meteor_decode.dart'; +import 'package:dpip/features/weather/data/meteor_snapshot_repository_impl.dart'; +import 'package:dpip/features/weather/data/meteor_station_decode.dart'; import 'package:dpip/features/weather/data/meteor_weather_api.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; import 'package:dpip/features/weather/domain/weather_forecast.dart'; @@ -14,39 +15,27 @@ import 'package:dpip/features/weather/domain/weather_trend.dart'; /// Maps the datasource's raw JSON to domain models, converting transport/decode /// errors to typed failures via [guardResult]. -class MeteorWeatherRepositoryImpl implements MeteorWeatherRepository { - const MeteorWeatherRepositoryImpl(this._api); +class MeteorWeatherRepositoryImpl + extends MeteorSnapshotRepositoryImpl + implements MeteorWeatherRepository { + MeteorWeatherRepositoryImpl(this._weatherApi) : super(_weatherApi.snapshots); - final MeteorWeatherApi _api; + final MeteorWeatherApi _weatherApi; @override - Future>> stations() => - guardResult(() async { - final raw = await _api.getStation(); - return raw.map( - (code, value) => MapEntry( - code, - WeatherStation.fromJson(value as Map), - ), - ); - }); - - @override - Future> latest() => - guardResult(() async => WeatherSnapshot.decode(await _api.getLatest())); + WeatherSnapshot decodeSnapshot(Map json) => + WeatherSnapshot.decode(json); @override - Future>> history() => - guardResult(() async => MeteorDecode.deltaSeconds(await _api.getList())); - - @override - Future> at(int second) => - guardResult(() async => WeatherSnapshot.decode(await _api.getAt(second))); + Future>> stations() => + fetchStations(_weatherApi.snapshots.getStation); @override Future> trend(String id, {String range = '24h'}) => guardResult( - () async => WeatherTrend.decode(await _api.getTrend(id, range)), + () async => WeatherTrend.decode( + await _weatherApi.snapshots.getTrend(id, range), + ), ); @override @@ -54,13 +43,13 @@ class MeteorWeatherRepositoryImpl implements MeteorWeatherRepository { double latitude, double longitude, ) => guardResult(() async { - final raw = await _api.getRealtime(latitude, longitude); + final raw = await _weatherApi.getRealtime(latitude, longitude); // Outside Taiwan the API returns `{}` — a valid "no station", not an error. return raw.isEmpty ? null : WeatherRealtime.fromJson(raw); }); @override Future> forecast(String code) => guardResult( - () async => WeatherForecast.fromJson(await _api.getForecast(code)), + () async => WeatherForecast.fromJson(await _weatherApi.getForecast(code)), ); } diff --git a/lib/features/weather/data/radar_api.dart b/lib/features/weather/data/radar_api.dart deleted file mode 100644 index 7b91d3f4d..000000000 --- a/lib/features/weather/data/radar_api.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:dpip/core/network/api_client.dart'; -import 'package:dpip/core/network/api_region.dart'; - -/// Radar echo (雷達回波) tile endpoints — **API v2**. -/// -/// Everything is keyed by 10-digit Unix **seconds**; a frame id *is* the second. -/// The frame list is delta-encoded (`[baseSec, Δ, Δ, …]`) and served from -/// `api.core-tnn1` with ETag/304. Tiles are WebP on `static.core-tnn1` -/// ([ApiTier.coreStaticExclusive]); [tileUrl] feeds MapLibre. Prefetch warms -/// SQLite + ambient under the same origin URL. Caching is **ETag-only**. -class RadarApi { - const RadarApi(this._client); - - final ApiClient _client; - - /// The v2 frame list lives on `api.core-tnn1` (no region failover). - static const ApiTier _listTier = ApiTier.coreExclusiveApi; - - /// v2 tiles live on `static.core-tnn1` (no region failover). - static const ApiTier _tileTier = ApiTier.coreStaticExclusive; - - /// Available radar frame seconds, **newest first**. - /// - /// `https://api.core-tnn1.exptech.dev/api/v2/tiles/radar/list` - Future> getFrames() async => framesFromList( - await _client.get(_listTier, '/api/v2/tiles/radar/list') as List, - ); - - /// XYZ WebP raster tile URL template for a radar [frame] (a Unix second). - /// - /// `https://static.core-tnn1.exptech.dev/api/v2/tiles/radar//{z}/{x}/{y}.webp` - String tileUrl(String frame) => - '${_client.hostsFor(_tileTier).first}' - '/api/v2/tiles/radar/$frame/{z}/{x}/{y}.webp'; - - /// Restores the delta-encoded list `[baseSec, Δ, Δ, …]` to absolute seconds - /// and returns them **newest first** as strings (the frame id used by - /// [tileUrl]). Empty in → empty out. Exposed for unit testing. - static List framesFromList(List deltas) { - if (deltas.isEmpty) return const []; - var second = (deltas.first as num).toInt(); - final seconds = [second]; - for (var i = 1; i < deltas.length; i++) { - second += (deltas[i] as num).toInt(); - seconds.add(second); - } - return [for (final s in seconds.reversed) s.toString()]; - } -} diff --git a/lib/features/weather/data/radar_repository_impl.dart b/lib/features/weather/data/radar_repository_impl.dart deleted file mode 100644 index 726946e8d..000000000 --- a/lib/features/weather/data/radar_repository_impl.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:dpip/core/error/result.dart'; -import 'package:dpip/core/network/api_exception.dart'; -import 'package:dpip/features/weather/data/frame_tile_repository.dart'; -import 'package:dpip/features/weather/data/radar_api.dart'; -import 'package:dpip/features/weather/domain/radar_repository.dart'; - -/// [RadarRepository] backed by [RadarApi], with tile warming from -/// [FrameTileRepository]. -final class RadarRepositoryImpl extends FrameTileRepository - implements RadarRepository { - RadarRepositoryImpl(this._api, super.warmer); - - final RadarApi _api; - - @override - int get maxZoom => 11; - - @override - String get tilePathPrefix => '/api/v2/tiles/radar/'; - - @override - Future>> frames() => guardResult(_api.getFrames); - - @override - String tileUrl(String frame) => _api.tileUrl(frame); -} diff --git a/lib/features/weather/data/rain_hour_trend_api.dart b/lib/features/weather/data/rain_hour_trend_api.dart new file mode 100644 index 000000000..0aeb71b44 --- /dev/null +++ b/lib/features/weather/data/rain_hour_trend_api.dart @@ -0,0 +1,26 @@ +/// Next-hour per-minute rain forecast on the external `exptech.dingbot.tw` +/// host. +library; + +import 'package:dpip/core/network/api_client.dart'; + +/// The `rainforecast` endpoint — the per-township next-hour rain series behind +/// the home-sheet trend card. +/// +/// Lives on `exptech.dingbot.tw` (not in the region topology), so it goes +/// through [ApiClient.getAbsolute] rather than a region-pinned [ApiTier]. The +/// `{code}` path segment is the same 3-digit township code as the v5 forecast. +class RainHourTrendApi { + const RainHourTrendApi(this._client); + + final ApiClient _client; + + /// `https://exptech.dingbot.tw/api/weather/rainforecast/{code}` + static const String _base = + 'https://exptech.dingbot.tw/api/weather/rainforecast'; + + /// The next-hour trend for township [code], as the raw envelope + /// `{"": [{"start": , "rain": [60 × mm]}]}`. + Future> getForecast(String code) async => + (await _client.getAbsolute('$_base/$code')) as Map; +} diff --git a/lib/features/weather/data/rain_hour_trend_repository_impl.dart b/lib/features/weather/data/rain_hour_trend_repository_impl.dart new file mode 100644 index 000000000..e1171394e --- /dev/null +++ b/lib/features/weather/data/rain_hour_trend_repository_impl.dart @@ -0,0 +1,21 @@ +/// [RainHourTrendRepository] backed by [RainHourTrendApi]. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/network/api_exception.dart'; +import 'package:dpip/features/weather/data/rain_hour_trend_api.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; + +/// Maps the datasource's raw envelope to [RainHourTrend], converting +/// transport/decode errors to typed failures via [guardResult]. +class RainHourTrendRepositoryImpl implements RainHourTrendRepository { + const RainHourTrendRepositoryImpl(this._api); + + final RainHourTrendApi _api; + + @override + Future> hourTrend(String code) => guardResult( + () async => RainHourTrend.decode(await _api.getForecast(code)), + ); +} diff --git a/lib/features/weather/data/satellite_api.dart b/lib/features/weather/data/satellite_api.dart deleted file mode 100644 index df8453e15..000000000 --- a/lib/features/weather/data/satellite_api.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:dpip/core/network/api_client.dart'; -import 'package:dpip/core/network/api_region.dart'; - -/// Satellite IR cloud (衛星雲圖) tile endpoints — **API v2**. -/// -/// Everything is keyed by 10-digit Unix **seconds**; a frame id *is* the second. -/// The frame list is delta-encoded (`[baseSec, Δ, Δ, …]`) and served from -/// `api.core-tnn1` with ETag/304. Tiles are WebP on `static.core-tnn1` -/// ([ApiTier.coreStaticExclusive]); Prefetch warms SQLite + ambient. Same -/// dual-host + delta-list shape as radar. -class SatelliteApi { - const SatelliteApi(this._client); - - final ApiClient _client; - - /// The v2 frame list lives on `api.core-tnn1` (no region failover). - static const ApiTier _listTier = ApiTier.coreExclusiveApi; - - /// v2 tiles live on `static.core-tnn1` (no region failover). - static const ApiTier _tileTier = ApiTier.coreStaticExclusive; - - /// Available satellite frame seconds, **newest first**. - /// - /// `https://api.core-tnn1.exptech.dev/api/v2/tiles/satellite/list` - Future> getFrames() async => framesFromList( - await _client.get(_listTier, '/api/v2/tiles/satellite/list') as List, - ); - - /// XYZ WebP raster tile URL template for a satellite [frame] (a Unix second). - /// - /// `https://static.core-tnn1.exptech.dev/api/v2/tiles/satellite//{z}/{x}/{y}.webp` - String tileUrl(String frame) => - '${_client.hostsFor(_tileTier).first}' - '/api/v2/tiles/satellite/$frame/{z}/{x}/{y}.webp'; - - /// Restores the delta-encoded list `[baseSec, Δ, Δ, …]` to absolute seconds - /// and returns them **newest first** as strings (the frame id used by - /// [tileUrl]). Empty in → empty out. Exposed for unit testing. - static List framesFromList(List deltas) { - if (deltas.isEmpty) return const []; - var second = (deltas.first as num).toInt(); - final seconds = [second]; - for (var i = 1; i < deltas.length; i++) { - second += (deltas[i] as num).toInt(); - seconds.add(second); - } - return [for (final s in seconds.reversed) s.toString()]; - } -} diff --git a/lib/features/weather/data/satellite_repository_impl.dart b/lib/features/weather/data/satellite_repository_impl.dart deleted file mode 100644 index 4d24486ca..000000000 --- a/lib/features/weather/data/satellite_repository_impl.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:dpip/core/error/result.dart'; -import 'package:dpip/core/network/api_exception.dart'; -import 'package:dpip/features/weather/data/frame_tile_repository.dart'; -import 'package:dpip/features/weather/data/satellite_api.dart'; -import 'package:dpip/features/weather/domain/satellite_repository.dart'; - -/// [SatelliteRepository] backed by [SatelliteApi], with tile warming from -/// [FrameTileRepository]. -final class SatelliteRepositoryImpl extends FrameTileRepository - implements SatelliteRepository { - SatelliteRepositoryImpl(this._api, super.warmer); - - final SatelliteApi _api; - - @override - int get maxZoom => 11; - - @override - String get tilePathPrefix => '/api/v2/tiles/satellite/'; - - @override - Future>> frames() => guardResult(_api.getFrames); - - @override - String tileUrl(String frame) => _api.tileUrl(frame); -} diff --git a/lib/features/weather/domain/meteor_rain_repository.dart b/lib/features/weather/domain/meteor_rain_repository.dart index fbd59775a..b7f0e8ac2 100644 --- a/lib/features/weather/domain/meteor_rain_repository.dart +++ b/lib/features/weather/domain/meteor_rain_repository.dart @@ -4,17 +4,21 @@ library; import 'package:dpip/core/error/result.dart'; import 'package:dpip/features/weather/domain/rain_snapshot.dart'; import 'package:dpip/features/weather/domain/rain_trend.dart'; +import 'package:dpip/features/weather/domain/station_value_repository.dart'; import 'package:dpip/features/weather/domain/weather_station.dart'; /// Rainfall observations from the v5 meteor API. Returns a [Result] so a failed /// fetch/decode is explicit (never a silently blank rain panel); the impl in /// `data/` maps transport/decode errors to a typed `Failure`. -abstract interface class MeteorRainRepository { +abstract interface class MeteorRainRepository + implements StationValueRepository { /// The static station directory, keyed by station code. Rain shares weather's /// `{n,c,t,alt,lat,lon}` catalogue shape, so it reuses [WeatherStation]. + @override Future>> stations(); /// The latest accumulation snapshot. + @override Future> latest(); /// Available history snapshot times (Unix seconds, ascending); `Ok([])` when @@ -25,5 +29,6 @@ abstract interface class MeteorRainRepository { Future> at(int second); /// The rainfall trend series for station [id] over [range] (`24h` | `7d`). + @override Future> trend(String id, {String range = '24h'}); } diff --git a/lib/features/weather/domain/meteor_weather_repository.dart b/lib/features/weather/domain/meteor_weather_repository.dart index d8a599420..9fecdf740 100644 --- a/lib/features/weather/domain/meteor_weather_repository.dart +++ b/lib/features/weather/domain/meteor_weather_repository.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/weather/domain/station_value_repository.dart'; import 'package:dpip/features/weather/domain/weather_forecast.dart'; import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:dpip/features/weather/domain/weather_snapshot.dart'; @@ -11,11 +12,14 @@ import 'package:dpip/features/weather/domain/weather_trend.dart'; /// Weather observations from the v5 meteor API. Returns a [Result] so a failed /// fetch/decode is explicit (never a silently blank weather panel); the impl in /// `data/` maps transport/decode errors to a typed `Failure`. -abstract interface class MeteorWeatherRepository { +abstract interface class MeteorWeatherRepository + implements StationValueRepository { /// The static station directory, keyed by station code. + @override Future>> stations(); /// The latest observation snapshot. + @override Future> latest(); /// Available history snapshot times (Unix seconds, ascending); `Ok([])` when @@ -26,6 +30,7 @@ abstract interface class MeteorWeatherRepository { Future> at(int second); /// The weather trend series for station [id] over [range] (`24h` | `7d`). + @override Future> trend(String id, {String range = '24h'}); /// The nearest station's realtime observation to ([latitude], [longitude]). diff --git a/lib/features/weather/domain/qpesums_repository.dart b/lib/features/weather/domain/qpesums_repository.dart new file mode 100644 index 000000000..4f2f10d65 --- /dev/null +++ b/lib/features/weather/domain/qpesums_repository.dart @@ -0,0 +1,11 @@ +import 'package:dpip/shared/map/raster_frame_source.dart'; + +/// Access to QPESUMS next-1-hour precipitation forecast (未來一小時降水預報) +/// frames — the weather feature's QPESUMS overlay data, consumed by the map +/// surface. +/// +/// The whole surface is [RasterFrameSource]: frame list, tile URL template, and +/// the tile-memory controls a scrubbable overlay needs. Named separately so +/// call sites read as "the QPESUMS repository" and so QPESUMS-only additions +/// have a home. +abstract interface class QpesumsRepository implements RasterFrameSource {} diff --git a/lib/features/weather/domain/rain_hour_trend.dart b/lib/features/weather/domain/rain_hour_trend.dart index 6d865c7b4..87d2cd11f 100644 --- a/lib/features/weather/domain/rain_hour_trend.dart +++ b/lib/features/weather/domain/rain_hour_trend.dart @@ -1,32 +1,162 @@ /// Near-term 1-hour rainfall trend (per-minute mm), for the home sheet card. /// -/// Wire shape (API not shipped yet): a minute-aligned Unix-second -/// [startSecond] and sixty [mm] samples — one millimetre total for each of the -/// following sixty minutes. UI can render without a live feed via [placeholder]. +/// Wire shape (`GET /api/weather/rainforecast/{code}` on `exptech.dingbot.tw`): +/// the response is a single-series envelope keyed by name (e.g. +/// `rainfallWarnings-rkai`) whose value is a list of `{start, rain}` records — +/// a minute-aligned Unix-second [startSecond] and sixty [mm] samples, one +/// millimetre value per minute. UI can render without a live feed via +/// [placeholder]. library; +import 'dart:math' as math; + +import 'package:dpip/core/realtime/app_time.dart'; + +/// Rain intensity grade for the next hour, from the peak per-minute value. +enum RainHourTrendGrade { + /// No rain forecast at all. + none, + + /// Peak below [RainHourTrend.scatteredMm] — light, isolated showers. + scattered, + + /// Peak `[scatteredMm, lightMm)` — steady light rain. + light, + + /// Peak `>= lightMm` — heavy rain. + heavy, +} + +/// How the next hour reads — the structured answer the home-sheet card's +/// subtitle text is derived from. +class RainHourTrendSummary { + const RainHourTrendSummary({ + required this.grade, + required this.sustained, + this.stopInMinutes, + }); + + /// Intensity grade for the hour. + final RainHourTrendGrade grade; + + /// Rain is still falling near the end of the hour (no [stopInMinutes]). + /// Only meaningful for [RainHourTrendGrade.light] / heavy. + final bool sustained; + + /// Minutes from [RainHourTrend.startSecond] at which the rain is forecast to + /// have stopped — the minute after the last wet sample. Null when [sustained] + /// or [RainHourTrendGrade.scattered]. + final int? stopInMinutes; +} + /// Sixty minute-buckets of forecast rainfall starting at [startSecond]. class RainHourTrend { const RainHourTrend({required this.startSecond, required this.mm}) : assert(mm.length == 60, 'expected 60 one-minute samples'); + /// Peak per-minute mm below which the hour reads as isolated showers. + static const double scatteredMm = 5; + + /// Peak per-minute mm at which the hour reads as heavy rain. + static const double lightMm = 15; + + /// The hour is "continuous" when rain still falls this late into it — the + /// last wet minute is [sustainedFrom] or later. + static const int sustainedFrom = 45; + + /// Intensity grade + continuity of the next hour, derived from [mm]: the peak + /// value picks the grade, and for light/heavy the position of the last wet + /// minute decides [RainHourTrendSummary.sustained] / stop time. + RainHourTrendSummary get summary { + final peak = mm.reduce(math.max); + if (peak <= 0) { + return const RainHourTrendSummary( + grade: RainHourTrendGrade.none, + sustained: false, + ); + } + final grade = switch (peak) { + < scatteredMm => RainHourTrendGrade.scattered, + < lightMm => RainHourTrendGrade.light, + _ => RainHourTrendGrade.heavy, + }; + if (grade == RainHourTrendGrade.scattered) { + return RainHourTrendSummary(grade: grade, sustained: false); + } + final lastRain = mm.lastIndexWhere((v) => v > 0); + final sustained = lastRain >= sustainedFrom; + return RainHourTrendSummary( + grade: grade, + sustained: sustained, + stopInMinutes: sustained ? null : lastRain + 1, + ); + } + + /// Decodes the `rainforecast` envelope: the first non-empty series wins; its + /// `start` (Unix seconds) and `rain` (60 mm samples) become this trend. An + /// empty response — every series is an empty list — means the API forecasts + /// no rain this hour, so this returns a dry trend ([isDry]) instead of + /// failing. A series with a non-numeric `start`, non-list `rain`, or wrong + /// sample count still throws, which `guardResult` folds into a + /// `DecodeFailure`. + factory RainHourTrend.decode(Map json) { + for (final series in json.values) { + if (series is! List || series.isEmpty) continue; + // An empty series is the API's "no rain"; a non-empty one must be the + // expected record — anything else is a malformed response, not a dry + // hour, and keeps failing the fetch. + final raw = series.first; + if (raw is! Map) { + throw const FormatException('Malformed rainforecast series'); + } + final start = raw['start']; + final rain = raw['rain']; + if (start is! num || rain is! List) { + throw const FormatException('Malformed rainforecast series'); + } + if (rain.length != 60) { + throw const FormatException('Expected 60 rain samples'); + } + return RainHourTrend( + startSecond: start.toInt(), + mm: [for (final value in rain) (value as num).toDouble()], + ); + } + // Every series was empty — the API's `[]` for "no rain this hour". + return RainHourTrend.dry(); + } + /// Prediction origin, Unix **seconds**, aligned to a whole minute /// (`startSecond % 60 == 0`). final int startSecond; - /// Rainfall for each of the next 60 minutes, mm (index 0 = first minute after - /// [startSecond]). + /// Rainfall for each of the next 60 minutes, mm (index 0 = the minute + /// starting at [startSecond]). final List mm; /// Wall-clock UTC instant of [startSecond]. DateTime get startUtc => DateTime.fromMillisecondsSinceEpoch(startSecond * 1000, isUtc: true); + /// The hour carries no rain at all — an empty `[]` response (the API's way of + /// saying no rain is coming) and an all-zero series both land here. The home + /// sheet hides the trend card for a dry hour rather than drawing an empty + /// chart. + bool get isDry => summary.grade == RainHourTrendGrade.none; + + /// A dry hour: all sixty samples are zero, aligned to [startUtc] (defaults to + /// "now", minute-aligned). Used by [decode] when the API returns no forecast + /// series at all. + factory RainHourTrend.dry({DateTime? startUtc}) { + final seconds = (startUtc ?? AppTime.utc).millisecondsSinceEpoch ~/ 1000; + final aligned = seconds - (seconds % 60); + return RainHourTrend(startSecond: aligned, mm: List.filled(60, 0)); + } + /// Synthetic series for UI work until the API lands — a soft pulse peaking /// mid-hour so the bar chart has shape. static RainHourTrend placeholder({int? startSecond}) { - final raw = - startSecond ?? (DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000); + final raw = startSecond ?? (AppTime.utc.millisecondsSinceEpoch ~/ 1000); final aligned = raw - (raw % 60); return RainHourTrend( startSecond: aligned, diff --git a/lib/features/weather/domain/rain_hour_trend_repository.dart b/lib/features/weather/domain/rain_hour_trend_repository.dart new file mode 100644 index 000000000..8cf0f9ff6 --- /dev/null +++ b/lib/features/weather/domain/rain_hour_trend_repository.dart @@ -0,0 +1,15 @@ +/// Access to the next-hour per-minute rain forecast. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; + +/// The next-hour rain trend for a township, from the `rainforecast` endpoint. +/// Returns a [Result] so a failed fetch/decode is explicit (never a fabricated +/// trend in the home-sheet card); the impl in `data/` maps transport/decode +/// errors to a typed `Failure`. +abstract interface class RainHourTrendRepository { + /// The next 60 minutes of per-minute rainfall for the 3-digit township + /// [code]. + Future> hourTrend(String code); +} diff --git a/lib/features/weather/domain/rain_snapshot.dart b/lib/features/weather/domain/rain_snapshot.dart index c386fe27f..ff83f8a71 100644 --- a/lib/features/weather/domain/rain_snapshot.dart +++ b/lib/features/weather/domain/rain_snapshot.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/network/meteor_decode.dart'; +import 'package:dpip/features/weather/domain/station_value_repository.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'rain_snapshot.freezed.dart'; @@ -11,7 +12,9 @@ part 'rain_snapshot.freezed.dart'; /// it has no `fromJson`; nullable fields are the API's `-99` missing sentinel /// decoded to null. @freezed -abstract class RainObservation with _$RainObservation { +abstract class RainObservation + with _$RainObservation + implements StationObservation { const factory RainObservation({ /// 6-char station code (the `/station` directory key). required String id, @@ -48,7 +51,9 @@ abstract class RainObservation with _$RainObservation { /// A rainfall snapshot: the observation [time] and one [RainObservation] per /// station, aligned by index to the station directory. @freezed -abstract class RainSnapshot with _$RainSnapshot { +abstract class RainSnapshot + with _$RainSnapshot + implements StationSnapshot { const factory RainSnapshot({ required int time, required List stations, diff --git a/lib/features/weather/domain/rain_trend.dart b/lib/features/weather/domain/rain_trend.dart index 0f414590c..46c6a04b5 100644 --- a/lib/features/weather/domain/rain_trend.dart +++ b/lib/features/weather/domain/rain_trend.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/network/meteor_decode.dart'; +import 'package:dpip/features/weather/domain/station_value_repository.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'rain_trend.freezed.dart'; @@ -11,7 +12,7 @@ part 'rain_trend.freezed.dart'; /// by index to [rain] (rolling 1-hour rainfall, mm; `-99` → null). Built from a /// field-array payload, so it has no `fromJson`. @freezed -abstract class RainTrend with _$RainTrend { +abstract class RainTrend with _$RainTrend implements TrendTimeAxis { const factory RainTrend({ /// 6-char station code (the `/station` directory key). required String id, diff --git a/lib/features/weather/domain/satellite_channel.dart b/lib/features/weather/domain/satellite_channel.dart new file mode 100644 index 000000000..888511918 --- /dev/null +++ b/lib/features/weather/domain/satellite_channel.dart @@ -0,0 +1,95 @@ +/// One selectable Himawari-9 AHI view on the map — either a raw band (B01–B16) +/// or a derived product (True Color, overshooting top, cloud-top temperature, +/// …) the tile service can render. +/// +/// [key] is the `?channel=` value the satellite tile endpoints accept: a raw +/// band's decimal number, or a named product. Bands and products are the same +/// kind of choice from a client's point of view — which imagery do I want — so +/// they share the one enum; the wire only differs in whether [key] is numeric. +/// +/// [subtitle] is the layer picker's secondary line: a band's wavelength, or a +/// product's formula / composition in the service's own notation (`B13−B15`, +/// `(B04−B03)/(B04+B03)`, `B03·B02·B01`). It is pure data (math and band ids, +/// not display prose), so it stays out of the ARB files and reads the same in +/// every locale. +/// +/// The display order here is the layer-picker order: visible through thermal +/// bands first, then the RGB / difference / Level-2 products. See +/// `satellite-tiles-go/docs.md` for what each one is. +enum SatelliteChannel { + visibleBlue('1', '0.47 µm'), + visibleGreen('2', '0.51 µm'), + visibleRed('3', '0.64 µm'), + nir('4', '0.86 µm'), + nirPhase('5', '1.6 µm'), + nirCloud('6', '2.3 µm'), + swir('7', '3.9 µm'), + wvUpper('8', '6.2 µm'), + wvMid('9', '6.9 µm'), + wvLow('10', '7.3 µm'), + so2('11', '8.6 µm'), + ozone('12', '9.6 µm'), + irClean('13', '10.4 µm'), + irLong('14', '11.2 µm'), + irLong2('15', '12.4 µm'), + co2('16', '13.3 µm'), + truecolor('truecolor', 'B03·B02·B01'), + naturalcolor('naturalcolor', 'B05·B04·B03'), + ash('ash', 'B13−B15 · B11−B13 · B13'), + dust('dust', 'B13−B15 · B11−B13 · B13'), + airmass('airmass', 'B10−B08 · B13−B12 · B08'), + nightmicrophysics('nightmicrophysics', 'B13−B15 · B07−B13 · B13'), + watervapor('watervapor', 'B08 · 6.2 µm'), + btdSplit('btd_split', 'B13−B15'), + btdFog('btd_fog', 'B07−B13'), + btdWvirw('btd_wvirw', 'B08−B13'), + btdSo2('btd_so2', 'B11−B13'), + btdCo2('btd_co2', 'B16−B13'), + btdOzone('btd_ozone', 'B12−B13'), + cloudtop('cloudtop', 'AHI-CHGT'), + cloudmask('cloudmask', 'AHI-CMSK'), + sst('sst', 'ACSPO L3C'), + ndvi('ndvi', '(B04−B03)/(B04+B03)'), + ndwi('ndwi', '(B02−B04)/(B02+B04)'), + mndwi('mndwi', '(B02−B06)/(B02+B06)'); + + const SatelliteChannel(this.key, this.subtitle); + + /// The `?channel=` value naming this view on the tile endpoints. + final String key; + + /// Picker subtitle — the band's wavelength, or the product's composition / + /// formula in the tile service's band notation. + final String subtitle; + + /// Whether this is a raw single band. Only bands accept a colour [style]; + /// named products carry their own palette (`?style=` is ignored for them). + bool get isBand => int.tryParse(key) != null; + + /// Whether this band is **thermal infrared** (B07–B16). + /// + /// The `jma` (cloud-top enhancement) and `bd` (Dvorak BD) styles are + /// temperature maps — they colour the cloud-top temperature field. Only IR + /// bands carry that field. Visible / near-infrared bands (B01–B06) measure + /// reflected sunlight and have no temperature, so [SatelliteStyle.gray] is + /// their only meaningful rendering. + bool get isThermal { + final band = int.tryParse(key); + return band != null && band >= 7; + } +} + +/// One colour rendering of a raw band (`?style=` on the tile endpoints). +/// +/// Only [SatelliteChannel.isBand] channels honour this; the backend defaults to +/// [gray] when the parameter is absent, so the default needs no wire value. +enum SatelliteStyle { + gray('gray'), + jma('jma'), + bd('bd'); + + const SatelliteStyle(this.key); + + /// The `?style=` value naming this rendering. + final String key; +} diff --git a/lib/features/weather/domain/satellite_repository.dart b/lib/features/weather/domain/satellite_repository.dart index 824e27a17..6af7a59c7 100644 --- a/lib/features/weather/domain/satellite_repository.dart +++ b/lib/features/weather/domain/satellite_repository.dart @@ -7,4 +7,12 @@ import 'package:dpip/shared/map/raster_frame_source.dart'; /// the tile-memory controls a scrubbable overlay needs. Named separately so call /// sites read as "the satellite repository" and so satellite-only additions have /// a home. -abstract interface class SatelliteRepository implements RasterFrameSource {} +abstract interface class SatelliteRepository implements RasterFrameSource { + /// Switches the colour rendering of **single-band** channels to [style] (a + /// `?style=` value — `gray` / `jma` / `bd`); `null` restores the server + /// default. Named-product channels carry their own palette and ignore this. + /// + /// Live only — it changes the URL of subsequently fetched tiles; the caller + /// decides whether to re-mount the layer (reload) so the map reflects it. + void setStyle(String? style); +} diff --git a/lib/features/weather/domain/station_value_repository.dart b/lib/features/weather/domain/station_value_repository.dart new file mode 100644 index 000000000..abfa5ec1a --- /dev/null +++ b/lib/features/weather/domain/station_value_repository.dart @@ -0,0 +1,41 @@ +/// The station-value surface the shared station map layer consumes — a common +/// shape for the weather and rain repositories so one base layer serves both. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/weather/domain/weather_station.dart'; + +/// A repository exposing the station catalogue, a latest observation snapshot, +/// and per-station trends — the three calls the shared station layer makes. +/// +/// [S] is the snapshot type (weather / rain) and [T] the trend type; both +/// implement the small contracts below so the layer can read them generically. +abstract interface class StationValueRepository { + /// The static station directory, keyed by station code. + Future>> stations(); + + /// The latest observation snapshot. + Future> latest(); + + /// The trend series for station [id] over [range] (`24h` | `7d`). + Future> trend(String id, {String range = '24h'}); +} + +/// A decoded snapshot exposing the per-station observations, aligned by index +/// to the station directory. +abstract interface class StationSnapshot { + /// One observation per station, in station-directory order. + List get stations; +} + +/// A per-station observation carrying the station code it belongs to. +abstract interface class StationObservation { + /// The 6-char station code (the `/station` directory key). + String get id; +} + +/// A trend payload exposing the sample time axis (Unix seconds, ascending). +abstract interface class TrendTimeAxis { + /// Sample times, aligned by index to the trend values. + List get times; +} diff --git a/lib/features/weather/domain/weather_snapshot.dart b/lib/features/weather/domain/weather_snapshot.dart index 39c0eed6e..26354eff5 100644 --- a/lib/features/weather/domain/weather_snapshot.dart +++ b/lib/features/weather/domain/weather_snapshot.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/network/meteor_decode.dart'; +import 'package:dpip/features/weather/domain/station_value_repository.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'weather_snapshot.freezed.dart'; @@ -10,7 +11,9 @@ part 'weather_snapshot.freezed.dart'; /// from a per-station JSON object), so it has no `fromJson`; nullable fields are /// the API's `-99` missing sentinel decoded to null. @freezed -abstract class WeatherObservation with _$WeatherObservation { +abstract class WeatherObservation + with _$WeatherObservation + implements StationObservation { const factory WeatherObservation({ /// 6-char station code (the `/station` directory key). required String id, @@ -46,7 +49,9 @@ abstract class WeatherObservation with _$WeatherObservation { /// A weather snapshot: the observation [time] and one [WeatherObservation] per /// station, aligned by index to the station directory. @freezed -abstract class WeatherSnapshot with _$WeatherSnapshot { +abstract class WeatherSnapshot + with _$WeatherSnapshot + implements StationSnapshot { const factory WeatherSnapshot({ required int time, required List stations, diff --git a/lib/features/weather/domain/weather_trend.dart b/lib/features/weather/domain/weather_trend.dart index 178ff0942..257bd6f0f 100644 --- a/lib/features/weather/domain/weather_trend.dart +++ b/lib/features/weather/domain/weather_trend.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/network/meteor_decode.dart'; +import 'package:dpip/features/weather/domain/station_value_repository.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'weather_trend.freezed.dart'; @@ -12,7 +13,7 @@ part 'weather_trend.freezed.dart'; /// [pressure] hPa, [windSpeed] m/s, [windDirection] °; each `-99` → null). Built /// from a field-array payload, so it has no `fromJson`. @freezed -abstract class WeatherTrend with _$WeatherTrend { +abstract class WeatherTrend with _$WeatherTrend implements TrendTimeAxis { const factory WeatherTrend({ /// 6-char station code (the `/station` directory key). required String id, diff --git a/lib/features/weather/domain/wind_field.dart b/lib/features/weather/domain/wind_field.dart new file mode 100644 index 000000000..edd9a4380 --- /dev/null +++ b/lib/features/weather/domain/wind_field.dart @@ -0,0 +1,129 @@ +import 'dart:typed_data'; + +/// A decoded WND1 wind field — the numeric grid that drives the wind-forecast +/// particle animation. +/// +/// The wire format is a fixed-size header followed by two quantised planes: +/// +/// ``` +/// 0 4 'WND1' 10 lat0 (f64) 42 uMin (f32) +/// 4 2 version (u16) 18 lon0 (f64) 46 uMax (f32) +/// 6 2 width (u16) 26 dLat (f64) 50 vMin (f32) +/// 8 2 height (u16) 34 dLon (f64) 54 vMax (f32) +/// 58 timeMs (u64) 66 nameLen (u8) +/// 67 model name … u plane (u8 × n) +/// … v plane (u8 × n) +/// ``` +/// +/// All multi-byte values are little-endian. Each plane holds one quantised +/// component per cell, raster order, values `0..255` mapped onto +/// `min..max` — so [u]/[v] act as texture texels, exactly what the GPU +/// particle renderer consumes ([satellite-tiles-go/web/wind.js]). +/// +/// [u] and [v] are views over the source bytes, not copies: a parsed field +/// keeps the whole binary alive so the per-frame animation can read it without +/// another allocation. At ~2 MB (a 1440×721 global grid) that is a deliberate +/// one-field-at-a-time trade. +class WindField { + const WindField({ + required this.width, + required this.height, + required this.lat0, + required this.lon0, + required this.dLat, + required this.dLon, + required this.uMin, + required this.uMax, + required this.vMin, + required this.vMax, + required this.timeMs, + required this.model, + required this.u, + required this.v, + }); + + /// Cells across — the field spans `dLon × width` degrees of longitude. + final int width; + + /// Cells down — the field spans `dLat × height` degrees of latitude. + final int height; + + /// Latitude of row 0 (the north edge, `90` for a global grid). + final double lat0; + + /// Longitude of column 0. + final double lon0; + + /// Degrees of latitude per row (negative — rows run north → south). + final double dLat; + + /// Degrees of longitude per column. + final double dLon; + + /// Quantisation range of the u (eastward) component, m/s. + final double uMin; + final double uMax; + + /// Quantisation range of the v (northward) component, m/s. + final double vMin; + final double vMax; + + /// Valid time of the field, Unix milliseconds. + final int timeMs; + + /// The forecast model this grid came from (`gfs` / `ecmwf`). + final String model; + + /// Quantised eastward component, `width × height`, raster order. + final Uint8List u; + + /// Quantised northward component, `width × height`, raster order. + final Uint8List v; + + /// Parses a WND1 payload. Throws [FormatException] on any structural + /// mismatch (bad magic, unsupported version, truncation) — the data layer + /// wraps that into a [DecodeFailure] before anything else sees it. + factory WindField.fromWnd1(Uint8List bytes) { + const header = 67; + if (bytes.length < header) { + throw const FormatException('wind field truncated before its header'); + } + if (String.fromCharCodes(bytes.sublist(0, 4)) != 'WND1') { + throw const FormatException('not a WND1 wind field'); + } + final data = ByteData.sublistView(bytes); + if (data.getUint16(4, Endian.little) != 1) { + throw const FormatException('unsupported WND1 version'); + } + final width = data.getUint16(6, Endian.little); + final height = data.getUint16(8, Endian.little); + final n = width * height; + final nameLen = data.getUint8(66); + final planeOffset = header + nameLen; + if (bytes.length < planeOffset + n * 2) { + throw const FormatException('wind field truncated after its header'); + } + return WindField( + width: width, + height: height, + lat0: data.getFloat64(10, Endian.little), + lon0: data.getFloat64(18, Endian.little), + dLat: data.getFloat64(26, Endian.little), + dLon: data.getFloat64(34, Endian.little), + uMin: data.getFloat32(42, Endian.little), + uMax: data.getFloat32(46, Endian.little), + vMin: data.getFloat32(50, Endian.little), + vMax: data.getFloat32(54, Endian.little), + timeMs: data.getUint64(58, Endian.little).toInt(), + model: String.fromCharCodes(bytes.sublist(67, planeOffset)), + u: Uint8List.sublistView(bytes, planeOffset, planeOffset + n), + v: Uint8List.sublistView(bytes, planeOffset + n, planeOffset + n * 2), + ); + } + + /// Dequantised eastward component at cell [i], m/s. + double uAt(int i) => uMin + (u[i] / 255) * (uMax - uMin); + + /// Dequantised northward component at cell [i], m/s. + double vAt(int i) => vMin + (v[i] / 255) * (vMax - vMin); +} diff --git a/lib/features/weather/domain/wind_forecast_model.dart b/lib/features/weather/domain/wind_forecast_model.dart new file mode 100644 index 000000000..ee2693670 --- /dev/null +++ b/lib/features/weather/domain/wind_forecast_model.dart @@ -0,0 +1,24 @@ +/// One selectable numerical weather prediction (數值預報) model on the map — +/// ECMWF or GFS — rendered as a wind-field overlay from the `wind` tile +/// endpoints. +/// +/// [key] is the `?model=` value the wind tile endpoints accept. [subtitle] is +/// the layer picker's secondary line — the model's grid and time step in the +/// service's own notation (`0.25° · 1 h`). It is pure data (not display prose), +/// so it stays out of the ARB files and reads the same in every locale. +/// +/// The display order here is the layer-picker order. Coverage and step follow +/// `satellite-tiles-go/web/index.html` (GFS hourly to +16 days, ECMWF every 3 +/// hours to +10 days). +enum WindForecastModel { + ecmwf('ecmwf', '0.25° · 3 h'), + gfs('gfs', '0.25° · 1 h'); + + const WindForecastModel(this.key, this.subtitle); + + /// The `?model=` value naming this model on the wind tile endpoints. + final String key; + + /// Picker subtitle — the model's grid resolution and time step. + final String subtitle; +} diff --git a/lib/features/weather/domain/wind_forecast_repository.dart b/lib/features/weather/domain/wind_forecast_repository.dart new file mode 100644 index 000000000..2808c58cc --- /dev/null +++ b/lib/features/weather/domain/wind_forecast_repository.dart @@ -0,0 +1,17 @@ +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/weather/domain/wind_field.dart'; +import 'package:dpip/shared/map/raster_frame_source.dart'; + +/// Access to a numerical weather prediction wind field (數值預報風場) — one of +/// the ECMWF / GFS forecast models, consumed by the map surface. +/// +/// The surface is [RasterFrameSource] (frame list, tile URL template, and the +/// tile-memory controls a scrubbable overlay needs) plus a [fetchWindField] +/// that returns the raw grid a frame's particle animation is advected by. +/// Named separately so call sites read as "the ECMWF/GFS wind repository" and +/// so forecast-specific additions have a home. +abstract interface class WindForecastRepository implements RasterFrameSource { + /// The WND1 wind grid for [frame] (a Unix-second timestamp, the same id the + /// tiles are keyed by) — the velocity field the overlay's particles ride on. + Future> fetchWindField(String frame); +} diff --git a/lib/features/weather/presentation/pages/weather_ranking_page.dart b/lib/features/weather/presentation/pages/weather_ranking_page.dart index 7e1e21327..3d4f1abb9 100644 --- a/lib/features/weather/presentation/pages/weather_ranking_page.dart +++ b/lib/features/weather/presentation/pages/weather_ranking_page.dart @@ -1,10 +1,10 @@ /// Weather observation ranking — rain / temp / wind / pressure / humidity. library; -import 'dart:math' as math; - import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/geo_math.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; import 'package:dpip/features/weather/domain/rain_interval.dart'; @@ -315,19 +315,17 @@ class _WeatherRankingPageState extends State } String _formatSnapshotTime(int unixSeconds) { - final taipei = DateTime.fromMillisecondsSinceEpoch( - unixSeconds * 1000, - isUtc: true, - ).add(const Duration(hours: 8)); + final taipei = AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000, isUtc: true), + ); return DateFormat('yyyy/MM/dd HH:mm').format(taipei); } /// Taipei wall-clock `HH:mm` for an occurrence timestamp. String _formatClock(int unixSeconds) { - final taipei = DateTime.fromMillisecondsSinceEpoch( - unixSeconds * 1000, - isUtc: true, - ).add(const Duration(hours: 8)); + final taipei = AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000, isUtc: true), + ); return DateFormat('HH:mm').format(taipei); } @@ -647,7 +645,7 @@ class _WeatherMetricPanelState extends State<_WeatherMetricPanel> { final dir = item.windDirection; if (dir != null) { // Meteorological "from" → blow-to for Icons.navigation. - final radians = (dir + 180) * math.pi / 180; + final radians = degToRad(dir + 180); windArrow = Transform.rotate( angle: radians, child: Icon( diff --git a/lib/features/weather/weather_providers.dart b/lib/features/weather/weather_providers.dart index 1babca4b0..f23ffd92d 100644 --- a/lib/features/weather/weather_providers.dart +++ b/lib/features/weather/weather_providers.dart @@ -1,50 +1,92 @@ import 'package:dpip/core/di/shared_deps.dart'; -import 'package:dpip/features/weather/data/meteor_lightning_api.dart'; +import 'package:dpip/features/weather/data/frame_tile_api.dart'; +import 'package:dpip/features/weather/data/frame_tile_repository.dart'; import 'package:dpip/features/weather/data/meteor_lightning_repository_impl.dart'; -import 'package:dpip/features/weather/data/meteor_rain_api.dart'; import 'package:dpip/features/weather/data/meteor_rain_repository_impl.dart'; +import 'package:dpip/features/weather/data/meteor_snapshot_api.dart'; import 'package:dpip/features/weather/data/meteor_weather_api.dart'; import 'package:dpip/features/weather/data/meteor_weather_repository_impl.dart'; -import 'package:dpip/features/weather/data/radar_api.dart'; -import 'package:dpip/features/weather/data/radar_repository_impl.dart'; -import 'package:dpip/features/weather/data/satellite_api.dart'; -import 'package:dpip/features/weather/data/satellite_repository_impl.dart'; +import 'package:dpip/features/weather/data/rain_hour_trend_api.dart'; +import 'package:dpip/features/weather/data/rain_hour_trend_repository_impl.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/qpesums_repository.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; +import 'package:dpip/features/weather/domain/satellite_channel.dart'; import 'package:dpip/features/weather/domain/satellite_repository.dart'; +import 'package:dpip/features/weather/domain/wind_forecast_model.dart'; +import 'package:dpip/features/weather/domain/wind_forecast_repository.dart'; import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; -/// Weather providers: radar / satellite overlays, meteor weather / rain / -/// lightning. +/// Weather providers: radar / satellite / QPESUMS overlays, meteor weather / +/// rain / lightning, the next-hour rain trend, and the wind forecast models. /// -/// Radar and satellite get **their own** warmer: cancelling one layer's warm on -/// a layer switch must not abandon the other's. A null tile cache (the DB -/// wouldn't open) degrades to a no-op warmer, never a failed launch. +/// The raster overlays each get **their own** warmer: cancelling one layer's +/// warm on a layer switch must not abandon the other's. A null tile cache (the +/// DB wouldn't open) degrades to a no-op warmer, never a failed launch. List weatherProviders(SharedDeps deps) { return [ Provider.value( - value: RadarRepositoryImpl( - RadarApi(deps.apiClient), + value: FrameTileRepositoryImpl( + FrameTileApi(deps.apiClient, 'radar'), + deps.mapTileWarmer(), + ), + ), + Provider.value( + value: FrameTileRepositoryImpl( + FrameTileApi(deps.apiClient, 'qpesums'), deps.mapTileWarmer(), ), ), Provider.value( - value: SatelliteRepositoryImpl( - SatelliteApi(deps.apiClient), + value: FrameTileRepositoryImpl( + FrameTileApi(deps.apiClient, 'satellite'), deps.mapTileWarmer(), ), ), + // One repository per channel the satellite layer picker offers — each needs + // its own `?channel=` on both the frame list and every tile URL, and its + // own warmer so switching channels never abandons another channel's warm. + Provider>.value( + value: { + for (final channel in SatelliteChannel.values) + channel: FrameTileRepositoryImpl( + FrameTileApi(deps.apiClient, 'satellite', channel: channel.key), + deps.mapTileWarmer(), + ), + }, + ), + // One repository per wind forecast model — each needs its own `?model=` on + // both the frame list and every tile URL, and its own warmer. The 0.25° + // grids stop publishing at z7. + Provider>.value( + value: { + for (final model in WindForecastModel.values) + model: FrameTileRepositoryImpl( + FrameTileApi(deps.apiClient, 'wind', model: model.key), + deps.mapTileWarmer(), + maxZoom: 7, + ), + }, + ), Provider.value( value: MeteorWeatherRepositoryImpl(MeteorWeatherApi(deps.apiClient)), ), Provider.value( - value: MeteorRainRepositoryImpl(MeteorRainApi(deps.apiClient)), + value: MeteorRainRepositoryImpl( + MeteorSnapshotApi(deps.apiClient, '/api/v5/meteor/rain'), + ), ), Provider.value( - value: MeteorLightningRepositoryImpl(MeteorLightningApi(deps.apiClient)), + value: MeteorLightningRepositoryImpl( + MeteorSnapshotApi(deps.apiClient, '/api/v5/meteor/lightning'), + ), + ), + Provider.value( + value: RainHourTrendRepositoryImpl(RainHourTrendApi(deps.apiClient)), ), ]; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2088badf2..fd92bf70d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -709,22 +709,315 @@ "@homeActiveEventsEmpty": { "description": "Empty state when the realtime event feed has nothing in effect" }, - "homeRainTrendTitle": "Next hour rain", + "homeRainTrendTitle": "Next hour precipitation", "@homeRainTrendTitle": { "description": "Section title for the home sheet 1-hour per-minute rainfall bar chart" }, + "homeRainTrendMinute": "{minute} min", + "@homeRainTrendMinute": { + "description": "X-axis tick label on the home rain trend chart, minutes from now", + "placeholders": { + "minute": { + "type": "int" + } + } + }, + "homeRainTrendUpdated": "Updated {time}", + "@homeRainTrendUpdated": { + "description": "Data-update time beside the home rain trend title, Taipei wall clock HH:mm", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "homeRainTrendNoData": "No data", + "@homeRainTrendNoData": { + "description": "Label on the home rain trend chart for minutes beyond the forecast window, and the empty-card hint" + }, + + "homeRainTrendScattered": "Light showers possible", + "@homeRainTrendScattered": { + "description": "Home rain trend subtitle: peak intensity below the light-rain threshold" + }, + "homeRainTrendLightSustained": "Light rain continuing for the next hour", + "@homeRainTrendLightSustained": { + "description": "Home rain trend subtitle: light rain that keeps up through the hour" + }, + "homeRainTrendLightStopping": "Light rain likely to stop in {minutes} minutes", + "@homeRainTrendLightStopping": { + "description": "Home rain trend subtitle: light rain forecast to stop partway through the hour", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "homeRainTrendHeavySustained": "Heavy rain continuing for the next hour", + "@homeRainTrendHeavySustained": { + "description": "Home rain trend subtitle: heavy rain that keeps up through the hour" + }, + "homeRainTrendHeavyStopping": "Heavy rain likely to stop in {minutes} minutes", + "@homeRainTrendHeavyStopping": { + "description": "Home rain trend subtitle: heavy rain forecast to stop partway through the hour", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "mapLayers": "Layers", "@mapLayers": { "description": "Title of the map layer-picker sheet" }, + "mapLayerOrderTitle": "Reorder layers", + "@mapLayerOrderTitle": { + "description": "Title of the layer-order editor, also the tooltip of the reorder button in the layer picker" + }, + "mapLayerOrderReset": "Reset order", + "@mapLayerOrderReset": { + "description": "Button that restores the layer picker's default order" + }, "mapLayerRadar": "Composite Radar Reflectivity", "@mapLayerRadar": { "description": "Name of the composite radar reflectivity layer in the layer picker" }, - "mapLayerSatellite": "Himawari Infrared", + "mapLayerSatellite": "Himawari Infrared (B13)", "@mapLayerSatellite": { "description": "Name of the Himawari infrared layer in the layer picker" }, + "mapLayerSatelliteB01": "Himawari Blue (B01)", + "@mapLayerSatelliteB01": { + "description": "Himawari visible-blue channel (B01, 0.47 µm) layer name" + }, + "mapLayerSatelliteB02": "Himawari Green (B02)", + "@mapLayerSatelliteB02": { + "description": "Himawari visible-green channel (B02, 0.51 µm) layer name" + }, + "mapLayerSatelliteB03": "Himawari Red (B03)", + "@mapLayerSatelliteB03": { + "description": "Himawari visible-red channel (B03, 0.64 µm) layer name" + }, + "mapLayerSatelliteB04": "Himawari Near-Infrared (B04)", + "@mapLayerSatelliteB04": { + "description": "Himawari near-infrared channel (B04, 0.86 µm) layer name" + }, + "mapLayerSatelliteB05": "Himawari Near-Infrared (B05)", + "@mapLayerSatelliteB05": { + "description": "Himawari near-infrared channel (B05, 1.6 µm) layer name" + }, + "mapLayerSatelliteB06": "Himawari Near-Infrared (B06)", + "@mapLayerSatelliteB06": { + "description": "Himawari near-infrared channel (B06, 2.3 µm) layer name" + }, + "mapLayerSatelliteB07": "Himawari Shortwave Infrared (B07)", + "@mapLayerSatelliteB07": { + "description": "Himawari shortwave-infrared channel (B07, 3.9 µm) layer name" + }, + "mapLayerSatelliteB08": "Himawari Upper Water Vapour (B08)", + "@mapLayerSatelliteB08": { + "description": "Himawari upper-level water-vapour channel (B08, 6.2 µm) layer name" + }, + "mapLayerSatelliteB09": "Himawari Mid Water Vapour (B09)", + "@mapLayerSatelliteB09": { + "description": "Himawari mid-level water-vapour channel (B09, 6.9 µm) layer name" + }, + "mapLayerSatelliteB10": "Himawari Lower Water Vapour (B10)", + "@mapLayerSatelliteB10": { + "description": "Himawari lower-level water-vapour channel (B10, 7.3 µm) layer name" + }, + "mapLayerSatelliteB11": "Himawari SO₂ / Cloud Phase (B11)", + "@mapLayerSatelliteB11": { + "description": "Himawari SO₂ absorption channel (B11, 8.6 µm) layer name" + }, + "mapLayerSatelliteB12": "Himawari Ozone (B12)", + "@mapLayerSatelliteB12": { + "description": "Himawari ozone-band channel (B12, 9.6 µm) layer name" + }, + "mapLayerSatelliteB13": "Himawari Infrared (B13)", + "@mapLayerSatelliteB13": { + "description": "Himawari clean-infrared window channel (B13, 10.4 µm) layer name" + }, + "mapLayerSatelliteB14": "Himawari Longwave Infrared (B14)", + "@mapLayerSatelliteB14": { + "description": "Himawari longwave-infrared channel (B14, 11.2 µm) layer name" + }, + "mapLayerSatelliteB15": "Himawari Longwave Infrared (B15)", + "@mapLayerSatelliteB15": { + "description": "Himawari longwave-infrared channel (B15, 12.4 µm) layer name" + }, + "mapLayerSatelliteB16": "Himawari CO₂ (B16)", + "@mapLayerSatelliteB16": { + "description": "Himawari CO₂-band channel (B16, 13.3 µm) layer name" + }, + "mapLayerSatelliteTruecolor": "Himawari True Color", + "@mapLayerSatelliteTruecolor": { + "description": "Himawari True Color RGB composite layer name" + }, + "mapLayerSatelliteNaturalcolor": "Himawari Natural Color", + "@mapLayerSatelliteNaturalcolor": { + "description": "Himawari Natural Color RGB composite layer name" + }, + "mapLayerSatelliteAsh": "Himawari Ash", + "@mapLayerSatelliteAsh": { + "description": "Himawari Ash RGB composite layer name" + }, + "mapLayerSatelliteDust": "Himawari Dust", + "@mapLayerSatelliteDust": { + "description": "Himawari Dust RGB composite layer name" + }, + "mapLayerSatelliteAirmass": "Himawari Airmass", + "@mapLayerSatelliteAirmass": { + "description": "Himawari Airmass RGB composite layer name" + }, + "mapLayerSatelliteNightmicrophysics": "Himawari Night Microphysics", + "@mapLayerSatelliteNightmicrophysics": { + "description": "Himawari Night Microphysics RGB composite layer name" + }, + "mapLayerSatelliteWatervapor": "Himawari Water Vapour", + "@mapLayerSatelliteWatervapor": { + "description": "Himawari water-vapour layer name" + }, + "mapLayerSatelliteBtdSplit": "Himawari Split Window", + "@mapLayerSatelliteBtdSplit": { + "description": "Himawari split-window brightness-temperature-difference layer name" + }, + "mapLayerSatelliteBtdFog": "Himawari Night Fog", + "@mapLayerSatelliteBtdFog": { + "description": "Himawari night fog / low-cloud brightness-temperature-difference layer name" + }, + "mapLayerSatelliteBtdWvirw": "Himawari Overshooting Top", + "@mapLayerSatelliteBtdWvirw": { + "description": "Himawari overshooting-cloud-top brightness-temperature-difference layer name" + }, + "mapLayerSatelliteBtdSo2": "Himawari SO₂ / Cloud Phase", + "@mapLayerSatelliteBtdSo2": { + "description": "Himawari SO₂ / cloud-phase brightness-temperature-difference layer name" + }, + "mapLayerSatelliteBtdCo2": "Himawari Cirrus / Cloud Height", + "@mapLayerSatelliteBtdCo2": { + "description": "Himawari cirrus / cloud-height brightness-temperature-difference layer name" + }, + "mapLayerSatelliteBtdOzone": "Himawari Tropopause", + "@mapLayerSatelliteBtdOzone": { + "description": "Himawari tropopause brightness-temperature-difference layer name" + }, + "mapLayerSatelliteCloudtop": "Himawari Cloud Top Temperature", + "@mapLayerSatelliteCloudtop": { + "description": "Himawari cloud-top-temperature (Level-2 retrieval) layer name" + }, + "mapLayerSatelliteCloudmask": "Himawari Cloud Mask", + "@mapLayerSatelliteCloudmask": { + "description": "Himawari cloud-mask (Level-2 retrieval) layer name" + }, + "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", + "@mapLayerSatelliteSst": { + "description": "Himawari sea-surface-temperature (ACSPO L3C) layer name" + }, + "mapLayerSatelliteNdvi": "Himawari NDVI", + "@mapLayerSatelliteNdvi": { + "description": "Himawari normalised-difference vegetation-index layer name" + }, + "mapLayerSatelliteNdwi": "Himawari NDWI", + "@mapLayerSatelliteNdwi": { + "description": "Himawari normalised-difference water-index layer name" + }, + "mapLayerSatelliteMndwi": "Himawari MNDWI", + "@mapLayerSatelliteMndwi": { + "description": "Himawari modified normalised-difference water-index layer name" + }, + "mapLayerSatelliteGlobalOutline": "Country border", + "@mapLayerSatelliteGlobalOutline": { + "description": "Satellite legend row: the country/global border, drawn bright yellow over the imagery" + }, + "mapLayerSatelliteRgbComposite": "RGB composite (JMA recipe)", + "@mapLayerSatelliteRgbComposite": { + "description": "Satellite legend note for the RGB-recipe products (True Color, Ash, …) that carry no single numerical scale" + }, + "mapLayerSatelliteCloudClear": "Clear", + "@mapLayerSatelliteCloudClear": { + "description": "Cloud-mask category: clear sky, transparent on the map" + }, + "mapLayerSatelliteCloudProbablyClear": "Probably clear", + "@mapLayerSatelliteCloudProbablyClear": { + "description": "Cloud-mask category: probably clear" + }, + "mapLayerSatelliteCloudProbablyCloudy": "Probably cloudy", + "@mapLayerSatelliteCloudProbablyCloudy": { + "description": "Cloud-mask category: probably cloudy" + }, + "mapLayerSatelliteCloudCloudy": "Cloudy", + "@mapLayerSatelliteCloudCloudy": { + "description": "Cloud-mask category: cloudy" + }, + "mapLayerSatelliteTransparentWarm": "Clear sky (warm end) = transparent, the basemap shows", + "@mapLayerSatelliteTransparentWarm": { + "description": "Satellite legend note: on the IR grayscale/enhancements the warm end is clear sky, drawn transparent so the basemap shows" + }, + "mapLayerSatelliteTransparentReflectance": "Low reflectance / night = transparent, the basemap shows", + "@mapLayerSatelliteTransparentReflectance": { + "description": "Satellite legend note: on the reflectance bands a dark or night pixel is transparent so the basemap shows" + }, + "mapLayerSatelliteTransparentZero": "Zero difference = transparent (no signal)", + "@mapLayerSatelliteTransparentZero": { + "description": "Satellite legend note: on the brightness-temperature-difference layers a near-zero difference is transparent — no absorber is present" + }, + "mapLayerSatelliteTransparentNight": "Night = transparent, the basemap shows", + "@mapLayerSatelliteTransparentNight": { + "description": "Satellite legend note: the daytime RGB recipes fade out across the terminator and are transparent at night" + }, + "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", + "@mapLayerSatelliteTransparentNoData": { + "description": "Satellite legend note: the SST retrieval has no value over land, drawn transparent" + }, + "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", + "@mapLayerSatelliteTransparentNoVegetation": { + "description": "Satellite legend note: NDVI below the bare-soil threshold is transparent" + }, + "mapLayerSatelliteTransparentNoWater": "≤ 0 = transparent (no water)", + "@mapLayerSatelliteTransparentNoWater": { + "description": "Satellite legend note: NDWI/MNDWI at zero or below is transparent — no water signal" + }, + "mapLayerSatelliteTransparentClear": "Clear sky = transparent, the basemap shows", + "@mapLayerSatelliteTransparentClear": { + "description": "Satellite legend note: the cloud-mask clear category is transparent so the basemap shows" + }, + "mapLayerStyleSection": "Colour style", + "@mapLayerStyleSection": { + "description": "Section header of the satellite band colour-style menu on the map" + }, + "mapLayerStyleTooltip": "Colour style", + "@mapLayerStyleTooltip": { + "description": "Tooltip of the colour-style chip beside the layer switcher" + }, + "mapLayerStyleGray": "Grayscale (JMA)", + "@mapLayerStyleGray": { + "description": "Colour-style option: JMA grayscale, the default radar-image convention" + }, + "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", + "@mapLayerStyleGrayTooltip": { + "description": "Explains the JMA grayscale band rendering" + }, + "mapLayerStyleJma": "Cloud-top enhancement (JMA)", + "@mapLayerStyleJma": { + "description": "Colour-style option: JMA cloud-top enhancement, tinted below −40 °C" + }, + "mapLayerStyleJmaTooltip": "Grayscale base, tinted below −40 °C to highlight cloud-top height", + "@mapLayerStyleJmaTooltip": { + "description": "Explains the JMA cloud-top enhancement band rendering" + }, + "mapLayerStyleBd": "Dvorak BD", + "@mapLayerStyleBd": { + "description": "Colour-style option: Dvorak BD curve stepped grayscale" + }, + "mapLayerStyleBdTooltip": "Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis", + "@mapLayerStyleBdTooltip": { + "description": "Explains the Dvorak BD band rendering" + }, + "mapLayerQpesums": "1h Precipitation Forecast", + "@mapLayerQpesums": { + "description": "Name of the QPESUMS next-1-hour precipitation forecast layer in the layer picker" + }, "mapLayerLightning": "Lightning", "@mapLayerLightning": { "description": "Map layer switcher label for the lightning strike timeline" @@ -751,10 +1044,31 @@ "@mapTimelineNow": { "description": "Label on the map timeline when the newest (latest) frame is selected" }, + "mapTimelinePast": "Past", + "@mapTimelinePast": { + "description": "Label on the map timeline when the selected frame predates the present" + }, + "mapTimelineFuture": "Future", + "@mapTimelineFuture": { + "description": "Label on the map timeline when the selected frame postdates the present" + }, "mapTimelineObserved": "Observed", "@mapTimelineObserved": { "description": "Label above the map timeline's date (the radar observation time), e.g. Observed / 2026/07/14" }, + "mapTimelineForecast": "Forecast", + "@mapTimelineForecast": { + "description": "Label above the map timeline's date when the frame times are forecast times, e.g. Forecast / 2026/07/14" + }, + "mapTimelineDataTime": "Data {time}", + "@mapTimelineDataTime": { + "description": "Model-run issue time shown on the map timeline under a forecast layer's caption, e.g. Data 8/11 14:00", + "placeholders": { + "time": { + "type": "String" + } + } + }, "notifySettingsMenu": "Notification settings", "@notifySettingsMenu": { "description": "More-menu entry that opens the notification-settings page" @@ -1035,6 +1349,10 @@ "@mapNavRadar": { "description": "Short Map-tab bottom-nav / default-layer picker label for radar" }, + "mapNavQpesums": "Forecast", + "@mapNavQpesums": { + "description": "Short Map-tab bottom-nav / default-layer picker label for the 1h QPESUMS precipitation forecast" + }, "mapNavSatellite": "Satellite", "@mapNavSatellite": { "description": "Short Map-tab bottom-nav / default-layer picker label for satellite" @@ -1334,6 +1652,14 @@ "@mapLayerWind": { "description": "Map layer switcher label for the wind-direction layer" }, + "mapLayerWindForecastEcmwf": "ECMWF", + "@mapLayerWindForecastEcmwf": { + "description": "Map layer switcher label for the ECMWF wind-forecast layer" + }, + "mapLayerWindForecastGfs": "GFS", + "@mapLayerWindForecastGfs": { + "description": "Map layer switcher label for the GFS wind-forecast layer" + }, "mapLayerMonitor": "Seismic Monitor", "@mapLayerMonitor": { "description": "Map layer switcher label for the real-time seismic monitor (RTS)" @@ -1418,6 +1744,100 @@ "@disasterMapOverlayShelterTooltip": { "description": "Tooltip for the shelter toggle in the disaster-map overlay menu" }, + "dpmOpenInMaps": "Open in maps", + "@dpmOpenInMaps": { + "description": "Action in the disaster-map detail sheet: open the point in an external map app" + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + "description": "External map app choice: Google Maps" + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + "description": "External map app choice: Apple Maps" + }, + "mapAppDefault": "{app} (default)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + }, + "description": "Choice-sheet label suffix marking the platform home map app, with the app name" + }, + "mapAppCopyCoordinates": "Copy coordinates", + "@mapAppCopyCoordinates": { + "description": "Choice-sheet action: copy the point's coordinates" + }, + "mapAppCoordinatesCopied": "Coordinates copied", + "@mapAppCoordinatesCopied": { + "description": "Snackbar confirming the coordinates were copied" + }, + "mapAppOpenFailed": "Could not open {app}", + "@mapAppOpenFailed": { + "placeholders": { + "app": {"type": "String"} + }, + "description": "Snackbar when the chosen map app cannot be opened on this device" + }, + "mapAppCallFailed": "This device cannot make phone calls", + "@mapAppCallFailed": { + "description": "Snackbar when tapping the emergency phone and the device has no phone handler" + }, + + "mapOverlaySectionReference": "Reference layers", + "@mapOverlaySectionReference": { + "description": "Section title in map overlay settings menus: the reference overlays" + }, + "mapLayerCategoryEarthquake": "Earthquake", + "@mapLayerCategoryEarthquake": { + "description": "Section title in map overlay lists: the seismic-monitor overlays" + }, + + "mapLayerCategoryTyphoon": "Typhoon", + "@mapLayerCategoryTyphoon": { + "description": "Section title in map overlay lists: typhoon overlays" + }, + + "mapLayerCategoryWeather": "Weather observations", + "@mapLayerCategoryWeather": { + "description": "Section title in map overlay lists: the weather-observation overlays" + }, + + "mapLayerCategorySatellite": "Satellite", + "@mapLayerCategorySatellite": { + "description": "Section title in map overlay lists: satellite-imagery overlays" + }, + + "mapLayerCategoryRadar": "Radar", + "@mapLayerCategoryRadar": { + "description": "Section title in map overlay lists: radar and precipitation-forecast overlays" + }, + + "mapLayerCategoryLife": "Daily life", + "@mapLayerCategoryLife": { + "description": "Section title in map overlay lists: everyday-life facility overlays" + }, + + "mapLayerCategoryForecast": "Numerical forecast", + "@mapLayerCategoryForecast": { + "description": "Section title in map overlay lists: numerical weather prediction (ECMWF/GFS) wind-field overlays" + }, "mapOverlaySectionMap": "Map", + "@mapOverlaySectionMap": { + "description": "Section title in map overlay settings menus: base-map settings" + }, + "rainIntervalSection": "Time window", + "@rainIntervalSection": { + "description": "Section title in the rainfall menu: the accumulation-interval choices" + }, + + "mapTownLabels": "Township names", + "@mapTownLabels": { + "description": "Map setting: show township-name labels when the map is zoomed in" + }, + "mapTownLabelsHint": "Show township names when zoomed in", + "@mapTownLabelsHint": { + "description": "Hint under the township-names setting" + }, + "dpmSheetEmpty": "Tap a marker on the map for details", "@dpmSheetEmpty": { "description": "Hint in the disaster-map detail sheet when nothing is selected" @@ -1601,15 +2021,39 @@ "@typhoonPickerNamed": { "description": "Sheet picker: named typhoon (CWA name + TY tyNo)", "placeholders": { - "no": { "type": "String" }, - "name": { "type": "String" } + "no": { + "type": "String" + }, + "name": { + "type": "String" + } } }, "typhoonPickerTd": "Tropical depression TD {no}", "@typhoonPickerTd": { "description": "Sheet picker: unnamed tropical depression (CWA tdNo)", "placeholders": { - "no": { "type": "String" } + "no": { + "type": "String" + } + } + }, + "typhoonTyNo": "TY {no}", + "@typhoonTyNo": { + "description": "Secondary badge on the typhoon sheet hero: the CWA typhoon serial number, e.g. TY 4", + "placeholders": { + "no": { + "type": "String" + } + } + }, + "typhoonTdNo": "TD {no}", + "@typhoonTdNo": { + "description": "Secondary badge on the typhoon sheet hero: the CWA tropical-depression serial number, e.g. TD 14", + "placeholders": { + "no": { + "type": "String" + } } }, "typhoonIntensityMild": "Mild typhoon", @@ -1652,6 +2096,10 @@ "@mapMyLocation": { "description": "Map control that centers the camera on the device GPS fix" }, + "mapResetNorth": "Reset north", + "@mapResetNorth": { + "description": "Map compass tooltip: re-points the camera to north-up" + }, "typhoonLegendCircle15": "Gale circle (L7)", "typhoonLegendCircleAvg": "Average circle", "@typhoonLegendCircleAvg": { @@ -1834,5 +2282,133 @@ "dpmDisasterNuclear": "Nuclear accident", "@dpmDisasterNuclear": { "description": "Shelter disaster-type filter chip: nuclear accident" + }, + "skyTime": "Sky time", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "Auto", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "Dawn", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "Sunrise", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "Morning", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "Noon", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "Afternoon", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "Golden hour", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "Sunset", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "Dusk", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "Night", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "Cloudy", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "Overcast", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "Snow", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "Dust", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "Show scan range", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "Outlines the area the four radars actually observe.", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "Blank outside means unobserved", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "Radar overlay options", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "County borders", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "National borders", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "Every country's outer frame", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "Drawn over the echo", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "Keeps county borders legible under the radar echo.", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "Township borders", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "The finer mesh", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "Keeps township borders legible under the radar echo.", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "QPESUMS overlay options", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "Wind forecast overlay options", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "Drawn over the wind field", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "Every country's outer frame", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "The finer mesh", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." } -} \ No newline at end of file +} diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 393a9f3f1..af8a747a5 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -151,14 +151,88 @@ "homeActiveEventsTitle": "Mga aktibong event", "homeActiveEventsEmpty": "Walang aktibong event", "homeRainTrendTitle": "Ulan sa susunod na oras", + "homeRainTrendMinute": "{minute} min", + "homeRainTrendUpdated": "Na-update {time}", + "homeRainTrendNoData": "Walang data", + + "homeRainTrendScattered": "Posibleng mahinang ulan", + "homeRainTrendLightSustained": "Tuloy-tuloy na mahinang ulan sa susunod na oras", + "homeRainTrendLightStopping": "Baka huminto ang mahinang ulan sa loob ng {minutes} minuto", + "homeRainTrendHeavySustained": "Tuloy-tuloy na malakas na ulan sa susunod na oras", + "homeRainTrendHeavyStopping": "Baka huminto ang malakas na ulan sa loob ng {minutes} minuto", "mapLayers": "Mga Layer", + "mapLayerOrderTitle": "Ayusin ang ayos ng layer", + "mapLayerOrderReset": "I-reset ang ayos", "mapLayerRadar": "Composite Radar Reflectivity", - "mapLayerSatellite": "Himawari Infrared", + "mapLayerSatellite": "Himawari Infrared (B13)", + "mapLayerSatelliteB01": "Himawari Blue (B01)", + "mapLayerSatelliteB02": "Himawari Green (B02)", + "mapLayerSatelliteB03": "Himawari Red (B03)", + "mapLayerSatelliteB04": "Himawari Near-Infrared (B04)", + "mapLayerSatelliteB05": "Himawari Near-Infrared (B05)", + "mapLayerSatelliteB06": "Himawari Near-Infrared (B06)", + "mapLayerSatelliteB07": "Himawari Shortwave Infrared (B07)", + "mapLayerSatelliteB08": "Himawari Upper Water Vapour (B08)", + "mapLayerSatelliteB09": "Himawari Mid Water Vapour (B09)", + "mapLayerSatelliteB10": "Himawari Lower Water Vapour (B10)", + "mapLayerSatelliteB11": "Himawari SO₂ / Cloud Phase (B11)", + "mapLayerSatelliteB12": "Himawari Ozone (B12)", + "mapLayerSatelliteB13": "Himawari Infrared (B13)", + "mapLayerSatelliteB14": "Himawari Longwave Infrared (B14)", + "mapLayerSatelliteB15": "Himawari Longwave Infrared (B15)", + "mapLayerSatelliteB16": "Himawari CO₂ (B16)", + "mapLayerSatelliteTruecolor": "Himawari True Color", + "mapLayerSatelliteNaturalcolor": "Himawari Natural Color", + "mapLayerSatelliteAsh": "Himawari Ash", + "mapLayerSatelliteDust": "Himawari Dust", + "mapLayerSatelliteAirmass": "Himawari Airmass", + "mapLayerSatelliteNightmicrophysics": "Himawari Night Microphysics", + "mapLayerSatelliteWatervapor": "Himawari Water Vapour", + "mapLayerSatelliteBtdSplit": "Himawari Split Window", + "mapLayerSatelliteBtdFog": "Himawari Night Fog", + "mapLayerSatelliteBtdWvirw": "Himawari Overshooting Top", + "mapLayerSatelliteBtdSo2": "Himawari SO₂ / Cloud Phase", + "mapLayerSatelliteBtdCo2": "Himawari Cirrus / Cloud Height", + "mapLayerSatelliteBtdOzone": "Himawari Tropopause", + "mapLayerSatelliteCloudtop": "Himawari Cloud Top Temperature", + "mapLayerSatelliteCloudmask": "Himawari Cloud Mask", + "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", + "mapLayerSatelliteNdvi": "Himawari NDVI", + "mapLayerSatelliteNdwi": "Himawari NDWI", + "mapLayerSatelliteMndwi": "Himawari MNDWI", + "mapLayerSatelliteGlobalOutline": "Country border", + "mapLayerSatelliteRgbComposite": "RGB composite (JMA recipe)", + "mapLayerSatelliteCloudClear": "Clear", + "mapLayerSatelliteCloudProbablyClear": "Probably clear", + "mapLayerSatelliteCloudProbablyCloudy": "Probably cloudy", + "mapLayerSatelliteCloudCloudy": "Cloudy", + "mapLayerSatelliteTransparentWarm": "Clear sky (warm end) = transparent, the basemap shows", + "mapLayerSatelliteTransparentReflectance": "Low reflectance / night = transparent, the basemap shows", + "mapLayerSatelliteTransparentZero": "Zero difference = transparent (no signal)", + "mapLayerSatelliteTransparentNight": "Night = transparent, the basemap shows", + "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", + "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", + "mapLayerSatelliteTransparentNoWater": "≤ 0 = transparent (no water)", + "mapLayerSatelliteTransparentClear": "Clear sky = transparent, the basemap shows", + "mapLayerStyleSection": "Colour style", + "mapLayerStyleTooltip": "Colour style", + "mapLayerStyleGray": "Grayscale (JMA)", + "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", + "mapLayerStyleJma": "Cloud-top enhancement (JMA)", + "mapLayerStyleJmaTooltip": "Grayscale base, tinted below −40 °C to highlight cloud-top height", + "mapLayerStyleBd": "Dvorak BD", + "mapLayerStyleBdTooltip": "Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis", + "mapLayerQpesums": "Pagtaya ng ulan sa susunod na 1 oras", "mapLayerLightning": "Kidlat", "lightningLegendCg": "Ulap–lupa · {minutes} min", "lightningLegendCc": "Ulap–ulap · {minutes} min", "mapTimelineNow": "Ngayon", + "mapTimelinePast": "Nakaraan", + "mapTimelineFuture": "Hinaharap", "mapTimelineObserved": "Naobserbahan", + "mapTimelineForecast": "Pagtaya", + "mapTimelineDataTime": "Oras ng data {time}", + "mapTimelineDataTime": "Oras ng data {time}", "notifySettingsMenu": "Mga setting ng notipikasyon", "notifyTitle": "Mga Notipikasyon", "notifyUnavailable": "Hindi pa handa ang push notifications — subukan muli mamaya.", @@ -229,6 +303,7 @@ "defaultMapLayerSettings": "Default na layer ng mapa", "defaultMapLayerSubtitle": "Bubukas ang tab ng Mapa sa layer na ito. Susunod ang icon at label ng bottom navigation.", "mapNavRadar": "Radar", + "mapNavQpesums": "Pagtaya", "mapNavSatellite": "Satellite", "mapNavLightning": "Kidlat", "mapNavTyphoon": "Bagyo", @@ -268,6 +343,8 @@ "mapLayerHumidity": "Halumigmig", "mapLayerPressure": "Presyon", "mapLayerWind": "Hangin", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "Ulan", "rainIntervalMenu": "Bintana ng akumulasyon", "rainIntervalNow": "Ngayon", @@ -306,6 +383,46 @@ "mapLayerShelter": "Silungan", "disasterMapOverlayRestroomTooltip": "Ipakita ang mga pampublikong palikuran", "disasterMapOverlayShelterTooltip": "Ipakita ang mga silungan", + "dpmOpenInMaps": "Buksan sa mapa", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app} (default)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "Kopyahin ang coordinates", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "Na-kopya ang coordinates", + "@mapAppCoordinatesCopied": { + }, + "mapAppOpenFailed": "Hindi mabuksan ang {app}", + "@mapAppOpenFailed": { + }, + + "mapAppCallFailed": "Hindi makatawag ang device na ito", + + "mapOverlaySectionReference": "Layer ng sanggunian", + "mapLayerCategoryEarthquake": "Lindol", + "mapLayerCategoryTyphoon": "Bagyo", + "mapLayerCategoryWeather": "Obserbasyon sa panahon", + "mapLayerCategorySatellite": "Satellite", + "mapLayerCategoryRadar": "Radar", + "mapLayerCategoryLife": "Pang-araw-araw na buhay", + "mapLayerCategoryForecast": "Numerical forecast", "mapOverlaySectionMap": "Mapa", + "rainIntervalSection": "Window ng oras", + + "mapTownLabels": "Mga pangalan ng bayan", + "mapTownLabelsHint": "Ipakita ang mga pangalan ng bayan kapag naka-zoom", + "dpmSheetEmpty": "I-tap ang marker sa mapa para sa detalye", "dpmAddress": "Address", "restroomTypeLabel": "Uri", @@ -353,6 +470,7 @@ "mapLegendExpand": "Alamat", "mapLegendCollapse": "Itago ang alamat", "mapMyLocation": "Aking lokasyon", + "mapResetNorth": "Bumalik sa hilaga", "typhoonLegendCircle15": "Gale circle (L7)", "typhoonLegendCircle25": "Storm circle (L10)", "typhoonLegendProbability": "Strike probability", @@ -371,6 +489,8 @@ "typhoonIntensityTd": "Tropical depression", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "Tropical depression TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "Past movement direction", "typhoonLabelGaleAvg": "Avg. radius of Beaufort 7 winds", "typhoonLabelGust": "Peak gust", @@ -417,5 +537,133 @@ "dpmDisasterLandslide": "Pagguho ng lupa", "dpmDisasterTsunami": "Tsunami", "dpmDisasterSlope": "Panganib sa dalisdis", - "dpmDisasterNuclear": "Aksidente sa nukleyar" -} \ No newline at end of file + "dpmDisasterNuclear": "Aksidente sa nukleyar", + "skyTime": "Oras ng langit", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "Awtomatiko", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "Bukang-liwayway", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "Pagsikat ng araw", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "Umaga", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "Tanghali", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "Hapon", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "Gintong oras", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "Paglubog ng araw", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "Takipsilim", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "Gabi", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "Maulap", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "Makulimlim", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "Niyebe", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "Alikabok", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "Ipakita ang saklaw ng pag-scan", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "Ipinapakita ang aktwal na saklaw ng apat na radar.", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "Sa labas: hindi naoobserbahan", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "Mga opsyon sa layer ng radar", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "Mga hangganan ng lalawigan", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "Mga hangganan ng bansa", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "Panlabas na balangkas ng bawat bansa", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "Iginuguhit sa ibabaw ng echo", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "Nananatiling mababasa ang mga hangganan sa ilalim ng radar echo.", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "Mga hangganan ng bayan", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "Mas pinong hati", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "Nananatiling mababasa ang mga hangganan ng bayan sa ilalim ng radar echo.", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "Mga opsyon sa layer ng pagtataya ng pag-ulan", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "Mga opsyon sa layer ng pagtataya ng hangin", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "Iginuhit sa itaas ng patlang ng hangin", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "Panlabas na balangkas ng bawat bansa", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "Ang mas pinong mesh", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." + } +} diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index e3a7de1ca..0184ae547 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -151,14 +151,88 @@ "homeActiveEventsTitle": "Peristiwa aktif", "homeActiveEventsEmpty": "Tidak ada peristiwa aktif", "homeRainTrendTitle": "Hujan 1 jam ke depan", + "homeRainTrendMinute": "{minute} mnt", + "homeRainTrendUpdated": "Diperbarui {time}", + "homeRainTrendNoData": "Tidak ada data", + + "homeRainTrendScattered": "Kemungkinan hujan ringan", + "homeRainTrendLightSustained": "Hujan ringan berlanjut selama 1 jam ke depan", + "homeRainTrendLightStopping": "Hujan ringan diperkirakan berhenti dalam {minutes} menit", + "homeRainTrendHeavySustained": "Hujan deras berlanjut selama 1 jam ke depan", + "homeRainTrendHeavyStopping": "Hujan deras diperkirakan berhenti dalam {minutes} menit", "mapLayers": "Lapisan", + "mapLayerOrderTitle": "Urutkan lapisan", + "mapLayerOrderReset": "Atur ulang urutan", "mapLayerRadar": "Radar Komposit", - "mapLayerSatellite": "Himawari Inframerah", + "mapLayerSatellite": "Himawari Infrared (B13)", + "mapLayerSatelliteB01": "Himawari Blue (B01)", + "mapLayerSatelliteB02": "Himawari Green (B02)", + "mapLayerSatelliteB03": "Himawari Red (B03)", + "mapLayerSatelliteB04": "Himawari Near-Infrared (B04)", + "mapLayerSatelliteB05": "Himawari Near-Infrared (B05)", + "mapLayerSatelliteB06": "Himawari Near-Infrared (B06)", + "mapLayerSatelliteB07": "Himawari Shortwave Infrared (B07)", + "mapLayerSatelliteB08": "Himawari Upper Water Vapour (B08)", + "mapLayerSatelliteB09": "Himawari Mid Water Vapour (B09)", + "mapLayerSatelliteB10": "Himawari Lower Water Vapour (B10)", + "mapLayerSatelliteB11": "Himawari SO₂ / Cloud Phase (B11)", + "mapLayerSatelliteB12": "Himawari Ozone (B12)", + "mapLayerSatelliteB13": "Himawari Infrared (B13)", + "mapLayerSatelliteB14": "Himawari Longwave Infrared (B14)", + "mapLayerSatelliteB15": "Himawari Longwave Infrared (B15)", + "mapLayerSatelliteB16": "Himawari CO₂ (B16)", + "mapLayerSatelliteTruecolor": "Himawari True Color", + "mapLayerSatelliteNaturalcolor": "Himawari Natural Color", + "mapLayerSatelliteAsh": "Himawari Ash", + "mapLayerSatelliteDust": "Himawari Dust", + "mapLayerSatelliteAirmass": "Himawari Airmass", + "mapLayerSatelliteNightmicrophysics": "Himawari Night Microphysics", + "mapLayerSatelliteWatervapor": "Himawari Water Vapour", + "mapLayerSatelliteBtdSplit": "Himawari Split Window", + "mapLayerSatelliteBtdFog": "Himawari Night Fog", + "mapLayerSatelliteBtdWvirw": "Himawari Overshooting Top", + "mapLayerSatelliteBtdSo2": "Himawari SO₂ / Cloud Phase", + "mapLayerSatelliteBtdCo2": "Himawari Cirrus / Cloud Height", + "mapLayerSatelliteBtdOzone": "Himawari Tropopause", + "mapLayerSatelliteCloudtop": "Himawari Cloud Top Temperature", + "mapLayerSatelliteCloudmask": "Himawari Cloud Mask", + "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", + "mapLayerSatelliteNdvi": "Himawari NDVI", + "mapLayerSatelliteNdwi": "Himawari NDWI", + "mapLayerSatelliteMndwi": "Himawari MNDWI", + "mapLayerSatelliteGlobalOutline": "Country border", + "mapLayerSatelliteRgbComposite": "RGB composite (JMA recipe)", + "mapLayerSatelliteCloudClear": "Clear", + "mapLayerSatelliteCloudProbablyClear": "Probably clear", + "mapLayerSatelliteCloudProbablyCloudy": "Probably cloudy", + "mapLayerSatelliteCloudCloudy": "Cloudy", + "mapLayerSatelliteTransparentWarm": "Clear sky (warm end) = transparent, the basemap shows", + "mapLayerSatelliteTransparentReflectance": "Low reflectance / night = transparent, the basemap shows", + "mapLayerSatelliteTransparentZero": "Zero difference = transparent (no signal)", + "mapLayerSatelliteTransparentNight": "Night = transparent, the basemap shows", + "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", + "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", + "mapLayerSatelliteTransparentNoWater": "≤ 0 = transparent (no water)", + "mapLayerSatelliteTransparentClear": "Clear sky = transparent, the basemap shows", + "mapLayerStyleSection": "Colour style", + "mapLayerStyleTooltip": "Colour style", + "mapLayerStyleGray": "Grayscale (JMA)", + "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", + "mapLayerStyleJma": "Cloud-top enhancement (JMA)", + "mapLayerStyleJmaTooltip": "Grayscale base, tinted below −40 °C to highlight cloud-top height", + "mapLayerStyleBd": "Dvorak BD", + "mapLayerStyleBdTooltip": "Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis", + "mapLayerQpesums": "Prakiraan hujan 1 jam ke depan", "mapLayerLightning": "Petir", "lightningLegendCg": "Awan–tanah · {minutes} mnt", "lightningLegendCc": "Awan–awan · {minutes} mnt", "mapTimelineNow": "Sekarang", + "mapTimelinePast": "Lampau", + "mapTimelineFuture": "Mendatang", "mapTimelineObserved": "Diamati", + "mapTimelineForecast": "Prakiraan", + "mapTimelineDataTime": "Waktu data {time}", + "mapTimelineDataTime": "Waktu data {time}", "notifySettingsMenu": "Pengaturan notifikasi", "notifyTitle": "Notifikasi", "notifyUnavailable": "Notifikasi push belum siap — coba lagi sebentar lagi.", @@ -229,6 +303,7 @@ "defaultMapLayerSettings": "Lapisan peta bawaan", "defaultMapLayerSubtitle": "Tab Peta membuka lapisan ini. Ikon dan label navigasi bawah ikut pilihan ini.", "mapNavRadar": "Radar", + "mapNavQpesums": "Prakiraan", "mapNavSatellite": "Satelit", "mapNavLightning": "Petir", "mapNavTyphoon": "Topan", @@ -268,6 +343,8 @@ "mapLayerHumidity": "Kelembapan", "mapLayerPressure": "Tekanan", "mapLayerWind": "Angin", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "Curah hujan", "rainIntervalMenu": "Jendela akumulasi", "rainIntervalNow": "Hari ini", @@ -306,6 +383,46 @@ "mapLayerShelter": "Tempat Evakuasi", "disasterMapOverlayRestroomTooltip": "Tampilkan toilet umum", "disasterMapOverlayShelterTooltip": "Tampilkan tempat evakuasi", + "dpmOpenInMaps": "Buka di peta", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app} (bawaan)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "Salin koordinat", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "Koordinat disalin", + "@mapAppCoordinatesCopied": { + }, + "mapAppOpenFailed": "Tidak dapat membuka {app}", + "@mapAppOpenFailed": { + }, + + "mapAppCallFailed": "Perangkat ini tidak dapat melakukan panggilan telepon", + + "mapOverlaySectionReference": "Lapisan referensi", + "mapLayerCategoryEarthquake": "Gempa", + "mapLayerCategoryTyphoon": "Topan", + "mapLayerCategoryWeather": "Pengamatan cuaca", + "mapLayerCategorySatellite": "Satelit", + "mapLayerCategoryRadar": "Radar", + "mapLayerCategoryLife": "Kehidupan sehari-hari", + "mapLayerCategoryForecast": "Prakiraan numerik", "mapOverlaySectionMap": "Peta", + "rainIntervalSection": "Jendela waktu", + + "mapTownLabels": "Nama kecamatan", + "mapTownLabelsHint": "Tampilkan nama kecamatan saat diperbesar", + "dpmSheetEmpty": "Ketuk penanda di peta untuk detail", "dpmAddress": "Alamat", "restroomTypeLabel": "Jenis", @@ -353,6 +470,7 @@ "mapLegendExpand": "Legenda", "mapLegendCollapse": "Sembunyikan legenda", "mapMyLocation": "Lokasi saya", + "mapResetNorth": "Kembali ke utara", "typhoonLegendCircle15": "Lingkar angin kencang", "typhoonLegendCircle25": "Lingkar badai", "typhoonLegendProbability": "Probabilitas serangan", @@ -371,6 +489,8 @@ "typhoonIntensityTd": "Tropical depression", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "Tropical depression TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "Past movement direction", "typhoonLabelGaleAvg": "Avg. radius of Beaufort 7 winds", "typhoonLabelGust": "Peak gust", @@ -417,5 +537,133 @@ "dpmDisasterLandslide": "Tanah longsor", "dpmDisasterTsunami": "Tsunami", "dpmDisasterSlope": "Bencana lereng", - "dpmDisasterNuclear": "Kecelakaan nuklir" -} \ No newline at end of file + "dpmDisasterNuclear": "Kecelakaan nuklir", + "skyTime": "Waktu langit", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "Otomatis", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "Fajar", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "Matahari terbit", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "Pagi", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "Siang", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "Sore", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "Jam emas", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "Matahari terbenam", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "Senja", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "Malam", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "Berawan", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "Mendung", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "Salju", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "Debu", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "Tampilkan jangkauan pindai", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "Menandai area yang benar-benar dipantau keempat radar.", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "Di luar kotak berarti tak terpantau", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "Opsi lapisan radar", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "Batas kabupaten/kota", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "Batas negara", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "Bingkai luar setiap negara", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "Digambar di atas gema", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "Menjaga batas wilayah tetap terbaca di bawah gema radar.", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "Batas kecamatan", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "Kisi yang lebih rapat", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "Menjaga batas kecamatan tetap terbaca di bawah gema radar.", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "Opsi lapisan prakiraan curah hujan", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "Opsi lapisan prakiraan angin", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "Digambar di atas bidang angin", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "Bingkai luar setiap negara", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "Jaring yang lebih halus", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." + } +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index e73b42b37..c0b869e12 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -151,14 +151,88 @@ "homeActiveEventsTitle": "発生中の事象", "homeActiveEventsEmpty": "発生中の事象はありません", "homeRainTrendTitle": "今後1時間の雨", + "homeRainTrendMinute": "{minute}分", + "homeRainTrendUpdated": "更新 {time}", + "homeRainTrendNoData": "データなし", + + "homeRainTrendScattered": "にわか雨の可能性があります", + "homeRainTrendLightSustained": "今後1時間は小雨が続きます", + "homeRainTrendLightStopping": "{minutes}分後に小雨が止む見込みです", + "homeRainTrendHeavySustained": "今後1時間は大雨が続きます", + "homeRainTrendHeavyStopping": "{minutes}分後に大雨が止む見込みです", "mapLayers": "レイヤー", + "mapLayerOrderTitle": "レイヤーの順番", + "mapLayerOrderReset": "既定の順序に戻す", "mapLayerRadar": "レーダー合成エコー図", - "mapLayerSatellite": "ひまわり 赤外線図", + "mapLayerSatellite": "ひまわり 赤外線(B13)", + "mapLayerSatelliteB01": "ひまわり 可視青(B01)", + "mapLayerSatelliteB02": "ひまわり 可視緑(B02)", + "mapLayerSatelliteB03": "ひまわり 可視赤(B03)", + "mapLayerSatelliteB04": "ひまわり 近赤外(B04)", + "mapLayerSatelliteB05": "ひまわり 近赤外(B05)", + "mapLayerSatelliteB06": "ひまわり 近赤外(B06)", + "mapLayerSatelliteB07": "ひまわり 短波長赤外(B07)", + "mapLayerSatelliteB08": "ひまわり 上層水蒸気(B08)", + "mapLayerSatelliteB09": "ひまわり 中層水蒸気(B09)", + "mapLayerSatelliteB10": "ひまわり 下層水蒸気(B10)", + "mapLayerSatelliteB11": "ひまわり 二酸化硫黄/雲相(B11)", + "mapLayerSatelliteB12": "ひまわり オゾン(B12)", + "mapLayerSatelliteB13": "ひまわり 赤外線(B13)", + "mapLayerSatelliteB14": "ひまわり 長波長赤外線(B14)", + "mapLayerSatelliteB15": "ひまわり 長波長赤外線(B15)", + "mapLayerSatelliteB16": "ひまわり 二酸化炭素(B16)", + "mapLayerSatelliteTruecolor": "ひまわり トゥルーカラー", + "mapLayerSatelliteNaturalcolor": "ひまわり ナチュラルカラー", + "mapLayerSatelliteAsh": "ひまわり 火山灰", + "mapLayerSatelliteDust": "ひまわり 黄砂", + "mapLayerSatelliteAirmass": "ひまわり エアマス", + "mapLayerSatelliteNightmicrophysics": "ひまわり 夜間微物理", + "mapLayerSatelliteWatervapor": "ひまわり 水蒸気", + "mapLayerSatelliteBtdSplit": "ひまわり スプリットウィンドウ", + "mapLayerSatelliteBtdFog": "ひまわり 夜間霧", + "mapLayerSatelliteBtdWvirw": "ひまわり オーバーシューティングトップ", + "mapLayerSatelliteBtdSo2": "ひまわり 二酸化硫黄/雲相", + "mapLayerSatelliteBtdCo2": "ひまわり 巻雲/雲頂高度", + "mapLayerSatelliteBtdOzone": "ひまわり 対流圏界面", + "mapLayerSatelliteCloudtop": "ひまわり 雲頂温度", + "mapLayerSatelliteCloudmask": "ひまわり 雲マスク", + "mapLayerSatelliteSst": "ひまわり 海面水温", + "mapLayerSatelliteNdvi": "ひまわり NDVI", + "mapLayerSatelliteNdwi": "ひまわり NDWI", + "mapLayerSatelliteMndwi": "ひまわり MNDWI", + "mapLayerSatelliteGlobalOutline": "国境線", + "mapLayerSatelliteRgbComposite": "RGB 合成(JMA レシピ)", + "mapLayerSatelliteCloudClear": "晴れ", + "mapLayerSatelliteCloudProbablyClear": "おそらく晴れ", + "mapLayerSatelliteCloudProbablyCloudy": "おそらく雲", + "mapLayerSatelliteCloudCloudy": "雲", + "mapLayerSatelliteTransparentWarm": "晴れ(暖域) = 透明、地図が透ける", + "mapLayerSatelliteTransparentReflectance": "低反射率・夜間 = 透明、地図が透ける", + "mapLayerSatelliteTransparentZero": "差ゼロ = 透明(信号なし)", + "mapLayerSatelliteTransparentNight": "夜間 = 透明、地図が透ける", + "mapLayerSatelliteTransparentNoData": "データなし(陸上) = 透明", + "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(植生なし)", + "mapLayerSatelliteTransparentNoWater": "≤ 0 = 透明(水域なし)", + "mapLayerSatelliteTransparentClear": "晴れ = 透明、地図が透ける", + "mapLayerStyleSection": "色調", + "mapLayerStyleTooltip": "色調", + "mapLayerStyleGray": "グレースケール(JMA)", + "mapLayerStyleGrayTooltip": "気象庁の赤外画像の慣例:温度が低いほど白", + "mapLayerStyleJma": "雲頂強調(JMA)", + "mapLayerStyleJmaTooltip": "グレースケールをベースに −40 °C 以下を着色し、雲頂高度を強調", + "mapLayerStyleBd": "Dvorak BD", + "mapLayerStyleBdTooltip": "Dvorak BD カーブ——熱帯低気圧の強度解析に使う階段グレースケール", + "mapLayerQpesums": "1時間降水量予報", "mapLayerLightning": "雷", "lightningLegendCg": "対地 · {minutes} 分以内", "lightningLegendCc": "雲間 · {minutes} 分以内", "mapTimelineNow": "現在", + "mapTimelinePast": "過去", + "mapTimelineFuture": "未来", "mapTimelineObserved": "観測", + "mapTimelineForecast": "予報", + "mapTimelineDataTime": "データ時刻 {time}", + "mapTimelineDataTime": "データ時刻 {time}", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "プッシュ通知はまだ準備できていません。しばらくしてから再度お試しください。", @@ -229,6 +303,7 @@ "defaultMapLayerSettings": "地図の初期レイヤー", "defaultMapLayerSubtitle": "地図タブを開いたときに表示するレイヤーです。下部ナビのアイコンとラベルもこれに合わせます。", "mapNavRadar": "レーダー", + "mapNavQpesums": "予報", "mapNavSatellite": "衛星", "mapNavLightning": "稲妻", "mapNavTyphoon": "台風", @@ -268,6 +343,8 @@ "mapLayerHumidity": "湿度", "mapLayerPressure": "気圧", "mapLayerWind": "風向", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "降水量", "rainIntervalMenu": "累積期間", "rainIntervalNow": "今日", @@ -306,6 +383,46 @@ "mapLayerShelter": "避難所", "disasterMapOverlayRestroomTooltip": "トイレを表示", "disasterMapOverlayShelterTooltip": "避難所を表示", + "dpmOpenInMaps": "地図アプリで開く", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app}(デフォルト)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "座標をコピー", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "座標をコピーしました", + "@mapAppCoordinatesCopied": { + }, + "mapAppOpenFailed": "{app} を開けませんでした", + "@mapAppOpenFailed": { + }, + + "mapAppCallFailed": "この端末では通話できません", + + "mapOverlaySectionReference": "参照レイヤー", + "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryTyphoon": "台風", + "mapLayerCategoryWeather": "気象観測", + "mapLayerCategorySatellite": "衛星", + "mapLayerCategoryRadar": "レーダー", + "mapLayerCategoryLife": "生活", + "mapLayerCategoryForecast": "数値予報", "mapOverlaySectionMap": "地図", + "rainIntervalSection": "集計時間", + + "mapTownLabels": "郷鎮名", + "mapTownLabelsHint": "拡大すると郷鎮名を表示", + "dpmSheetEmpty": "地図上のマーカーをタップして詳細を表示", "dpmAddress": "住所", "restroomTypeLabel": "種別", @@ -353,6 +470,7 @@ "mapLegendExpand": "凡例", "mapLegendCollapse": "凡例を閉じる", "mapMyLocation": "現在地", + "mapResetNorth": "北を上にする", "typhoonLegendCircle15": "強風域(30kt)", "typhoonLegendCircle25": "暴風域(50kt)", "typhoonLegendProbability": "接近確率", @@ -371,6 +489,8 @@ "typhoonIntensityTd": "熱帯低気圧", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "熱帯低気圧 TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "これまでの進行方向", "typhoonLabelGaleAvg": "強風域の平均半径", "typhoonLabelGust": "最大瞬間風速", @@ -417,5 +537,133 @@ "dpmDisasterLandslide": "土石流", "dpmDisasterTsunami": "津波", "dpmDisasterSlope": "斜面災害", - "dpmDisasterNuclear": "原子力事故" -} \ No newline at end of file + "dpmDisasterNuclear": "原子力事故", + "skyTime": "空の時刻", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "自動", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "夜明け前", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "日の出", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "午前", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "正午", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "午後", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "ゴールデンアワー", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "日の入り", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "薄暮", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "夜", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "曇り", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "本曇り", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "雪", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "砂じん", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "走査範囲を表示", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "4基のレーダーが実際に観測する範囲を示します。", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "枠外の空白は未観測", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "レーダーレイヤー設定", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "県市境界", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "国境線", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "各国の国境外枠", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "エコーの上に描画", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "レーダーエコーの下でも県市境界が見えるようにします。", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "市町村境界", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "より細かい区分", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "レーダーエコーの下でも市町村境界が見えるようにします。", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "定量降水予報レイヤー設定", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "風予報レイヤー設定", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "風場の上に描画", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "各国の国境外枠", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "より細かいメッシュ", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." + } +} diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 2ba761fed..d093ffbdc 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -151,14 +151,88 @@ "homeActiveEventsTitle": "발효 중 이벤트", "homeActiveEventsEmpty": "발효 중인 이벤트가 없습니다", "homeRainTrendTitle": "향후 1시간 강수", + "homeRainTrendMinute": "{minute}분", + "homeRainTrendUpdated": "업데이트 {time}", + "homeRainTrendNoData": "데이터 없음", + + "homeRainTrendScattered": "약한 비가 올 수 있어요", + "homeRainTrendLightSustained": "앞으로 1시간 동안 약한 비가 이어질 거예요", + "homeRainTrendLightStopping": "{minutes}분 후에 비가 그칠 것으로 예상돼요", + "homeRainTrendHeavySustained": "앞으로 1시간 동안 강한 비가 이어질 거예요", + "homeRainTrendHeavyStopping": "{minutes}분 후에 강한 비가 그칠 것으로 예상돼요", "mapLayers": "레이어", + "mapLayerOrderTitle": "레이어 순서", + "mapLayerOrderReset": "기본 순서로 재설정", "mapLayerRadar": "레이더 합성 에코", - "mapLayerSatellite": "히마와리 적외", + "mapLayerSatellite": "히마와리 적외(B13)", + "mapLayerSatelliteB01": "히마와리 가시 청색(B01)", + "mapLayerSatelliteB02": "히마와리 가시 녹색(B02)", + "mapLayerSatelliteB03": "히마와리 가시 적색(B03)", + "mapLayerSatelliteB04": "히마와리 근적외(B04)", + "mapLayerSatelliteB05": "히마와리 근적외(B05)", + "mapLayerSatelliteB06": "히마와리 근적외(B06)", + "mapLayerSatelliteB07": "히마와리 단파 적외(B07)", + "mapLayerSatelliteB08": "히마와리 상층 수증기(B08)", + "mapLayerSatelliteB09": "히마와리 중층 수증기(B09)", + "mapLayerSatelliteB10": "히마와리 하층 수증기(B10)", + "mapLayerSatelliteB11": "히마와리 이산화황/구름상(B11)", + "mapLayerSatelliteB12": "히마와리 오존(B12)", + "mapLayerSatelliteB13": "히마와리 적외(B13)", + "mapLayerSatelliteB14": "히마와리 장파 적외(B14)", + "mapLayerSatelliteB15": "히마와리 장파 적외(B15)", + "mapLayerSatelliteB16": "히마와리 이산화탄소(B16)", + "mapLayerSatelliteTruecolor": "히마와리 트루컬러", + "mapLayerSatelliteNaturalcolor": "히마와리 내추럴컬러", + "mapLayerSatelliteAsh": "히마와리 화산재", + "mapLayerSatelliteDust": "히마와리 황사", + "mapLayerSatelliteAirmass": "히마와리 에어매스", + "mapLayerSatelliteNightmicrophysics": "히마와리 야간 미세물리", + "mapLayerSatelliteWatervapor": "히마와리 수증기", + "mapLayerSatelliteBtdSplit": "히마와리 스플릿 윈도우", + "mapLayerSatelliteBtdFog": "히마와리 야간 안개", + "mapLayerSatelliteBtdWvirw": "히마와리 오버슈팅 탑", + "mapLayerSatelliteBtdSo2": "히마와리 이산화황/구름상", + "mapLayerSatelliteBtdCo2": "히마와리 권운/운고", + "mapLayerSatelliteBtdOzone": "히마와리 대류권계면", + "mapLayerSatelliteCloudtop": "히마와리 운정 온도", + "mapLayerSatelliteCloudmask": "히마와리 구름 마스크", + "mapLayerSatelliteSst": "히마와리 해수면 온도", + "mapLayerSatelliteNdvi": "히마와리 NDVI", + "mapLayerSatelliteNdwi": "히마와리 NDWI", + "mapLayerSatelliteMndwi": "히마와리 MNDWI", + "mapLayerSatelliteGlobalOutline": "국경선", + "mapLayerSatelliteRgbComposite": "RGB 합성(JMA 레시피)", + "mapLayerSatelliteCloudClear": "맑음", + "mapLayerSatelliteCloudProbablyClear": "아마 맑음", + "mapLayerSatelliteCloudProbablyCloudy": "아마 구름", + "mapLayerSatelliteCloudCloudy": "구름", + "mapLayerSatelliteTransparentWarm": "맑음(고온부) = 투명,배경 지도 표시", + "mapLayerSatelliteTransparentReflectance": "낮은 반사율/야간 = 투명,배경 지도 표시", + "mapLayerSatelliteTransparentZero": "차이 0 = 투명(신호 없음)", + "mapLayerSatelliteTransparentNight": "야간 = 투명,배경 지도 표시", + "mapLayerSatelliteTransparentNoData": "데이터 없음(육지) = 투명", + "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 투명(식생 없음)", + "mapLayerSatelliteTransparentNoWater": "≤ 0 = 투명(수역 없음)", + "mapLayerSatelliteTransparentClear": "맑음 = 투명,배경 지도 표시", + "mapLayerStyleSection": "색상 스타일", + "mapLayerStyleTooltip": "색상 스타일", + "mapLayerStyleGray": "그레이스케일(JMA)", + "mapLayerStyleGrayTooltip": "기상청 적외 영상 관례:온도가 낮을수록 흰색", + "mapLayerStyleJma": "운정 강조(JMA)", + "mapLayerStyleJmaTooltip": "그레이스케일 바탕에 −40 °C 이하를 채색, 운정 고도 강조", + "mapLayerStyleBd": "Dvorak BD", + "mapLayerStyleBdTooltip": "Dvorak BD 커브——열대저기압 강도 분석용 계단 그레이스케일", + "mapLayerQpesums": "1시간 강수 예보", "mapLayerLightning": "번개", "lightningLegendCg": "대지로 · {minutes}분 이내", "lightningLegendCc": "구름 사이 · {minutes}분 이내", "mapTimelineNow": "현재", + "mapTimelinePast": "과거", + "mapTimelineFuture": "미래", "mapTimelineObserved": "관측", + "mapTimelineForecast": "예보", + "mapTimelineDataTime": "데이터 시간 {time}", + "mapTimelineDataTime": "자료 시간 {time}", "notifySettingsMenu": "알림 설정", "notifyTitle": "알림", "notifyUnavailable": "푸시 알림이 아직 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.", @@ -229,6 +303,7 @@ "defaultMapLayerSettings": "지도 기본 레이어", "defaultMapLayerSubtitle": "지도 탭을 열 때 표시할 레이어입니다. 하단 탐색 아이콘과 라벨도 함께 바뀝니다.", "mapNavRadar": "레이더", + "mapNavQpesums": "예보", "mapNavSatellite": "위성", "mapNavLightning": "번개", "mapNavTyphoon": "태풍", @@ -268,6 +343,8 @@ "mapLayerHumidity": "습도", "mapLayerPressure": "기압", "mapLayerWind": "바람", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "강수량", "rainIntervalMenu": "누적 구간", "rainIntervalNow": "오늘", @@ -306,6 +383,46 @@ "mapLayerShelter": "대피소", "disasterMapOverlayRestroomTooltip": "공중화장실 표시", "disasterMapOverlayShelterTooltip": "대피소 표시", + "dpmOpenInMaps": "지도에서 열기", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app} (기본)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "좌표 복사", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "좌표가 복사되었습니다", + "@mapAppCoordinatesCopied": { + }, + "mapAppOpenFailed": "{app}을(를) 열 수 없습니다", + "@mapAppOpenFailed": { + }, + + "mapAppCallFailed": "이 기기에서는 전화를 걸 수 없습니다", + + "mapOverlaySectionReference": "참조 레이어", + "mapLayerCategoryEarthquake": "지진", + "mapLayerCategoryTyphoon": "태풍", + "mapLayerCategoryWeather": "기상 관측", + "mapLayerCategorySatellite": "위성", + "mapLayerCategoryRadar": "레이더", + "mapLayerCategoryLife": "생활", + "mapLayerCategoryForecast": "수치 예보", "mapOverlaySectionMap": "지도", + "rainIntervalSection": "집계 시간", + + "mapTownLabels": "읍면동 이름", + "mapTownLabelsHint": "확대하면 읍면동 이름 표시", + "dpmSheetEmpty": "지도에서 마커를 눌러 상세 보기", "dpmAddress": "주소", "restroomTypeLabel": "유형", @@ -353,6 +470,7 @@ "mapLegendExpand": "범례", "mapLegendCollapse": "범례 숨기기", "mapMyLocation": "내 위치", + "mapResetNorth": "북쪽으로 되돌리기", "typhoonLegendCircle15": "강풍권 (7급)", "typhoonLegendCircle25": "폭풍권 (10급)", "typhoonLegendProbability": "내습 확률", @@ -371,6 +489,8 @@ "typhoonIntensityTd": "Tropical depression", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "Tropical depression TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "Past movement direction", "typhoonLabelGaleAvg": "Avg. radius of Beaufort 7 winds", "typhoonLabelGust": "Peak gust", @@ -417,5 +537,133 @@ "dpmDisasterLandslide": "산사태", "dpmDisasterTsunami": "쓰나미", "dpmDisasterSlope": "사면 재해", - "dpmDisasterNuclear": "핵 사고" -} \ No newline at end of file + "dpmDisasterNuclear": "핵 사고", + "skyTime": "하늘 시각", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "자동", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "여명", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "일출", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "오전", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "정오", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "오후", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "골든아워", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "일몰", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "땅거미", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "밤", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "구름 많음", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "흐림", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "눈", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "황사", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "스캔 범위 표시", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "레이더 4기가 실제로 관측하는 범위를 표시합니다.", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "범위 밖 공백은 미관측", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "레이더 레이어 옵션", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "시·군 경계", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "국경", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "각국 국경선", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "에코 위에 표시", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "레이더 에코 아래에서도 경계가 보이도록 합니다.", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "읍·면·동 경계", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "더 세밀한 구획", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "레이더 에코 아래에서도 읍·면·동 경계가 보이도록 합니다.", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "정량 강수 예보 레이어 옵션", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "바람 예보 레이어 옵션", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "바람장 위에 표시", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "각국 국경선", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "더 촘촘한 망", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." + } +} diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 497c349ca..08c59c276 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -151,14 +151,88 @@ "homeActiveEventsTitle": "เหตุการณ์ที่ยังมีผล", "homeActiveEventsEmpty": "ไม่มีเหตุการณ์ที่ยังมีผล", "homeRainTrendTitle": "ฝนชั่วโมงถัดไป", + "homeRainTrendMinute": "{minute} นาที", + "homeRainTrendUpdated": "อัปเดต {time}", + "homeRainTrendNoData": "ไม่มีข้อมูล", + + "homeRainTrendScattered": "อาจมีฝนตกประปราย", + "homeRainTrendLightSustained": "ฝนตกเล็กน้อยต่อเนื่องตลอดชั่วโมงหน้า", + "homeRainTrendLightStopping": "คาดว่าฝนจะหยุดในอีก {minutes} นาที", + "homeRainTrendHeavySustained": "ฝนตกหนักต่อเนื่องตลอดชั่วโมงหน้า", + "homeRainTrendHeavyStopping": "คาดว่าฝนตกหนักจะหยุดในอีก {minutes} นาที", "mapLayers": "ชั้นข้อมูล", + "mapLayerOrderTitle": "จัดเรียงเลเยอร์", + "mapLayerOrderReset": "รีเซ็ตลำดับ", "mapLayerRadar": "เรดาร์สะท้อนสังเคราะห์", - "mapLayerSatellite": "ฮิมาวาริ อินฟราเรด", + "mapLayerSatellite": "Himawari Infrared (B13)", + "mapLayerSatelliteB01": "Himawari Blue (B01)", + "mapLayerSatelliteB02": "Himawari Green (B02)", + "mapLayerSatelliteB03": "Himawari Red (B03)", + "mapLayerSatelliteB04": "Himawari Near-Infrared (B04)", + "mapLayerSatelliteB05": "Himawari Near-Infrared (B05)", + "mapLayerSatelliteB06": "Himawari Near-Infrared (B06)", + "mapLayerSatelliteB07": "Himawari Shortwave Infrared (B07)", + "mapLayerSatelliteB08": "Himawari Upper Water Vapour (B08)", + "mapLayerSatelliteB09": "Himawari Mid Water Vapour (B09)", + "mapLayerSatelliteB10": "Himawari Lower Water Vapour (B10)", + "mapLayerSatelliteB11": "Himawari SO₂ / Cloud Phase (B11)", + "mapLayerSatelliteB12": "Himawari Ozone (B12)", + "mapLayerSatelliteB13": "Himawari Infrared (B13)", + "mapLayerSatelliteB14": "Himawari Longwave Infrared (B14)", + "mapLayerSatelliteB15": "Himawari Longwave Infrared (B15)", + "mapLayerSatelliteB16": "Himawari CO₂ (B16)", + "mapLayerSatelliteTruecolor": "Himawari True Color", + "mapLayerSatelliteNaturalcolor": "Himawari Natural Color", + "mapLayerSatelliteAsh": "Himawari Ash", + "mapLayerSatelliteDust": "Himawari Dust", + "mapLayerSatelliteAirmass": "Himawari Airmass", + "mapLayerSatelliteNightmicrophysics": "Himawari Night Microphysics", + "mapLayerSatelliteWatervapor": "Himawari Water Vapour", + "mapLayerSatelliteBtdSplit": "Himawari Split Window", + "mapLayerSatelliteBtdFog": "Himawari Night Fog", + "mapLayerSatelliteBtdWvirw": "Himawari Overshooting Top", + "mapLayerSatelliteBtdSo2": "Himawari SO₂ / Cloud Phase", + "mapLayerSatelliteBtdCo2": "Himawari Cirrus / Cloud Height", + "mapLayerSatelliteBtdOzone": "Himawari Tropopause", + "mapLayerSatelliteCloudtop": "Himawari Cloud Top Temperature", + "mapLayerSatelliteCloudmask": "Himawari Cloud Mask", + "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", + "mapLayerSatelliteNdvi": "Himawari NDVI", + "mapLayerSatelliteNdwi": "Himawari NDWI", + "mapLayerSatelliteMndwi": "Himawari MNDWI", + "mapLayerSatelliteGlobalOutline": "Country border", + "mapLayerSatelliteRgbComposite": "RGB composite (JMA recipe)", + "mapLayerSatelliteCloudClear": "Clear", + "mapLayerSatelliteCloudProbablyClear": "Probably clear", + "mapLayerSatelliteCloudProbablyCloudy": "Probably cloudy", + "mapLayerSatelliteCloudCloudy": "Cloudy", + "mapLayerSatelliteTransparentWarm": "Clear sky (warm end) = transparent, the basemap shows", + "mapLayerSatelliteTransparentReflectance": "Low reflectance / night = transparent, the basemap shows", + "mapLayerSatelliteTransparentZero": "Zero difference = transparent (no signal)", + "mapLayerSatelliteTransparentNight": "Night = transparent, the basemap shows", + "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", + "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", + "mapLayerSatelliteTransparentNoWater": "≤ 0 = transparent (no water)", + "mapLayerSatelliteTransparentClear": "Clear sky = transparent, the basemap shows", + "mapLayerStyleSection": "Colour style", + "mapLayerStyleTooltip": "Colour style", + "mapLayerStyleGray": "Grayscale (JMA)", + "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", + "mapLayerStyleJma": "Cloud-top enhancement (JMA)", + "mapLayerStyleJmaTooltip": "Grayscale base, tinted below −40 °C to highlight cloud-top height", + "mapLayerStyleBd": "Dvorak BD", + "mapLayerStyleBdTooltip": "Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis", + "mapLayerQpesums": "พยากรณ์ฝน 1 ชั่วโมงข้างหน้า", "mapLayerLightning": "ฟ้าผ่า", "lightningLegendCg": "เมฆสู่พื้น · {minutes} นาที", "lightningLegendCc": "เมฆสู่เมฆ · {minutes} นาที", "mapTimelineNow": "ตอนนี้", + "mapTimelinePast": "อดีต", + "mapTimelineFuture": "อนาคต", "mapTimelineObserved": "เวลาตรวจวัด", + "mapTimelineForecast": "พยากรณ์", + "mapTimelineDataTime": "เวลาข้อมูล {time}", + "mapTimelineDataTime": "เวลาข้อมูล {time}", "notifySettingsMenu": "การตั้งค่าการแจ้งเตือน", "notifyTitle": "การแจ้งเตือน", "notifyUnavailable": "การแจ้งเตือนแบบพุชยังไม่พร้อม — โปรดลองอีกครั้งในภายหลัง", @@ -229,6 +303,7 @@ "defaultMapLayerSettings": "ชั้นแผนที่เริ่มต้น", "defaultMapLayerSubtitle": "แท็บแผนที่จะเปิดชั้นนี้ ไอคอนและป้ายนำทางด้านล่างจะเปลี่ยนตาม", "mapNavRadar": "เรดาร์", + "mapNavQpesums": "พยากรณ์", "mapNavSatellite": "ดาวเทียม", "mapNavLightning": "ฟ้าผ่า", "mapNavTyphoon": "ไต้ฝุ่น", @@ -268,6 +343,8 @@ "mapLayerHumidity": "ความชื้น", "mapLayerPressure": "ความกดอากาศ", "mapLayerWind": "ลม", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "ปริมาณฝน", "rainIntervalMenu": "ช่วงสะสม", "rainIntervalNow": "วันนี้", @@ -306,6 +383,46 @@ "mapLayerShelter": "ศูนย์อพยพ", "disasterMapOverlayRestroomTooltip": "แสดงห้องน้ำสาธารณะ", "disasterMapOverlayShelterTooltip": "แสดงศูนย์อพยพ", + "dpmOpenInMaps": "เปิดในแผนที่", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app} (ค่าเริ่มต้น)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "คัดลอกพิกัด", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "คัดลอกพิกัดแล้ว", + "@mapAppCoordinatesCopied": { + }, + "mapAppOpenFailed": "ไม่สามารถเปิด {app} ได้", + "@mapAppOpenFailed": { + }, + + "mapAppCallFailed": "อุปกรณ์นี้ไม่สามารถโทรออกได้", + + "mapOverlaySectionReference": "เลเยอร์อ้างอิง", + "mapLayerCategoryEarthquake": "แผ่นดินไหว", + "mapLayerCategoryTyphoon": "พายุไต้ฝุ่น", + "mapLayerCategoryWeather": "การสังเกตสภาพอากาศ", + "mapLayerCategorySatellite": "ดาวเทียม", + "mapLayerCategoryRadar": "เรดาร์", + "mapLayerCategoryLife": "ชีวิตประจำวัน", + "mapLayerCategoryForecast": "การพยากรณ์เชิงตัวเลข", "mapOverlaySectionMap": "แผนที่", + "rainIntervalSection": "ช่วงเวลา", + + "mapTownLabels": "ชื่อตำบล", + "mapTownLabelsHint": "แสดงชื่อตำบลเมื่อขยายแผนที่", + "dpmSheetEmpty": "แตะเครื่องหมายบนแผนที่เพื่อดูรายละเอียด", "dpmAddress": "ที่อยู่", "restroomTypeLabel": "ประเภท", @@ -353,6 +470,7 @@ "mapLegendExpand": "คำอธิบาย", "mapLegendCollapse": "ซ่อนคำอธิบาย", "mapMyLocation": "ตำแหน่งของฉัน", + "mapResetNorth": "กลับไปทางเหนือ", "typhoonLegendCircle15": "วงพายุ (แรง)", "typhoonLegendCircle25": "วงพายุ (รุนแรง)", "typhoonLegendProbability": "โอกาสกระทบ", @@ -371,6 +489,8 @@ "typhoonIntensityTd": "Tropical depression", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "Tropical depression TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "Past movement direction", "typhoonLabelGaleAvg": "Avg. radius of Beaufort 7 winds", "typhoonLabelGust": "Peak gust", @@ -417,5 +537,133 @@ "dpmDisasterLandslide": "ดินถล่ม", "dpmDisasterTsunami": "สึนามิ", "dpmDisasterSlope": "ภัยพิบัติลาดชัน", - "dpmDisasterNuclear": "อุบัติเหตุนิวเคลียร์" -} \ No newline at end of file + "dpmDisasterNuclear": "อุบัติเหตุนิวเคลียร์", + "skyTime": "เวลาท้องฟ้า", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "อัตโนมัติ", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "รุ่งอรุณ", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "พระอาทิตย์ขึ้น", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "ตอนเช้า", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "เที่ยงวัน", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "ตอนบ่าย", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "ช่วงเวลาทอง", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "พระอาทิตย์ตก", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "สนธยา", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "กลางคืน", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "มีเมฆมาก", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "ฟ้าปิด", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "หิมะตก", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "ฝุ่นทราย", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "แสดงขอบเขตการสแกน", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "แสดงพื้นที่ที่เรดาร์ทั้งสี่ตรวจวัดได้จริง", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "นอกกรอบคือไม่ได้ตรวจวัด", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "ตัวเลือกชั้นเรดาร์", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "เส้นแบ่งเขตจังหวัด", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "เส้นแบ่งเขตประเทศ", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "กรอบนอกของทุกประเทศ", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "วาดทับภาพเอคโค", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "ทำให้เส้นแบ่งเขตยังอ่านออกใต้ภาพเอคโคเรดาร์", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "เส้นแบ่งเขตอำเภอ", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "เส้นแบ่งย่อยกว่า", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "ทำให้เส้นแบ่งเขตอำเภอยังอ่านออกใต้ภาพเอคโคเรดาร์", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "ตัวเลือกชั้นพยากรณ์น้ำฝน", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "ตัวเลือกชั้นพยากรณ์ลม", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "วาดทับบนสนามลม", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "กรอบนอกของทุกประเทศ", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "ตาข่ายที่ละเอียดกว่า", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." + } +} diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 4b70691a2..e7a2538b9 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -151,14 +151,88 @@ "homeActiveEventsTitle": "Sự kiện đang hiệu lực", "homeActiveEventsEmpty": "Không có sự kiện đang hiệu lực", "homeRainTrendTitle": "Mưa 1 giờ tới", + "homeRainTrendMinute": "{minute} phút", + "homeRainTrendUpdated": "Cập nhật {time}", + "homeRainTrendNoData": "Không có dữ liệu", + + "homeRainTrendScattered": "Có thể có mưa rào nhẹ", + "homeRainTrendLightSustained": "Mưa nhỏ tiếp diễn trong 1 giờ tới", + "homeRainTrendLightStopping": "Mưa nhỏ có thể tạnh trong {minutes} phút nữa", + "homeRainTrendHeavySustained": "Mưa lớn tiếp diễn trong 1 giờ tới", + "homeRainTrendHeavyStopping": "Mưa lớn có thể tạnh trong {minutes} phút nữa", "mapLayers": "Lớp bản đồ", + "mapLayerOrderTitle": "Sắp xếp thứ tự lớp", + "mapLayerOrderReset": "Đặt lại thứ tự mặc định", "mapLayerRadar": "Radar phản xạ tổng hợp", - "mapLayerSatellite": "Himawari hồng ngoại", + "mapLayerSatellite": "Himawari Infrared (B13)", + "mapLayerSatelliteB01": "Himawari Blue (B01)", + "mapLayerSatelliteB02": "Himawari Green (B02)", + "mapLayerSatelliteB03": "Himawari Red (B03)", + "mapLayerSatelliteB04": "Himawari Near-Infrared (B04)", + "mapLayerSatelliteB05": "Himawari Near-Infrared (B05)", + "mapLayerSatelliteB06": "Himawari Near-Infrared (B06)", + "mapLayerSatelliteB07": "Himawari Shortwave Infrared (B07)", + "mapLayerSatelliteB08": "Himawari Upper Water Vapour (B08)", + "mapLayerSatelliteB09": "Himawari Mid Water Vapour (B09)", + "mapLayerSatelliteB10": "Himawari Lower Water Vapour (B10)", + "mapLayerSatelliteB11": "Himawari SO₂ / Cloud Phase (B11)", + "mapLayerSatelliteB12": "Himawari Ozone (B12)", + "mapLayerSatelliteB13": "Himawari Infrared (B13)", + "mapLayerSatelliteB14": "Himawari Longwave Infrared (B14)", + "mapLayerSatelliteB15": "Himawari Longwave Infrared (B15)", + "mapLayerSatelliteB16": "Himawari CO₂ (B16)", + "mapLayerSatelliteTruecolor": "Himawari True Color", + "mapLayerSatelliteNaturalcolor": "Himawari Natural Color", + "mapLayerSatelliteAsh": "Himawari Ash", + "mapLayerSatelliteDust": "Himawari Dust", + "mapLayerSatelliteAirmass": "Himawari Airmass", + "mapLayerSatelliteNightmicrophysics": "Himawari Night Microphysics", + "mapLayerSatelliteWatervapor": "Himawari Water Vapour", + "mapLayerSatelliteBtdSplit": "Himawari Split Window", + "mapLayerSatelliteBtdFog": "Himawari Night Fog", + "mapLayerSatelliteBtdWvirw": "Himawari Overshooting Top", + "mapLayerSatelliteBtdSo2": "Himawari SO₂ / Cloud Phase", + "mapLayerSatelliteBtdCo2": "Himawari Cirrus / Cloud Height", + "mapLayerSatelliteBtdOzone": "Himawari Tropopause", + "mapLayerSatelliteCloudtop": "Himawari Cloud Top Temperature", + "mapLayerSatelliteCloudmask": "Himawari Cloud Mask", + "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", + "mapLayerSatelliteNdvi": "Himawari NDVI", + "mapLayerSatelliteNdwi": "Himawari NDWI", + "mapLayerSatelliteMndwi": "Himawari MNDWI", + "mapLayerSatelliteGlobalOutline": "Country border", + "mapLayerSatelliteRgbComposite": "RGB composite (JMA recipe)", + "mapLayerSatelliteCloudClear": "Clear", + "mapLayerSatelliteCloudProbablyClear": "Probably clear", + "mapLayerSatelliteCloudProbablyCloudy": "Probably cloudy", + "mapLayerSatelliteCloudCloudy": "Cloudy", + "mapLayerSatelliteTransparentWarm": "Clear sky (warm end) = transparent, the basemap shows", + "mapLayerSatelliteTransparentReflectance": "Low reflectance / night = transparent, the basemap shows", + "mapLayerSatelliteTransparentZero": "Zero difference = transparent (no signal)", + "mapLayerSatelliteTransparentNight": "Night = transparent, the basemap shows", + "mapLayerSatelliteTransparentNoData": "No data (land) = transparent", + "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", + "mapLayerSatelliteTransparentNoWater": "≤ 0 = transparent (no water)", + "mapLayerSatelliteTransparentClear": "Clear sky = transparent, the basemap shows", + "mapLayerStyleSection": "Colour style", + "mapLayerStyleTooltip": "Colour style", + "mapLayerStyleGray": "Grayscale (JMA)", + "mapLayerStyleGrayTooltip": "JMA grayscale — colder is whiter", + "mapLayerStyleJma": "Cloud-top enhancement (JMA)", + "mapLayerStyleJmaTooltip": "Grayscale base, tinted below −40 °C to highlight cloud-top height", + "mapLayerStyleBd": "Dvorak BD", + "mapLayerStyleBdTooltip": "Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis", + "mapLayerQpesums": "Dự báo mưa 1 giờ tới", "mapLayerLightning": "Sét", "lightningLegendCg": "Mây–đất · {minutes} phút", "lightningLegendCc": "Mây–mây · {minutes} phút", "mapTimelineNow": "Bây giờ", + "mapTimelinePast": "Quá khứ", + "mapTimelineFuture": "Tương lai", "mapTimelineObserved": "Quan trắc", + "mapTimelineForecast": "Dự báo", + "mapTimelineDataTime": "Thời gian dữ liệu {time}", + "mapTimelineDataTime": "Thời điểm dữ liệu {time}", "notifySettingsMenu": "Cài đặt thông báo", "notifyTitle": "Thông báo", "notifyUnavailable": "Thông báo đẩy chưa sẵn sàng — vui lòng thử lại sau giây lát.", @@ -229,6 +303,7 @@ "defaultMapLayerSettings": "Lớp bản đồ mặc định", "defaultMapLayerSubtitle": "Tab Bản đồ mở lớp này. Biểu tượng và nhãn thanh điều hướng dưới cũng theo lựa chọn.", "mapNavRadar": "Radar", + "mapNavQpesums": "Dự báo", "mapNavSatellite": "Vệ tinh", "mapNavLightning": "Sét", "mapNavTyphoon": "Bão", @@ -268,6 +343,8 @@ "mapLayerHumidity": "Độ ẩm", "mapLayerPressure": "Áp suất", "mapLayerWind": "Gió", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "Lượng mưa", "rainIntervalMenu": "Khung tích lũy", "rainIntervalNow": "Hôm nay", @@ -306,6 +383,46 @@ "mapLayerShelter": "Nơi trú ẩn", "disasterMapOverlayRestroomTooltip": "Hiển thị nhà vệ sinh công cộng", "disasterMapOverlayShelterTooltip": "Hiển thị nơi trú ẩn", + "dpmOpenInMaps": "Mở trong bản đồ", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app} (mặc định)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "Sao chép tọa độ", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "Đã sao chép tọa độ", + "@mapAppCoordinatesCopied": { + }, + "mapAppOpenFailed": "Không thể mở {app}", + "@mapAppOpenFailed": { + }, + + "mapAppCallFailed": "Thiết bị này không thể thực hiện cuộc gọi", + + "mapOverlaySectionReference": "Lớp tham chiếu", + "mapLayerCategoryEarthquake": "Động đất", + "mapLayerCategoryTyphoon": "Bão", + "mapLayerCategoryWeather": "Quan sát thời tiết", + "mapLayerCategorySatellite": "Vệ tinh", + "mapLayerCategoryRadar": "Ra đa", + "mapLayerCategoryLife": "Đời sống", + "mapLayerCategoryForecast": "Dự báo số", "mapOverlaySectionMap": "Bản đồ", + "rainIntervalSection": "Khoảng thời gian", + + "mapTownLabels": "Tên hương trấn", + "mapTownLabelsHint": "Hiển thị tên hương trấn khi phóng to", + "dpmSheetEmpty": "Chạm vào điểm đánh dấu trên bản đồ để xem chi tiết", "dpmAddress": "Địa chỉ", "restroomTypeLabel": "Loại", @@ -353,6 +470,7 @@ "mapLegendExpand": "Chú giải", "mapLegendCollapse": "Ẩn chú giải", "mapMyLocation": "Vị trí của tôi", + "mapResetNorth": "Về hướng bắc", "typhoonLegendCircle15": "Vòng gió mạnh", "typhoonLegendCircle25": "Vòng bão", "typhoonLegendProbability": "Xác suất đổ bộ", @@ -371,6 +489,8 @@ "typhoonIntensityTd": "Tropical depression", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "Tropical depression TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "Past movement direction", "typhoonLabelGaleAvg": "Avg. radius of Beaufort 7 winds", "typhoonLabelGust": "Peak gust", @@ -417,5 +537,133 @@ "dpmDisasterLandslide": "Sạt lở đất", "dpmDisasterTsunami": "Sóng thần", "dpmDisasterSlope": "Thiên tai sườn dốc", - "dpmDisasterNuclear": "Sự cố hạt nhân" -} \ No newline at end of file + "dpmDisasterNuclear": "Sự cố hạt nhân", + "skyTime": "Thời gian bầu trời", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "Tự động", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "Rạng đông", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "Bình minh", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "Buổi sáng", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "Buổi trưa", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "Buổi chiều", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "Giờ vàng", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "Hoàng hôn", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "Chạng vạng", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "Ban đêm", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "Nhiều mây", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "Trời âm u", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "Tuyết rơi", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "Bụi cát", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "Hiện phạm vi quét", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "Đánh dấu vùng bốn radar thực sự quan trắc.", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "Ngoài khung là chưa quan trắc", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "Tùy chọn lớp radar", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "Ranh giới huyện thị", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "Biên giới quốc gia", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "Khung ngoài của mỗi quốc gia", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "Vẽ đè lên tiếng vọng", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "Giữ ranh giới rõ ràng dưới lớp phản hồi radar.", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "Ranh giới xã phường", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "Lưới chi tiết hơn", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "Giữ ranh giới xã phường rõ ràng dưới lớp phản hồi radar.", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "Tùy chọn lớp dự báo mưa định lượng", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "Tùy chọn lớp dự báo gió", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "Vẽ trên trường gió", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "Khung ngoài của mỗi quốc gia", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "Lưới mịn hơn", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." + } +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bb957b328..ab9566b17 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,7 +1,7 @@ { "@@locale": "zh", "languageName": "繁體中文(臺灣)", - "navHome": "主頁", + "navHome": "首頁", "navEvents": "事件", "navMap": "地圖", "navData": "資料", @@ -150,15 +150,88 @@ "homeForecastEmpty": "目前沒有預報資料", "homeActiveEventsTitle": "生效中事件", "homeActiveEventsEmpty": "目前沒有生效中的事件", - "homeRainTrendTitle": "近 1 小時降雨趨勢", + "homeRainTrendTitle": "近 1 小時降水趨勢", + "homeRainTrendMinute": "{minute}分", + "homeRainTrendUpdated": "更新 {time}", + "homeRainTrendNoData": "無資料", + + "homeRainTrendScattered": "可能會有零星降雨", + "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", + "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", + "homeRainTrendHeavySustained": "未來 1 小時會有持續大雨", + "homeRainTrendHeavyStopping": "預計 {minutes} 分鐘後停止下大雨", "mapLayers": "圖層", + "mapLayerOrderTitle": "調整圖層順序", + "mapLayerOrderReset": "回復預設順序", "mapLayerRadar": "雷達合成回波圖", - "mapLayerSatellite": "ひまわり 紅外線圖", + "mapLayerSatellite": "ひまわり 紅外線(B13)", + "mapLayerSatelliteB01": "ひまわり 可見光-藍(B01)", + "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", + "mapLayerSatelliteB03": "ひまわり 可見光-紅(B03)", + "mapLayerSatelliteB04": "ひまわり 近紅外(B04)", + "mapLayerSatelliteB05": "ひまわり 近紅外(B05)", + "mapLayerSatelliteB06": "ひまわり 近紅外(B06)", + "mapLayerSatelliteB07": "ひまわり 短波紅外(B07)", + "mapLayerSatelliteB08": "ひまわり 上層水氣(B08)", + "mapLayerSatelliteB09": "ひまわり 中層水氣(B09)", + "mapLayerSatelliteB10": "ひまわり 低層水氣(B10)", + "mapLayerSatelliteB11": "ひまわり 二氧化硫/雲相(B11)", + "mapLayerSatelliteB12": "ひまわり 臭氧(B12)", + "mapLayerSatelliteB13": "ひまわり 紅外線(B13)", + "mapLayerSatelliteB14": "ひまわり 長波紅外線(B14)", + "mapLayerSatelliteB15": "ひまわり 長波紅外線(B15)", + "mapLayerSatelliteB16": "ひまわり 二氧化碳(B16)", + "mapLayerSatelliteTruecolor": "ひまわり 真彩色", + "mapLayerSatelliteNaturalcolor": "ひまわり 自然色", + "mapLayerSatelliteAsh": "ひまわり 火山灰", + "mapLayerSatelliteDust": "ひまわり 沙塵", + "mapLayerSatelliteAirmass": "ひまわり 氣團", + "mapLayerSatelliteNightmicrophysics": "ひまわり 夜間微物理", + "mapLayerSatelliteWatervapor": "ひまわり 水氣", + "mapLayerSatelliteBtdSplit": "ひまわり 分割視窗", + "mapLayerSatelliteBtdFog": "ひまわり 夜間霧", + "mapLayerSatelliteBtdWvirw": "ひまわり 過衝雲頂", + "mapLayerSatelliteBtdSo2": "ひまわり 二氧化硫/雲相", + "mapLayerSatelliteBtdCo2": "ひまわり 卷雲/雲高", + "mapLayerSatelliteBtdOzone": "ひまわり 對流層頂", + "mapLayerSatelliteCloudtop": "ひまわり 雲頂溫度", + "mapLayerSatelliteCloudmask": "ひまわり 雲遮罩", + "mapLayerSatelliteSst": "ひまわり 海表溫度", + "mapLayerSatelliteNdvi": "ひまわり 植生指數", + "mapLayerSatelliteNdwi": "ひまわり 水體指數", + "mapLayerSatelliteMndwi": "ひまわり 改良水體指數", + "mapLayerSatelliteGlobalOutline": "國界", + "mapLayerSatelliteRgbComposite": "RGB 合成(JMA 配方)", + "mapLayerSatelliteCloudClear": "晴空", + "mapLayerSatelliteCloudProbablyClear": "可能晴空", + "mapLayerSatelliteCloudProbablyCloudy": "可能有雲", + "mapLayerSatelliteCloudCloudy": "有雲", + "mapLayerSatelliteTransparentWarm": "晴空(暖端) = 透明,顯示底圖", + "mapLayerSatelliteTransparentReflectance": "低反射率/夜間 = 透明,顯示底圖", + "mapLayerSatelliteTransparentZero": "零差值 = 透明(無訊號)", + "mapLayerSatelliteTransparentNight": "夜間 = 透明,顯示底圖", + "mapLayerSatelliteTransparentNoData": "無資料(陸地) = 透明", + "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", + "mapLayerSatelliteTransparentNoWater": "≤ 0 = 透明(無水體)", + "mapLayerSatelliteTransparentClear": "晴空 = 透明,顯示底圖", + "mapLayerStyleSection": "顯示樣式", + "mapLayerStyleTooltip": "顯示樣式", + "mapLayerStyleGray": "灰階(JMA)", + "mapLayerStyleGrayTooltip": "氣象廳灰階慣例:溫度越低越白", + "mapLayerStyleJma": "雲頂強調(JMA)", + "mapLayerStyleJmaTooltip": "灰階為底,−40 °C 以下上色,凸顯雲頂高度", + "mapLayerStyleBd": "Dvorak BD", + "mapLayerStyleBdTooltip": "Dvorak BD 曲線——熱帶氣旋強度分析的階梯灰階", + "mapLayerQpesums": "未來 1 小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", "mapTimelineNow": "現在", + "mapTimelinePast": "歷史", + "mapTimelineFuture": "未來", "mapTimelineObserved": "觀測", + "mapTimelineForecast": "預報", + "mapTimelineDataTime": "資料時間 {time}", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "推播尚未就緒,請稍後再試。", @@ -229,6 +302,7 @@ "defaultMapLayerSettings": "地圖預設圖層", "defaultMapLayerSubtitle": "開啟地圖分頁時顯示此圖層,底部導覽列圖示與文字會一併更新。", "mapNavRadar": "雷達", + "mapNavQpesums": "預報", "mapNavSatellite": "衛星", "mapNavLightning": "閃電", "mapNavTyphoon": "颱風", @@ -268,6 +342,8 @@ "mapLayerHumidity": "濕度", "mapLayerPressure": "氣壓", "mapLayerWind": "風向", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "雨量", "rainIntervalMenu": "累積時段", "rainIntervalNow": "今日", @@ -326,6 +402,46 @@ "mapLayerShelter": "避難收容場所", "disasterMapOverlayRestroomTooltip": "顯示公廁", "disasterMapOverlayShelterTooltip": "顯示避難收容場所", + "dpmOpenInMaps": "開啟地圖", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app}(預設)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "複製座標", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "已複製座標", + "@mapAppCoordinatesCopied": { + }, + "mapAppOpenFailed": "無法開啟 {app}", + "@mapAppOpenFailed": { + }, + + "mapAppCallFailed": "此裝置無法撥打電話", + + "mapOverlaySectionReference": "參考圖層", + "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryTyphoon": "颱風", + "mapLayerCategoryWeather": "氣象觀測", + "mapLayerCategorySatellite": "衛星", + "mapLayerCategoryRadar": "雷達", + "mapLayerCategoryLife": "生活", + "mapLayerCategoryForecast": "數值預報", "mapOverlaySectionMap": "地圖", + "rainIntervalSection": "統計時間", + + "mapTownLabels": "鄉鎮名稱", + "mapTownLabelsHint": "放大時顯示鄉鎮名稱", + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", @@ -369,6 +485,8 @@ "typhoonIntensityTd": "熱帶性低氣壓", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "熱帶性低氣壓 TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonIntensityMild": "輕度颱風", "typhoonIntensityModerate": "中度颱風", "typhoonIntensityIntense": "強烈颱風", @@ -379,6 +497,7 @@ "mapLegendExpand": "圖例", "mapLegendCollapse": "收合圖例", "mapMyLocation": "我的位置", + "mapResetNorth": "回到北方", "typhoonLegendCircle15": "七級風暴風圈", "typhoonLegendCircleAvg": "平均圓", "typhoonLegendCircle25": "十級風暴風圈", @@ -417,5 +536,133 @@ "dpmDisasterLandslide": "土石流", "dpmDisasterTsunami": "海嘯", "dpmDisasterSlope": "坡地災害", - "dpmDisasterNuclear": "核子事故" -} \ No newline at end of file + "dpmDisasterNuclear": "核子事故", + "skyTime": "天空時間", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "自動", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "黎明", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "日出", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "上午", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "正午", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "下午", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "黃金時刻", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "日落", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "暮色", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "夜晚", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "多雲", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "陰天", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "下雪", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "沙塵", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "顯示掃描範圍", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "標示四座雷達實際觀測到的範圍。", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "框外空白代表未觀測", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "雷達圖層選項", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "縣市界線", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "國界", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "各國國界外框", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "畫在回波之上", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "讓縣市界線在雷達回波下仍然清楚。", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "鄉鎮界線", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "較細的分區", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "讓鄉鎮界線在雷達回波下仍然清楚。", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "定量降水預報圖層選項", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "風場預報圖層選項", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "繪製於風場之上", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "各國國界外框", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "更細的網格", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." + } +} diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index faceeb3dc..3b5661bb4 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,7 +1,7 @@ { "@@locale": "zh_Hans", "languageName": "简体中文", - "navHome": "首页", + "navHome": "主页", "navEvents": "事件", "navMap": "地图", "navData": "资料", @@ -150,15 +150,89 @@ "homeForecastEmpty": "目前没有预报数据", "homeActiveEventsTitle": "生效中事件", "homeActiveEventsEmpty": "目前没有生效中的事件", - "homeRainTrendTitle": "近 1 小时降雨趋势", + "homeRainTrendTitle": "近 1 小时降水趋势", + "homeRainTrendMinute": "{minute}分", + "homeRainTrendUpdated": "更新 {time}", + "homeRainTrendNoData": "无资料", + + "homeRainTrendScattered": "可能会有零星降雨", + "homeRainTrendLightSustained": "未来 1 小时会有持续小雨", + "homeRainTrendLightStopping": "预计 {minutes} 分钟后停止下小雨", + "homeRainTrendHeavySustained": "未来 1 小时会有持续大雨", + "homeRainTrendHeavyStopping": "预计 {minutes} 分钟后停止下大雨", "mapLayers": "图层", + "mapLayerOrderTitle": "调整图层顺序", + "mapLayerOrderReset": "恢复默认顺序", "mapLayerRadar": "雷达合成回波图", - "mapLayerSatellite": "ひまわり 红外线图", + "mapLayerSatellite": "ひまわり 红外线(B13)", + "mapLayerSatelliteB01": "ひまわり 可见光-蓝(B01)", + "mapLayerSatelliteB02": "ひまわり 可见光-绿(B02)", + "mapLayerSatelliteB03": "ひまわり 可见光-红(B03)", + "mapLayerSatelliteB04": "ひまわり 近红外(B04)", + "mapLayerSatelliteB05": "ひまわり 近红外(B05)", + "mapLayerSatelliteB06": "ひまわり 近红外(B06)", + "mapLayerSatelliteB07": "ひまわり 短波红外(B07)", + "mapLayerSatelliteB08": "ひまわり 上层水气(B08)", + "mapLayerSatelliteB09": "ひまわり 中层水气(B09)", + "mapLayerSatelliteB10": "ひまわり 低层水气(B10)", + "mapLayerSatelliteB11": "ひまわり 二氧化硫/云相(B11)", + "mapLayerSatelliteB12": "ひまわり 臭氧(B12)", + "mapLayerSatelliteB13": "ひまわり 红外线(B13)", + "mapLayerSatelliteB14": "ひまわり 长波红外线(B14)", + "mapLayerSatelliteB15": "ひまわり 长波红外线(B15)", + "mapLayerSatelliteB16": "ひまわり 二氧化碳(B16)", + "mapLayerSatelliteTruecolor": "ひまわり 真彩色", + "mapLayerSatelliteNaturalcolor": "ひまわり 自然色", + "mapLayerSatelliteAsh": "ひまわり 火山灰", + "mapLayerSatelliteDust": "ひまわり 沙尘", + "mapLayerSatelliteAirmass": "ひまわり 气团", + "mapLayerSatelliteNightmicrophysics": "ひまわり 夜间微物理", + "mapLayerSatelliteWatervapor": "ひまわり 水气", + "mapLayerSatelliteBtdSplit": "ひまわり 分割视窗", + "mapLayerSatelliteBtdFog": "ひまわり 夜间雾", + "mapLayerSatelliteBtdWvirw": "ひまわり 过冲云顶", + "mapLayerSatelliteBtdSo2": "ひまわり 二氧化硫/云相", + "mapLayerSatelliteBtdCo2": "ひまわり 卷云/云高", + "mapLayerSatelliteBtdOzone": "ひまわり 对流层顶", + "mapLayerSatelliteCloudtop": "ひまわり 云顶温度", + "mapLayerSatelliteCloudmask": "ひまわり 云遮罩", + "mapLayerSatelliteSst": "ひまわり 海表温度", + "mapLayerSatelliteNdvi": "ひまわり 植被指数", + "mapLayerSatelliteNdwi": "ひまわり 水体指数", + "mapLayerSatelliteMndwi": "ひまわり 改良水体指数", + "mapLayerSatelliteGlobalOutline": "国界", + "mapLayerSatelliteRgbComposite": "RGB 合成(JMA 配方)", + "mapLayerSatelliteCloudClear": "晴空", + "mapLayerSatelliteCloudProbablyClear": "可能晴空", + "mapLayerSatelliteCloudProbablyCloudy": "可能有云", + "mapLayerSatelliteCloudCloudy": "有云", + "mapLayerSatelliteTransparentWarm": "晴空(暖端) = 透明,显示底图", + "mapLayerSatelliteTransparentReflectance": "低反射率/夜间 = 透明,显示底图", + "mapLayerSatelliteTransparentZero": "零差值 = 透明(无信号)", + "mapLayerSatelliteTransparentNight": "夜间 = 透明,显示底图", + "mapLayerSatelliteTransparentNoData": "无资料(陆地) = 透明", + "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(无植被)", + "mapLayerSatelliteTransparentNoWater": "≤ 0 = 透明(无水体)", + "mapLayerSatelliteTransparentClear": "晴空 = 透明,显示底图", + "mapLayerStyleSection": "显示样式", + "mapLayerStyleTooltip": "显示样式", + "mapLayerStyleGray": "灰度(JMA)", + "mapLayerStyleGrayTooltip": "气象厅灰度惯例:温度越低越白", + "mapLayerStyleJma": "云顶强调(JMA)", + "mapLayerStyleJmaTooltip": "灰阶为底,−40 °C 以下上色,凸显云顶高度", + "mapLayerStyleBd": "Dvorak BD", + "mapLayerStyleBdTooltip": "Dvorak BD 曲线——热带气旋强度分析的阶梯灰度", + "mapLayerQpesums": "未来 1 小时降水预报", "mapLayerLightning": "闪电", "lightningLegendCg": "对地 · {minutes} 分钟内", "lightningLegendCc": "云间 · {minutes} 分钟内", "mapTimelineNow": "现在", + "mapTimelinePast": "历史", + "mapTimelineFuture": "未来", "mapTimelineObserved": "观测", + "mapTimelineForecast": "预报", + "mapTimelineDataTime": "资料时间 {time}", + "mapTimelineDataTime": "资料时间 {time}", "notifySettingsMenu": "通知设置", "notifyTitle": "通知", "notifyUnavailable": "推送通知尚未就绪,请稍后再试。", @@ -229,6 +303,7 @@ "defaultMapLayerSettings": "地图默认图层", "defaultMapLayerSubtitle": "打开地图标签页时显示此图层,底部导航栏图标与文字会一并更新。", "mapNavRadar": "雷达", + "mapNavQpesums": "预报", "mapNavSatellite": "卫星", "mapNavLightning": "闪电", "mapNavTyphoon": "台风", @@ -268,6 +343,8 @@ "mapLayerHumidity": "湿度", "mapLayerPressure": "气压", "mapLayerWind": "风向", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "雨量", "rainIntervalMenu": "累积时段", "rainIntervalNow": "今日", @@ -306,6 +383,46 @@ "mapLayerShelter": "避难收容场所", "disasterMapOverlayRestroomTooltip": "显示公厕", "disasterMapOverlayShelterTooltip": "显示避难收容场所", + "dpmOpenInMaps": "打开地图", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app}(默认)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "复制坐标", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "已复制坐标", + "@mapAppCoordinatesCopied": { + }, + "mapAppOpenFailed": "无法打开 {app}", + "@mapAppOpenFailed": { + }, + + "mapAppCallFailed": "此设备无法拨打电话", + + "mapOverlaySectionReference": "参考图层", + "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryTyphoon": "台风", + "mapLayerCategoryWeather": "气象观测", + "mapLayerCategorySatellite": "卫星", + "mapLayerCategoryRadar": "雷达", + "mapLayerCategoryLife": "生活", + "mapLayerCategoryForecast": "数值预报", "mapOverlaySectionMap": "地图", + "rainIntervalSection": "统计时间", + + "mapTownLabels": "乡镇名称", + "mapTownLabelsHint": "放大时显示乡镇名称", + "dpmSheetEmpty": "点击地图上的标记查看详情", "dpmAddress": "地址", "restroomTypeLabel": "厕所类型", @@ -353,6 +470,7 @@ "mapLegendExpand": "图例", "mapLegendCollapse": "收起图例", "mapMyLocation": "我的位置", + "mapResetNorth": "回到正北", "typhoonLegendCircle15": "七级风暴风圈", "typhoonLegendCircle25": "十级风暴风圈", "typhoonLegendProbability": "侵袭概率", @@ -371,6 +489,8 @@ "typhoonIntensityTd": "热带性低气压", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "热带性低气压 TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "过去移动方向", "typhoonLabelGaleAvg": "七级风平均暴风半径", "typhoonLabelGust": "瞬间最大阵风", @@ -417,5 +537,133 @@ "dpmDisasterLandslide": "土石流", "dpmDisasterTsunami": "海啸", "dpmDisasterSlope": "坡地灾害", - "dpmDisasterNuclear": "核子事故" -} \ No newline at end of file + "dpmDisasterNuclear": "核子事故", + "skyTime": "天空时间", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "自动", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "黎明", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "日出", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "上午", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "正午", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "下午", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "黄金时刻", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "日落", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "暮色", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "夜晚", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "多云", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "阴天", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "下雪", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "沙尘", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "显示扫描范围", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "标示四座雷达实际观测到的范围。", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "框外空白代表未观测", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "雷达图层选项", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "县市界线", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "国界", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "各国国界外框", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "画在回波之上", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "让县市界线在雷达回波下仍然清楚。", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "乡镇界线", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "较细的分区", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "让乡镇界线在雷达回波下仍然清楚。", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "定量降水预报图层选项", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "风场预报图层选项", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "绘制于风场之上", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "各国国界外框", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "更细的网格", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." + } +} diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index f59ada750..f522ea7c8 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -150,15 +150,89 @@ "homeForecastEmpty": "目前沒有預報資料", "homeActiveEventsTitle": "生效中事件", "homeActiveEventsEmpty": "目前沒有生效中的事件", - "homeRainTrendTitle": "近 1 小時降雨趨勢", + "homeRainTrendTitle": "近 1 小時降水趨勢", + "homeRainTrendMinute": "{minute}分", + "homeRainTrendUpdated": "更新 {time}", + "homeRainTrendNoData": "無資料", + + "homeRainTrendScattered": "可能會有零星降雨", + "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", + "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", + "homeRainTrendHeavySustained": "未來 1 小時會有持續大雨", + "homeRainTrendHeavyStopping": "預計 {minutes} 分鐘後停止下大雨", "mapLayers": "圖層", + "mapLayerOrderTitle": "調整圖層順序", + "mapLayerOrderReset": "回復預設順序", "mapLayerRadar": "雷達合成回波圖", - "mapLayerSatellite": "ひまわり 紅外線圖", + "mapLayerSatellite": "ひまわり 紅外線(B13)", + "mapLayerSatelliteB01": "ひまわり 可見光-藍(B01)", + "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", + "mapLayerSatelliteB03": "ひまわり 可見光-紅(B03)", + "mapLayerSatelliteB04": "ひまわり 近紅外(B04)", + "mapLayerSatelliteB05": "ひまわり 近紅外(B05)", + "mapLayerSatelliteB06": "ひまわり 近紅外(B06)", + "mapLayerSatelliteB07": "ひまわり 短波紅外(B07)", + "mapLayerSatelliteB08": "ひまわり 上層水氣(B08)", + "mapLayerSatelliteB09": "ひまわり 中層水氣(B09)", + "mapLayerSatelliteB10": "ひまわり 低層水氣(B10)", + "mapLayerSatelliteB11": "ひまわり 二氧化硫/雲相(B11)", + "mapLayerSatelliteB12": "ひまわり 臭氧(B12)", + "mapLayerSatelliteB13": "ひまわり 紅外線(B13)", + "mapLayerSatelliteB14": "ひまわり 長波紅外線(B14)", + "mapLayerSatelliteB15": "ひまわり 長波紅外線(B15)", + "mapLayerSatelliteB16": "ひまわり 二氧化碳(B16)", + "mapLayerSatelliteTruecolor": "ひまわり 真彩色", + "mapLayerSatelliteNaturalcolor": "ひまわり 自然色", + "mapLayerSatelliteAsh": "ひまわり 火山灰", + "mapLayerSatelliteDust": "ひまわり 沙塵", + "mapLayerSatelliteAirmass": "ひまわり 氣團", + "mapLayerSatelliteNightmicrophysics": "ひまわり 夜間微物理", + "mapLayerSatelliteWatervapor": "ひまわり 水氣", + "mapLayerSatelliteBtdSplit": "ひまわり 分割視窗", + "mapLayerSatelliteBtdFog": "ひまわり 夜間霧", + "mapLayerSatelliteBtdWvirw": "ひまわり 過衝雲頂", + "mapLayerSatelliteBtdSo2": "ひまわり 二氧化硫/雲相", + "mapLayerSatelliteBtdCo2": "ひまわり 卷雲/雲高", + "mapLayerSatelliteBtdOzone": "ひまわり 對流層頂", + "mapLayerSatelliteCloudtop": "ひまわり 雲頂溫度", + "mapLayerSatelliteCloudmask": "ひまわり 雲遮罩", + "mapLayerSatelliteSst": "ひまわり 海表溫度", + "mapLayerSatelliteNdvi": "ひまわり 植生指數", + "mapLayerSatelliteNdwi": "ひまわり 水體指數", + "mapLayerSatelliteMndwi": "ひまわり 改良水體指數", + "mapLayerSatelliteGlobalOutline": "國界", + "mapLayerSatelliteRgbComposite": "RGB 合成(JMA 配方)", + "mapLayerSatelliteCloudClear": "晴空", + "mapLayerSatelliteCloudProbablyClear": "可能晴空", + "mapLayerSatelliteCloudProbablyCloudy": "可能有雲", + "mapLayerSatelliteCloudCloudy": "有雲", + "mapLayerSatelliteTransparentWarm": "晴空(暖端) = 透明,顯示底圖", + "mapLayerSatelliteTransparentReflectance": "低反射率/夜間 = 透明,顯示底圖", + "mapLayerSatelliteTransparentZero": "零差值 = 透明(無訊號)", + "mapLayerSatelliteTransparentNight": "夜間 = 透明,顯示底圖", + "mapLayerSatelliteTransparentNoData": "無資料(陸地) = 透明", + "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", + "mapLayerSatelliteTransparentNoWater": "≤ 0 = 透明(無水體)", + "mapLayerSatelliteTransparentClear": "晴空 = 透明,顯示底圖", + "mapLayerStyleSection": "顯示樣式", + "mapLayerStyleTooltip": "顯示樣式", + "mapLayerStyleGray": "灰階(JMA)", + "mapLayerStyleGrayTooltip": "氣象廳灰階慣例:溫度越低越白", + "mapLayerStyleJma": "雲頂強調(JMA)", + "mapLayerStyleJmaTooltip": "灰階為底,−40 °C 以下上色,凸顯雲頂高度", + "mapLayerStyleBd": "Dvorak BD", + "mapLayerStyleBdTooltip": "Dvorak BD 曲線——熱帶氣旋強度分析的階梯灰階", + "mapLayerQpesums": "未來 1 小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", "mapTimelineNow": "現在", + "mapTimelinePast": "歷史", + "mapTimelineFuture": "未來", "mapTimelineObserved": "觀測", + "mapTimelineForecast": "預報", + "mapTimelineDataTime": "資料時間 {time}", + "mapTimelineDataTime": "資料時間 {time}", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "推送尚未就緒,請稍後再試。", @@ -229,6 +303,7 @@ "defaultMapLayerSettings": "地圖預設圖層", "defaultMapLayerSubtitle": "開啟地圖分頁時顯示此圖層,底部導覽列圖示與文字會一併更新。", "mapNavRadar": "雷達", + "mapNavQpesums": "預報", "mapNavSatellite": "衛星", "mapNavLightning": "閃電", "mapNavTyphoon": "颱風", @@ -268,6 +343,8 @@ "mapLayerHumidity": "濕度", "mapLayerPressure": "氣壓", "mapLayerWind": "風向", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "雨量", "rainIntervalMenu": "累積時段", "rainIntervalNow": "今日", @@ -306,6 +383,46 @@ "mapLayerShelter": "避難收容場所", "disasterMapOverlayRestroomTooltip": "顯示公廁", "disasterMapOverlayShelterTooltip": "顯示避難收容場所", + "dpmOpenInMaps": "開啟地圖", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app}(預設)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "複製座標", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "已複製座標", + "@mapAppCoordinatesCopied": { + }, + "mapAppOpenFailed": "無法開啟 {app}", + "@mapAppOpenFailed": { + }, + + "mapAppCallFailed": "此裝置無法撥打電話", + + "mapOverlaySectionReference": "參考圖層", + "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryTyphoon": "颱風", + "mapLayerCategoryWeather": "氣象觀測", + "mapLayerCategorySatellite": "衛星", + "mapLayerCategoryRadar": "雷達", + "mapLayerCategoryLife": "生活", + "mapLayerCategoryForecast": "數值預報", "mapOverlaySectionMap": "地圖", + "rainIntervalSection": "統計時間", + + "mapTownLabels": "鄉鎮名稱", + "mapTownLabelsHint": "放大時顯示鄉鎮名稱", + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", @@ -353,6 +470,7 @@ "mapLegendExpand": "圖例", "mapLegendCollapse": "收合圖例", "mapMyLocation": "我的位置", + "mapResetNorth": "回到北方", "typhoonLegendCircle15": "七級風暴風圈", "typhoonLegendCircle25": "十級風暴風圈", "typhoonLegendProbability": "侵襲機率", @@ -371,6 +489,8 @@ "typhoonIntensityTd": "熱帶性低氣壓", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "熱帶性低氣壓 TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "過去移動方向", "typhoonLabelGaleAvg": "七級風平均暴風半徑", "typhoonLabelGust": "瞬間最大陣風", @@ -417,5 +537,133 @@ "dpmDisasterLandslide": "土石流", "dpmDisasterTsunami": "海嘯", "dpmDisasterSlope": "坡地災害", - "dpmDisasterNuclear": "核子事故" -} \ No newline at end of file + "dpmDisasterNuclear": "核子事故", + "skyTime": "天空時間", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "自動", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "黎明", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "日出", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "上午", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "正午", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "下午", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "黃金時刻", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "日落", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "暮色", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "夜晚", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "多雲", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "陰天", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "下雪", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "沙塵", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "顯示掃描範圍", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "標示四座雷達實際觀測到的範圍。", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "框外空白代表未觀測", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "雷達圖層選項", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "縣市界線", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "國界", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "各國國界外框", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "畫在回波之上", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "讓縣市界線在雷達回波下仍然清楚。", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "鄉鎮界線", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "較細的分區", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "讓鄉鎮界線在雷達回波下仍然清楚。", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "定量降水預報圖層選項", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "風場預報圖層選項", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "繪製於風場之上", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "各國國界外框", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "更細的網格", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." + } +} diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 1754df009..7c2d7cc3e 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1,7 +1,7 @@ { "@@locale": "zh_TW", "languageName": "繁體中文(臺灣)", - "navHome": "主頁", + "navHome": "首頁", "navEvents": "事件", "navMap": "地圖", "navData": "資料", @@ -150,15 +150,88 @@ "homeForecastEmpty": "目前沒有預報資料", "homeActiveEventsTitle": "生效中事件", "homeActiveEventsEmpty": "目前沒有生效中的事件", - "homeRainTrendTitle": "近 1 小時降雨趨勢", + "homeRainTrendTitle": "近 1 小時降水趨勢", + "homeRainTrendMinute": "{minute}分", + "homeRainTrendUpdated": "更新 {time}", + "homeRainTrendNoData": "無資料", + + "homeRainTrendScattered": "可能會有零星降雨", + "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", + "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", + "homeRainTrendHeavySustained": "未來 1 小時會有持續大雨", + "homeRainTrendHeavyStopping": "預計 {minutes} 分鐘後停止下大雨", "mapLayers": "圖層", + "mapLayerOrderTitle": "調整圖層順序", + "mapLayerOrderReset": "回復預設順序", "mapLayerRadar": "雷達合成回波圖", - "mapLayerSatellite": "ひまわり 紅外線圖", + "mapLayerSatellite": "ひまわり 紅外線(B13)", + "mapLayerSatelliteB01": "ひまわり 可見光-藍(B01)", + "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", + "mapLayerSatelliteB03": "ひまわり 可見光-紅(B03)", + "mapLayerSatelliteB04": "ひまわり 近紅外(B04)", + "mapLayerSatelliteB05": "ひまわり 近紅外(B05)", + "mapLayerSatelliteB06": "ひまわり 近紅外(B06)", + "mapLayerSatelliteB07": "ひまわり 短波紅外(B07)", + "mapLayerSatelliteB08": "ひまわり 上層水氣(B08)", + "mapLayerSatelliteB09": "ひまわり 中層水氣(B09)", + "mapLayerSatelliteB10": "ひまわり 低層水氣(B10)", + "mapLayerSatelliteB11": "ひまわり 二氧化硫/雲相(B11)", + "mapLayerSatelliteB12": "ひまわり 臭氧(B12)", + "mapLayerSatelliteB13": "ひまわり 紅外線(B13)", + "mapLayerSatelliteB14": "ひまわり 長波紅外線(B14)", + "mapLayerSatelliteB15": "ひまわり 長波紅外線(B15)", + "mapLayerSatelliteB16": "ひまわり 二氧化碳(B16)", + "mapLayerSatelliteTruecolor": "ひまわり 真彩色", + "mapLayerSatelliteNaturalcolor": "ひまわり 自然色", + "mapLayerSatelliteAsh": "ひまわり 火山灰", + "mapLayerSatelliteDust": "ひまわり 沙塵", + "mapLayerSatelliteAirmass": "ひまわり 氣團", + "mapLayerSatelliteNightmicrophysics": "ひまわり 夜間微物理", + "mapLayerSatelliteWatervapor": "ひまわり 水氣", + "mapLayerSatelliteBtdSplit": "ひまわり 分割視窗", + "mapLayerSatelliteBtdFog": "ひまわり 夜間霧", + "mapLayerSatelliteBtdWvirw": "ひまわり 過衝雲頂", + "mapLayerSatelliteBtdSo2": "ひまわり 二氧化硫/雲相", + "mapLayerSatelliteBtdCo2": "ひまわり 卷雲/雲高", + "mapLayerSatelliteBtdOzone": "ひまわり 對流層頂", + "mapLayerSatelliteCloudtop": "ひまわり 雲頂溫度", + "mapLayerSatelliteCloudmask": "ひまわり 雲遮罩", + "mapLayerSatelliteSst": "ひまわり 海表溫度", + "mapLayerSatelliteNdvi": "ひまわり 植生指數", + "mapLayerSatelliteNdwi": "ひまわり 水體指數", + "mapLayerSatelliteMndwi": "ひまわり 改良水體指數", + "mapLayerSatelliteGlobalOutline": "國界", + "mapLayerSatelliteRgbComposite": "RGB 合成(JMA 配方)", + "mapLayerSatelliteCloudClear": "晴空", + "mapLayerSatelliteCloudProbablyClear": "可能晴空", + "mapLayerSatelliteCloudProbablyCloudy": "可能有雲", + "mapLayerSatelliteCloudCloudy": "有雲", + "mapLayerSatelliteTransparentWarm": "晴空(暖端) = 透明,顯示底圖", + "mapLayerSatelliteTransparentReflectance": "低反射率/夜間 = 透明,顯示底圖", + "mapLayerSatelliteTransparentZero": "零差值 = 透明(無訊號)", + "mapLayerSatelliteTransparentNight": "夜間 = 透明,顯示底圖", + "mapLayerSatelliteTransparentNoData": "無資料(陸地) = 透明", + "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", + "mapLayerSatelliteTransparentNoWater": "≤ 0 = 透明(無水體)", + "mapLayerSatelliteTransparentClear": "晴空 = 透明,顯示底圖", + "mapLayerStyleSection": "顯示樣式", + "mapLayerStyleTooltip": "顯示樣式", + "mapLayerStyleGray": "灰階(JMA)", + "mapLayerStyleGrayTooltip": "氣象廳灰階慣例:溫度越低越白", + "mapLayerStyleJma": "雲頂強調(JMA)", + "mapLayerStyleJmaTooltip": "灰階為底,−40 °C 以下上色,凸顯雲頂高度", + "mapLayerStyleBd": "Dvorak BD", + "mapLayerStyleBdTooltip": "Dvorak BD 曲線——熱帶氣旋強度分析的階梯灰階", + "mapLayerQpesums": "未來 1 小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", "mapTimelineNow": "現在", + "mapTimelinePast": "歷史", + "mapTimelineFuture": "未來", "mapTimelineObserved": "觀測", + "mapTimelineForecast": "預報", + "mapTimelineDataTime": "資料時間 {time}", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "推播尚未就緒,請稍後再試。", @@ -229,6 +302,7 @@ "defaultMapLayerSettings": "地圖預設圖層", "defaultMapLayerSubtitle": "開啟地圖分頁時顯示此圖層,底部導覽列圖示與文字會一併更新。", "mapNavRadar": "雷達", + "mapNavQpesums": "預報", "mapNavSatellite": "衛星", "mapNavLightning": "閃電", "mapNavTyphoon": "颱風", @@ -268,6 +342,8 @@ "mapLayerHumidity": "濕度", "mapLayerPressure": "氣壓", "mapLayerWind": "風向", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "雨量", "rainIntervalMenu": "累積時段", "rainIntervalNow": "今日", @@ -326,6 +402,46 @@ "mapLayerShelter": "避難收容場所", "disasterMapOverlayRestroomTooltip": "顯示公廁", "disasterMapOverlayShelterTooltip": "顯示避難收容場所", + "dpmOpenInMaps": "開啟地圖", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app}(預設)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "複製座標", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "已複製座標", + "@mapAppCoordinatesCopied": { + }, + "mapAppOpenFailed": "無法開啟 {app}", + "@mapAppOpenFailed": { + }, + + "mapAppCallFailed": "此裝置無法撥打電話", + + "mapOverlaySectionReference": "參考圖層", + "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryTyphoon": "颱風", + "mapLayerCategoryWeather": "氣象觀測", + "mapLayerCategorySatellite": "衛星", + "mapLayerCategoryRadar": "雷達", + "mapLayerCategoryLife": "生活", + "mapLayerCategoryForecast": "數值預報", "mapOverlaySectionMap": "地圖", + "rainIntervalSection": "統計時間", + + "mapTownLabels": "鄉鎮名稱", + "mapTownLabelsHint": "放大時顯示鄉鎮名稱", + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", @@ -369,6 +485,8 @@ "typhoonIntensityTd": "熱帶性低氣壓", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "熱帶性低氣壓 TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonIntensityMild": "輕度颱風", "typhoonIntensityModerate": "中度颱風", "typhoonIntensityIntense": "強烈颱風", @@ -379,6 +497,7 @@ "mapLegendExpand": "圖例", "mapLegendCollapse": "收合圖例", "mapMyLocation": "我的位置", + "mapResetNorth": "回到北方", "typhoonLegendCircle15": "七級風暴風圈", "typhoonLegendCircleAvg": "平均圓", "typhoonLegendCircle25": "十級風暴風圈", @@ -417,5 +536,133 @@ "dpmDisasterLandslide": "土石流", "dpmDisasterTsunami": "海嘯", "dpmDisasterSlope": "坡地災害", - "dpmDisasterNuclear": "核子事故" -} \ No newline at end of file + "dpmDisasterNuclear": "核子事故", + "skyTime": "天空時間", + "@skyTime": { + "description": "Label for the experimental sky time-of-day override." + }, + "skyTimeAuto": "自動", + "@skyTimeAuto": { + "description": "Label for the skyTimeAuto option in the experimental backdrop settings." + }, + "skyTimeDawn": "黎明", + "@skyTimeDawn": { + "description": "Label for the skyTimeDawn option in the experimental backdrop settings." + }, + "skyTimeSunrise": "日出", + "@skyTimeSunrise": { + "description": "Label for the skyTimeSunrise option in the experimental backdrop settings." + }, + "skyTimeMorning": "上午", + "@skyTimeMorning": { + "description": "Label for the skyTimeMorning option in the experimental backdrop settings." + }, + "skyTimeNoon": "正午", + "@skyTimeNoon": { + "description": "Label for the skyTimeNoon option in the experimental backdrop settings." + }, + "skyTimeAfternoon": "下午", + "@skyTimeAfternoon": { + "description": "Label for the skyTimeAfternoon option in the experimental backdrop settings." + }, + "skyTimeGolden": "黃金時刻", + "@skyTimeGolden": { + "description": "Label for the skyTimeGolden option in the experimental backdrop settings." + }, + "skyTimeSunset": "日落", + "@skyTimeSunset": { + "description": "Label for the skyTimeSunset option in the experimental backdrop settings." + }, + "skyTimeDusk": "暮色", + "@skyTimeDusk": { + "description": "Label for the skyTimeDusk option in the experimental backdrop settings." + }, + "skyTimeNight": "夜晚", + "@skyTimeNight": { + "description": "Label for the skyTimeNight option in the experimental backdrop settings." + }, + "weatherModeCloudy": "多雲", + "@weatherModeCloudy": { + "description": "Label for the weatherModeCloudy option in the experimental backdrop settings." + }, + "weatherModeOvercast": "陰天", + "@weatherModeOvercast": { + "description": "Label for the weatherModeOvercast option in the experimental backdrop settings." + }, + "weatherModeSnow": "下雪", + "@weatherModeSnow": { + "description": "Label for the weatherModeSnow option in the experimental backdrop settings." + }, + "weatherModeSand": "沙塵", + "@weatherModeSand": { + "description": "Label for the weatherModeSand option in the experimental backdrop settings." + }, + "radarScanRange": "顯示掃描範圍", + "@radarScanRange": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeSubtitle": "標示四座雷達實際觀測到的範圍。", + "@radarScanRangeSubtitle": { + "description": "Radar scan-range overlay toggle in the map's radar overlay menu." + }, + "radarScanRangeHint": "框外空白代表未觀測", + "@radarScanRangeHint": { + "description": "Hint under the radar scan-range toggle in the radar overlay menu." + }, + "radarOverlayMenuTooltip": "雷達圖層選項", + "@radarOverlayMenuTooltip": { + "description": "Tooltip for the radar overlay-options chip beside the layer switcher" + }, + "radarCountyOutline": "縣市界線", + "@radarCountyOutline": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarGlobalOutline": "國界", + "@radarGlobalOutline": { + "description": "World-country-border overlay toggle in the map's reference-layer overlay menus." + }, + "radarGlobalOutlineHint": "各國國界外框", + "@radarGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the radar overlay menu." + }, + "radarCountyOutlineHint": "畫在回波之上", + "@radarCountyOutlineHint": { + "description": "Hint under the county-border toggle in the radar overlay menu." + }, + "radarCountyOutlineSubtitle": "讓縣市界線在雷達回波下仍然清楚。", + "@radarCountyOutlineSubtitle": { + "description": "County-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutline": "鄉鎮界線", + "@radarTownOutline": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "radarTownOutlineHint": "較細的分區", + "@radarTownOutlineHint": { + "description": "Hint under the township-border toggle in the radar overlay menu." + }, + "radarTownOutlineSubtitle": "讓鄉鎮界線在雷達回波下仍然清楚。", + "@radarTownOutlineSubtitle": { + "description": "Township-border overlay toggle in the map's radar overlay menu." + }, + "qpesumsOverlayMenuTooltip": "定量降水預報圖層選項", + "@qpesumsOverlayMenuTooltip": { + "description": "Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher." + }, + "windForecastOverlayMenuTooltip": "風場預報圖層選項", + "@windForecastOverlayMenuTooltip": { + "description": "Tooltip for the wind-forecast overlay-options chip beside the layer switcher." + }, + "windForecastCountyOutlineHint": "繪製於風場之上", + "@windForecastCountyOutlineHint": { + "description": "Hint under the county-border toggle in the wind-forecast overlay menu." + }, + "windForecastGlobalOutlineHint": "各國國界外框", + "@windForecastGlobalOutlineHint": { + "description": "Hint under the national-border toggle in the wind-forecast overlay menu." + }, + "windForecastTownOutlineHint": "更細的網格", + "@windForecastTownOutlineHint": { + "description": "Hint under the township-border toggle in the wind-forecast overlay menu." + } +} diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 06bddd95f..51afffd59 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1020,15 +1020,75 @@ abstract class AppLocalizations { /// Section title for the home sheet 1-hour per-minute rainfall bar chart /// /// In en, this message translates to: - /// **'Next hour rain'** + /// **'Next hour precipitation'** String get homeRainTrendTitle; + /// X-axis tick label on the home rain trend chart, minutes from now + /// + /// In en, this message translates to: + /// **'{minute} min'** + String homeRainTrendMinute(int minute); + + /// Data-update time beside the home rain trend title, Taipei wall clock HH:mm + /// + /// In en, this message translates to: + /// **'Updated {time}'** + String homeRainTrendUpdated(String time); + + /// Label on the home rain trend chart for minutes beyond the forecast window, and the empty-card hint + /// + /// In en, this message translates to: + /// **'No data'** + String get homeRainTrendNoData; + + /// Home rain trend subtitle: peak intensity below the light-rain threshold + /// + /// In en, this message translates to: + /// **'Light showers possible'** + String get homeRainTrendScattered; + + /// Home rain trend subtitle: light rain that keeps up through the hour + /// + /// In en, this message translates to: + /// **'Light rain continuing for the next hour'** + String get homeRainTrendLightSustained; + + /// Home rain trend subtitle: light rain forecast to stop partway through the hour + /// + /// In en, this message translates to: + /// **'Light rain likely to stop in {minutes} minutes'** + String homeRainTrendLightStopping(int minutes); + + /// Home rain trend subtitle: heavy rain that keeps up through the hour + /// + /// In en, this message translates to: + /// **'Heavy rain continuing for the next hour'** + String get homeRainTrendHeavySustained; + + /// Home rain trend subtitle: heavy rain forecast to stop partway through the hour + /// + /// In en, this message translates to: + /// **'Heavy rain likely to stop in {minutes} minutes'** + String homeRainTrendHeavyStopping(int minutes); + /// Title of the map layer-picker sheet /// /// In en, this message translates to: /// **'Layers'** String get mapLayers; + /// Title of the layer-order editor, also the tooltip of the reorder button in the layer picker + /// + /// In en, this message translates to: + /// **'Reorder layers'** + String get mapLayerOrderTitle; + + /// Button that restores the layer picker's default order + /// + /// In en, this message translates to: + /// **'Reset order'** + String get mapLayerOrderReset; + /// Name of the composite radar reflectivity layer in the layer picker /// /// In en, this message translates to: @@ -1038,9 +1098,357 @@ abstract class AppLocalizations { /// Name of the Himawari infrared layer in the layer picker /// /// In en, this message translates to: - /// **'Himawari Infrared'** + /// **'Himawari Infrared (B13)'** String get mapLayerSatellite; + /// Himawari visible-blue channel (B01, 0.47 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Blue (B01)'** + String get mapLayerSatelliteB01; + + /// Himawari visible-green channel (B02, 0.51 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Green (B02)'** + String get mapLayerSatelliteB02; + + /// Himawari visible-red channel (B03, 0.64 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Red (B03)'** + String get mapLayerSatelliteB03; + + /// Himawari near-infrared channel (B04, 0.86 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Near-Infrared (B04)'** + String get mapLayerSatelliteB04; + + /// Himawari near-infrared channel (B05, 1.6 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Near-Infrared (B05)'** + String get mapLayerSatelliteB05; + + /// Himawari near-infrared channel (B06, 2.3 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Near-Infrared (B06)'** + String get mapLayerSatelliteB06; + + /// Himawari shortwave-infrared channel (B07, 3.9 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Shortwave Infrared (B07)'** + String get mapLayerSatelliteB07; + + /// Himawari upper-level water-vapour channel (B08, 6.2 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Upper Water Vapour (B08)'** + String get mapLayerSatelliteB08; + + /// Himawari mid-level water-vapour channel (B09, 6.9 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Mid Water Vapour (B09)'** + String get mapLayerSatelliteB09; + + /// Himawari lower-level water-vapour channel (B10, 7.3 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Lower Water Vapour (B10)'** + String get mapLayerSatelliteB10; + + /// Himawari SO₂ absorption channel (B11, 8.6 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari SO₂ / Cloud Phase (B11)'** + String get mapLayerSatelliteB11; + + /// Himawari ozone-band channel (B12, 9.6 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Ozone (B12)'** + String get mapLayerSatelliteB12; + + /// Himawari clean-infrared window channel (B13, 10.4 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Infrared (B13)'** + String get mapLayerSatelliteB13; + + /// Himawari longwave-infrared channel (B14, 11.2 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Longwave Infrared (B14)'** + String get mapLayerSatelliteB14; + + /// Himawari longwave-infrared channel (B15, 12.4 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari Longwave Infrared (B15)'** + String get mapLayerSatelliteB15; + + /// Himawari CO₂-band channel (B16, 13.3 µm) layer name + /// + /// In en, this message translates to: + /// **'Himawari CO₂ (B16)'** + String get mapLayerSatelliteB16; + + /// Himawari True Color RGB composite layer name + /// + /// In en, this message translates to: + /// **'Himawari True Color'** + String get mapLayerSatelliteTruecolor; + + /// Himawari Natural Color RGB composite layer name + /// + /// In en, this message translates to: + /// **'Himawari Natural Color'** + String get mapLayerSatelliteNaturalcolor; + + /// Himawari Ash RGB composite layer name + /// + /// In en, this message translates to: + /// **'Himawari Ash'** + String get mapLayerSatelliteAsh; + + /// Himawari Dust RGB composite layer name + /// + /// In en, this message translates to: + /// **'Himawari Dust'** + String get mapLayerSatelliteDust; + + /// Himawari Airmass RGB composite layer name + /// + /// In en, this message translates to: + /// **'Himawari Airmass'** + String get mapLayerSatelliteAirmass; + + /// Himawari Night Microphysics RGB composite layer name + /// + /// In en, this message translates to: + /// **'Himawari Night Microphysics'** + String get mapLayerSatelliteNightmicrophysics; + + /// Himawari water-vapour layer name + /// + /// In en, this message translates to: + /// **'Himawari Water Vapour'** + String get mapLayerSatelliteWatervapor; + + /// Himawari split-window brightness-temperature-difference layer name + /// + /// In en, this message translates to: + /// **'Himawari Split Window'** + String get mapLayerSatelliteBtdSplit; + + /// Himawari night fog / low-cloud brightness-temperature-difference layer name + /// + /// In en, this message translates to: + /// **'Himawari Night Fog'** + String get mapLayerSatelliteBtdFog; + + /// Himawari overshooting-cloud-top brightness-temperature-difference layer name + /// + /// In en, this message translates to: + /// **'Himawari Overshooting Top'** + String get mapLayerSatelliteBtdWvirw; + + /// Himawari SO₂ / cloud-phase brightness-temperature-difference layer name + /// + /// In en, this message translates to: + /// **'Himawari SO₂ / Cloud Phase'** + String get mapLayerSatelliteBtdSo2; + + /// Himawari cirrus / cloud-height brightness-temperature-difference layer name + /// + /// In en, this message translates to: + /// **'Himawari Cirrus / Cloud Height'** + String get mapLayerSatelliteBtdCo2; + + /// Himawari tropopause brightness-temperature-difference layer name + /// + /// In en, this message translates to: + /// **'Himawari Tropopause'** + String get mapLayerSatelliteBtdOzone; + + /// Himawari cloud-top-temperature (Level-2 retrieval) layer name + /// + /// In en, this message translates to: + /// **'Himawari Cloud Top Temperature'** + String get mapLayerSatelliteCloudtop; + + /// Himawari cloud-mask (Level-2 retrieval) layer name + /// + /// In en, this message translates to: + /// **'Himawari Cloud Mask'** + String get mapLayerSatelliteCloudmask; + + /// Himawari sea-surface-temperature (ACSPO L3C) layer name + /// + /// In en, this message translates to: + /// **'Himawari Sea Surface Temperature'** + String get mapLayerSatelliteSst; + + /// Himawari normalised-difference vegetation-index layer name + /// + /// In en, this message translates to: + /// **'Himawari NDVI'** + String get mapLayerSatelliteNdvi; + + /// Himawari normalised-difference water-index layer name + /// + /// In en, this message translates to: + /// **'Himawari NDWI'** + String get mapLayerSatelliteNdwi; + + /// Himawari modified normalised-difference water-index layer name + /// + /// In en, this message translates to: + /// **'Himawari MNDWI'** + String get mapLayerSatelliteMndwi; + + /// Satellite legend row: the country/global border, drawn bright yellow over the imagery + /// + /// In en, this message translates to: + /// **'Country border'** + String get mapLayerSatelliteGlobalOutline; + + /// Satellite legend note for the RGB-recipe products (True Color, Ash, …) that carry no single numerical scale + /// + /// In en, this message translates to: + /// **'RGB composite (JMA recipe)'** + String get mapLayerSatelliteRgbComposite; + + /// Cloud-mask category: clear sky, transparent on the map + /// + /// In en, this message translates to: + /// **'Clear'** + String get mapLayerSatelliteCloudClear; + + /// Cloud-mask category: probably clear + /// + /// In en, this message translates to: + /// **'Probably clear'** + String get mapLayerSatelliteCloudProbablyClear; + + /// Cloud-mask category: probably cloudy + /// + /// In en, this message translates to: + /// **'Probably cloudy'** + String get mapLayerSatelliteCloudProbablyCloudy; + + /// Cloud-mask category: cloudy + /// + /// In en, this message translates to: + /// **'Cloudy'** + String get mapLayerSatelliteCloudCloudy; + + /// Satellite legend note: on the IR grayscale/enhancements the warm end is clear sky, drawn transparent so the basemap shows + /// + /// In en, this message translates to: + /// **'Clear sky (warm end) = transparent, the basemap shows'** + String get mapLayerSatelliteTransparentWarm; + + /// Satellite legend note: on the reflectance bands a dark or night pixel is transparent so the basemap shows + /// + /// In en, this message translates to: + /// **'Low reflectance / night = transparent, the basemap shows'** + String get mapLayerSatelliteTransparentReflectance; + + /// Satellite legend note: on the brightness-temperature-difference layers a near-zero difference is transparent — no absorber is present + /// + /// In en, this message translates to: + /// **'Zero difference = transparent (no signal)'** + String get mapLayerSatelliteTransparentZero; + + /// Satellite legend note: the daytime RGB recipes fade out across the terminator and are transparent at night + /// + /// In en, this message translates to: + /// **'Night = transparent, the basemap shows'** + String get mapLayerSatelliteTransparentNight; + + /// Satellite legend note: the SST retrieval has no value over land, drawn transparent + /// + /// In en, this message translates to: + /// **'No data (land) = transparent'** + String get mapLayerSatelliteTransparentNoData; + + /// Satellite legend note: NDVI below the bare-soil threshold is transparent + /// + /// In en, this message translates to: + /// **'Below 0.1 = transparent (no vegetation)'** + String get mapLayerSatelliteTransparentNoVegetation; + + /// Satellite legend note: NDWI/MNDWI at zero or below is transparent — no water signal + /// + /// In en, this message translates to: + /// **'≤ 0 = transparent (no water)'** + String get mapLayerSatelliteTransparentNoWater; + + /// Satellite legend note: the cloud-mask clear category is transparent so the basemap shows + /// + /// In en, this message translates to: + /// **'Clear sky = transparent, the basemap shows'** + String get mapLayerSatelliteTransparentClear; + + /// Section header of the satellite band colour-style menu on the map + /// + /// In en, this message translates to: + /// **'Colour style'** + String get mapLayerStyleSection; + + /// Tooltip of the colour-style chip beside the layer switcher + /// + /// In en, this message translates to: + /// **'Colour style'** + String get mapLayerStyleTooltip; + + /// Colour-style option: JMA grayscale, the default radar-image convention + /// + /// In en, this message translates to: + /// **'Grayscale (JMA)'** + String get mapLayerStyleGray; + + /// Explains the JMA grayscale band rendering + /// + /// In en, this message translates to: + /// **'JMA grayscale — colder is whiter'** + String get mapLayerStyleGrayTooltip; + + /// Colour-style option: JMA cloud-top enhancement, tinted below −40 °C + /// + /// In en, this message translates to: + /// **'Cloud-top enhancement (JMA)'** + String get mapLayerStyleJma; + + /// Explains the JMA cloud-top enhancement band rendering + /// + /// In en, this message translates to: + /// **'Grayscale base, tinted below −40 °C to highlight cloud-top height'** + String get mapLayerStyleJmaTooltip; + + /// Colour-style option: Dvorak BD curve stepped grayscale + /// + /// In en, this message translates to: + /// **'Dvorak BD'** + String get mapLayerStyleBd; + + /// Explains the Dvorak BD band rendering + /// + /// In en, this message translates to: + /// **'Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis'** + String get mapLayerStyleBdTooltip; + + /// Name of the QPESUMS next-1-hour precipitation forecast layer in the layer picker + /// + /// In en, this message translates to: + /// **'1h Precipitation Forecast'** + String get mapLayerQpesums; + /// Map layer switcher label for the lightning strike timeline /// /// In en, this message translates to: @@ -1065,12 +1473,36 @@ abstract class AppLocalizations { /// **'Now'** String get mapTimelineNow; + /// Label on the map timeline when the selected frame predates the present + /// + /// In en, this message translates to: + /// **'Past'** + String get mapTimelinePast; + + /// Label on the map timeline when the selected frame postdates the present + /// + /// In en, this message translates to: + /// **'Future'** + String get mapTimelineFuture; + /// Label above the map timeline's date (the radar observation time), e.g. Observed / 2026/07/14 /// /// In en, this message translates to: /// **'Observed'** String get mapTimelineObserved; + /// Label above the map timeline's date when the frame times are forecast times, e.g. Forecast / 2026/07/14 + /// + /// In en, this message translates to: + /// **'Forecast'** + String get mapTimelineForecast; + + /// Model-run issue time shown on the map timeline under a forecast layer's caption, e.g. Data 8/11 14:00 + /// + /// In en, this message translates to: + /// **'Data {time}'** + String mapTimelineDataTime(String time); + /// More-menu entry that opens the notification-settings page /// /// In en, this message translates to: @@ -1491,6 +1923,12 @@ abstract class AppLocalizations { /// **'Radar'** String get mapNavRadar; + /// Short Map-tab bottom-nav / default-layer picker label for the 1h QPESUMS precipitation forecast + /// + /// In en, this message translates to: + /// **'Forecast'** + String get mapNavQpesums; + /// Short Map-tab bottom-nav / default-layer picker label for satellite /// /// In en, this message translates to: @@ -1947,6 +2385,18 @@ abstract class AppLocalizations { /// **'Data time\n{time}'** String typhoonDataTime(String time); + /// Map layer switcher label for the ECMWF wind-forecast layer + /// + /// In en, this message translates to: + /// **'ECMWF'** + String get mapLayerWindForecastEcmwf; + + /// Map layer switcher label for the GFS wind-forecast layer + /// + /// In en, this message translates to: + /// **'GFS'** + String get mapLayerWindForecastGfs; + /// Map layer switcher label for the real-time seismic monitor (RTS) /// /// In en, this message translates to: @@ -2073,6 +2523,126 @@ abstract class AppLocalizations { /// **'Show evacuation shelters'** String get disasterMapOverlayShelterTooltip; + /// Action in the disaster-map detail sheet: open the point in an external map app + /// + /// In en, this message translates to: + /// **'Open in maps'** + String get dpmOpenInMaps; + + /// External map app choice: Google Maps + /// + /// In en, this message translates to: + /// **'Google Maps'** + String get mapAppGoogleMaps; + + /// External map app choice: Apple Maps + /// + /// In en, this message translates to: + /// **'Apple Maps'** + String get mapAppAppleMaps; + + /// Choice-sheet label suffix marking the platform home map app, with the app name + /// + /// In en, this message translates to: + /// **'{app} (default)'** + String mapAppDefault(String app); + + /// Choice-sheet action: copy the point's coordinates + /// + /// In en, this message translates to: + /// **'Copy coordinates'** + String get mapAppCopyCoordinates; + + /// Snackbar confirming the coordinates were copied + /// + /// In en, this message translates to: + /// **'Coordinates copied'** + String get mapAppCoordinatesCopied; + + /// Snackbar when the chosen map app cannot be opened on this device + /// + /// In en, this message translates to: + /// **'Could not open {app}'** + String mapAppOpenFailed(String app); + + /// Snackbar when tapping the emergency phone and the device has no phone handler + /// + /// In en, this message translates to: + /// **'This device cannot make phone calls'** + String get mapAppCallFailed; + + /// Section title in map overlay settings menus: the reference overlays + /// + /// In en, this message translates to: + /// **'Reference layers'** + String get mapOverlaySectionReference; + + /// Section title in map overlay lists: the seismic-monitor overlays + /// + /// In en, this message translates to: + /// **'Earthquake'** + String get mapLayerCategoryEarthquake; + + /// Section title in map overlay lists: typhoon overlays + /// + /// In en, this message translates to: + /// **'Typhoon'** + String get mapLayerCategoryTyphoon; + + /// Section title in map overlay lists: the weather-observation overlays + /// + /// In en, this message translates to: + /// **'Weather observations'** + String get mapLayerCategoryWeather; + + /// Section title in map overlay lists: satellite-imagery overlays + /// + /// In en, this message translates to: + /// **'Satellite'** + String get mapLayerCategorySatellite; + + /// Section title in map overlay lists: radar and precipitation-forecast overlays + /// + /// In en, this message translates to: + /// **'Radar'** + String get mapLayerCategoryRadar; + + /// Section title in map overlay lists: everyday-life facility overlays + /// + /// In en, this message translates to: + /// **'Daily life'** + String get mapLayerCategoryLife; + + /// Section title in map overlay lists: numerical weather prediction (ECMWF/GFS) wind-field overlays + /// + /// In en, this message translates to: + /// **'Numerical forecast'** + String get mapLayerCategoryForecast; + + /// Section title in map overlay settings menus: base-map settings + /// + /// In en, this message translates to: + /// **'Map'** + String get mapOverlaySectionMap; + + /// Section title in the rainfall menu: the accumulation-interval choices + /// + /// In en, this message translates to: + /// **'Time window'** + String get rainIntervalSection; + + /// Map setting: show township-name labels when the map is zoomed in + /// + /// In en, this message translates to: + /// **'Township names'** + String get mapTownLabels; + + /// Hint under the township-names setting + /// + /// In en, this message translates to: + /// **'Show township names when zoomed in'** + String get mapTownLabelsHint; + /// Hint in the disaster-map detail sheet when nothing is selected /// /// In en, this message translates to: @@ -2331,6 +2901,18 @@ abstract class AppLocalizations { /// **'Tropical depression TD {no}'** String typhoonPickerTd(String no); + /// Secondary badge on the typhoon sheet hero: the CWA typhoon serial number, e.g. TY 4 + /// + /// In en, this message translates to: + /// **'TY {no}'** + String typhoonTyNo(String no); + + /// Secondary badge on the typhoon sheet hero: the CWA tropical-depression serial number, e.g. TD 14 + /// + /// In en, this message translates to: + /// **'TD {no}'** + String typhoonTdNo(String no); + /// CWA class: mild typhoon (past-track colour) /// /// In en, this message translates to: @@ -2391,6 +2973,12 @@ abstract class AppLocalizations { /// **'My location'** String get mapMyLocation; + /// Map compass tooltip: re-points the camera to north-up + /// + /// In en, this message translates to: + /// **'Reset north'** + String get mapResetNorth; + /// Typhoon UI: typhoonLegendCircle15 /// /// In en, this message translates to: @@ -2624,6 +3212,198 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Nuclear accident'** String get dpmDisasterNuclear; + + /// Label for the experimental sky time-of-day override. + /// + /// In en, this message translates to: + /// **'Sky time'** + String get skyTime; + + /// Label for the skyTimeAuto option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Auto'** + String get skyTimeAuto; + + /// Label for the skyTimeDawn option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Dawn'** + String get skyTimeDawn; + + /// Label for the skyTimeSunrise option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Sunrise'** + String get skyTimeSunrise; + + /// Label for the skyTimeMorning option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Morning'** + String get skyTimeMorning; + + /// Label for the skyTimeNoon option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Noon'** + String get skyTimeNoon; + + /// Label for the skyTimeAfternoon option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Afternoon'** + String get skyTimeAfternoon; + + /// Label for the skyTimeGolden option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Golden hour'** + String get skyTimeGolden; + + /// Label for the skyTimeSunset option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Sunset'** + String get skyTimeSunset; + + /// Label for the skyTimeDusk option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Dusk'** + String get skyTimeDusk; + + /// Label for the skyTimeNight option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Night'** + String get skyTimeNight; + + /// Label for the weatherModeCloudy option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Cloudy'** + String get weatherModeCloudy; + + /// Label for the weatherModeOvercast option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Overcast'** + String get weatherModeOvercast; + + /// Label for the weatherModeSnow option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Snow'** + String get weatherModeSnow; + + /// Label for the weatherModeSand option in the experimental backdrop settings. + /// + /// In en, this message translates to: + /// **'Dust'** + String get weatherModeSand; + + /// Radar scan-range overlay toggle in the map's radar overlay menu. + /// + /// In en, this message translates to: + /// **'Show scan range'** + String get radarScanRange; + + /// Radar scan-range overlay toggle in the map's radar overlay menu. + /// + /// In en, this message translates to: + /// **'Outlines the area the four radars actually observe.'** + String get radarScanRangeSubtitle; + + /// Hint under the radar scan-range toggle in the radar overlay menu. + /// + /// In en, this message translates to: + /// **'Blank outside means unobserved'** + String get radarScanRangeHint; + + /// Tooltip for the radar overlay-options chip beside the layer switcher + /// + /// In en, this message translates to: + /// **'Radar overlay options'** + String get radarOverlayMenuTooltip; + + /// County-border overlay toggle in the map's radar overlay menu. + /// + /// In en, this message translates to: + /// **'County borders'** + String get radarCountyOutline; + + /// World-country-border overlay toggle in the map's reference-layer overlay menus. + /// + /// In en, this message translates to: + /// **'National borders'** + String get radarGlobalOutline; + + /// Hint under the national-border toggle in the radar overlay menu. + /// + /// In en, this message translates to: + /// **'Every country\'s outer frame'** + String get radarGlobalOutlineHint; + + /// Hint under the county-border toggle in the radar overlay menu. + /// + /// In en, this message translates to: + /// **'Drawn over the echo'** + String get radarCountyOutlineHint; + + /// County-border overlay toggle in the map's radar overlay menu. + /// + /// In en, this message translates to: + /// **'Keeps county borders legible under the radar echo.'** + String get radarCountyOutlineSubtitle; + + /// Township-border overlay toggle in the map's radar overlay menu. + /// + /// In en, this message translates to: + /// **'Township borders'** + String get radarTownOutline; + + /// Hint under the township-border toggle in the radar overlay menu. + /// + /// In en, this message translates to: + /// **'The finer mesh'** + String get radarTownOutlineHint; + + /// Township-border overlay toggle in the map's radar overlay menu. + /// + /// In en, this message translates to: + /// **'Keeps township borders legible under the radar echo.'** + String get radarTownOutlineSubtitle; + + /// Tooltip for the QPESUMS forecast overlay-options chip beside the layer switcher. + /// + /// In en, this message translates to: + /// **'QPESUMS overlay options'** + String get qpesumsOverlayMenuTooltip; + + /// Tooltip for the wind-forecast overlay-options chip beside the layer switcher. + /// + /// In en, this message translates to: + /// **'Wind forecast overlay options'** + String get windForecastOverlayMenuTooltip; + + /// Hint under the county-border toggle in the wind-forecast overlay menu. + /// + /// In en, this message translates to: + /// **'Drawn over the wind field'** + String get windForecastCountyOutlineHint; + + /// Hint under the national-border toggle in the wind-forecast overlay menu. + /// + /// In en, this message translates to: + /// **'Every country\'s outer frame'** + String get windForecastGlobalOutlineHint; + + /// Hint under the township-border toggle in the wind-forecast overlay menu. + /// + /// In en, this message translates to: + /// **'The finer mesh'** + String get windForecastTownOutlineHint; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index f9609731b..93e9b7ff4 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -504,16 +504,241 @@ class AppLocalizationsEn extends AppLocalizations { String get homeActiveEventsEmpty => 'No active events'; @override - String get homeRainTrendTitle => 'Next hour rain'; + String get homeRainTrendTitle => 'Next hour precipitation'; + + @override + String homeRainTrendMinute(int minute) { + return '$minute min'; + } + + @override + String homeRainTrendUpdated(String time) { + return 'Updated $time'; + } + + @override + String get homeRainTrendNoData => 'No data'; + + @override + String get homeRainTrendScattered => 'Light showers possible'; + + @override + String get homeRainTrendLightSustained => + 'Light rain continuing for the next hour'; + + @override + String homeRainTrendLightStopping(int minutes) { + return 'Light rain likely to stop in $minutes minutes'; + } + + @override + String get homeRainTrendHeavySustained => + 'Heavy rain continuing for the next hour'; + + @override + String homeRainTrendHeavyStopping(int minutes) { + return 'Heavy rain likely to stop in $minutes minutes'; + } @override String get mapLayers => 'Layers'; + @override + String get mapLayerOrderTitle => 'Reorder layers'; + + @override + String get mapLayerOrderReset => 'Reset order'; + @override String get mapLayerRadar => 'Composite Radar Reflectivity'; @override - String get mapLayerSatellite => 'Himawari Infrared'; + String get mapLayerSatellite => 'Himawari Infrared (B13)'; + + @override + String get mapLayerSatelliteB01 => 'Himawari Blue (B01)'; + + @override + String get mapLayerSatelliteB02 => 'Himawari Green (B02)'; + + @override + String get mapLayerSatelliteB03 => 'Himawari Red (B03)'; + + @override + String get mapLayerSatelliteB04 => 'Himawari Near-Infrared (B04)'; + + @override + String get mapLayerSatelliteB05 => 'Himawari Near-Infrared (B05)'; + + @override + String get mapLayerSatelliteB06 => 'Himawari Near-Infrared (B06)'; + + @override + String get mapLayerSatelliteB07 => 'Himawari Shortwave Infrared (B07)'; + + @override + String get mapLayerSatelliteB08 => 'Himawari Upper Water Vapour (B08)'; + + @override + String get mapLayerSatelliteB09 => 'Himawari Mid Water Vapour (B09)'; + + @override + String get mapLayerSatelliteB10 => 'Himawari Lower Water Vapour (B10)'; + + @override + String get mapLayerSatelliteB11 => 'Himawari SO₂ / Cloud Phase (B11)'; + + @override + String get mapLayerSatelliteB12 => 'Himawari Ozone (B12)'; + + @override + String get mapLayerSatelliteB13 => 'Himawari Infrared (B13)'; + + @override + String get mapLayerSatelliteB14 => 'Himawari Longwave Infrared (B14)'; + + @override + String get mapLayerSatelliteB15 => 'Himawari Longwave Infrared (B15)'; + + @override + String get mapLayerSatelliteB16 => 'Himawari CO₂ (B16)'; + + @override + String get mapLayerSatelliteTruecolor => 'Himawari True Color'; + + @override + String get mapLayerSatelliteNaturalcolor => 'Himawari Natural Color'; + + @override + String get mapLayerSatelliteAsh => 'Himawari Ash'; + + @override + String get mapLayerSatelliteDust => 'Himawari Dust'; + + @override + String get mapLayerSatelliteAirmass => 'Himawari Airmass'; + + @override + String get mapLayerSatelliteNightmicrophysics => + 'Himawari Night Microphysics'; + + @override + String get mapLayerSatelliteWatervapor => 'Himawari Water Vapour'; + + @override + String get mapLayerSatelliteBtdSplit => 'Himawari Split Window'; + + @override + String get mapLayerSatelliteBtdFog => 'Himawari Night Fog'; + + @override + String get mapLayerSatelliteBtdWvirw => 'Himawari Overshooting Top'; + + @override + String get mapLayerSatelliteBtdSo2 => 'Himawari SO₂ / Cloud Phase'; + + @override + String get mapLayerSatelliteBtdCo2 => 'Himawari Cirrus / Cloud Height'; + + @override + String get mapLayerSatelliteBtdOzone => 'Himawari Tropopause'; + + @override + String get mapLayerSatelliteCloudtop => 'Himawari Cloud Top Temperature'; + + @override + String get mapLayerSatelliteCloudmask => 'Himawari Cloud Mask'; + + @override + String get mapLayerSatelliteSst => 'Himawari Sea Surface Temperature'; + + @override + String get mapLayerSatelliteNdvi => 'Himawari NDVI'; + + @override + String get mapLayerSatelliteNdwi => 'Himawari NDWI'; + + @override + String get mapLayerSatelliteMndwi => 'Himawari MNDWI'; + + @override + String get mapLayerSatelliteGlobalOutline => 'Country border'; + + @override + String get mapLayerSatelliteRgbComposite => 'RGB composite (JMA recipe)'; + + @override + String get mapLayerSatelliteCloudClear => 'Clear'; + + @override + String get mapLayerSatelliteCloudProbablyClear => 'Probably clear'; + + @override + String get mapLayerSatelliteCloudProbablyCloudy => 'Probably cloudy'; + + @override + String get mapLayerSatelliteCloudCloudy => 'Cloudy'; + + @override + String get mapLayerSatelliteTransparentWarm => + 'Clear sky (warm end) = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentReflectance => + 'Low reflectance / night = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentZero => + 'Zero difference = transparent (no signal)'; + + @override + String get mapLayerSatelliteTransparentNight => + 'Night = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentNoData => + 'No data (land) = transparent'; + + @override + String get mapLayerSatelliteTransparentNoVegetation => + 'Below 0.1 = transparent (no vegetation)'; + + @override + String get mapLayerSatelliteTransparentNoWater => + '≤ 0 = transparent (no water)'; + + @override + String get mapLayerSatelliteTransparentClear => + 'Clear sky = transparent, the basemap shows'; + + @override + String get mapLayerStyleSection => 'Colour style'; + + @override + String get mapLayerStyleTooltip => 'Colour style'; + + @override + String get mapLayerStyleGray => 'Grayscale (JMA)'; + + @override + String get mapLayerStyleGrayTooltip => 'JMA grayscale — colder is whiter'; + + @override + String get mapLayerStyleJma => 'Cloud-top enhancement (JMA)'; + + @override + String get mapLayerStyleJmaTooltip => + 'Grayscale base, tinted below −40 °C to highlight cloud-top height'; + + @override + String get mapLayerStyleBd => 'Dvorak BD'; + + @override + String get mapLayerStyleBdTooltip => + 'Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis'; + + @override + String get mapLayerQpesums => '1h Precipitation Forecast'; @override String get mapLayerLightning => 'Lightning'; @@ -531,9 +756,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapTimelineNow => 'Now'; + @override + String get mapTimelinePast => 'Past'; + + @override + String get mapTimelineFuture => 'Future'; + @override String get mapTimelineObserved => 'Observed'; + @override + String get mapTimelineForecast => 'Forecast'; + + @override + String mapTimelineDataTime(String time) { + return 'Data $time'; + } + @override String get notifySettingsMenu => 'Notification settings'; @@ -758,6 +997,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapNavRadar => 'Radar'; + @override + String get mapNavQpesums => 'Forecast'; + @override String get mapNavSatellite => 'Satellite'; @@ -1008,6 +1250,12 @@ class AppLocalizationsEn extends AppLocalizations { return 'Data time\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => 'Seismic Monitor'; @@ -1071,6 +1319,70 @@ class AppLocalizationsEn extends AppLocalizations { @override String get disasterMapOverlayShelterTooltip => 'Show evacuation shelters'; + @override + String get dpmOpenInMaps => 'Open in maps'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app (default)'; + } + + @override + String get mapAppCopyCoordinates => 'Copy coordinates'; + + @override + String get mapAppCoordinatesCopied => 'Coordinates copied'; + + @override + String mapAppOpenFailed(String app) { + return 'Could not open $app'; + } + + @override + String get mapAppCallFailed => 'This device cannot make phone calls'; + + @override + String get mapOverlaySectionReference => 'Reference layers'; + + @override + String get mapLayerCategoryEarthquake => 'Earthquake'; + + @override + String get mapLayerCategoryTyphoon => 'Typhoon'; + + @override + String get mapLayerCategoryWeather => 'Weather observations'; + + @override + String get mapLayerCategorySatellite => 'Satellite'; + + @override + String get mapLayerCategoryRadar => 'Radar'; + + @override + String get mapLayerCategoryLife => 'Daily life'; + + @override + String get mapLayerCategoryForecast => 'Numerical forecast'; + + @override + String get mapOverlaySectionMap => 'Map'; + + @override + String get rainIntervalSection => 'Time window'; + + @override + String get mapTownLabels => 'Township names'; + + @override + String get mapTownLabelsHint => 'Show township names when zoomed in'; + @override String get dpmSheetEmpty => 'Tap a marker on the map for details'; @@ -1210,6 +1522,16 @@ class AppLocalizationsEn extends AppLocalizations { return 'Tropical depression TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => 'Mild typhoon'; @@ -1240,6 +1562,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapMyLocation => 'My location'; + @override + String get mapResetNorth => 'Reset north'; + @override String get typhoonLegendCircle15 => 'Gale circle (L7)'; @@ -1370,4 +1695,103 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dpmDisasterNuclear => 'Nuclear accident'; + + @override + String get skyTime => 'Sky time'; + + @override + String get skyTimeAuto => 'Auto'; + + @override + String get skyTimeDawn => 'Dawn'; + + @override + String get skyTimeSunrise => 'Sunrise'; + + @override + String get skyTimeMorning => 'Morning'; + + @override + String get skyTimeNoon => 'Noon'; + + @override + String get skyTimeAfternoon => 'Afternoon'; + + @override + String get skyTimeGolden => 'Golden hour'; + + @override + String get skyTimeSunset => 'Sunset'; + + @override + String get skyTimeDusk => 'Dusk'; + + @override + String get skyTimeNight => 'Night'; + + @override + String get weatherModeCloudy => 'Cloudy'; + + @override + String get weatherModeOvercast => 'Overcast'; + + @override + String get weatherModeSnow => 'Snow'; + + @override + String get weatherModeSand => 'Dust'; + + @override + String get radarScanRange => 'Show scan range'; + + @override + String get radarScanRangeSubtitle => + 'Outlines the area the four radars actually observe.'; + + @override + String get radarScanRangeHint => 'Blank outside means unobserved'; + + @override + String get radarOverlayMenuTooltip => 'Radar overlay options'; + + @override + String get radarCountyOutline => 'County borders'; + + @override + String get radarGlobalOutline => 'National borders'; + + @override + String get radarGlobalOutlineHint => 'Every country\'s outer frame'; + + @override + String get radarCountyOutlineHint => 'Drawn over the echo'; + + @override + String get radarCountyOutlineSubtitle => + 'Keeps county borders legible under the radar echo.'; + + @override + String get radarTownOutline => 'Township borders'; + + @override + String get radarTownOutlineHint => 'The finer mesh'; + + @override + String get radarTownOutlineSubtitle => + 'Keeps township borders legible under the radar echo.'; + + @override + String get qpesumsOverlayMenuTooltip => 'QPESUMS overlay options'; + + @override + String get windForecastOverlayMenuTooltip => 'Wind forecast overlay options'; + + @override + String get windForecastCountyOutlineHint => 'Drawn over the wind field'; + + @override + String get windForecastGlobalOutlineHint => 'Every country\'s outer frame'; + + @override + String get windForecastTownOutlineHint => 'The finer mesh'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 2532d6aa2..5597c6670 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -511,14 +511,239 @@ class AppLocalizationsFil extends AppLocalizations { @override String get homeRainTrendTitle => 'Ulan sa susunod na oras'; + @override + String homeRainTrendMinute(int minute) { + return '$minute min'; + } + + @override + String homeRainTrendUpdated(String time) { + return 'Na-update $time'; + } + + @override + String get homeRainTrendNoData => 'Walang data'; + + @override + String get homeRainTrendScattered => 'Posibleng mahinang ulan'; + + @override + String get homeRainTrendLightSustained => + 'Tuloy-tuloy na mahinang ulan sa susunod na oras'; + + @override + String homeRainTrendLightStopping(int minutes) { + return 'Baka huminto ang mahinang ulan sa loob ng $minutes minuto'; + } + + @override + String get homeRainTrendHeavySustained => + 'Tuloy-tuloy na malakas na ulan sa susunod na oras'; + + @override + String homeRainTrendHeavyStopping(int minutes) { + return 'Baka huminto ang malakas na ulan sa loob ng $minutes minuto'; + } + @override String get mapLayers => 'Mga Layer'; + @override + String get mapLayerOrderTitle => 'Ayusin ang ayos ng layer'; + + @override + String get mapLayerOrderReset => 'I-reset ang ayos'; + @override String get mapLayerRadar => 'Composite Radar Reflectivity'; @override - String get mapLayerSatellite => 'Himawari Infrared'; + String get mapLayerSatellite => 'Himawari Infrared (B13)'; + + @override + String get mapLayerSatelliteB01 => 'Himawari Blue (B01)'; + + @override + String get mapLayerSatelliteB02 => 'Himawari Green (B02)'; + + @override + String get mapLayerSatelliteB03 => 'Himawari Red (B03)'; + + @override + String get mapLayerSatelliteB04 => 'Himawari Near-Infrared (B04)'; + + @override + String get mapLayerSatelliteB05 => 'Himawari Near-Infrared (B05)'; + + @override + String get mapLayerSatelliteB06 => 'Himawari Near-Infrared (B06)'; + + @override + String get mapLayerSatelliteB07 => 'Himawari Shortwave Infrared (B07)'; + + @override + String get mapLayerSatelliteB08 => 'Himawari Upper Water Vapour (B08)'; + + @override + String get mapLayerSatelliteB09 => 'Himawari Mid Water Vapour (B09)'; + + @override + String get mapLayerSatelliteB10 => 'Himawari Lower Water Vapour (B10)'; + + @override + String get mapLayerSatelliteB11 => 'Himawari SO₂ / Cloud Phase (B11)'; + + @override + String get mapLayerSatelliteB12 => 'Himawari Ozone (B12)'; + + @override + String get mapLayerSatelliteB13 => 'Himawari Infrared (B13)'; + + @override + String get mapLayerSatelliteB14 => 'Himawari Longwave Infrared (B14)'; + + @override + String get mapLayerSatelliteB15 => 'Himawari Longwave Infrared (B15)'; + + @override + String get mapLayerSatelliteB16 => 'Himawari CO₂ (B16)'; + + @override + String get mapLayerSatelliteTruecolor => 'Himawari True Color'; + + @override + String get mapLayerSatelliteNaturalcolor => 'Himawari Natural Color'; + + @override + String get mapLayerSatelliteAsh => 'Himawari Ash'; + + @override + String get mapLayerSatelliteDust => 'Himawari Dust'; + + @override + String get mapLayerSatelliteAirmass => 'Himawari Airmass'; + + @override + String get mapLayerSatelliteNightmicrophysics => + 'Himawari Night Microphysics'; + + @override + String get mapLayerSatelliteWatervapor => 'Himawari Water Vapour'; + + @override + String get mapLayerSatelliteBtdSplit => 'Himawari Split Window'; + + @override + String get mapLayerSatelliteBtdFog => 'Himawari Night Fog'; + + @override + String get mapLayerSatelliteBtdWvirw => 'Himawari Overshooting Top'; + + @override + String get mapLayerSatelliteBtdSo2 => 'Himawari SO₂ / Cloud Phase'; + + @override + String get mapLayerSatelliteBtdCo2 => 'Himawari Cirrus / Cloud Height'; + + @override + String get mapLayerSatelliteBtdOzone => 'Himawari Tropopause'; + + @override + String get mapLayerSatelliteCloudtop => 'Himawari Cloud Top Temperature'; + + @override + String get mapLayerSatelliteCloudmask => 'Himawari Cloud Mask'; + + @override + String get mapLayerSatelliteSst => 'Himawari Sea Surface Temperature'; + + @override + String get mapLayerSatelliteNdvi => 'Himawari NDVI'; + + @override + String get mapLayerSatelliteNdwi => 'Himawari NDWI'; + + @override + String get mapLayerSatelliteMndwi => 'Himawari MNDWI'; + + @override + String get mapLayerSatelliteGlobalOutline => 'Country border'; + + @override + String get mapLayerSatelliteRgbComposite => 'RGB composite (JMA recipe)'; + + @override + String get mapLayerSatelliteCloudClear => 'Clear'; + + @override + String get mapLayerSatelliteCloudProbablyClear => 'Probably clear'; + + @override + String get mapLayerSatelliteCloudProbablyCloudy => 'Probably cloudy'; + + @override + String get mapLayerSatelliteCloudCloudy => 'Cloudy'; + + @override + String get mapLayerSatelliteTransparentWarm => + 'Clear sky (warm end) = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentReflectance => + 'Low reflectance / night = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentZero => + 'Zero difference = transparent (no signal)'; + + @override + String get mapLayerSatelliteTransparentNight => + 'Night = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentNoData => + 'No data (land) = transparent'; + + @override + String get mapLayerSatelliteTransparentNoVegetation => + 'Below 0.1 = transparent (no vegetation)'; + + @override + String get mapLayerSatelliteTransparentNoWater => + '≤ 0 = transparent (no water)'; + + @override + String get mapLayerSatelliteTransparentClear => + 'Clear sky = transparent, the basemap shows'; + + @override + String get mapLayerStyleSection => 'Colour style'; + + @override + String get mapLayerStyleTooltip => 'Colour style'; + + @override + String get mapLayerStyleGray => 'Grayscale (JMA)'; + + @override + String get mapLayerStyleGrayTooltip => 'JMA grayscale — colder is whiter'; + + @override + String get mapLayerStyleJma => 'Cloud-top enhancement (JMA)'; + + @override + String get mapLayerStyleJmaTooltip => + 'Grayscale base, tinted below −40 °C to highlight cloud-top height'; + + @override + String get mapLayerStyleBd => 'Dvorak BD'; + + @override + String get mapLayerStyleBdTooltip => + 'Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis'; + + @override + String get mapLayerQpesums => 'Pagtaya ng ulan sa susunod na 1 oras'; @override String get mapLayerLightning => 'Kidlat'; @@ -536,9 +761,23 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapTimelineNow => 'Ngayon'; + @override + String get mapTimelinePast => 'Nakaraan'; + + @override + String get mapTimelineFuture => 'Hinaharap'; + @override String get mapTimelineObserved => 'Naobserbahan'; + @override + String get mapTimelineForecast => 'Pagtaya'; + + @override + String mapTimelineDataTime(String time) { + return 'Oras ng data $time'; + } + @override String get notifySettingsMenu => 'Mga setting ng notipikasyon'; @@ -764,6 +1003,9 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapNavRadar => 'Radar'; + @override + String get mapNavQpesums => 'Pagtaya'; + @override String get mapNavSatellite => 'Satellite'; @@ -1014,6 +1256,12 @@ class AppLocalizationsFil extends AppLocalizations { return 'Data time\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => 'Seismic Monitor'; @@ -1078,6 +1326,71 @@ class AppLocalizationsFil extends AppLocalizations { @override String get disasterMapOverlayShelterTooltip => 'Ipakita ang mga silungan'; + @override + String get dpmOpenInMaps => 'Buksan sa mapa'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app (default)'; + } + + @override + String get mapAppCopyCoordinates => 'Kopyahin ang coordinates'; + + @override + String get mapAppCoordinatesCopied => 'Na-kopya ang coordinates'; + + @override + String mapAppOpenFailed(String app) { + return 'Hindi mabuksan ang $app'; + } + + @override + String get mapAppCallFailed => 'Hindi makatawag ang device na ito'; + + @override + String get mapOverlaySectionReference => 'Layer ng sanggunian'; + + @override + String get mapLayerCategoryEarthquake => 'Lindol'; + + @override + String get mapLayerCategoryTyphoon => 'Bagyo'; + + @override + String get mapLayerCategoryWeather => 'Obserbasyon sa panahon'; + + @override + String get mapLayerCategorySatellite => 'Satellite'; + + @override + String get mapLayerCategoryRadar => 'Radar'; + + @override + String get mapLayerCategoryLife => 'Pang-araw-araw na buhay'; + + @override + String get mapLayerCategoryForecast => 'Numerical forecast'; + + @override + String get mapOverlaySectionMap => 'Mapa'; + + @override + String get rainIntervalSection => 'Window ng oras'; + + @override + String get mapTownLabels => 'Mga pangalan ng bayan'; + + @override + String get mapTownLabelsHint => + 'Ipakita ang mga pangalan ng bayan kapag naka-zoom'; + @override String get dpmSheetEmpty => 'I-tap ang marker sa mapa para sa detalye'; @@ -1217,6 +1530,16 @@ class AppLocalizationsFil extends AppLocalizations { return 'Tropical depression TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => 'Mild typhoon'; @@ -1247,6 +1570,9 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapMyLocation => 'Aking lokasyon'; + @override + String get mapResetNorth => 'Bumalik sa hilaga'; + @override String get typhoonLegendCircle15 => 'Gale circle (L7)'; @@ -1377,4 +1703,107 @@ class AppLocalizationsFil extends AppLocalizations { @override String get dpmDisasterNuclear => 'Aksidente sa nukleyar'; + + @override + String get skyTime => 'Oras ng langit'; + + @override + String get skyTimeAuto => 'Awtomatiko'; + + @override + String get skyTimeDawn => 'Bukang-liwayway'; + + @override + String get skyTimeSunrise => 'Pagsikat ng araw'; + + @override + String get skyTimeMorning => 'Umaga'; + + @override + String get skyTimeNoon => 'Tanghali'; + + @override + String get skyTimeAfternoon => 'Hapon'; + + @override + String get skyTimeGolden => 'Gintong oras'; + + @override + String get skyTimeSunset => 'Paglubog ng araw'; + + @override + String get skyTimeDusk => 'Takipsilim'; + + @override + String get skyTimeNight => 'Gabi'; + + @override + String get weatherModeCloudy => 'Maulap'; + + @override + String get weatherModeOvercast => 'Makulimlim'; + + @override + String get weatherModeSnow => 'Niyebe'; + + @override + String get weatherModeSand => 'Alikabok'; + + @override + String get radarScanRange => 'Ipakita ang saklaw ng pag-scan'; + + @override + String get radarScanRangeSubtitle => + 'Ipinapakita ang aktwal na saklaw ng apat na radar.'; + + @override + String get radarScanRangeHint => 'Sa labas: hindi naoobserbahan'; + + @override + String get radarOverlayMenuTooltip => 'Mga opsyon sa layer ng radar'; + + @override + String get radarCountyOutline => 'Mga hangganan ng lalawigan'; + + @override + String get radarGlobalOutline => 'Mga hangganan ng bansa'; + + @override + String get radarGlobalOutlineHint => 'Panlabas na balangkas ng bawat bansa'; + + @override + String get radarCountyOutlineHint => 'Iginuguhit sa ibabaw ng echo'; + + @override + String get radarCountyOutlineSubtitle => + 'Nananatiling mababasa ang mga hangganan sa ilalim ng radar echo.'; + + @override + String get radarTownOutline => 'Mga hangganan ng bayan'; + + @override + String get radarTownOutlineHint => 'Mas pinong hati'; + + @override + String get radarTownOutlineSubtitle => + 'Nananatiling mababasa ang mga hangganan ng bayan sa ilalim ng radar echo.'; + + @override + String get qpesumsOverlayMenuTooltip => + 'Mga opsyon sa layer ng pagtataya ng pag-ulan'; + + @override + String get windForecastOverlayMenuTooltip => + 'Mga opsyon sa layer ng pagtataya ng hangin'; + + @override + String get windForecastCountyOutlineHint => + 'Iginuhit sa itaas ng patlang ng hangin'; + + @override + String get windForecastGlobalOutlineHint => + 'Panlabas na balangkas ng bawat bansa'; + + @override + String get windForecastTownOutlineHint => 'Ang mas pinong mesh'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 95b7b6ba4..f7f66edec 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -507,14 +507,239 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeRainTrendTitle => 'Hujan 1 jam ke depan'; + @override + String homeRainTrendMinute(int minute) { + return '$minute mnt'; + } + + @override + String homeRainTrendUpdated(String time) { + return 'Diperbarui $time'; + } + + @override + String get homeRainTrendNoData => 'Tidak ada data'; + + @override + String get homeRainTrendScattered => 'Kemungkinan hujan ringan'; + + @override + String get homeRainTrendLightSustained => + 'Hujan ringan berlanjut selama 1 jam ke depan'; + + @override + String homeRainTrendLightStopping(int minutes) { + return 'Hujan ringan diperkirakan berhenti dalam $minutes menit'; + } + + @override + String get homeRainTrendHeavySustained => + 'Hujan deras berlanjut selama 1 jam ke depan'; + + @override + String homeRainTrendHeavyStopping(int minutes) { + return 'Hujan deras diperkirakan berhenti dalam $minutes menit'; + } + @override String get mapLayers => 'Lapisan'; + @override + String get mapLayerOrderTitle => 'Urutkan lapisan'; + + @override + String get mapLayerOrderReset => 'Atur ulang urutan'; + @override String get mapLayerRadar => 'Radar Komposit'; @override - String get mapLayerSatellite => 'Himawari Inframerah'; + String get mapLayerSatellite => 'Himawari Infrared (B13)'; + + @override + String get mapLayerSatelliteB01 => 'Himawari Blue (B01)'; + + @override + String get mapLayerSatelliteB02 => 'Himawari Green (B02)'; + + @override + String get mapLayerSatelliteB03 => 'Himawari Red (B03)'; + + @override + String get mapLayerSatelliteB04 => 'Himawari Near-Infrared (B04)'; + + @override + String get mapLayerSatelliteB05 => 'Himawari Near-Infrared (B05)'; + + @override + String get mapLayerSatelliteB06 => 'Himawari Near-Infrared (B06)'; + + @override + String get mapLayerSatelliteB07 => 'Himawari Shortwave Infrared (B07)'; + + @override + String get mapLayerSatelliteB08 => 'Himawari Upper Water Vapour (B08)'; + + @override + String get mapLayerSatelliteB09 => 'Himawari Mid Water Vapour (B09)'; + + @override + String get mapLayerSatelliteB10 => 'Himawari Lower Water Vapour (B10)'; + + @override + String get mapLayerSatelliteB11 => 'Himawari SO₂ / Cloud Phase (B11)'; + + @override + String get mapLayerSatelliteB12 => 'Himawari Ozone (B12)'; + + @override + String get mapLayerSatelliteB13 => 'Himawari Infrared (B13)'; + + @override + String get mapLayerSatelliteB14 => 'Himawari Longwave Infrared (B14)'; + + @override + String get mapLayerSatelliteB15 => 'Himawari Longwave Infrared (B15)'; + + @override + String get mapLayerSatelliteB16 => 'Himawari CO₂ (B16)'; + + @override + String get mapLayerSatelliteTruecolor => 'Himawari True Color'; + + @override + String get mapLayerSatelliteNaturalcolor => 'Himawari Natural Color'; + + @override + String get mapLayerSatelliteAsh => 'Himawari Ash'; + + @override + String get mapLayerSatelliteDust => 'Himawari Dust'; + + @override + String get mapLayerSatelliteAirmass => 'Himawari Airmass'; + + @override + String get mapLayerSatelliteNightmicrophysics => + 'Himawari Night Microphysics'; + + @override + String get mapLayerSatelliteWatervapor => 'Himawari Water Vapour'; + + @override + String get mapLayerSatelliteBtdSplit => 'Himawari Split Window'; + + @override + String get mapLayerSatelliteBtdFog => 'Himawari Night Fog'; + + @override + String get mapLayerSatelliteBtdWvirw => 'Himawari Overshooting Top'; + + @override + String get mapLayerSatelliteBtdSo2 => 'Himawari SO₂ / Cloud Phase'; + + @override + String get mapLayerSatelliteBtdCo2 => 'Himawari Cirrus / Cloud Height'; + + @override + String get mapLayerSatelliteBtdOzone => 'Himawari Tropopause'; + + @override + String get mapLayerSatelliteCloudtop => 'Himawari Cloud Top Temperature'; + + @override + String get mapLayerSatelliteCloudmask => 'Himawari Cloud Mask'; + + @override + String get mapLayerSatelliteSst => 'Himawari Sea Surface Temperature'; + + @override + String get mapLayerSatelliteNdvi => 'Himawari NDVI'; + + @override + String get mapLayerSatelliteNdwi => 'Himawari NDWI'; + + @override + String get mapLayerSatelliteMndwi => 'Himawari MNDWI'; + + @override + String get mapLayerSatelliteGlobalOutline => 'Country border'; + + @override + String get mapLayerSatelliteRgbComposite => 'RGB composite (JMA recipe)'; + + @override + String get mapLayerSatelliteCloudClear => 'Clear'; + + @override + String get mapLayerSatelliteCloudProbablyClear => 'Probably clear'; + + @override + String get mapLayerSatelliteCloudProbablyCloudy => 'Probably cloudy'; + + @override + String get mapLayerSatelliteCloudCloudy => 'Cloudy'; + + @override + String get mapLayerSatelliteTransparentWarm => + 'Clear sky (warm end) = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentReflectance => + 'Low reflectance / night = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentZero => + 'Zero difference = transparent (no signal)'; + + @override + String get mapLayerSatelliteTransparentNight => + 'Night = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentNoData => + 'No data (land) = transparent'; + + @override + String get mapLayerSatelliteTransparentNoVegetation => + 'Below 0.1 = transparent (no vegetation)'; + + @override + String get mapLayerSatelliteTransparentNoWater => + '≤ 0 = transparent (no water)'; + + @override + String get mapLayerSatelliteTransparentClear => + 'Clear sky = transparent, the basemap shows'; + + @override + String get mapLayerStyleSection => 'Colour style'; + + @override + String get mapLayerStyleTooltip => 'Colour style'; + + @override + String get mapLayerStyleGray => 'Grayscale (JMA)'; + + @override + String get mapLayerStyleGrayTooltip => 'JMA grayscale — colder is whiter'; + + @override + String get mapLayerStyleJma => 'Cloud-top enhancement (JMA)'; + + @override + String get mapLayerStyleJmaTooltip => + 'Grayscale base, tinted below −40 °C to highlight cloud-top height'; + + @override + String get mapLayerStyleBd => 'Dvorak BD'; + + @override + String get mapLayerStyleBdTooltip => + 'Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis'; + + @override + String get mapLayerQpesums => 'Prakiraan hujan 1 jam ke depan'; @override String get mapLayerLightning => 'Petir'; @@ -532,9 +757,23 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapTimelineNow => 'Sekarang'; + @override + String get mapTimelinePast => 'Lampau'; + + @override + String get mapTimelineFuture => 'Mendatang'; + @override String get mapTimelineObserved => 'Diamati'; + @override + String get mapTimelineForecast => 'Prakiraan'; + + @override + String mapTimelineDataTime(String time) { + return 'Waktu data $time'; + } + @override String get notifySettingsMenu => 'Pengaturan notifikasi'; @@ -761,6 +1000,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapNavRadar => 'Radar'; + @override + String get mapNavQpesums => 'Prakiraan'; + @override String get mapNavSatellite => 'Satelit'; @@ -1011,6 +1253,12 @@ class AppLocalizationsId extends AppLocalizations { return 'Data time\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => 'Monitor Seismik'; @@ -1074,6 +1322,71 @@ class AppLocalizationsId extends AppLocalizations { @override String get disasterMapOverlayShelterTooltip => 'Tampilkan tempat evakuasi'; + @override + String get dpmOpenInMaps => 'Buka di peta'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app (bawaan)'; + } + + @override + String get mapAppCopyCoordinates => 'Salin koordinat'; + + @override + String get mapAppCoordinatesCopied => 'Koordinat disalin'; + + @override + String mapAppOpenFailed(String app) { + return 'Tidak dapat membuka $app'; + } + + @override + String get mapAppCallFailed => + 'Perangkat ini tidak dapat melakukan panggilan telepon'; + + @override + String get mapOverlaySectionReference => 'Lapisan referensi'; + + @override + String get mapLayerCategoryEarthquake => 'Gempa'; + + @override + String get mapLayerCategoryTyphoon => 'Topan'; + + @override + String get mapLayerCategoryWeather => 'Pengamatan cuaca'; + + @override + String get mapLayerCategorySatellite => 'Satelit'; + + @override + String get mapLayerCategoryRadar => 'Radar'; + + @override + String get mapLayerCategoryLife => 'Kehidupan sehari-hari'; + + @override + String get mapLayerCategoryForecast => 'Prakiraan numerik'; + + @override + String get mapOverlaySectionMap => 'Peta'; + + @override + String get rainIntervalSection => 'Jendela waktu'; + + @override + String get mapTownLabels => 'Nama kecamatan'; + + @override + String get mapTownLabelsHint => 'Tampilkan nama kecamatan saat diperbesar'; + @override String get dpmSheetEmpty => 'Ketuk penanda di peta untuk detail'; @@ -1213,6 +1526,16 @@ class AppLocalizationsId extends AppLocalizations { return 'Tropical depression TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => 'Mild typhoon'; @@ -1243,6 +1566,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapMyLocation => 'Lokasi saya'; + @override + String get mapResetNorth => 'Kembali ke utara'; + @override String get typhoonLegendCircle15 => 'Lingkar angin kencang'; @@ -1373,4 +1699,103 @@ class AppLocalizationsId extends AppLocalizations { @override String get dpmDisasterNuclear => 'Kecelakaan nuklir'; + + @override + String get skyTime => 'Waktu langit'; + + @override + String get skyTimeAuto => 'Otomatis'; + + @override + String get skyTimeDawn => 'Fajar'; + + @override + String get skyTimeSunrise => 'Matahari terbit'; + + @override + String get skyTimeMorning => 'Pagi'; + + @override + String get skyTimeNoon => 'Siang'; + + @override + String get skyTimeAfternoon => 'Sore'; + + @override + String get skyTimeGolden => 'Jam emas'; + + @override + String get skyTimeSunset => 'Matahari terbenam'; + + @override + String get skyTimeDusk => 'Senja'; + + @override + String get skyTimeNight => 'Malam'; + + @override + String get weatherModeCloudy => 'Berawan'; + + @override + String get weatherModeOvercast => 'Mendung'; + + @override + String get weatherModeSnow => 'Salju'; + + @override + String get weatherModeSand => 'Debu'; + + @override + String get radarScanRange => 'Tampilkan jangkauan pindai'; + + @override + String get radarScanRangeSubtitle => + 'Menandai area yang benar-benar dipantau keempat radar.'; + + @override + String get radarScanRangeHint => 'Di luar kotak berarti tak terpantau'; + + @override + String get radarOverlayMenuTooltip => 'Opsi lapisan radar'; + + @override + String get radarCountyOutline => 'Batas kabupaten/kota'; + + @override + String get radarGlobalOutline => 'Batas negara'; + + @override + String get radarGlobalOutlineHint => 'Bingkai luar setiap negara'; + + @override + String get radarCountyOutlineHint => 'Digambar di atas gema'; + + @override + String get radarCountyOutlineSubtitle => + 'Menjaga batas wilayah tetap terbaca di bawah gema radar.'; + + @override + String get radarTownOutline => 'Batas kecamatan'; + + @override + String get radarTownOutlineHint => 'Kisi yang lebih rapat'; + + @override + String get radarTownOutlineSubtitle => + 'Menjaga batas kecamatan tetap terbaca di bawah gema radar.'; + + @override + String get qpesumsOverlayMenuTooltip => 'Opsi lapisan prakiraan curah hujan'; + + @override + String get windForecastOverlayMenuTooltip => 'Opsi lapisan prakiraan angin'; + + @override + String get windForecastCountyOutlineHint => 'Digambar di atas bidang angin'; + + @override + String get windForecastGlobalOutlineHint => 'Bingkai luar setiap negara'; + + @override + String get windForecastTownOutlineHint => 'Jaring yang lebih halus'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 85f0ffe35..1613102fd 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -504,14 +504,226 @@ class AppLocalizationsJa extends AppLocalizations { @override String get homeRainTrendTitle => '今後1時間の雨'; + @override + String homeRainTrendMinute(int minute) { + return '$minute分'; + } + + @override + String homeRainTrendUpdated(String time) { + return '更新 $time'; + } + + @override + String get homeRainTrendNoData => 'データなし'; + + @override + String get homeRainTrendScattered => 'にわか雨の可能性があります'; + + @override + String get homeRainTrendLightSustained => '今後1時間は小雨が続きます'; + + @override + String homeRainTrendLightStopping(int minutes) { + return '$minutes分後に小雨が止む見込みです'; + } + + @override + String get homeRainTrendHeavySustained => '今後1時間は大雨が続きます'; + + @override + String homeRainTrendHeavyStopping(int minutes) { + return '$minutes分後に大雨が止む見込みです'; + } + @override String get mapLayers => 'レイヤー'; + @override + String get mapLayerOrderTitle => 'レイヤーの順番'; + + @override + String get mapLayerOrderReset => '既定の順序に戻す'; + @override String get mapLayerRadar => 'レーダー合成エコー図'; @override - String get mapLayerSatellite => 'ひまわり 赤外線図'; + String get mapLayerSatellite => 'ひまわり 赤外線(B13)'; + + @override + String get mapLayerSatelliteB01 => 'ひまわり 可視青(B01)'; + + @override + String get mapLayerSatelliteB02 => 'ひまわり 可視緑(B02)'; + + @override + String get mapLayerSatelliteB03 => 'ひまわり 可視赤(B03)'; + + @override + String get mapLayerSatelliteB04 => 'ひまわり 近赤外(B04)'; + + @override + String get mapLayerSatelliteB05 => 'ひまわり 近赤外(B05)'; + + @override + String get mapLayerSatelliteB06 => 'ひまわり 近赤外(B06)'; + + @override + String get mapLayerSatelliteB07 => 'ひまわり 短波長赤外(B07)'; + + @override + String get mapLayerSatelliteB08 => 'ひまわり 上層水蒸気(B08)'; + + @override + String get mapLayerSatelliteB09 => 'ひまわり 中層水蒸気(B09)'; + + @override + String get mapLayerSatelliteB10 => 'ひまわり 下層水蒸気(B10)'; + + @override + String get mapLayerSatelliteB11 => 'ひまわり 二酸化硫黄/雲相(B11)'; + + @override + String get mapLayerSatelliteB12 => 'ひまわり オゾン(B12)'; + + @override + String get mapLayerSatelliteB13 => 'ひまわり 赤外線(B13)'; + + @override + String get mapLayerSatelliteB14 => 'ひまわり 長波長赤外線(B14)'; + + @override + String get mapLayerSatelliteB15 => 'ひまわり 長波長赤外線(B15)'; + + @override + String get mapLayerSatelliteB16 => 'ひまわり 二酸化炭素(B16)'; + + @override + String get mapLayerSatelliteTruecolor => 'ひまわり トゥルーカラー'; + + @override + String get mapLayerSatelliteNaturalcolor => 'ひまわり ナチュラルカラー'; + + @override + String get mapLayerSatelliteAsh => 'ひまわり 火山灰'; + + @override + String get mapLayerSatelliteDust => 'ひまわり 黄砂'; + + @override + String get mapLayerSatelliteAirmass => 'ひまわり エアマス'; + + @override + String get mapLayerSatelliteNightmicrophysics => 'ひまわり 夜間微物理'; + + @override + String get mapLayerSatelliteWatervapor => 'ひまわり 水蒸気'; + + @override + String get mapLayerSatelliteBtdSplit => 'ひまわり スプリットウィンドウ'; + + @override + String get mapLayerSatelliteBtdFog => 'ひまわり 夜間霧'; + + @override + String get mapLayerSatelliteBtdWvirw => 'ひまわり オーバーシューティングトップ'; + + @override + String get mapLayerSatelliteBtdSo2 => 'ひまわり 二酸化硫黄/雲相'; + + @override + String get mapLayerSatelliteBtdCo2 => 'ひまわり 巻雲/雲頂高度'; + + @override + String get mapLayerSatelliteBtdOzone => 'ひまわり 対流圏界面'; + + @override + String get mapLayerSatelliteCloudtop => 'ひまわり 雲頂温度'; + + @override + String get mapLayerSatelliteCloudmask => 'ひまわり 雲マスク'; + + @override + String get mapLayerSatelliteSst => 'ひまわり 海面水温'; + + @override + String get mapLayerSatelliteNdvi => 'ひまわり NDVI'; + + @override + String get mapLayerSatelliteNdwi => 'ひまわり NDWI'; + + @override + String get mapLayerSatelliteMndwi => 'ひまわり MNDWI'; + + @override + String get mapLayerSatelliteGlobalOutline => '国境線'; + + @override + String get mapLayerSatelliteRgbComposite => 'RGB 合成(JMA レシピ)'; + + @override + String get mapLayerSatelliteCloudClear => '晴れ'; + + @override + String get mapLayerSatelliteCloudProbablyClear => 'おそらく晴れ'; + + @override + String get mapLayerSatelliteCloudProbablyCloudy => 'おそらく雲'; + + @override + String get mapLayerSatelliteCloudCloudy => '雲'; + + @override + String get mapLayerSatelliteTransparentWarm => '晴れ(暖域) = 透明、地図が透ける'; + + @override + String get mapLayerSatelliteTransparentReflectance => '低反射率・夜間 = 透明、地図が透ける'; + + @override + String get mapLayerSatelliteTransparentZero => '差ゼロ = 透明(信号なし)'; + + @override + String get mapLayerSatelliteTransparentNight => '夜間 = 透明、地図が透ける'; + + @override + String get mapLayerSatelliteTransparentNoData => 'データなし(陸上) = 透明'; + + @override + String get mapLayerSatelliteTransparentNoVegetation => '< 0.1 = 透明(植生なし)'; + + @override + String get mapLayerSatelliteTransparentNoWater => '≤ 0 = 透明(水域なし)'; + + @override + String get mapLayerSatelliteTransparentClear => '晴れ = 透明、地図が透ける'; + + @override + String get mapLayerStyleSection => '色調'; + + @override + String get mapLayerStyleTooltip => '色調'; + + @override + String get mapLayerStyleGray => 'グレースケール(JMA)'; + + @override + String get mapLayerStyleGrayTooltip => '気象庁の赤外画像の慣例:温度が低いほど白'; + + @override + String get mapLayerStyleJma => '雲頂強調(JMA)'; + + @override + String get mapLayerStyleJmaTooltip => 'グレースケールをベースに −40 °C 以下を着色し、雲頂高度を強調'; + + @override + String get mapLayerStyleBd => 'Dvorak BD'; + + @override + String get mapLayerStyleBdTooltip => 'Dvorak BD カーブ——熱帯低気圧の強度解析に使う階段グレースケール'; + + @override + String get mapLayerQpesums => '1時間降水量予報'; @override String get mapLayerLightning => '雷'; @@ -529,9 +741,23 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapTimelineNow => '現在'; + @override + String get mapTimelinePast => '過去'; + + @override + String get mapTimelineFuture => '未来'; + @override String get mapTimelineObserved => '観測'; + @override + String get mapTimelineForecast => '予報'; + + @override + String mapTimelineDataTime(String time) { + return 'データ時刻 $time'; + } + @override String get notifySettingsMenu => '通知設定'; @@ -750,6 +976,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapNavRadar => 'レーダー'; + @override + String get mapNavQpesums => '予報'; + @override String get mapNavSatellite => '衛星'; @@ -999,6 +1228,12 @@ class AppLocalizationsJa extends AppLocalizations { return '資料時刻\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '強震モニタ'; @@ -1062,6 +1297,70 @@ class AppLocalizationsJa extends AppLocalizations { @override String get disasterMapOverlayShelterTooltip => '避難所を表示'; + @override + String get dpmOpenInMaps => '地図アプリで開く'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app(デフォルト)'; + } + + @override + String get mapAppCopyCoordinates => '座標をコピー'; + + @override + String get mapAppCoordinatesCopied => '座標をコピーしました'; + + @override + String mapAppOpenFailed(String app) { + return '$app を開けませんでした'; + } + + @override + String get mapAppCallFailed => 'この端末では通話できません'; + + @override + String get mapOverlaySectionReference => '参照レイヤー'; + + @override + String get mapLayerCategoryEarthquake => '地震'; + + @override + String get mapLayerCategoryTyphoon => '台風'; + + @override + String get mapLayerCategoryWeather => '気象観測'; + + @override + String get mapLayerCategorySatellite => '衛星'; + + @override + String get mapLayerCategoryRadar => 'レーダー'; + + @override + String get mapLayerCategoryLife => '生活'; + + @override + String get mapLayerCategoryForecast => '数値予報'; + + @override + String get mapOverlaySectionMap => '地図'; + + @override + String get rainIntervalSection => '集計時間'; + + @override + String get mapTownLabels => '郷鎮名'; + + @override + String get mapTownLabelsHint => '拡大すると郷鎮名を表示'; + @override String get dpmSheetEmpty => '地図上のマーカーをタップして詳細を表示'; @@ -1201,6 +1500,16 @@ class AppLocalizationsJa extends AppLocalizations { return '熱帯低気圧 TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => '弱い台風'; @@ -1231,6 +1540,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapMyLocation => '現在地'; + @override + String get mapResetNorth => '北を上にする'; + @override String get typhoonLegendCircle15 => '強風域(30kt)'; @@ -1353,4 +1665,100 @@ class AppLocalizationsJa extends AppLocalizations { @override String get dpmDisasterNuclear => '原子力事故'; + + @override + String get skyTime => '空の時刻'; + + @override + String get skyTimeAuto => '自動'; + + @override + String get skyTimeDawn => '夜明け前'; + + @override + String get skyTimeSunrise => '日の出'; + + @override + String get skyTimeMorning => '午前'; + + @override + String get skyTimeNoon => '正午'; + + @override + String get skyTimeAfternoon => '午後'; + + @override + String get skyTimeGolden => 'ゴールデンアワー'; + + @override + String get skyTimeSunset => '日の入り'; + + @override + String get skyTimeDusk => '薄暮'; + + @override + String get skyTimeNight => '夜'; + + @override + String get weatherModeCloudy => '曇り'; + + @override + String get weatherModeOvercast => '本曇り'; + + @override + String get weatherModeSnow => '雪'; + + @override + String get weatherModeSand => '砂じん'; + + @override + String get radarScanRange => '走査範囲を表示'; + + @override + String get radarScanRangeSubtitle => '4基のレーダーが実際に観測する範囲を示します。'; + + @override + String get radarScanRangeHint => '枠外の空白は未観測'; + + @override + String get radarOverlayMenuTooltip => 'レーダーレイヤー設定'; + + @override + String get radarCountyOutline => '県市境界'; + + @override + String get radarGlobalOutline => '国境線'; + + @override + String get radarGlobalOutlineHint => '各国の国境外枠'; + + @override + String get radarCountyOutlineHint => 'エコーの上に描画'; + + @override + String get radarCountyOutlineSubtitle => 'レーダーエコーの下でも県市境界が見えるようにします。'; + + @override + String get radarTownOutline => '市町村境界'; + + @override + String get radarTownOutlineHint => 'より細かい区分'; + + @override + String get radarTownOutlineSubtitle => 'レーダーエコーの下でも市町村境界が見えるようにします。'; + + @override + String get qpesumsOverlayMenuTooltip => '定量降水予報レイヤー設定'; + + @override + String get windForecastOverlayMenuTooltip => '風予報レイヤー設定'; + + @override + String get windForecastCountyOutlineHint => '風場の上に描画'; + + @override + String get windForecastGlobalOutlineHint => '各国の国境外枠'; + + @override + String get windForecastTownOutlineHint => 'より細かいメッシュ'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 2934f583d..92da0d8a1 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -504,14 +504,227 @@ class AppLocalizationsKo extends AppLocalizations { @override String get homeRainTrendTitle => '향후 1시간 강수'; + @override + String homeRainTrendMinute(int minute) { + return '$minute분'; + } + + @override + String homeRainTrendUpdated(String time) { + return '업데이트 $time'; + } + + @override + String get homeRainTrendNoData => '데이터 없음'; + + @override + String get homeRainTrendScattered => '약한 비가 올 수 있어요'; + + @override + String get homeRainTrendLightSustained => '앞으로 1시간 동안 약한 비가 이어질 거예요'; + + @override + String homeRainTrendLightStopping(int minutes) { + return '$minutes분 후에 비가 그칠 것으로 예상돼요'; + } + + @override + String get homeRainTrendHeavySustained => '앞으로 1시간 동안 강한 비가 이어질 거예요'; + + @override + String homeRainTrendHeavyStopping(int minutes) { + return '$minutes분 후에 강한 비가 그칠 것으로 예상돼요'; + } + @override String get mapLayers => '레이어'; + @override + String get mapLayerOrderTitle => '레이어 순서'; + + @override + String get mapLayerOrderReset => '기본 순서로 재설정'; + @override String get mapLayerRadar => '레이더 합성 에코'; @override - String get mapLayerSatellite => '히마와리 적외'; + String get mapLayerSatellite => '히마와리 적외(B13)'; + + @override + String get mapLayerSatelliteB01 => '히마와리 가시 청색(B01)'; + + @override + String get mapLayerSatelliteB02 => '히마와리 가시 녹색(B02)'; + + @override + String get mapLayerSatelliteB03 => '히마와리 가시 적색(B03)'; + + @override + String get mapLayerSatelliteB04 => '히마와리 근적외(B04)'; + + @override + String get mapLayerSatelliteB05 => '히마와리 근적외(B05)'; + + @override + String get mapLayerSatelliteB06 => '히마와리 근적외(B06)'; + + @override + String get mapLayerSatelliteB07 => '히마와리 단파 적외(B07)'; + + @override + String get mapLayerSatelliteB08 => '히마와리 상층 수증기(B08)'; + + @override + String get mapLayerSatelliteB09 => '히마와리 중층 수증기(B09)'; + + @override + String get mapLayerSatelliteB10 => '히마와리 하층 수증기(B10)'; + + @override + String get mapLayerSatelliteB11 => '히마와리 이산화황/구름상(B11)'; + + @override + String get mapLayerSatelliteB12 => '히마와리 오존(B12)'; + + @override + String get mapLayerSatelliteB13 => '히마와리 적외(B13)'; + + @override + String get mapLayerSatelliteB14 => '히마와리 장파 적외(B14)'; + + @override + String get mapLayerSatelliteB15 => '히마와리 장파 적외(B15)'; + + @override + String get mapLayerSatelliteB16 => '히마와리 이산화탄소(B16)'; + + @override + String get mapLayerSatelliteTruecolor => '히마와리 트루컬러'; + + @override + String get mapLayerSatelliteNaturalcolor => '히마와리 내추럴컬러'; + + @override + String get mapLayerSatelliteAsh => '히마와리 화산재'; + + @override + String get mapLayerSatelliteDust => '히마와리 황사'; + + @override + String get mapLayerSatelliteAirmass => '히마와리 에어매스'; + + @override + String get mapLayerSatelliteNightmicrophysics => '히마와리 야간 미세물리'; + + @override + String get mapLayerSatelliteWatervapor => '히마와리 수증기'; + + @override + String get mapLayerSatelliteBtdSplit => '히마와리 스플릿 윈도우'; + + @override + String get mapLayerSatelliteBtdFog => '히마와리 야간 안개'; + + @override + String get mapLayerSatelliteBtdWvirw => '히마와리 오버슈팅 탑'; + + @override + String get mapLayerSatelliteBtdSo2 => '히마와리 이산화황/구름상'; + + @override + String get mapLayerSatelliteBtdCo2 => '히마와리 권운/운고'; + + @override + String get mapLayerSatelliteBtdOzone => '히마와리 대류권계면'; + + @override + String get mapLayerSatelliteCloudtop => '히마와리 운정 온도'; + + @override + String get mapLayerSatelliteCloudmask => '히마와리 구름 마스크'; + + @override + String get mapLayerSatelliteSst => '히마와리 해수면 온도'; + + @override + String get mapLayerSatelliteNdvi => '히마와리 NDVI'; + + @override + String get mapLayerSatelliteNdwi => '히마와리 NDWI'; + + @override + String get mapLayerSatelliteMndwi => '히마와리 MNDWI'; + + @override + String get mapLayerSatelliteGlobalOutline => '국경선'; + + @override + String get mapLayerSatelliteRgbComposite => 'RGB 합성(JMA 레시피)'; + + @override + String get mapLayerSatelliteCloudClear => '맑음'; + + @override + String get mapLayerSatelliteCloudProbablyClear => '아마 맑음'; + + @override + String get mapLayerSatelliteCloudProbablyCloudy => '아마 구름'; + + @override + String get mapLayerSatelliteCloudCloudy => '구름'; + + @override + String get mapLayerSatelliteTransparentWarm => '맑음(고온부) = 투명,배경 지도 표시'; + + @override + String get mapLayerSatelliteTransparentReflectance => + '낮은 반사율/야간 = 투명,배경 지도 표시'; + + @override + String get mapLayerSatelliteTransparentZero => '차이 0 = 투명(신호 없음)'; + + @override + String get mapLayerSatelliteTransparentNight => '야간 = 투명,배경 지도 표시'; + + @override + String get mapLayerSatelliteTransparentNoData => '데이터 없음(육지) = 투명'; + + @override + String get mapLayerSatelliteTransparentNoVegetation => '< 0.1 = 투명(식생 없음)'; + + @override + String get mapLayerSatelliteTransparentNoWater => '≤ 0 = 투명(수역 없음)'; + + @override + String get mapLayerSatelliteTransparentClear => '맑음 = 투명,배경 지도 표시'; + + @override + String get mapLayerStyleSection => '색상 스타일'; + + @override + String get mapLayerStyleTooltip => '색상 스타일'; + + @override + String get mapLayerStyleGray => '그레이스케일(JMA)'; + + @override + String get mapLayerStyleGrayTooltip => '기상청 적외 영상 관례:온도가 낮을수록 흰색'; + + @override + String get mapLayerStyleJma => '운정 강조(JMA)'; + + @override + String get mapLayerStyleJmaTooltip => '그레이스케일 바탕에 −40 °C 이하를 채색, 운정 고도 강조'; + + @override + String get mapLayerStyleBd => 'Dvorak BD'; + + @override + String get mapLayerStyleBdTooltip => 'Dvorak BD 커브——열대저기압 강도 분석용 계단 그레이스케일'; + + @override + String get mapLayerQpesums => '1시간 강수 예보'; @override String get mapLayerLightning => '번개'; @@ -529,9 +742,23 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapTimelineNow => '현재'; + @override + String get mapTimelinePast => '과거'; + + @override + String get mapTimelineFuture => '미래'; + @override String get mapTimelineObserved => '관측'; + @override + String get mapTimelineForecast => '예보'; + + @override + String mapTimelineDataTime(String time) { + return '자료 시간 $time'; + } + @override String get notifySettingsMenu => '알림 설정'; @@ -750,6 +977,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapNavRadar => '레이더'; + @override + String get mapNavQpesums => '예보'; + @override String get mapNavSatellite => '위성'; @@ -999,6 +1229,12 @@ class AppLocalizationsKo extends AppLocalizations { return 'Data time\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '실시간 지진 모니터'; @@ -1062,6 +1298,70 @@ class AppLocalizationsKo extends AppLocalizations { @override String get disasterMapOverlayShelterTooltip => '대피소 표시'; + @override + String get dpmOpenInMaps => '지도에서 열기'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app (기본)'; + } + + @override + String get mapAppCopyCoordinates => '좌표 복사'; + + @override + String get mapAppCoordinatesCopied => '좌표가 복사되었습니다'; + + @override + String mapAppOpenFailed(String app) { + return '$app을(를) 열 수 없습니다'; + } + + @override + String get mapAppCallFailed => '이 기기에서는 전화를 걸 수 없습니다'; + + @override + String get mapOverlaySectionReference => '참조 레이어'; + + @override + String get mapLayerCategoryEarthquake => '지진'; + + @override + String get mapLayerCategoryTyphoon => '태풍'; + + @override + String get mapLayerCategoryWeather => '기상 관측'; + + @override + String get mapLayerCategorySatellite => '위성'; + + @override + String get mapLayerCategoryRadar => '레이더'; + + @override + String get mapLayerCategoryLife => '생활'; + + @override + String get mapLayerCategoryForecast => '수치 예보'; + + @override + String get mapOverlaySectionMap => '지도'; + + @override + String get rainIntervalSection => '집계 시간'; + + @override + String get mapTownLabels => '읍면동 이름'; + + @override + String get mapTownLabelsHint => '확대하면 읍면동 이름 표시'; + @override String get dpmSheetEmpty => '지도에서 마커를 눌러 상세 보기'; @@ -1201,6 +1501,16 @@ class AppLocalizationsKo extends AppLocalizations { return 'Tropical depression TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => 'Mild typhoon'; @@ -1231,6 +1541,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapMyLocation => '내 위치'; + @override + String get mapResetNorth => '북쪽으로 되돌리기'; + @override String get typhoonLegendCircle15 => '강풍권 (7급)'; @@ -1361,4 +1674,100 @@ class AppLocalizationsKo extends AppLocalizations { @override String get dpmDisasterNuclear => '핵 사고'; + + @override + String get skyTime => '하늘 시각'; + + @override + String get skyTimeAuto => '자동'; + + @override + String get skyTimeDawn => '여명'; + + @override + String get skyTimeSunrise => '일출'; + + @override + String get skyTimeMorning => '오전'; + + @override + String get skyTimeNoon => '정오'; + + @override + String get skyTimeAfternoon => '오후'; + + @override + String get skyTimeGolden => '골든아워'; + + @override + String get skyTimeSunset => '일몰'; + + @override + String get skyTimeDusk => '땅거미'; + + @override + String get skyTimeNight => '밤'; + + @override + String get weatherModeCloudy => '구름 많음'; + + @override + String get weatherModeOvercast => '흐림'; + + @override + String get weatherModeSnow => '눈'; + + @override + String get weatherModeSand => '황사'; + + @override + String get radarScanRange => '스캔 범위 표시'; + + @override + String get radarScanRangeSubtitle => '레이더 4기가 실제로 관측하는 범위를 표시합니다.'; + + @override + String get radarScanRangeHint => '범위 밖 공백은 미관측'; + + @override + String get radarOverlayMenuTooltip => '레이더 레이어 옵션'; + + @override + String get radarCountyOutline => '시·군 경계'; + + @override + String get radarGlobalOutline => '국경'; + + @override + String get radarGlobalOutlineHint => '각국 국경선'; + + @override + String get radarCountyOutlineHint => '에코 위에 표시'; + + @override + String get radarCountyOutlineSubtitle => '레이더 에코 아래에서도 경계가 보이도록 합니다.'; + + @override + String get radarTownOutline => '읍·면·동 경계'; + + @override + String get radarTownOutlineHint => '더 세밀한 구획'; + + @override + String get radarTownOutlineSubtitle => '레이더 에코 아래에서도 읍·면·동 경계가 보이도록 합니다.'; + + @override + String get qpesumsOverlayMenuTooltip => '정량 강수 예보 레이어 옵션'; + + @override + String get windForecastOverlayMenuTooltip => '바람 예보 레이어 옵션'; + + @override + String get windForecastCountyOutlineHint => '바람장 위에 표시'; + + @override + String get windForecastGlobalOutlineHint => '각국 국경선'; + + @override + String get windForecastTownOutlineHint => '더 촘촘한 망'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index e1f1b60e3..ba21b5d13 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -505,14 +505,238 @@ class AppLocalizationsTh extends AppLocalizations { @override String get homeRainTrendTitle => 'ฝนชั่วโมงถัดไป'; + @override + String homeRainTrendMinute(int minute) { + return '$minute นาที'; + } + + @override + String homeRainTrendUpdated(String time) { + return 'อัปเดต $time'; + } + + @override + String get homeRainTrendNoData => 'ไม่มีข้อมูล'; + + @override + String get homeRainTrendScattered => 'อาจมีฝนตกประปราย'; + + @override + String get homeRainTrendLightSustained => + 'ฝนตกเล็กน้อยต่อเนื่องตลอดชั่วโมงหน้า'; + + @override + String homeRainTrendLightStopping(int minutes) { + return 'คาดว่าฝนจะหยุดในอีก $minutes นาที'; + } + + @override + String get homeRainTrendHeavySustained => 'ฝนตกหนักต่อเนื่องตลอดชั่วโมงหน้า'; + + @override + String homeRainTrendHeavyStopping(int minutes) { + return 'คาดว่าฝนตกหนักจะหยุดในอีก $minutes นาที'; + } + @override String get mapLayers => 'ชั้นข้อมูล'; + @override + String get mapLayerOrderTitle => 'จัดเรียงเลเยอร์'; + + @override + String get mapLayerOrderReset => 'รีเซ็ตลำดับ'; + @override String get mapLayerRadar => 'เรดาร์สะท้อนสังเคราะห์'; @override - String get mapLayerSatellite => 'ฮิมาวาริ อินฟราเรด'; + String get mapLayerSatellite => 'Himawari Infrared (B13)'; + + @override + String get mapLayerSatelliteB01 => 'Himawari Blue (B01)'; + + @override + String get mapLayerSatelliteB02 => 'Himawari Green (B02)'; + + @override + String get mapLayerSatelliteB03 => 'Himawari Red (B03)'; + + @override + String get mapLayerSatelliteB04 => 'Himawari Near-Infrared (B04)'; + + @override + String get mapLayerSatelliteB05 => 'Himawari Near-Infrared (B05)'; + + @override + String get mapLayerSatelliteB06 => 'Himawari Near-Infrared (B06)'; + + @override + String get mapLayerSatelliteB07 => 'Himawari Shortwave Infrared (B07)'; + + @override + String get mapLayerSatelliteB08 => 'Himawari Upper Water Vapour (B08)'; + + @override + String get mapLayerSatelliteB09 => 'Himawari Mid Water Vapour (B09)'; + + @override + String get mapLayerSatelliteB10 => 'Himawari Lower Water Vapour (B10)'; + + @override + String get mapLayerSatelliteB11 => 'Himawari SO₂ / Cloud Phase (B11)'; + + @override + String get mapLayerSatelliteB12 => 'Himawari Ozone (B12)'; + + @override + String get mapLayerSatelliteB13 => 'Himawari Infrared (B13)'; + + @override + String get mapLayerSatelliteB14 => 'Himawari Longwave Infrared (B14)'; + + @override + String get mapLayerSatelliteB15 => 'Himawari Longwave Infrared (B15)'; + + @override + String get mapLayerSatelliteB16 => 'Himawari CO₂ (B16)'; + + @override + String get mapLayerSatelliteTruecolor => 'Himawari True Color'; + + @override + String get mapLayerSatelliteNaturalcolor => 'Himawari Natural Color'; + + @override + String get mapLayerSatelliteAsh => 'Himawari Ash'; + + @override + String get mapLayerSatelliteDust => 'Himawari Dust'; + + @override + String get mapLayerSatelliteAirmass => 'Himawari Airmass'; + + @override + String get mapLayerSatelliteNightmicrophysics => + 'Himawari Night Microphysics'; + + @override + String get mapLayerSatelliteWatervapor => 'Himawari Water Vapour'; + + @override + String get mapLayerSatelliteBtdSplit => 'Himawari Split Window'; + + @override + String get mapLayerSatelliteBtdFog => 'Himawari Night Fog'; + + @override + String get mapLayerSatelliteBtdWvirw => 'Himawari Overshooting Top'; + + @override + String get mapLayerSatelliteBtdSo2 => 'Himawari SO₂ / Cloud Phase'; + + @override + String get mapLayerSatelliteBtdCo2 => 'Himawari Cirrus / Cloud Height'; + + @override + String get mapLayerSatelliteBtdOzone => 'Himawari Tropopause'; + + @override + String get mapLayerSatelliteCloudtop => 'Himawari Cloud Top Temperature'; + + @override + String get mapLayerSatelliteCloudmask => 'Himawari Cloud Mask'; + + @override + String get mapLayerSatelliteSst => 'Himawari Sea Surface Temperature'; + + @override + String get mapLayerSatelliteNdvi => 'Himawari NDVI'; + + @override + String get mapLayerSatelliteNdwi => 'Himawari NDWI'; + + @override + String get mapLayerSatelliteMndwi => 'Himawari MNDWI'; + + @override + String get mapLayerSatelliteGlobalOutline => 'Country border'; + + @override + String get mapLayerSatelliteRgbComposite => 'RGB composite (JMA recipe)'; + + @override + String get mapLayerSatelliteCloudClear => 'Clear'; + + @override + String get mapLayerSatelliteCloudProbablyClear => 'Probably clear'; + + @override + String get mapLayerSatelliteCloudProbablyCloudy => 'Probably cloudy'; + + @override + String get mapLayerSatelliteCloudCloudy => 'Cloudy'; + + @override + String get mapLayerSatelliteTransparentWarm => + 'Clear sky (warm end) = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentReflectance => + 'Low reflectance / night = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentZero => + 'Zero difference = transparent (no signal)'; + + @override + String get mapLayerSatelliteTransparentNight => + 'Night = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentNoData => + 'No data (land) = transparent'; + + @override + String get mapLayerSatelliteTransparentNoVegetation => + 'Below 0.1 = transparent (no vegetation)'; + + @override + String get mapLayerSatelliteTransparentNoWater => + '≤ 0 = transparent (no water)'; + + @override + String get mapLayerSatelliteTransparentClear => + 'Clear sky = transparent, the basemap shows'; + + @override + String get mapLayerStyleSection => 'Colour style'; + + @override + String get mapLayerStyleTooltip => 'Colour style'; + + @override + String get mapLayerStyleGray => 'Grayscale (JMA)'; + + @override + String get mapLayerStyleGrayTooltip => 'JMA grayscale — colder is whiter'; + + @override + String get mapLayerStyleJma => 'Cloud-top enhancement (JMA)'; + + @override + String get mapLayerStyleJmaTooltip => + 'Grayscale base, tinted below −40 °C to highlight cloud-top height'; + + @override + String get mapLayerStyleBd => 'Dvorak BD'; + + @override + String get mapLayerStyleBdTooltip => + 'Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis'; + + @override + String get mapLayerQpesums => 'พยากรณ์ฝน 1 ชั่วโมงข้างหน้า'; @override String get mapLayerLightning => 'ฟ้าผ่า'; @@ -530,9 +754,23 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapTimelineNow => 'ตอนนี้'; + @override + String get mapTimelinePast => 'อดีต'; + + @override + String get mapTimelineFuture => 'อนาคต'; + @override String get mapTimelineObserved => 'เวลาตรวจวัด'; + @override + String get mapTimelineForecast => 'พยากรณ์'; + + @override + String mapTimelineDataTime(String time) { + return 'เวลาข้อมูล $time'; + } + @override String get notifySettingsMenu => 'การตั้งค่าการแจ้งเตือน'; @@ -757,6 +995,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapNavRadar => 'เรดาร์'; + @override + String get mapNavQpesums => 'พยากรณ์'; + @override String get mapNavSatellite => 'ดาวเทียม'; @@ -1007,6 +1248,12 @@ class AppLocalizationsTh extends AppLocalizations { return 'Data time\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => 'เครื่องตรวจแผ่นดินไหว'; @@ -1070,6 +1317,70 @@ class AppLocalizationsTh extends AppLocalizations { @override String get disasterMapOverlayShelterTooltip => 'แสดงศูนย์อพยพ'; + @override + String get dpmOpenInMaps => 'เปิดในแผนที่'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app (ค่าเริ่มต้น)'; + } + + @override + String get mapAppCopyCoordinates => 'คัดลอกพิกัด'; + + @override + String get mapAppCoordinatesCopied => 'คัดลอกพิกัดแล้ว'; + + @override + String mapAppOpenFailed(String app) { + return 'ไม่สามารถเปิด $app ได้'; + } + + @override + String get mapAppCallFailed => 'อุปกรณ์นี้ไม่สามารถโทรออกได้'; + + @override + String get mapOverlaySectionReference => 'เลเยอร์อ้างอิง'; + + @override + String get mapLayerCategoryEarthquake => 'แผ่นดินไหว'; + + @override + String get mapLayerCategoryTyphoon => 'พายุไต้ฝุ่น'; + + @override + String get mapLayerCategoryWeather => 'การสังเกตสภาพอากาศ'; + + @override + String get mapLayerCategorySatellite => 'ดาวเทียม'; + + @override + String get mapLayerCategoryRadar => 'เรดาร์'; + + @override + String get mapLayerCategoryLife => 'ชีวิตประจำวัน'; + + @override + String get mapLayerCategoryForecast => 'การพยากรณ์เชิงตัวเลข'; + + @override + String get mapOverlaySectionMap => 'แผนที่'; + + @override + String get rainIntervalSection => 'ช่วงเวลา'; + + @override + String get mapTownLabels => 'ชื่อตำบล'; + + @override + String get mapTownLabelsHint => 'แสดงชื่อตำบลเมื่อขยายแผนที่'; + @override String get dpmSheetEmpty => 'แตะเครื่องหมายบนแผนที่เพื่อดูรายละเอียด'; @@ -1209,6 +1520,16 @@ class AppLocalizationsTh extends AppLocalizations { return 'Tropical depression TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => 'Mild typhoon'; @@ -1239,6 +1560,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapMyLocation => 'ตำแหน่งของฉัน'; + @override + String get mapResetNorth => 'กลับไปทางเหนือ'; + @override String get typhoonLegendCircle15 => 'วงพายุ (แรง)'; @@ -1369,4 +1693,103 @@ class AppLocalizationsTh extends AppLocalizations { @override String get dpmDisasterNuclear => 'อุบัติเหตุนิวเคลียร์'; + + @override + String get skyTime => 'เวลาท้องฟ้า'; + + @override + String get skyTimeAuto => 'อัตโนมัติ'; + + @override + String get skyTimeDawn => 'รุ่งอรุณ'; + + @override + String get skyTimeSunrise => 'พระอาทิตย์ขึ้น'; + + @override + String get skyTimeMorning => 'ตอนเช้า'; + + @override + String get skyTimeNoon => 'เที่ยงวัน'; + + @override + String get skyTimeAfternoon => 'ตอนบ่าย'; + + @override + String get skyTimeGolden => 'ช่วงเวลาทอง'; + + @override + String get skyTimeSunset => 'พระอาทิตย์ตก'; + + @override + String get skyTimeDusk => 'สนธยา'; + + @override + String get skyTimeNight => 'กลางคืน'; + + @override + String get weatherModeCloudy => 'มีเมฆมาก'; + + @override + String get weatherModeOvercast => 'ฟ้าปิด'; + + @override + String get weatherModeSnow => 'หิมะตก'; + + @override + String get weatherModeSand => 'ฝุ่นทราย'; + + @override + String get radarScanRange => 'แสดงขอบเขตการสแกน'; + + @override + String get radarScanRangeSubtitle => + 'แสดงพื้นที่ที่เรดาร์ทั้งสี่ตรวจวัดได้จริง'; + + @override + String get radarScanRangeHint => 'นอกกรอบคือไม่ได้ตรวจวัด'; + + @override + String get radarOverlayMenuTooltip => 'ตัวเลือกชั้นเรดาร์'; + + @override + String get radarCountyOutline => 'เส้นแบ่งเขตจังหวัด'; + + @override + String get radarGlobalOutline => 'เส้นแบ่งเขตประเทศ'; + + @override + String get radarGlobalOutlineHint => 'กรอบนอกของทุกประเทศ'; + + @override + String get radarCountyOutlineHint => 'วาดทับภาพเอคโค'; + + @override + String get radarCountyOutlineSubtitle => + 'ทำให้เส้นแบ่งเขตยังอ่านออกใต้ภาพเอคโคเรดาร์'; + + @override + String get radarTownOutline => 'เส้นแบ่งเขตอำเภอ'; + + @override + String get radarTownOutlineHint => 'เส้นแบ่งย่อยกว่า'; + + @override + String get radarTownOutlineSubtitle => + 'ทำให้เส้นแบ่งเขตอำเภอยังอ่านออกใต้ภาพเอคโคเรดาร์'; + + @override + String get qpesumsOverlayMenuTooltip => 'ตัวเลือกชั้นพยากรณ์น้ำฝน'; + + @override + String get windForecastOverlayMenuTooltip => 'ตัวเลือกชั้นพยากรณ์ลม'; + + @override + String get windForecastCountyOutlineHint => 'วาดทับบนสนามลม'; + + @override + String get windForecastGlobalOutlineHint => 'กรอบนอกของทุกประเทศ'; + + @override + String get windForecastTownOutlineHint => 'ตาข่ายที่ละเอียดกว่า'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 34ca59586..b0273d988 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -506,14 +506,237 @@ class AppLocalizationsVi extends AppLocalizations { @override String get homeRainTrendTitle => 'Mưa 1 giờ tới'; + @override + String homeRainTrendMinute(int minute) { + return '$minute phút'; + } + + @override + String homeRainTrendUpdated(String time) { + return 'Cập nhật $time'; + } + + @override + String get homeRainTrendNoData => 'Không có dữ liệu'; + + @override + String get homeRainTrendScattered => 'Có thể có mưa rào nhẹ'; + + @override + String get homeRainTrendLightSustained => 'Mưa nhỏ tiếp diễn trong 1 giờ tới'; + + @override + String homeRainTrendLightStopping(int minutes) { + return 'Mưa nhỏ có thể tạnh trong $minutes phút nữa'; + } + + @override + String get homeRainTrendHeavySustained => 'Mưa lớn tiếp diễn trong 1 giờ tới'; + + @override + String homeRainTrendHeavyStopping(int minutes) { + return 'Mưa lớn có thể tạnh trong $minutes phút nữa'; + } + @override String get mapLayers => 'Lớp bản đồ'; + @override + String get mapLayerOrderTitle => 'Sắp xếp thứ tự lớp'; + + @override + String get mapLayerOrderReset => 'Đặt lại thứ tự mặc định'; + @override String get mapLayerRadar => 'Radar phản xạ tổng hợp'; @override - String get mapLayerSatellite => 'Himawari hồng ngoại'; + String get mapLayerSatellite => 'Himawari Infrared (B13)'; + + @override + String get mapLayerSatelliteB01 => 'Himawari Blue (B01)'; + + @override + String get mapLayerSatelliteB02 => 'Himawari Green (B02)'; + + @override + String get mapLayerSatelliteB03 => 'Himawari Red (B03)'; + + @override + String get mapLayerSatelliteB04 => 'Himawari Near-Infrared (B04)'; + + @override + String get mapLayerSatelliteB05 => 'Himawari Near-Infrared (B05)'; + + @override + String get mapLayerSatelliteB06 => 'Himawari Near-Infrared (B06)'; + + @override + String get mapLayerSatelliteB07 => 'Himawari Shortwave Infrared (B07)'; + + @override + String get mapLayerSatelliteB08 => 'Himawari Upper Water Vapour (B08)'; + + @override + String get mapLayerSatelliteB09 => 'Himawari Mid Water Vapour (B09)'; + + @override + String get mapLayerSatelliteB10 => 'Himawari Lower Water Vapour (B10)'; + + @override + String get mapLayerSatelliteB11 => 'Himawari SO₂ / Cloud Phase (B11)'; + + @override + String get mapLayerSatelliteB12 => 'Himawari Ozone (B12)'; + + @override + String get mapLayerSatelliteB13 => 'Himawari Infrared (B13)'; + + @override + String get mapLayerSatelliteB14 => 'Himawari Longwave Infrared (B14)'; + + @override + String get mapLayerSatelliteB15 => 'Himawari Longwave Infrared (B15)'; + + @override + String get mapLayerSatelliteB16 => 'Himawari CO₂ (B16)'; + + @override + String get mapLayerSatelliteTruecolor => 'Himawari True Color'; + + @override + String get mapLayerSatelliteNaturalcolor => 'Himawari Natural Color'; + + @override + String get mapLayerSatelliteAsh => 'Himawari Ash'; + + @override + String get mapLayerSatelliteDust => 'Himawari Dust'; + + @override + String get mapLayerSatelliteAirmass => 'Himawari Airmass'; + + @override + String get mapLayerSatelliteNightmicrophysics => + 'Himawari Night Microphysics'; + + @override + String get mapLayerSatelliteWatervapor => 'Himawari Water Vapour'; + + @override + String get mapLayerSatelliteBtdSplit => 'Himawari Split Window'; + + @override + String get mapLayerSatelliteBtdFog => 'Himawari Night Fog'; + + @override + String get mapLayerSatelliteBtdWvirw => 'Himawari Overshooting Top'; + + @override + String get mapLayerSatelliteBtdSo2 => 'Himawari SO₂ / Cloud Phase'; + + @override + String get mapLayerSatelliteBtdCo2 => 'Himawari Cirrus / Cloud Height'; + + @override + String get mapLayerSatelliteBtdOzone => 'Himawari Tropopause'; + + @override + String get mapLayerSatelliteCloudtop => 'Himawari Cloud Top Temperature'; + + @override + String get mapLayerSatelliteCloudmask => 'Himawari Cloud Mask'; + + @override + String get mapLayerSatelliteSst => 'Himawari Sea Surface Temperature'; + + @override + String get mapLayerSatelliteNdvi => 'Himawari NDVI'; + + @override + String get mapLayerSatelliteNdwi => 'Himawari NDWI'; + + @override + String get mapLayerSatelliteMndwi => 'Himawari MNDWI'; + + @override + String get mapLayerSatelliteGlobalOutline => 'Country border'; + + @override + String get mapLayerSatelliteRgbComposite => 'RGB composite (JMA recipe)'; + + @override + String get mapLayerSatelliteCloudClear => 'Clear'; + + @override + String get mapLayerSatelliteCloudProbablyClear => 'Probably clear'; + + @override + String get mapLayerSatelliteCloudProbablyCloudy => 'Probably cloudy'; + + @override + String get mapLayerSatelliteCloudCloudy => 'Cloudy'; + + @override + String get mapLayerSatelliteTransparentWarm => + 'Clear sky (warm end) = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentReflectance => + 'Low reflectance / night = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentZero => + 'Zero difference = transparent (no signal)'; + + @override + String get mapLayerSatelliteTransparentNight => + 'Night = transparent, the basemap shows'; + + @override + String get mapLayerSatelliteTransparentNoData => + 'No data (land) = transparent'; + + @override + String get mapLayerSatelliteTransparentNoVegetation => + 'Below 0.1 = transparent (no vegetation)'; + + @override + String get mapLayerSatelliteTransparentNoWater => + '≤ 0 = transparent (no water)'; + + @override + String get mapLayerSatelliteTransparentClear => + 'Clear sky = transparent, the basemap shows'; + + @override + String get mapLayerStyleSection => 'Colour style'; + + @override + String get mapLayerStyleTooltip => 'Colour style'; + + @override + String get mapLayerStyleGray => 'Grayscale (JMA)'; + + @override + String get mapLayerStyleGrayTooltip => 'JMA grayscale — colder is whiter'; + + @override + String get mapLayerStyleJma => 'Cloud-top enhancement (JMA)'; + + @override + String get mapLayerStyleJmaTooltip => + 'Grayscale base, tinted below −40 °C to highlight cloud-top height'; + + @override + String get mapLayerStyleBd => 'Dvorak BD'; + + @override + String get mapLayerStyleBdTooltip => + 'Dvorak BD curve — the stepped grayscale for tropical-cyclone intensity analysis'; + + @override + String get mapLayerQpesums => 'Dự báo mưa 1 giờ tới'; @override String get mapLayerLightning => 'Sét'; @@ -531,9 +754,23 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapTimelineNow => 'Bây giờ'; + @override + String get mapTimelinePast => 'Quá khứ'; + + @override + String get mapTimelineFuture => 'Tương lai'; + @override String get mapTimelineObserved => 'Quan trắc'; + @override + String get mapTimelineForecast => 'Dự báo'; + + @override + String mapTimelineDataTime(String time) { + return 'Thời điểm dữ liệu $time'; + } + @override String get notifySettingsMenu => 'Cài đặt thông báo'; @@ -759,6 +996,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapNavRadar => 'Radar'; + @override + String get mapNavQpesums => 'Dự báo'; + @override String get mapNavSatellite => 'Vệ tinh'; @@ -1009,6 +1249,12 @@ class AppLocalizationsVi extends AppLocalizations { return 'Data time\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => 'Giám sát địa chấn'; @@ -1073,6 +1319,70 @@ class AppLocalizationsVi extends AppLocalizations { @override String get disasterMapOverlayShelterTooltip => 'Hiển thị nơi trú ẩn'; + @override + String get dpmOpenInMaps => 'Mở trong bản đồ'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app (mặc định)'; + } + + @override + String get mapAppCopyCoordinates => 'Sao chép tọa độ'; + + @override + String get mapAppCoordinatesCopied => 'Đã sao chép tọa độ'; + + @override + String mapAppOpenFailed(String app) { + return 'Không thể mở $app'; + } + + @override + String get mapAppCallFailed => 'Thiết bị này không thể thực hiện cuộc gọi'; + + @override + String get mapOverlaySectionReference => 'Lớp tham chiếu'; + + @override + String get mapLayerCategoryEarthquake => 'Động đất'; + + @override + String get mapLayerCategoryTyphoon => 'Bão'; + + @override + String get mapLayerCategoryWeather => 'Quan sát thời tiết'; + + @override + String get mapLayerCategorySatellite => 'Vệ tinh'; + + @override + String get mapLayerCategoryRadar => 'Ra đa'; + + @override + String get mapLayerCategoryLife => 'Đời sống'; + + @override + String get mapLayerCategoryForecast => 'Dự báo số'; + + @override + String get mapOverlaySectionMap => 'Bản đồ'; + + @override + String get rainIntervalSection => 'Khoảng thời gian'; + + @override + String get mapTownLabels => 'Tên hương trấn'; + + @override + String get mapTownLabelsHint => 'Hiển thị tên hương trấn khi phóng to'; + @override String get dpmSheetEmpty => 'Chạm vào điểm đánh dấu trên bản đồ để xem chi tiết'; @@ -1213,6 +1523,16 @@ class AppLocalizationsVi extends AppLocalizations { return 'Tropical depression TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => 'Mild typhoon'; @@ -1243,6 +1563,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapMyLocation => 'Vị trí của tôi'; + @override + String get mapResetNorth => 'Về hướng bắc'; + @override String get typhoonLegendCircle15 => 'Vòng gió mạnh'; @@ -1373,4 +1696,103 @@ class AppLocalizationsVi extends AppLocalizations { @override String get dpmDisasterNuclear => 'Sự cố hạt nhân'; + + @override + String get skyTime => 'Thời gian bầu trời'; + + @override + String get skyTimeAuto => 'Tự động'; + + @override + String get skyTimeDawn => 'Rạng đông'; + + @override + String get skyTimeSunrise => 'Bình minh'; + + @override + String get skyTimeMorning => 'Buổi sáng'; + + @override + String get skyTimeNoon => 'Buổi trưa'; + + @override + String get skyTimeAfternoon => 'Buổi chiều'; + + @override + String get skyTimeGolden => 'Giờ vàng'; + + @override + String get skyTimeSunset => 'Hoàng hôn'; + + @override + String get skyTimeDusk => 'Chạng vạng'; + + @override + String get skyTimeNight => 'Ban đêm'; + + @override + String get weatherModeCloudy => 'Nhiều mây'; + + @override + String get weatherModeOvercast => 'Trời âm u'; + + @override + String get weatherModeSnow => 'Tuyết rơi'; + + @override + String get weatherModeSand => 'Bụi cát'; + + @override + String get radarScanRange => 'Hiện phạm vi quét'; + + @override + String get radarScanRangeSubtitle => + 'Đánh dấu vùng bốn radar thực sự quan trắc.'; + + @override + String get radarScanRangeHint => 'Ngoài khung là chưa quan trắc'; + + @override + String get radarOverlayMenuTooltip => 'Tùy chọn lớp radar'; + + @override + String get radarCountyOutline => 'Ranh giới huyện thị'; + + @override + String get radarGlobalOutline => 'Biên giới quốc gia'; + + @override + String get radarGlobalOutlineHint => 'Khung ngoài của mỗi quốc gia'; + + @override + String get radarCountyOutlineHint => 'Vẽ đè lên tiếng vọng'; + + @override + String get radarCountyOutlineSubtitle => + 'Giữ ranh giới rõ ràng dưới lớp phản hồi radar.'; + + @override + String get radarTownOutline => 'Ranh giới xã phường'; + + @override + String get radarTownOutlineHint => 'Lưới chi tiết hơn'; + + @override + String get radarTownOutlineSubtitle => + 'Giữ ranh giới xã phường rõ ràng dưới lớp phản hồi radar.'; + + @override + String get qpesumsOverlayMenuTooltip => 'Tùy chọn lớp dự báo mưa định lượng'; + + @override + String get windForecastOverlayMenuTooltip => 'Tùy chọn lớp dự báo gió'; + + @override + String get windForecastCountyOutlineHint => 'Vẽ trên trường gió'; + + @override + String get windForecastGlobalOutlineHint => 'Khung ngoài của mỗi quốc gia'; + + @override + String get windForecastTownOutlineHint => 'Lưới mịn hơn'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index ae734223b..d05c52ec0 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -12,7 +12,7 @@ class AppLocalizationsZh extends AppLocalizations { String get languageName => '繁體中文(臺灣)'; @override - String get navHome => '主頁'; + String get navHome => '首頁'; @override String get navEvents => '事件'; @@ -501,16 +501,228 @@ class AppLocalizationsZh extends AppLocalizations { String get homeActiveEventsEmpty => '目前沒有生效中的事件'; @override - String get homeRainTrendTitle => '近 1 小時降雨趨勢'; + String get homeRainTrendTitle => '近 1 小時降水趨勢'; + + @override + String homeRainTrendMinute(int minute) { + return '$minute分'; + } + + @override + String homeRainTrendUpdated(String time) { + return '更新 $time'; + } + + @override + String get homeRainTrendNoData => '無資料'; + + @override + String get homeRainTrendScattered => '可能會有零星降雨'; + + @override + String get homeRainTrendLightSustained => '未來 1 小時會有持續小雨'; + + @override + String homeRainTrendLightStopping(int minutes) { + return '預計 $minutes 分鐘後停止下小雨'; + } + + @override + String get homeRainTrendHeavySustained => '未來 1 小時會有持續大雨'; + + @override + String homeRainTrendHeavyStopping(int minutes) { + return '預計 $minutes 分鐘後停止下大雨'; + } @override String get mapLayers => '圖層'; + @override + String get mapLayerOrderTitle => '調整圖層順序'; + + @override + String get mapLayerOrderReset => '回復預設順序'; + @override String get mapLayerRadar => '雷達合成回波圖'; @override - String get mapLayerSatellite => 'ひまわり 紅外線圖'; + String get mapLayerSatellite => 'ひまわり 紅外線(B13)'; + + @override + String get mapLayerSatelliteB01 => 'ひまわり 可見光-藍(B01)'; + + @override + String get mapLayerSatelliteB02 => 'ひまわり 可見光-綠(B02)'; + + @override + String get mapLayerSatelliteB03 => 'ひまわり 可見光-紅(B03)'; + + @override + String get mapLayerSatelliteB04 => 'ひまわり 近紅外(B04)'; + + @override + String get mapLayerSatelliteB05 => 'ひまわり 近紅外(B05)'; + + @override + String get mapLayerSatelliteB06 => 'ひまわり 近紅外(B06)'; + + @override + String get mapLayerSatelliteB07 => 'ひまわり 短波紅外(B07)'; + + @override + String get mapLayerSatelliteB08 => 'ひまわり 上層水氣(B08)'; + + @override + String get mapLayerSatelliteB09 => 'ひまわり 中層水氣(B09)'; + + @override + String get mapLayerSatelliteB10 => 'ひまわり 低層水氣(B10)'; + + @override + String get mapLayerSatelliteB11 => 'ひまわり 二氧化硫/雲相(B11)'; + + @override + String get mapLayerSatelliteB12 => 'ひまわり 臭氧(B12)'; + + @override + String get mapLayerSatelliteB13 => 'ひまわり 紅外線(B13)'; + + @override + String get mapLayerSatelliteB14 => 'ひまわり 長波紅外線(B14)'; + + @override + String get mapLayerSatelliteB15 => 'ひまわり 長波紅外線(B15)'; + + @override + String get mapLayerSatelliteB16 => 'ひまわり 二氧化碳(B16)'; + + @override + String get mapLayerSatelliteTruecolor => 'ひまわり 真彩色'; + + @override + String get mapLayerSatelliteNaturalcolor => 'ひまわり 自然色'; + + @override + String get mapLayerSatelliteAsh => 'ひまわり 火山灰'; + + @override + String get mapLayerSatelliteDust => 'ひまわり 沙塵'; + + @override + String get mapLayerSatelliteAirmass => 'ひまわり 氣團'; + + @override + String get mapLayerSatelliteNightmicrophysics => 'ひまわり 夜間微物理'; + + @override + String get mapLayerSatelliteWatervapor => 'ひまわり 水氣'; + + @override + String get mapLayerSatelliteBtdSplit => 'ひまわり 分割視窗'; + + @override + String get mapLayerSatelliteBtdFog => 'ひまわり 夜間霧'; + + @override + String get mapLayerSatelliteBtdWvirw => 'ひまわり 過衝雲頂'; + + @override + String get mapLayerSatelliteBtdSo2 => 'ひまわり 二氧化硫/雲相'; + + @override + String get mapLayerSatelliteBtdCo2 => 'ひまわり 卷雲/雲高'; + + @override + String get mapLayerSatelliteBtdOzone => 'ひまわり 對流層頂'; + + @override + String get mapLayerSatelliteCloudtop => 'ひまわり 雲頂溫度'; + + @override + String get mapLayerSatelliteCloudmask => 'ひまわり 雲遮罩'; + + @override + String get mapLayerSatelliteSst => 'ひまわり 海表溫度'; + + @override + String get mapLayerSatelliteNdvi => 'ひまわり 植生指數'; + + @override + String get mapLayerSatelliteNdwi => 'ひまわり 水體指數'; + + @override + String get mapLayerSatelliteMndwi => 'ひまわり 改良水體指數'; + + @override + String get mapLayerSatelliteGlobalOutline => '國界'; + + @override + String get mapLayerSatelliteRgbComposite => 'RGB 合成(JMA 配方)'; + + @override + String get mapLayerSatelliteCloudClear => '晴空'; + + @override + String get mapLayerSatelliteCloudProbablyClear => '可能晴空'; + + @override + String get mapLayerSatelliteCloudProbablyCloudy => '可能有雲'; + + @override + String get mapLayerSatelliteCloudCloudy => '有雲'; + + @override + String get mapLayerSatelliteTransparentWarm => '晴空(暖端) = 透明,顯示底圖'; + + @override + String get mapLayerSatelliteTransparentReflectance => '低反射率/夜間 = 透明,顯示底圖'; + + @override + String get mapLayerSatelliteTransparentZero => '零差值 = 透明(無訊號)'; + + @override + String get mapLayerSatelliteTransparentNight => '夜間 = 透明,顯示底圖'; + + @override + String get mapLayerSatelliteTransparentNoData => '無資料(陸地) = 透明'; + + @override + String get mapLayerSatelliteTransparentNoVegetation => '< 0.1 = 透明(無植被)'; + + @override + String get mapLayerSatelliteTransparentNoWater => '≤ 0 = 透明(無水體)'; + + @override + String get mapLayerSatelliteTransparentClear => '晴空 = 透明,顯示底圖'; + + @override + String get mapLayerStyleSection => '顯示樣式'; + + @override + String get mapLayerStyleTooltip => '顯示樣式'; + + @override + String get mapLayerStyleGray => '灰階(JMA)'; + + @override + String get mapLayerStyleGrayTooltip => '氣象廳灰階慣例:溫度越低越白'; + + @override + String get mapLayerStyleJma => '雲頂強調(JMA)'; + + @override + String get mapLayerStyleJmaTooltip => '灰階為底,−40 °C 以下上色,凸顯雲頂高度'; + + @override + String get mapLayerStyleBd => 'Dvorak BD'; + + @override + String get mapLayerStyleBdTooltip => 'Dvorak BD 曲線——熱帶氣旋強度分析的階梯灰階'; + + @override + String get mapLayerQpesums => '未來 1 小時降水預報'; @override String get mapLayerLightning => '閃電'; @@ -528,9 +740,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapTimelineNow => '現在'; + @override + String get mapTimelinePast => '歷史'; + + @override + String get mapTimelineFuture => '未來'; + @override String get mapTimelineObserved => '觀測'; + @override + String get mapTimelineForecast => '預報'; + + @override + String mapTimelineDataTime(String time) { + return '資料時間 $time'; + } + @override String get notifySettingsMenu => '通知設定'; @@ -744,6 +970,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapNavRadar => '雷達'; + @override + String get mapNavQpesums => '預報'; + @override String get mapNavSatellite => '衛星'; @@ -993,6 +1222,12 @@ class AppLocalizationsZh extends AppLocalizations { return '資料時間\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '強震監視器'; @@ -1056,6 +1291,70 @@ class AppLocalizationsZh extends AppLocalizations { @override String get disasterMapOverlayShelterTooltip => '顯示避難收容場所'; + @override + String get dpmOpenInMaps => '開啟地圖'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app(預設)'; + } + + @override + String get mapAppCopyCoordinates => '複製座標'; + + @override + String get mapAppCoordinatesCopied => '已複製座標'; + + @override + String mapAppOpenFailed(String app) { + return '無法開啟 $app'; + } + + @override + String get mapAppCallFailed => '此裝置無法撥打電話'; + + @override + String get mapOverlaySectionReference => '參考圖層'; + + @override + String get mapLayerCategoryEarthquake => '地震'; + + @override + String get mapLayerCategoryTyphoon => '颱風'; + + @override + String get mapLayerCategoryWeather => '氣象觀測'; + + @override + String get mapLayerCategorySatellite => '衛星'; + + @override + String get mapLayerCategoryRadar => '雷達'; + + @override + String get mapLayerCategoryLife => '生活'; + + @override + String get mapLayerCategoryForecast => '數值預報'; + + @override + String get mapOverlaySectionMap => '地圖'; + + @override + String get rainIntervalSection => '統計時間'; + + @override + String get mapTownLabels => '鄉鎮名稱'; + + @override + String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -1195,6 +1494,16 @@ class AppLocalizationsZh extends AppLocalizations { return '熱帶性低氣壓 TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => '輕度颱風'; @@ -1225,6 +1534,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapMyLocation => '我的位置'; + @override + String get mapResetNorth => '回到北方'; + @override String get typhoonLegendCircle15 => '七級風暴風圈'; @@ -1347,121 +1659,217 @@ class AppLocalizationsZh extends AppLocalizations { @override String get dpmDisasterNuclear => '核子事故'; -} - -/// The translations for Chinese, using the Han script (`zh_Hans`). -class AppLocalizationsZhHans extends AppLocalizationsZh { - AppLocalizationsZhHans() : super('zh_Hans'); @override - String get languageName => '简体中文'; + String get skyTime => '天空時間'; @override - String get navHome => '首页'; + String get skyTimeAuto => '自動'; @override - String get navEvents => '事件'; + String get skyTimeDawn => '黎明'; @override - String get navMap => '地图'; + String get skyTimeSunrise => '日出'; @override - String get navData => '资料'; + String get skyTimeMorning => '上午'; @override - String get navEarthquake => '地震'; + String get skyTimeNoon => '正午'; @override - String get dataSectionSeismic => '地震'; + String get skyTimeAfternoon => '下午'; @override - String get dataEarthquakeSubtitle => '地震报告'; + String get skyTimeGolden => '黃金時刻'; @override - String get dataSectionWeather => '气象'; + String get skyTimeSunset => '日落'; @override - String get dataWeatherRankingSubtitle => '即时观测排行'; + String get skyTimeDusk => '暮色'; @override - String get weatherRankingTitle => '观测排行'; + String get skyTimeNight => '夜晚'; @override - String weatherRankingMeta(String time, int count) { - return '资料时间:$time\n共 $count 观测点'; - } + String get weatherModeCloudy => '多雲'; @override - String get weatherRankingEmpty => '目前没有可排序的观测'; + String get weatherModeOvercast => '陰天'; @override - String get weatherRankingBy => '依'; + String get weatherModeSnow => '下雪'; @override - String get weatherRankingHighest => '最高'; + String get weatherModeSand => '沙塵'; @override - String get weatherRankingLowest => '最低'; + String get radarScanRange => '顯示掃描範圍'; @override - String get weatherRankingMergeTo => '合并至'; + String get radarScanRangeSubtitle => '標示四座雷達實際觀測到的範圍。'; @override - String get weatherRankingMergeTown => '乡镇'; + String get radarScanRangeHint => '框外空白代表未觀測'; @override - String get weatherRankingMergeCounty => '县市'; + String get radarOverlayMenuTooltip => '雷達圖層選項'; @override - String get weatherRankingWind => '风速'; + String get radarCountyOutline => '縣市界線'; @override - String get weatherRankingGust => '阵风'; + String get radarGlobalOutline => '國界'; @override - String get weatherRankingTempExtremes => '温度极值'; + String get radarGlobalOutlineHint => '各國國界外框'; @override - String get weatherRankingExtremeHigh => '今日最高'; + String get radarCountyOutlineHint => '畫在回波之上'; @override - String get weatherRankingExtremeLow => '今日最低'; + String get radarCountyOutlineSubtitle => '讓縣市界線在雷達回波下仍然清楚。'; @override - String get weatherRankingExtremeRange => '日温差'; + String get radarTownOutline => '鄉鎮界線'; @override - String weatherRankingRecordedAt(String time) { - return '记录于 $time'; - } + String get radarTownOutlineHint => '較細的分區'; @override - String weatherRankingAnalysisCurrent(String value) { - return '当下 $value°C'; - } + String get radarTownOutlineSubtitle => '讓鄉鎮界線在雷達回波下仍然清楚。'; @override - String weatherRankingAnalysisHigh(String value) { - return '最高 $value'; - } + String get qpesumsOverlayMenuTooltip => '定量降水預報圖層選項'; @override - String weatherRankingAnalysisLow(String value) { - return '最低 $value'; - } + String get windForecastOverlayMenuTooltip => '風場預報圖層選項'; @override - String weatherRankingAnalysisRange(String value) { - return '温差 $value°C'; - } + String get windForecastCountyOutlineHint => '繪製於風場之上'; @override - String get reportListEmpty => '当前没有地震报告'; + String get windForecastGlobalOutlineHint => '各國國界外框'; @override - String get reportListEmptyFiltered => '没有符合条件的地震报告'; + String get windForecastTownOutlineHint => '更細的網格'; +} - @override +/// The translations for Chinese, using the Han script (`zh_Hans`). +class AppLocalizationsZhHans extends AppLocalizationsZh { + AppLocalizationsZhHans() : super('zh_Hans'); + + @override + String get languageName => '简体中文'; + + @override + String get navHome => '主页'; + + @override + String get navEvents => '事件'; + + @override + String get navMap => '地图'; + + @override + String get navData => '资料'; + + @override + String get navEarthquake => '地震'; + + @override + String get dataSectionSeismic => '地震'; + + @override + String get dataEarthquakeSubtitle => '地震报告'; + + @override + String get dataSectionWeather => '气象'; + + @override + String get dataWeatherRankingSubtitle => '即时观测排行'; + + @override + String get weatherRankingTitle => '观测排行'; + + @override + String weatherRankingMeta(String time, int count) { + return '资料时间:$time\n共 $count 观测点'; + } + + @override + String get weatherRankingEmpty => '目前没有可排序的观测'; + + @override + String get weatherRankingBy => '依'; + + @override + String get weatherRankingHighest => '最高'; + + @override + String get weatherRankingLowest => '最低'; + + @override + String get weatherRankingMergeTo => '合并至'; + + @override + String get weatherRankingMergeTown => '乡镇'; + + @override + String get weatherRankingMergeCounty => '县市'; + + @override + String get weatherRankingWind => '风速'; + + @override + String get weatherRankingGust => '阵风'; + + @override + String get weatherRankingTempExtremes => '温度极值'; + + @override + String get weatherRankingExtremeHigh => '今日最高'; + + @override + String get weatherRankingExtremeLow => '今日最低'; + + @override + String get weatherRankingExtremeRange => '日温差'; + + @override + String weatherRankingRecordedAt(String time) { + return '记录于 $time'; + } + + @override + String weatherRankingAnalysisCurrent(String value) { + return '当下 $value°C'; + } + + @override + String weatherRankingAnalysisHigh(String value) { + return '最高 $value'; + } + + @override + String weatherRankingAnalysisLow(String value) { + return '最低 $value'; + } + + @override + String weatherRankingAnalysisRange(String value) { + return '温差 $value°C'; + } + + @override + String get reportListEmpty => '当前没有地震报告'; + + @override + String get reportListEmptyFiltered => '没有符合条件的地震报告'; + + @override String reportListMeta(String magnitude, String depth) { return 'M$magnitude · $depth 公里'; } @@ -1846,321 +2254,550 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get homeActiveEventsEmpty => '目前没有生效中的事件'; @override - String get homeRainTrendTitle => '近 1 小时降雨趋势'; + String get homeRainTrendTitle => '近 1 小时降水趋势'; @override - String get mapLayers => '图层'; + String homeRainTrendMinute(int minute) { + return '$minute分'; + } @override - String get mapLayerRadar => '雷达合成回波图'; + String homeRainTrendUpdated(String time) { + return '更新 $time'; + } @override - String get mapLayerSatellite => 'ひまわり 红外线图'; + String get homeRainTrendNoData => '无资料'; @override - String get mapLayerLightning => '闪电'; + String get homeRainTrendScattered => '可能会有零星降雨'; @override - String lightningLegendCg(int minutes) { - return '对地 · $minutes 分钟内'; - } + String get homeRainTrendLightSustained => '未来 1 小时会有持续小雨'; @override - String lightningLegendCc(int minutes) { - return '云间 · $minutes 分钟内'; + String homeRainTrendLightStopping(int minutes) { + return '预计 $minutes 分钟后停止下小雨'; } @override - String get mapTimelineNow => '现在'; + String get homeRainTrendHeavySustained => '未来 1 小时会有持续大雨'; @override - String get mapTimelineObserved => '观测'; + String homeRainTrendHeavyStopping(int minutes) { + return '预计 $minutes 分钟后停止下大雨'; + } @override - String get notifySettingsMenu => '通知设置'; + String get mapLayers => '图层'; @override - String get notifyTitle => '通知'; + String get mapLayerOrderTitle => '调整图层顺序'; @override - String get notifyUnavailable => '推送通知尚未就绪,请稍后再试。'; + String get mapLayerOrderReset => '恢复默认顺序'; @override - String get notifySetFailed => '设置失败,请稍后再试。'; + String get mapLayerRadar => '雷达合成回波图'; @override - String get notifySectionEew => '地震预警'; + String get mapLayerSatellite => 'ひまわり 红外线(B13)'; @override - String get notifySectionEarthquake => '地震'; + String get mapLayerSatelliteB01 => 'ひまわり 可见光-蓝(B01)'; @override - String get notifySectionWeather => '天气'; + String get mapLayerSatelliteB02 => 'ひまわり 可见光-绿(B02)'; @override - String get notifySectionTsunami => '海啸'; + String get mapLayerSatelliteB03 => 'ひまわり 可见光-红(B03)'; @override - String get notifySectionOther => '其他'; + String get mapLayerSatelliteB04 => 'ひまわり 近红外(B04)'; @override - String get notifyEew => '紧急地震预警'; + String get mapLayerSatelliteB05 => 'ひまわり 近红外(B05)'; @override - String get notifyMonitor => '强震监视器'; + String get mapLayerSatelliteB06 => 'ひまわり 近红外(B06)'; @override - String get notifyReport => '地震报告'; + String get mapLayerSatelliteB07 => 'ひまわり 短波红外(B07)'; @override - String get notifyIntensity => '震度速报'; + String get mapLayerSatelliteB08 => 'ひまわり 上层水气(B08)'; @override - String get notifyThunderstorm => '雷雨预警'; + String get mapLayerSatelliteB09 => 'ひまわり 中层水气(B09)'; @override - String get notifyAdvisory => '气象预警'; + String get mapLayerSatelliteB10 => 'ひまわり 低层水气(B10)'; @override - String get notifyEvacuation => '防灾信息'; + String get mapLayerSatelliteB11 => 'ひまわり 二氧化硫/云相(B11)'; @override - String get notifyTsunami => '海啸信息'; + String get mapLayerSatelliteB12 => 'ひまわり 臭氧(B12)'; @override - String get notifyAnnouncement => '公告'; + String get mapLayerSatelliteB13 => 'ひまわり 红外线(B13)'; @override - String get notifyOptOff => '关闭'; + String get mapLayerSatelliteB14 => 'ひまわり 长波红外线(B14)'; @override - String get notifyOptAll => '接收全部'; + String get mapLayerSatelliteB15 => 'ひまわり 长波红外线(B15)'; @override - String get notifyOptLocalIntensity4 => '本地震度4以上'; + String get mapLayerSatelliteB16 => 'ひまわり 二氧化碳(B16)'; @override - String get notifyOptLocalIntensity1 => '本地震度1以上'; + String get mapLayerSatelliteTruecolor => 'ひまわり 真彩色'; @override - String get notifyOptWeatherLocal => '仅接收当前位置'; + String get mapLayerSatelliteNaturalcolor => 'ひまわり 自然色'; @override - String get notifyOptTsunamiWarning => '仅接收海啸警报'; + String get mapLayerSatelliteAsh => 'ひまわり 火山灰'; @override - String get notifyOptTsunamiAll => '海啸消息、海啸警报'; + String get mapLayerSatelliteDust => 'ひまわり 沙尘'; @override - String get onboardingNext => '下一步'; + String get mapLayerSatelliteAirmass => 'ひまわり 气团'; @override - String get onboardingBack => '上一步'; + String get mapLayerSatelliteNightmicrophysics => 'ひまわり 夜间微物理'; @override - String get onboardingScrollHint => '向下滚动以继续'; + String get mapLayerSatelliteWatervapor => 'ひまわり 水气'; @override - String get onboardingIntroTitle => '欢迎使用 DPIP'; + String get mapLayerSatelliteBtdSplit => 'ひまわり 分割视窗'; @override - String get onboardingIntroBody => - 'DPIP 是与你并肩的防灾伙伴,整合地震预警、地震报告、天气与各类灾害信息,在关键时刻即时通知你。\n\n• 地震:地震预警、震度速报与详细报告\n• 天气:实时雷雨消息与气象预警\n• 海啸与防灾信息\n\n接下来,我们会请你阅读服务条款,并授权几项权限,让 DPIP 能实时守护你。'; + String get mapLayerSatelliteBtdFog => 'ひまわり 夜间雾'; @override - String get onboardingTermsTitle => '服务条款'; + String get mapLayerSatelliteBtdWvirw => 'ひまわり 过冲云顶'; @override - String get onboardingTermsBody => - '使用 DPIP 前,请详细阅读以下注意事项:\n\n• 任何信息均应以中央气象署(CWA)发布的内容为准。\n\n• 受网络状态、服务器状态、应用程序状态、上游数据来源状态等因素影响,存在收不到信息的可能,我们会尽力避免此类情况,但不保证一定不会发生。\n\n• 强烈震动有可能比通知更早抵达您所在的位置。\n\n• 地震预警为快速计算的结果,可能存在较大误差,请理解并谨慎使用。\n\n• 任何未获官方认可的行为均可能承担法律风险,请务必遵守相关规定。\n\n此外,为提供本地化预警,本服务会在前台及后台收集并上传您的大致位置与设备推送标识符,仅用于决定应向您推送哪些预警。\n\n点击下方“同意并继续”即表示您已阅读、理解并同意上述事项。'; + String get mapLayerSatelliteBtdSo2 => 'ひまわり 二氧化硫/云相'; @override - String get onboardingTermsAgree => '我已阅读并同意服务条款'; + String get mapLayerSatelliteBtdCo2 => 'ひまわり 卷云/云高'; @override - String get onboardingAgreeContinue => '同意并继续'; + String get mapLayerSatelliteBtdOzone => 'ひまわり 对流层顶'; @override - String get onboardingPermsTitle => '权限授权'; + String get mapLayerSatelliteCloudtop => 'ひまわり 云顶温度'; @override - String get onboardingPermsBody => '为了在灾害发生的第一时间通知你,请授权以下权限。你可以随时在系统设置中更改。'; + String get mapLayerSatelliteCloudmask => 'ひまわり 云遮罩'; @override - String get onboardingPermNotify => '通知'; + String get mapLayerSatelliteSst => 'ひまわり 海表温度'; @override - String get onboardingPermNotifyDesc => '在地震、天气与灾害发生时,即时推送预警通知。'; + String get mapLayerSatelliteNdvi => 'ひまわり 植被指数'; @override - String get onboardingPermCritical => '重要警告'; + String get mapLayerSatelliteNdwi => 'ひまわり 水体指数'; @override - String get onboardingPermCriticalDesc => '让危及生命的地震预警,即使在静音或勿扰模式下也能发出声响。'; + String get mapLayerSatelliteMndwi => 'ひまわり 改良水体指数'; @override - String get onboardingPermLocation => '定位'; + String get mapLayerSatelliteGlobalOutline => '国界'; @override - String get onboardingPermLocationDesc => '根据你所在的位置推送本地预警。'; + String get mapLayerSatelliteRgbComposite => 'RGB 合成(JMA 配方)'; @override - String get onboardingPermBackground => '后台定位'; + String get mapLayerSatelliteCloudClear => '晴空'; @override - String get onboardingPermBackgroundDesc => '选择“始终允许”,关闭应用后也能向你推送本地预警。'; + String get mapLayerSatelliteCloudProbablyClear => '可能晴空'; @override - String get onboardingPermBattery => '电池优化白名单'; + String get mapLayerSatelliteCloudProbablyCloudy => '可能有云'; @override - String get onboardingPermBatteryDesc => '允许 DPIP 在后台持续运行,避免预警延迟或漏收。'; + String get mapLayerSatelliteCloudCloudy => '有云'; @override - String get onboardingGrant => '授权'; + String get mapLayerSatelliteTransparentWarm => '晴空(暖端) = 透明,显示底图'; @override - String get onboardingGranted => '已授权'; + String get mapLayerSatelliteTransparentReflectance => '低反射率/夜间 = 透明,显示底图'; @override - String get onboardingStart => '开始使用'; + String get mapLayerSatelliteTransparentZero => '零差值 = 透明(无信号)'; @override - String get language => '语言'; + String get mapLayerSatelliteTransparentNight => '夜间 = 透明,显示底图'; @override - String get languageSettings => '语言设置'; + String get mapLayerSatelliteTransparentNoData => '无资料(陆地) = 透明'; @override - String get languageSystem => '系统默认'; + String get mapLayerSatelliteTransparentNoVegetation => '< 0.1 = 透明(无植被)'; @override - String get locationBannerServiceOff => '定位服务已关闭,无法向你所在的区域推送本地预警。'; + String get mapLayerSatelliteTransparentNoWater => '≤ 0 = 透明(无水体)'; @override - String get locationBannerPermission => '尚未授予定位权限,无法向你所在的区域推送本地预警。'; + String get mapLayerSatelliteTransparentClear => '晴空 = 透明,显示底图'; @override - String get locationBannerFix => '打开设置'; + String get mapLayerStyleSection => '显示样式'; @override - String get notifyBannerDisabled => '通知已关闭,将收不到灾害警报。'; + String get mapLayerStyleTooltip => '显示样式'; @override - String get onboardingSkipTitle => '尚未完成授权'; + String get mapLayerStyleGray => '灰度(JMA)'; @override - String get onboardingSkipBody => - '未授权定位与通知,DPIP 将无法实时通知你所在地的地震与灾害。你仍可稍后在设置中开启。'; + String get mapLayerStyleGrayTooltip => '气象厅灰度惯例:温度越低越白'; @override - String get onboardingSkipStay => '返回授权'; + String get mapLayerStyleJma => '云顶强调(JMA)'; @override - String get onboardingSkipLeave => '仍要跳过'; + String get mapLayerStyleJmaTooltip => '灰阶为底,−40 °C 以下上色,凸显云顶高度'; @override - String get moreYoutube => 'YouTube'; + String get mapLayerStyleBd => 'Dvorak BD'; @override - String get moreGithub => 'ExpTech GitHub'; + String get mapLayerStyleBdTooltip => 'Dvorak BD 曲线——热带气旋强度分析的阶梯灰度'; @override - String get moreSourceCode => '源代码'; + String get mapLayerQpesums => '未来 1 小时降水预报'; @override - String get moreSectionApp => '获取 App'; + String get mapLayerLightning => '闪电'; @override - String get moreGooglePlay => 'Google Play'; + String lightningLegendCg(int minutes) { + return '对地 · $minutes 分钟内'; + } @override - String get moreAppStore => 'App Store'; + String lightningLegendCc(int minutes) { + return '云间 · $minutes 分钟内'; + } @override - String get displaySettings => '显示设置'; + String get mapTimelineNow => '现在'; @override - String get defaultMapLayerSettings => '地图默认图层'; + String get mapTimelinePast => '历史'; @override - String get defaultMapLayerSubtitle => '打开地图标签页时显示此图层,底部导航栏图标与文字会一并更新。'; + String get mapTimelineFuture => '未来'; @override - String get mapNavRadar => '雷达'; + String get mapTimelineObserved => '观测'; @override - String get mapNavSatellite => '卫星'; + String get mapTimelineForecast => '预报'; @override - String get mapNavLightning => '闪电'; + String mapTimelineDataTime(String time) { + return '资料时间 $time'; + } @override - String get mapNavTyphoon => '台风'; + String get notifySettingsMenu => '通知设置'; @override - String get mapNavEarthquake => '地震'; + String get notifyTitle => '通知'; @override - String get mapNavTemperature => '温度'; + String get notifyUnavailable => '推送通知尚未就绪,请稍后再试。'; @override - String get mapNavHumidity => '湿度'; + String get notifySetFailed => '设置失败,请稍后再试。'; @override - String get mapNavPressure => '气压'; + String get notifySectionEew => '地震预警'; @override - String get mapNavWind => '风向'; + String get notifySectionEarthquake => '地震'; @override - String get mapNavRain => '雨量'; + String get notifySectionWeather => '天气'; @override - String get mapNavDisaster => '防灾'; + String get notifySectionTsunami => '海啸'; @override - String get displayTheme => '主题'; + String get notifySectionOther => '其他'; @override - String get themeSystem => '跟随系统'; + String get notifyEew => '紧急地震预警'; @override - String get themeLight => '浅色'; + String get notifyMonitor => '强震监视器'; @override - String get themeDark => '深色'; + String get notifyReport => '地震报告'; @override - String get moreSectionAbout => '关于'; + String get notifyIntensity => '震度速报'; @override - String get termsOfService => '服务条款'; + String get notifyThunderstorm => '雷雨预警'; @override - String get faq => '常见问题'; + String get notifyAdvisory => '气象预警'; @override - String get openSourceLicenses => '开源许可'; + String get notifyEvacuation => '防灾信息'; @override - String get sponsorTitle => '支持 DPIP'; + String get notifyTsunami => '海啸信息'; @override - String get sponsorIntro => - 'DPIP 致力于提供实时防灾信息,没有广告或其他盈利模式。您的支持能帮助我们维持服务器运行并持续开发。'; + String get notifyAnnouncement => '公告'; @override - String get sponsorSubscriptions => '订阅制'; + String get notifyOptOff => '关闭'; @override - String get sponsorRecommended => '推荐'; + String get notifyOptAll => '接收全部'; @override - String get sponsorOneTime => '单次支持'; + String get notifyOptLocalIntensity4 => '本地震度4以上'; @override - String sponsorPerMonth(String price) { + String get notifyOptLocalIntensity1 => '本地震度1以上'; + + @override + String get notifyOptWeatherLocal => '仅接收当前位置'; + + @override + String get notifyOptTsunamiWarning => '仅接收海啸警报'; + + @override + String get notifyOptTsunamiAll => '海啸消息、海啸警报'; + + @override + String get onboardingNext => '下一步'; + + @override + String get onboardingBack => '上一步'; + + @override + String get onboardingScrollHint => '向下滚动以继续'; + + @override + String get onboardingIntroTitle => '欢迎使用 DPIP'; + + @override + String get onboardingIntroBody => + 'DPIP 是与你并肩的防灾伙伴,整合地震预警、地震报告、天气与各类灾害信息,在关键时刻即时通知你。\n\n• 地震:地震预警、震度速报与详细报告\n• 天气:实时雷雨消息与气象预警\n• 海啸与防灾信息\n\n接下来,我们会请你阅读服务条款,并授权几项权限,让 DPIP 能实时守护你。'; + + @override + String get onboardingTermsTitle => '服务条款'; + + @override + String get onboardingTermsBody => + '使用 DPIP 前,请详细阅读以下注意事项:\n\n• 任何信息均应以中央气象署(CWA)发布的内容为准。\n\n• 受网络状态、服务器状态、应用程序状态、上游数据来源状态等因素影响,存在收不到信息的可能,我们会尽力避免此类情况,但不保证一定不会发生。\n\n• 强烈震动有可能比通知更早抵达您所在的位置。\n\n• 地震预警为快速计算的结果,可能存在较大误差,请理解并谨慎使用。\n\n• 任何未获官方认可的行为均可能承担法律风险,请务必遵守相关规定。\n\n此外,为提供本地化预警,本服务会在前台及后台收集并上传您的大致位置与设备推送标识符,仅用于决定应向您推送哪些预警。\n\n点击下方“同意并继续”即表示您已阅读、理解并同意上述事项。'; + + @override + String get onboardingTermsAgree => '我已阅读并同意服务条款'; + + @override + String get onboardingAgreeContinue => '同意并继续'; + + @override + String get onboardingPermsTitle => '权限授权'; + + @override + String get onboardingPermsBody => '为了在灾害发生的第一时间通知你,请授权以下权限。你可以随时在系统设置中更改。'; + + @override + String get onboardingPermNotify => '通知'; + + @override + String get onboardingPermNotifyDesc => '在地震、天气与灾害发生时,即时推送预警通知。'; + + @override + String get onboardingPermCritical => '重要警告'; + + @override + String get onboardingPermCriticalDesc => '让危及生命的地震预警,即使在静音或勿扰模式下也能发出声响。'; + + @override + String get onboardingPermLocation => '定位'; + + @override + String get onboardingPermLocationDesc => '根据你所在的位置推送本地预警。'; + + @override + String get onboardingPermBackground => '后台定位'; + + @override + String get onboardingPermBackgroundDesc => '选择“始终允许”,关闭应用后也能向你推送本地预警。'; + + @override + String get onboardingPermBattery => '电池优化白名单'; + + @override + String get onboardingPermBatteryDesc => '允许 DPIP 在后台持续运行,避免预警延迟或漏收。'; + + @override + String get onboardingGrant => '授权'; + + @override + String get onboardingGranted => '已授权'; + + @override + String get onboardingStart => '开始使用'; + + @override + String get language => '语言'; + + @override + String get languageSettings => '语言设置'; + + @override + String get languageSystem => '系统默认'; + + @override + String get locationBannerServiceOff => '定位服务已关闭,无法向你所在的区域推送本地预警。'; + + @override + String get locationBannerPermission => '尚未授予定位权限,无法向你所在的区域推送本地预警。'; + + @override + String get locationBannerFix => '打开设置'; + + @override + String get notifyBannerDisabled => '通知已关闭,将收不到灾害警报。'; + + @override + String get onboardingSkipTitle => '尚未完成授权'; + + @override + String get onboardingSkipBody => + '未授权定位与通知,DPIP 将无法实时通知你所在地的地震与灾害。你仍可稍后在设置中开启。'; + + @override + String get onboardingSkipStay => '返回授权'; + + @override + String get onboardingSkipLeave => '仍要跳过'; + + @override + String get moreYoutube => 'YouTube'; + + @override + String get moreGithub => 'ExpTech GitHub'; + + @override + String get moreSourceCode => '源代码'; + + @override + String get moreSectionApp => '获取 App'; + + @override + String get moreGooglePlay => 'Google Play'; + + @override + String get moreAppStore => 'App Store'; + + @override + String get displaySettings => '显示设置'; + + @override + String get defaultMapLayerSettings => '地图默认图层'; + + @override + String get defaultMapLayerSubtitle => '打开地图标签页时显示此图层,底部导航栏图标与文字会一并更新。'; + + @override + String get mapNavRadar => '雷达'; + + @override + String get mapNavQpesums => '预报'; + + @override + String get mapNavSatellite => '卫星'; + + @override + String get mapNavLightning => '闪电'; + + @override + String get mapNavTyphoon => '台风'; + + @override + String get mapNavEarthquake => '地震'; + + @override + String get mapNavTemperature => '温度'; + + @override + String get mapNavHumidity => '湿度'; + + @override + String get mapNavPressure => '气压'; + + @override + String get mapNavWind => '风向'; + + @override + String get mapNavRain => '雨量'; + + @override + String get mapNavDisaster => '防灾'; + + @override + String get displayTheme => '主题'; + + @override + String get themeSystem => '跟随系统'; + + @override + String get themeLight => '浅色'; + + @override + String get themeDark => '深色'; + + @override + String get moreSectionAbout => '关于'; + + @override + String get termsOfService => '服务条款'; + + @override + String get faq => '常见问题'; + + @override + String get openSourceLicenses => '开源许可'; + + @override + String get sponsorTitle => '支持 DPIP'; + + @override + String get sponsorIntro => + 'DPIP 致力于提供实时防灾信息,没有广告或其他盈利模式。您的支持能帮助我们维持服务器运行并持续开发。'; + + @override + String get sponsorSubscriptions => '订阅制'; + + @override + String get sponsorRecommended => '推荐'; + + @override + String get sponsorOneTime => '单次支持'; + + @override + String sponsorPerMonth(String price) { return '$price / 月'; } @@ -2338,6 +2975,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { return '资料时间\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '强震监视器'; @@ -2401,6 +3044,70 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get disasterMapOverlayShelterTooltip => '显示避难收容场所'; + @override + String get dpmOpenInMaps => '打开地图'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app(默认)'; + } + + @override + String get mapAppCopyCoordinates => '复制坐标'; + + @override + String get mapAppCoordinatesCopied => '已复制坐标'; + + @override + String mapAppOpenFailed(String app) { + return '无法打开 $app'; + } + + @override + String get mapAppCallFailed => '此设备无法拨打电话'; + + @override + String get mapOverlaySectionReference => '参考图层'; + + @override + String get mapLayerCategoryEarthquake => '地震'; + + @override + String get mapLayerCategoryTyphoon => '台风'; + + @override + String get mapLayerCategoryWeather => '气象观测'; + + @override + String get mapLayerCategorySatellite => '卫星'; + + @override + String get mapLayerCategoryRadar => '雷达'; + + @override + String get mapLayerCategoryLife => '生活'; + + @override + String get mapLayerCategoryForecast => '数值预报'; + + @override + String get mapOverlaySectionMap => '地图'; + + @override + String get rainIntervalSection => '统计时间'; + + @override + String get mapTownLabels => '乡镇名称'; + + @override + String get mapTownLabelsHint => '放大时显示乡镇名称'; + @override String get dpmSheetEmpty => '点击地图上的标记查看详情'; @@ -2540,6 +3247,16 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { return '热带性低气压 TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => '轻度台风'; @@ -2570,6 +3287,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapMyLocation => '我的位置'; + @override + String get mapResetNorth => '回到正北'; + @override String get typhoonLegendCircle15 => '七级风暴风圈'; @@ -2593,105 +3313,201 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get typhoonLegendProbability => '侵袭概率'; @override - String get typhoonLegendWarningAreas => '警报区域'; + String get typhoonLegendWarningAreas => '警报区域'; + + @override + String get typhoonOverlayMenuTooltip => '台风图层选项'; + + @override + String get typhoonOverlaySectionStorm => '暴风圈'; + + @override + String get typhoonOverlaySectionExtra => '叠加层'; + + @override + String get typhoonOverlayStormBandSubtitle => '含平均圆'; + + @override + String get typhoonOverlayProbabilityHint => '会隐藏预测圆锥'; + + @override + String get typhoonOverlayProbabilityTooltip => '显示侵袭概率(隐藏预测圆锥)'; + + @override + String get typhoonOverlayWarningTooltip => '标示发布台风警报的县市'; + + @override + String get typhoonOverlayStormL7Tooltip => '七级风风场 + 平均圆(紫)'; + + @override + String get typhoonOverlayStormL10Tooltip => '十级风风场 + 平均圆(黄)'; + + @override + String get typhoonOverlaySectionWeather => '天气底图'; + + @override + String get typhoonOverlayWeatherNone => '无'; + + @override + String get typhoonOverlayWeatherHint => '对齐报文时间'; + + @override + String get typhoonOverlayWeatherNoneTooltip => '不显示雷达或红外线底图'; + + @override + String get typhoonOverlayWeatherRadarTooltip => '最接近台风报文时间的雷达回波'; + + @override + String get typhoonOverlayWeatherSatelliteTooltip => '最接近台风报文时间的红外线'; + + @override + String get typhoonWarningTitle => '台风警报'; + + @override + String typhoonWarningAreas(String areas) { + return '警戒区域:$areas'; + } + + @override + String get typhoonTrackDetail => '路径详情'; + + @override + String get typhoonHistoryTitle => '资料时间'; + + @override + String get typhoonHistoryLive => '实时'; + + @override + String get typhoonSatelliteTitle => '卫星云图'; + + @override + String get typhoonOverlayForecastCallouts => '预测点信息'; + + @override + String get typhoonOverlayForecastCalloutsTooltip => '放大时显示预测点详细卡片'; + + @override + String get dpmFilterSectionRestroom => '场所类型'; + + @override + String get dpmFilterSectionRestroomType => '厕所类型'; + + @override + String get dpmFilterSectionShelter => '避难所灾害类型'; + + @override + String get dpmDisasterFlood => '水灾'; + + @override + String get dpmDisasterEarthquake => '震灾'; + + @override + String get dpmDisasterLandslide => '土石流'; + + @override + String get dpmDisasterTsunami => '海啸'; + + @override + String get dpmDisasterSlope => '坡地灾害'; + + @override + String get dpmDisasterNuclear => '核子事故'; @override - String get typhoonOverlayMenuTooltip => '台风图层选项'; + String get skyTime => '天空时间'; @override - String get typhoonOverlaySectionStorm => '暴风圈'; + String get skyTimeAuto => '自动'; @override - String get typhoonOverlaySectionExtra => '叠加层'; + String get skyTimeDawn => '黎明'; @override - String get typhoonOverlayStormBandSubtitle => '含平均圆'; + String get skyTimeSunrise => '日出'; @override - String get typhoonOverlayProbabilityHint => '会隐藏预测圆锥'; + String get skyTimeMorning => '上午'; @override - String get typhoonOverlayProbabilityTooltip => '显示侵袭概率(隐藏预测圆锥)'; + String get skyTimeNoon => '正午'; @override - String get typhoonOverlayWarningTooltip => '标示发布台风警报的县市'; + String get skyTimeAfternoon => '下午'; @override - String get typhoonOverlayStormL7Tooltip => '七级风风场 + 平均圆(紫)'; + String get skyTimeGolden => '黄金时刻'; @override - String get typhoonOverlayStormL10Tooltip => '十级风风场 + 平均圆(黄)'; + String get skyTimeSunset => '日落'; @override - String get typhoonOverlaySectionWeather => '天气底图'; + String get skyTimeDusk => '暮色'; @override - String get typhoonOverlayWeatherNone => '无'; + String get skyTimeNight => '夜晚'; @override - String get typhoonOverlayWeatherHint => '对齐报文时间'; + String get weatherModeCloudy => '多云'; @override - String get typhoonOverlayWeatherNoneTooltip => '不显示雷达或红外线底图'; + String get weatherModeOvercast => '阴天'; @override - String get typhoonOverlayWeatherRadarTooltip => '最接近台风报文时间的雷达回波'; + String get weatherModeSnow => '下雪'; @override - String get typhoonOverlayWeatherSatelliteTooltip => '最接近台风报文时间的红外线'; + String get weatherModeSand => '沙尘'; @override - String get typhoonWarningTitle => '台风警报'; + String get radarScanRange => '显示扫描范围'; @override - String typhoonWarningAreas(String areas) { - return '警戒区域:$areas'; - } + String get radarScanRangeSubtitle => '标示四座雷达实际观测到的范围。'; @override - String get typhoonTrackDetail => '路径详情'; + String get radarScanRangeHint => '框外空白代表未观测'; @override - String get typhoonHistoryTitle => '资料时间'; + String get radarOverlayMenuTooltip => '雷达图层选项'; @override - String get typhoonHistoryLive => '实时'; + String get radarCountyOutline => '县市界线'; @override - String get typhoonSatelliteTitle => '卫星云图'; + String get radarGlobalOutline => '国界'; @override - String get typhoonOverlayForecastCallouts => '预测点信息'; + String get radarGlobalOutlineHint => '各国国界外框'; @override - String get typhoonOverlayForecastCalloutsTooltip => '放大时显示预测点详细卡片'; + String get radarCountyOutlineHint => '画在回波之上'; @override - String get dpmFilterSectionRestroom => '场所类型'; + String get radarCountyOutlineSubtitle => '让县市界线在雷达回波下仍然清楚。'; @override - String get dpmFilterSectionRestroomType => '厕所类型'; + String get radarTownOutline => '乡镇界线'; @override - String get dpmFilterSectionShelter => '避难所灾害类型'; + String get radarTownOutlineHint => '较细的分区'; @override - String get dpmDisasterFlood => '水灾'; + String get radarTownOutlineSubtitle => '让乡镇界线在雷达回波下仍然清楚。'; @override - String get dpmDisasterEarthquake => '震灾'; + String get qpesumsOverlayMenuTooltip => '定量降水预报图层选项'; @override - String get dpmDisasterLandslide => '土石流'; + String get windForecastOverlayMenuTooltip => '风场预报图层选项'; @override - String get dpmDisasterTsunami => '海啸'; + String get windForecastCountyOutlineHint => '绘制于风场之上'; @override - String get dpmDisasterSlope => '坡地灾害'; + String get windForecastGlobalOutlineHint => '各国国界外框'; @override - String get dpmDisasterNuclear => '核子事故'; + String get windForecastTownOutlineHint => '更细的网格'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -2977,230 +3793,442 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get reportDetailImageUnavailable => '報告圖尚未提供'; @override - String get reportDetailOpenReport => '報告頁面'; + String get reportDetailOpenReport => '報告頁面'; + + @override + String get reportDetailReplay => '重播'; + + @override + String get navMore => '更多'; + + @override + String get appLogs => 'App 日誌'; + + @override + String get changelogTitle => '更新日誌'; + + @override + String get changelogEmpty => '目前沒有更新日誌'; + + @override + String get changelogTypePrerelease => '公測'; + + @override + String get changelogTypeStable => '正式'; + + @override + String get changelogCurrentVersion => '目前版本'; + + @override + String get changelogVersionDetails => '版本資訊'; + + @override + String get changelogBodyEmpty => '此版本沒有說明。'; + + @override + String get mapPlaceholderDisabled => '地圖(暫時停用)'; + + @override + String get moreSectionRegion => '地區'; + + @override + String get moreSectionNotify => '通知'; + + @override + String get moreSectionDisplay => '顯示'; + + @override + String get regionManageTitle => '常用地區'; + + @override + String get regionAddButton => '新增地區'; + + @override + String get regionEmpty => '尚未新增常用地區'; + + @override + String get regionSelectTitle => '選擇地區'; + + @override + String regionSelectCount(int count, int max) { + return '已選 $count/$max'; + } + + @override + String regionSelectFull(int max) { + return '最多只能選擇 $max 個地區'; + } + + @override + String get regionEdit => '修改'; + + @override + String get moreSectionAdvanced => '進階'; + + @override + String get moreDeveloper => '偵錯資訊'; + + @override + String get experimentalFeatures => '實驗性功能'; + + @override + String get moreSectionLinks => '相關連結'; + + @override + String get moreCwaEew => '中央氣象署強震即時警報'; + + @override + String get moreTremReport => 'TREM 偵測報告'; + + @override + String get moreServerStatus => '伺服器狀態'; + + @override + String get moreAnnouncements => '公告'; + + @override + String get moreDiscord => 'Discord 社群'; + + @override + String get moreNotifyLog => 'DPIP 通知發送記錄'; + + @override + String get moreLinkOpenFailed => '無法開啟連結'; + + @override + String get weatherDynamicState => '天氣動態狀態'; + + @override + String get weatherDynamicStateSubtitle => '覆寫主頁背景天氣'; + + @override + String get weatherModeAuto => '自動'; + + @override + String get weatherModeClear => '晴天'; + + @override + String get weatherModeRain => '雨天'; + + @override + String get weatherModeFog => '大霧'; + + @override + String get weatherModeThunderstorm => '雷暴'; + + @override + String get commonLoading => '載入中…'; + + @override + String get commonRetry => '重試'; + + @override + String get commonError => '發生錯誤'; + + @override + String get commonFetchFailed => '無法獲取資料,請稍後重試'; + + @override + String get commonEmpty => '沒有資料'; + + @override + String get feedConnecting => '連接中…'; + + @override + String get feedStale => '資料可能已過期'; + + @override + String get feedOffline => '連接中斷'; + + @override + String get eewTitle => '地震速報'; + + @override + String get eewNone => '目前沒有地震速報'; + + @override + String eewSummary(String magnitude, String depth) { + return '規模 $magnitude・深度 $depth 公里'; + } + + @override + String get regionNationwide => '全國'; + + @override + String get regionCurrent => '所在地'; + + @override + String get regionCurrentUnavailable => '無法取得所在地位置資訊'; + + @override + String get weatherPrecipitation => '降水量'; + + @override + String get weatherHumidity => '濕度'; + + @override + String get homeForecastTitle => '24小時預報'; + + @override + String homeForecastHighLow(String high, String low) { + return '高 $high° · 低 $low°'; + } + + @override + String homeForecastPop(String pop) { + return '$pop%'; + } + + @override + String homeForecastFeelsLike(String temp) { + return '體感 $temp°'; + } + + @override + String homeForecastHumidity(String value) { + return '濕度 $value%'; + } + + @override + String homeForecastWind(String direction, String level) { + return '$direction · $level 級'; + } + + @override + String get homeForecastUnavailable => '選擇地區後可查看預報'; + + @override + String get homeForecastEmpty => '目前沒有預報資料'; + + @override + String get homeActiveEventsTitle => '生效中事件'; + + @override + String get homeActiveEventsEmpty => '目前沒有生效中的事件'; + + @override + String get homeRainTrendTitle => '近 1 小時降水趨勢'; + + @override + String homeRainTrendMinute(int minute) { + return '$minute分'; + } + + @override + String homeRainTrendUpdated(String time) { + return '更新 $time'; + } @override - String get reportDetailReplay => '重播'; + String get homeRainTrendNoData => '無資料'; @override - String get navMore => '更多'; + String get homeRainTrendScattered => '可能會有零星降雨'; @override - String get appLogs => 'App 日誌'; + String get homeRainTrendLightSustained => '未來 1 小時會有持續小雨'; @override - String get changelogTitle => '更新日誌'; + String homeRainTrendLightStopping(int minutes) { + return '預計 $minutes 分鐘後停止下小雨'; + } @override - String get changelogEmpty => '目前沒有更新日誌'; + String get homeRainTrendHeavySustained => '未來 1 小時會有持續大雨'; @override - String get changelogTypePrerelease => '公測'; + String homeRainTrendHeavyStopping(int minutes) { + return '預計 $minutes 分鐘後停止下大雨'; + } @override - String get changelogTypeStable => '正式'; + String get mapLayers => '圖層'; @override - String get changelogCurrentVersion => '目前版本'; + String get mapLayerOrderTitle => '調整圖層順序'; @override - String get changelogVersionDetails => '版本資訊'; + String get mapLayerOrderReset => '回復預設順序'; @override - String get changelogBodyEmpty => '此版本沒有說明。'; + String get mapLayerRadar => '雷達合成回波圖'; @override - String get mapPlaceholderDisabled => '地圖(暫時停用)'; + String get mapLayerSatellite => 'ひまわり 紅外線(B13)'; @override - String get moreSectionRegion => '地區'; + String get mapLayerSatelliteB01 => 'ひまわり 可見光-藍(B01)'; @override - String get moreSectionNotify => '通知'; + String get mapLayerSatelliteB02 => 'ひまわり 可見光-綠(B02)'; @override - String get moreSectionDisplay => '顯示'; + String get mapLayerSatelliteB03 => 'ひまわり 可見光-紅(B03)'; @override - String get regionManageTitle => '常用地區'; + String get mapLayerSatelliteB04 => 'ひまわり 近紅外(B04)'; @override - String get regionAddButton => '新增地區'; + String get mapLayerSatelliteB05 => 'ひまわり 近紅外(B05)'; @override - String get regionEmpty => '尚未新增常用地區'; + String get mapLayerSatelliteB06 => 'ひまわり 近紅外(B06)'; @override - String get regionSelectTitle => '選擇地區'; + String get mapLayerSatelliteB07 => 'ひまわり 短波紅外(B07)'; @override - String regionSelectCount(int count, int max) { - return '已選 $count/$max'; - } + String get mapLayerSatelliteB08 => 'ひまわり 上層水氣(B08)'; @override - String regionSelectFull(int max) { - return '最多只能選擇 $max 個地區'; - } + String get mapLayerSatelliteB09 => 'ひまわり 中層水氣(B09)'; @override - String get regionEdit => '修改'; + String get mapLayerSatelliteB10 => 'ひまわり 低層水氣(B10)'; @override - String get moreSectionAdvanced => '進階'; + String get mapLayerSatelliteB11 => 'ひまわり 二氧化硫/雲相(B11)'; @override - String get moreDeveloper => '偵錯資訊'; + String get mapLayerSatelliteB12 => 'ひまわり 臭氧(B12)'; @override - String get experimentalFeatures => '實驗性功能'; + String get mapLayerSatelliteB13 => 'ひまわり 紅外線(B13)'; @override - String get moreSectionLinks => '相關連結'; + String get mapLayerSatelliteB14 => 'ひまわり 長波紅外線(B14)'; @override - String get moreCwaEew => '中央氣象署強震即時警報'; + String get mapLayerSatelliteB15 => 'ひまわり 長波紅外線(B15)'; @override - String get moreTremReport => 'TREM 偵測報告'; + String get mapLayerSatelliteB16 => 'ひまわり 二氧化碳(B16)'; @override - String get moreServerStatus => '伺服器狀態'; + String get mapLayerSatelliteTruecolor => 'ひまわり 真彩色'; @override - String get moreAnnouncements => '公告'; + String get mapLayerSatelliteNaturalcolor => 'ひまわり 自然色'; @override - String get moreDiscord => 'Discord 社群'; + String get mapLayerSatelliteAsh => 'ひまわり 火山灰'; @override - String get moreNotifyLog => 'DPIP 通知發送記錄'; + String get mapLayerSatelliteDust => 'ひまわり 沙塵'; @override - String get moreLinkOpenFailed => '無法開啟連結'; + String get mapLayerSatelliteAirmass => 'ひまわり 氣團'; @override - String get weatherDynamicState => '天氣動態狀態'; + String get mapLayerSatelliteNightmicrophysics => 'ひまわり 夜間微物理'; @override - String get weatherDynamicStateSubtitle => '覆寫主頁背景天氣'; + String get mapLayerSatelliteWatervapor => 'ひまわり 水氣'; @override - String get weatherModeAuto => '自動'; + String get mapLayerSatelliteBtdSplit => 'ひまわり 分割視窗'; @override - String get weatherModeClear => '晴天'; + String get mapLayerSatelliteBtdFog => 'ひまわり 夜間霧'; @override - String get weatherModeRain => '雨天'; + String get mapLayerSatelliteBtdWvirw => 'ひまわり 過衝雲頂'; @override - String get weatherModeFog => '大霧'; + String get mapLayerSatelliteBtdSo2 => 'ひまわり 二氧化硫/雲相'; @override - String get weatherModeThunderstorm => '雷暴'; + String get mapLayerSatelliteBtdCo2 => 'ひまわり 卷雲/雲高'; @override - String get commonLoading => '載入中…'; + String get mapLayerSatelliteBtdOzone => 'ひまわり 對流層頂'; @override - String get commonRetry => '重試'; + String get mapLayerSatelliteCloudtop => 'ひまわり 雲頂溫度'; @override - String get commonError => '發生錯誤'; + String get mapLayerSatelliteCloudmask => 'ひまわり 雲遮罩'; @override - String get commonFetchFailed => '無法獲取資料,請稍後重試'; + String get mapLayerSatelliteSst => 'ひまわり 海表溫度'; @override - String get commonEmpty => '沒有資料'; + String get mapLayerSatelliteNdvi => 'ひまわり 植生指數'; @override - String get feedConnecting => '連接中…'; + String get mapLayerSatelliteNdwi => 'ひまわり 水體指數'; @override - String get feedStale => '資料可能已過期'; + String get mapLayerSatelliteMndwi => 'ひまわり 改良水體指數'; @override - String get feedOffline => '連接中斷'; + String get mapLayerSatelliteGlobalOutline => '國界'; @override - String get eewTitle => '地震速報'; + String get mapLayerSatelliteRgbComposite => 'RGB 合成(JMA 配方)'; @override - String get eewNone => '目前沒有地震速報'; + String get mapLayerSatelliteCloudClear => '晴空'; @override - String eewSummary(String magnitude, String depth) { - return '規模 $magnitude・深度 $depth 公里'; - } + String get mapLayerSatelliteCloudProbablyClear => '可能晴空'; @override - String get regionNationwide => '全國'; + String get mapLayerSatelliteCloudProbablyCloudy => '可能有雲'; @override - String get regionCurrent => '所在地'; + String get mapLayerSatelliteCloudCloudy => '有雲'; @override - String get regionCurrentUnavailable => '無法取得所在地位置資訊'; + String get mapLayerSatelliteTransparentWarm => '晴空(暖端) = 透明,顯示底圖'; @override - String get weatherPrecipitation => '降水量'; + String get mapLayerSatelliteTransparentReflectance => '低反射率/夜間 = 透明,顯示底圖'; @override - String get weatherHumidity => '濕度'; + String get mapLayerSatelliteTransparentZero => '零差值 = 透明(無訊號)'; @override - String get homeForecastTitle => '24小時預報'; + String get mapLayerSatelliteTransparentNight => '夜間 = 透明,顯示底圖'; @override - String homeForecastHighLow(String high, String low) { - return '高 $high° · 低 $low°'; - } + String get mapLayerSatelliteTransparentNoData => '無資料(陸地) = 透明'; @override - String homeForecastPop(String pop) { - return '$pop%'; - } + String get mapLayerSatelliteTransparentNoVegetation => '< 0.1 = 透明(無植被)'; @override - String homeForecastFeelsLike(String temp) { - return '體感 $temp°'; - } + String get mapLayerSatelliteTransparentNoWater => '≤ 0 = 透明(無水體)'; @override - String homeForecastHumidity(String value) { - return '濕度 $value%'; - } + String get mapLayerSatelliteTransparentClear => '晴空 = 透明,顯示底圖'; @override - String homeForecastWind(String direction, String level) { - return '$direction · $level 級'; - } + String get mapLayerStyleSection => '顯示樣式'; @override - String get homeForecastUnavailable => '選擇地區後可查看預報'; + String get mapLayerStyleTooltip => '顯示樣式'; @override - String get homeForecastEmpty => '目前沒有預報資料'; + String get mapLayerStyleGray => '灰階(JMA)'; @override - String get homeActiveEventsTitle => '生效中事件'; + String get mapLayerStyleGrayTooltip => '氣象廳灰階慣例:溫度越低越白'; @override - String get homeActiveEventsEmpty => '目前沒有生效中的事件'; + String get mapLayerStyleJma => '雲頂強調(JMA)'; @override - String get homeRainTrendTitle => '近 1 小時降雨趨勢'; + String get mapLayerStyleJmaTooltip => '灰階為底,−40 °C 以下上色,凸顯雲頂高度'; @override - String get mapLayers => '圖層'; + String get mapLayerStyleBd => 'Dvorak BD'; @override - String get mapLayerRadar => '雷達合成回波圖'; + String get mapLayerStyleBdTooltip => 'Dvorak BD 曲線——熱帶氣旋強度分析的階梯灰階'; @override - String get mapLayerSatellite => 'ひまわり 紅外線圖'; + String get mapLayerQpesums => '未來 1 小時降水預報'; @override String get mapLayerLightning => '閃電'; @@ -3218,9 +4246,23 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapTimelineNow => '現在'; + @override + String get mapTimelinePast => '歷史'; + + @override + String get mapTimelineFuture => '未來'; + @override String get mapTimelineObserved => '觀測'; + @override + String get mapTimelineForecast => '預報'; + + @override + String mapTimelineDataTime(String time) { + return '資料時間 $time'; + } + @override String get notifySettingsMenu => '通知設定'; @@ -3434,6 +4476,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapNavRadar => '雷達'; + @override + String get mapNavQpesums => '預報'; + @override String get mapNavSatellite => '衛星'; @@ -3683,6 +4728,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { return '資料時間\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '強震監視器'; @@ -3746,6 +4797,70 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get disasterMapOverlayShelterTooltip => '顯示避難收容場所'; + @override + String get dpmOpenInMaps => '開啟地圖'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app(預設)'; + } + + @override + String get mapAppCopyCoordinates => '複製座標'; + + @override + String get mapAppCoordinatesCopied => '已複製座標'; + + @override + String mapAppOpenFailed(String app) { + return '無法開啟 $app'; + } + + @override + String get mapAppCallFailed => '此裝置無法撥打電話'; + + @override + String get mapOverlaySectionReference => '參考圖層'; + + @override + String get mapLayerCategoryEarthquake => '地震'; + + @override + String get mapLayerCategoryTyphoon => '颱風'; + + @override + String get mapLayerCategoryWeather => '氣象觀測'; + + @override + String get mapLayerCategorySatellite => '衛星'; + + @override + String get mapLayerCategoryRadar => '雷達'; + + @override + String get mapLayerCategoryLife => '生活'; + + @override + String get mapLayerCategoryForecast => '數值預報'; + + @override + String get mapOverlaySectionMap => '地圖'; + + @override + String get rainIntervalSection => '統計時間'; + + @override + String get mapTownLabels => '鄉鎮名稱'; + + @override + String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -3885,6 +5000,16 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { return '熱帶性低氣壓 TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => '輕度颱風'; @@ -3915,6 +5040,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapMyLocation => '我的位置'; + @override + String get mapResetNorth => '回到北方'; + @override String get typhoonLegendCircle15 => '七級風暴風圈'; @@ -3922,121 +5050,217 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get typhoonLegendCircleAvg => '平均圓'; @override - String get typhoonLegendCircle25 => '十級風暴風圈'; + String get typhoonLegendCircle25 => '十級風暴風圈'; + + @override + String typhoonStormRadii(String ne, String se, String sw, String nw) { + return '東北 $ne · 東南 $se · 西南 $sw · 西北 $nw km'; + } + + @override + String typhoonTimeChip(String day, String hour) { + return '$day日$hour時'; + } + + @override + String get typhoonLegendProbability => '侵襲機率'; + + @override + String get typhoonLegendWarningAreas => '警報區域'; + + @override + String get typhoonOverlayMenuTooltip => '颱風圖層選項'; + + @override + String get typhoonOverlaySectionStorm => '暴風圈'; + + @override + String get typhoonOverlaySectionExtra => '覆蓋層'; + + @override + String get typhoonOverlayStormBandSubtitle => '含平均圓'; + + @override + String get typhoonOverlayProbabilityHint => '會隱藏預測圓錐'; + + @override + String get typhoonOverlayProbabilityTooltip => '顯示侵襲機率(會隱藏預測圓錐)'; + + @override + String get typhoonOverlayWarningTooltip => '標示警報區域縣市'; + + @override + String get typhoonOverlayStormL7Tooltip => '七級暴風圈+平均圓(紫色)'; + + @override + String get typhoonOverlayStormL10Tooltip => '十級暴風圈+平均圓(黃色)'; + + @override + String get typhoonOverlaySectionWeather => '天氣底圖'; + + @override + String get typhoonOverlayWeatherNone => '無'; + + @override + String get typhoonOverlayWeatherHint => '對齊報文時間'; + + @override + String get typhoonOverlayWeatherNoneTooltip => '不疊雷達或紅外線'; + + @override + String get typhoonOverlayWeatherRadarTooltip => '雷達回波(對齊颱風報文時間)'; + + @override + String get typhoonOverlayWeatherSatelliteTooltip => '紅外線(對齊颱風報文時間)'; + + @override + String get typhoonWarningTitle => '颱風警報'; + + @override + String typhoonWarningAreas(String areas) { + return '警戒區域:$areas'; + } + + @override + String get typhoonTrackDetail => '路徑詳情'; + + @override + String get typhoonHistoryTitle => '資料時間'; + + @override + String get typhoonHistoryLive => '即時'; + + @override + String get typhoonSatelliteTitle => '衛星雲圖'; + + @override + String get typhoonOverlayForecastCallouts => '預測點資訊'; + + @override + String get typhoonOverlayForecastCalloutsTooltip => '放大時顯示預測點詳細卡片'; + + @override + String get dpmFilterSectionRestroom => '場所類型'; + + @override + String get dpmFilterSectionRestroomType => '廁所類型'; + + @override + String get dpmFilterSectionShelter => '避難所災害類型'; + + @override + String get dpmDisasterFlood => '水災'; + + @override + String get dpmDisasterEarthquake => '震災'; @override - String typhoonStormRadii(String ne, String se, String sw, String nw) { - return '東北 $ne · 東南 $se · 西南 $sw · 西北 $nw km'; - } + String get dpmDisasterLandslide => '土石流'; @override - String typhoonTimeChip(String day, String hour) { - return '$day日$hour時'; - } + String get dpmDisasterTsunami => '海嘯'; @override - String get typhoonLegendProbability => '侵襲機率'; + String get dpmDisasterSlope => '坡地災害'; @override - String get typhoonLegendWarningAreas => '警報區域'; + String get dpmDisasterNuclear => '核子事故'; @override - String get typhoonOverlayMenuTooltip => '颱風圖層選項'; + String get skyTime => '天空時間'; @override - String get typhoonOverlaySectionStorm => '暴風圈'; + String get skyTimeAuto => '自動'; @override - String get typhoonOverlaySectionExtra => '覆蓋層'; + String get skyTimeDawn => '黎明'; @override - String get typhoonOverlayStormBandSubtitle => '含平均圓'; + String get skyTimeSunrise => '日出'; @override - String get typhoonOverlayProbabilityHint => '會隱藏預測圓錐'; + String get skyTimeMorning => '上午'; @override - String get typhoonOverlayProbabilityTooltip => '顯示侵襲機率(會隱藏預測圓錐)'; + String get skyTimeNoon => '正午'; @override - String get typhoonOverlayWarningTooltip => '標示警報區域縣市'; + String get skyTimeAfternoon => '下午'; @override - String get typhoonOverlayStormL7Tooltip => '七級暴風圈+平均圓(紫色)'; + String get skyTimeGolden => '黃金時刻'; @override - String get typhoonOverlayStormL10Tooltip => '十級暴風圈+平均圓(黃色)'; + String get skyTimeSunset => '日落'; @override - String get typhoonOverlaySectionWeather => '天氣底圖'; + String get skyTimeDusk => '暮色'; @override - String get typhoonOverlayWeatherNone => '無'; + String get skyTimeNight => '夜晚'; @override - String get typhoonOverlayWeatherHint => '對齊報文時間'; + String get weatherModeCloudy => '多雲'; @override - String get typhoonOverlayWeatherNoneTooltip => '不疊雷達或紅外線'; + String get weatherModeOvercast => '陰天'; @override - String get typhoonOverlayWeatherRadarTooltip => '雷達回波(對齊颱風報文時間)'; + String get weatherModeSnow => '下雪'; @override - String get typhoonOverlayWeatherSatelliteTooltip => '紅外線(對齊颱風報文時間)'; + String get weatherModeSand => '沙塵'; @override - String get typhoonWarningTitle => '颱風警報'; + String get radarScanRange => '顯示掃描範圍'; @override - String typhoonWarningAreas(String areas) { - return '警戒區域:$areas'; - } + String get radarScanRangeSubtitle => '標示四座雷達實際觀測到的範圍。'; @override - String get typhoonTrackDetail => '路徑詳情'; + String get radarScanRangeHint => '框外空白代表未觀測'; @override - String get typhoonHistoryTitle => '資料時間'; + String get radarOverlayMenuTooltip => '雷達圖層選項'; @override - String get typhoonHistoryLive => '即時'; + String get radarCountyOutline => '縣市界線'; @override - String get typhoonSatelliteTitle => '衛星雲圖'; + String get radarGlobalOutline => '國界'; @override - String get typhoonOverlayForecastCallouts => '預測點資訊'; + String get radarGlobalOutlineHint => '各國國界外框'; @override - String get typhoonOverlayForecastCalloutsTooltip => '放大時顯示預測點詳細卡片'; + String get radarCountyOutlineHint => '畫在回波之上'; @override - String get dpmFilterSectionRestroom => '場所類型'; + String get radarCountyOutlineSubtitle => '讓縣市界線在雷達回波下仍然清楚。'; @override - String get dpmFilterSectionRestroomType => '廁所類型'; + String get radarTownOutline => '鄉鎮界線'; @override - String get dpmFilterSectionShelter => '避難所災害類型'; + String get radarTownOutlineHint => '較細的分區'; @override - String get dpmDisasterFlood => '水災'; + String get radarTownOutlineSubtitle => '讓鄉鎮界線在雷達回波下仍然清楚。'; @override - String get dpmDisasterEarthquake => '震災'; + String get qpesumsOverlayMenuTooltip => '定量降水預報圖層選項'; @override - String get dpmDisasterLandslide => '土石流'; + String get windForecastOverlayMenuTooltip => '風場預報圖層選項'; @override - String get dpmDisasterTsunami => '海嘯'; + String get windForecastCountyOutlineHint => '繪製於風場之上'; @override - String get dpmDisasterSlope => '坡地災害'; + String get windForecastGlobalOutlineHint => '各國國界外框'; @override - String get dpmDisasterNuclear => '核子事故'; + String get windForecastTownOutlineHint => '更細的網格'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -4047,7 +5271,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get languageName => '繁體中文(臺灣)'; @override - String get navHome => '主頁'; + String get navHome => '首頁'; @override String get navEvents => '事件'; @@ -4283,269 +5507,481 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { } @override - String get reportDetailLocalFelt => '小區域有感地震'; + String get reportDetailLocalFelt => '小區域有感地震'; + + @override + String get reportDetailInfo => '詳細資訊'; + + @override + String get reportDetailOriginTime => '發震時間'; + + @override + String get reportDetailEpicenter => '震央座標'; + + @override + String get reportDetailMagnitude => '地震規模'; + + @override + String get reportDetailDepth => '震源深度'; + + @override + String get reportDetailAreaIntensity => '各地震度'; + + @override + String get reportDetailLocalIntensity => '所在地的震度'; + + @override + String get reportDetailLocalIntensityUnavailable => '沒有震度訊息'; + + @override + String get reportDetailSortByIntensity => '依震度排序'; + + @override + String get reportDetailSortByCounty => '依縣市排序'; + + @override + String get reportDetailImage => '地震報告圖'; + + @override + String get reportDetailImageUnavailable => '報告圖尚未提供'; + + @override + String get reportDetailOpenReport => '報告頁面'; + + @override + String get reportDetailReplay => '重播'; + + @override + String get navMore => '更多'; + + @override + String get appLogs => 'App 日誌'; + + @override + String get changelogTitle => '更新日誌'; + + @override + String get changelogEmpty => '目前沒有更新日誌'; + + @override + String get changelogTypePrerelease => '公測'; + + @override + String get changelogTypeStable => '正式'; + + @override + String get changelogCurrentVersion => '目前版本'; + + @override + String get changelogVersionDetails => '版本資訊'; + + @override + String get changelogBodyEmpty => '此版本沒有說明。'; + + @override + String get mapPlaceholderDisabled => '地圖(暫時停用)'; + + @override + String get moreSectionRegion => '地區'; + + @override + String get moreSectionNotify => '通知'; + + @override + String get moreSectionDisplay => '顯示'; + + @override + String get regionManageTitle => '常用地區'; + + @override + String get regionAddButton => '新增地區'; + + @override + String get regionEmpty => '尚未新增常用地區'; + + @override + String get regionSelectTitle => '選擇地區'; + + @override + String regionSelectCount(int count, int max) { + return '已選 $count/$max'; + } + + @override + String regionSelectFull(int max) { + return '最多只能選擇 $max 個地區'; + } + + @override + String get regionEdit => '修改'; + + @override + String get moreSectionAdvanced => '進階'; + + @override + String get moreDeveloper => '除錯資訊'; + + @override + String get experimentalFeatures => '實驗性功能'; + + @override + String get moreSectionLinks => '相關連結'; + + @override + String get moreCwaEew => '中央氣象署強震即時警報'; + + @override + String get moreTremReport => 'TREM 檢知報告'; + + @override + String get moreServerStatus => '伺服器狀態'; + + @override + String get moreAnnouncements => '公告'; + + @override + String get moreDiscord => 'Discord 社群'; + + @override + String get moreNotifyLog => 'DPIP 通知發送記錄'; + + @override + String get moreLinkOpenFailed => '無法開啟連結'; + + @override + String get weatherDynamicState => '天氣動態狀態'; + + @override + String get weatherDynamicStateSubtitle => '覆寫主頁背景天氣'; + + @override + String get weatherModeAuto => '自動'; + + @override + String get weatherModeClear => '晴天'; + + @override + String get weatherModeRain => '雨天'; + + @override + String get weatherModeFog => '大霧'; + + @override + String get weatherModeThunderstorm => '雷雨'; + + @override + String get commonLoading => '載入中…'; + + @override + String get commonRetry => '重試'; + + @override + String get commonError => '發生錯誤'; + + @override + String get commonFetchFailed => '無法獲取資料,請稍後重試'; + + @override + String get commonEmpty => '沒有資料'; + + @override + String get feedConnecting => '連線中…'; + + @override + String get feedStale => '資料可能已過期'; + + @override + String get feedOffline => '連線中斷'; + + @override + String get eewTitle => '地震速報'; + + @override + String get eewNone => '目前沒有地震速報'; + + @override + String eewSummary(String magnitude, String depth) { + return '規模 $magnitude・深度 $depth 公里'; + } + + @override + String get regionNationwide => '全國'; + + @override + String get regionCurrent => '所在地'; + + @override + String get regionCurrentUnavailable => '無法取得所在地位置資訊'; + + @override + String get weatherPrecipitation => '降水量'; @override - String get reportDetailInfo => '詳細資訊'; + String get weatherHumidity => '濕度'; @override - String get reportDetailOriginTime => '發震時間'; + String get homeForecastTitle => '24小時預報'; @override - String get reportDetailEpicenter => '震央座標'; + String homeForecastHighLow(String high, String low) { + return '高 $high° · 低 $low°'; + } @override - String get reportDetailMagnitude => '地震規模'; + String homeForecastPop(String pop) { + return '$pop%'; + } @override - String get reportDetailDepth => '震源深度'; + String homeForecastFeelsLike(String temp) { + return '體感 $temp°'; + } @override - String get reportDetailAreaIntensity => '各地震度'; + String homeForecastHumidity(String value) { + return '濕度 $value%'; + } @override - String get reportDetailLocalIntensity => '所在地的震度'; + String homeForecastWind(String direction, String level) { + return '$direction · $level 級'; + } @override - String get reportDetailLocalIntensityUnavailable => '沒有震度訊息'; + String get homeForecastUnavailable => '選擇鄉鎮後可查看預報'; @override - String get reportDetailSortByIntensity => '依震度排序'; + String get homeForecastEmpty => '目前沒有預報資料'; @override - String get reportDetailSortByCounty => '依縣市排序'; + String get homeActiveEventsTitle => '生效中事件'; @override - String get reportDetailImage => '地震報告圖'; + String get homeActiveEventsEmpty => '目前沒有生效中的事件'; @override - String get reportDetailImageUnavailable => '報告圖尚未提供'; + String get homeRainTrendTitle => '近 1 小時降水趨勢'; @override - String get reportDetailOpenReport => '報告頁面'; + String homeRainTrendMinute(int minute) { + return '$minute分'; + } @override - String get reportDetailReplay => '重播'; + String homeRainTrendUpdated(String time) { + return '更新 $time'; + } @override - String get navMore => '更多'; + String get homeRainTrendNoData => '無資料'; @override - String get appLogs => 'App 日誌'; + String get homeRainTrendScattered => '可能會有零星降雨'; @override - String get changelogTitle => '更新日誌'; + String get homeRainTrendLightSustained => '未來 1 小時會有持續小雨'; @override - String get changelogEmpty => '目前沒有更新日誌'; + String homeRainTrendLightStopping(int minutes) { + return '預計 $minutes 分鐘後停止下小雨'; + } @override - String get changelogTypePrerelease => '公測'; + String get homeRainTrendHeavySustained => '未來 1 小時會有持續大雨'; @override - String get changelogTypeStable => '正式'; + String homeRainTrendHeavyStopping(int minutes) { + return '預計 $minutes 分鐘後停止下大雨'; + } @override - String get changelogCurrentVersion => '目前版本'; + String get mapLayers => '圖層'; @override - String get changelogVersionDetails => '版本資訊'; + String get mapLayerOrderTitle => '調整圖層順序'; @override - String get changelogBodyEmpty => '此版本沒有說明。'; + String get mapLayerOrderReset => '回復預設順序'; @override - String get mapPlaceholderDisabled => '地圖(暫時停用)'; + String get mapLayerRadar => '雷達合成回波圖'; @override - String get moreSectionRegion => '地區'; + String get mapLayerSatellite => 'ひまわり 紅外線(B13)'; @override - String get moreSectionNotify => '通知'; + String get mapLayerSatelliteB01 => 'ひまわり 可見光-藍(B01)'; @override - String get moreSectionDisplay => '顯示'; + String get mapLayerSatelliteB02 => 'ひまわり 可見光-綠(B02)'; @override - String get regionManageTitle => '常用地區'; + String get mapLayerSatelliteB03 => 'ひまわり 可見光-紅(B03)'; @override - String get regionAddButton => '新增地區'; + String get mapLayerSatelliteB04 => 'ひまわり 近紅外(B04)'; @override - String get regionEmpty => '尚未新增常用地區'; + String get mapLayerSatelliteB05 => 'ひまわり 近紅外(B05)'; @override - String get regionSelectTitle => '選擇地區'; + String get mapLayerSatelliteB06 => 'ひまわり 近紅外(B06)'; @override - String regionSelectCount(int count, int max) { - return '已選 $count/$max'; - } + String get mapLayerSatelliteB07 => 'ひまわり 短波紅外(B07)'; @override - String regionSelectFull(int max) { - return '最多只能選擇 $max 個地區'; - } + String get mapLayerSatelliteB08 => 'ひまわり 上層水氣(B08)'; @override - String get regionEdit => '修改'; + String get mapLayerSatelliteB09 => 'ひまわり 中層水氣(B09)'; @override - String get moreSectionAdvanced => '進階'; + String get mapLayerSatelliteB10 => 'ひまわり 低層水氣(B10)'; @override - String get moreDeveloper => '除錯資訊'; + String get mapLayerSatelliteB11 => 'ひまわり 二氧化硫/雲相(B11)'; @override - String get experimentalFeatures => '實驗性功能'; + String get mapLayerSatelliteB12 => 'ひまわり 臭氧(B12)'; @override - String get moreSectionLinks => '相關連結'; + String get mapLayerSatelliteB13 => 'ひまわり 紅外線(B13)'; @override - String get moreCwaEew => '中央氣象署強震即時警報'; + String get mapLayerSatelliteB14 => 'ひまわり 長波紅外線(B14)'; @override - String get moreTremReport => 'TREM 檢知報告'; + String get mapLayerSatelliteB15 => 'ひまわり 長波紅外線(B15)'; @override - String get moreServerStatus => '伺服器狀態'; + String get mapLayerSatelliteB16 => 'ひまわり 二氧化碳(B16)'; @override - String get moreAnnouncements => '公告'; + String get mapLayerSatelliteTruecolor => 'ひまわり 真彩色'; @override - String get moreDiscord => 'Discord 社群'; + String get mapLayerSatelliteNaturalcolor => 'ひまわり 自然色'; @override - String get moreNotifyLog => 'DPIP 通知發送記錄'; + String get mapLayerSatelliteAsh => 'ひまわり 火山灰'; @override - String get moreLinkOpenFailed => '無法開啟連結'; + String get mapLayerSatelliteDust => 'ひまわり 沙塵'; @override - String get weatherDynamicState => '天氣動態狀態'; + String get mapLayerSatelliteAirmass => 'ひまわり 氣團'; @override - String get weatherDynamicStateSubtitle => '覆寫主頁背景天氣'; + String get mapLayerSatelliteNightmicrophysics => 'ひまわり 夜間微物理'; @override - String get weatherModeAuto => '自動'; + String get mapLayerSatelliteWatervapor => 'ひまわり 水氣'; @override - String get weatherModeClear => '晴天'; + String get mapLayerSatelliteBtdSplit => 'ひまわり 分割視窗'; @override - String get weatherModeRain => '雨天'; + String get mapLayerSatelliteBtdFog => 'ひまわり 夜間霧'; @override - String get weatherModeFog => '大霧'; + String get mapLayerSatelliteBtdWvirw => 'ひまわり 過衝雲頂'; @override - String get weatherModeThunderstorm => '雷雨'; + String get mapLayerSatelliteBtdSo2 => 'ひまわり 二氧化硫/雲相'; @override - String get commonLoading => '載入中…'; + String get mapLayerSatelliteBtdCo2 => 'ひまわり 卷雲/雲高'; @override - String get commonRetry => '重試'; + String get mapLayerSatelliteBtdOzone => 'ひまわり 對流層頂'; @override - String get commonError => '發生錯誤'; + String get mapLayerSatelliteCloudtop => 'ひまわり 雲頂溫度'; @override - String get commonFetchFailed => '無法獲取資料,請稍後重試'; + String get mapLayerSatelliteCloudmask => 'ひまわり 雲遮罩'; @override - String get commonEmpty => '沒有資料'; + String get mapLayerSatelliteSst => 'ひまわり 海表溫度'; @override - String get feedConnecting => '連線中…'; + String get mapLayerSatelliteNdvi => 'ひまわり 植生指數'; @override - String get feedStale => '資料可能已過期'; + String get mapLayerSatelliteNdwi => 'ひまわり 水體指數'; @override - String get feedOffline => '連線中斷'; + String get mapLayerSatelliteMndwi => 'ひまわり 改良水體指數'; @override - String get eewTitle => '地震速報'; + String get mapLayerSatelliteGlobalOutline => '國界'; @override - String get eewNone => '目前沒有地震速報'; + String get mapLayerSatelliteRgbComposite => 'RGB 合成(JMA 配方)'; @override - String eewSummary(String magnitude, String depth) { - return '規模 $magnitude・深度 $depth 公里'; - } + String get mapLayerSatelliteCloudClear => '晴空'; @override - String get regionNationwide => '全國'; + String get mapLayerSatelliteCloudProbablyClear => '可能晴空'; @override - String get regionCurrent => '所在地'; + String get mapLayerSatelliteCloudProbablyCloudy => '可能有雲'; @override - String get regionCurrentUnavailable => '無法取得所在地位置資訊'; + String get mapLayerSatelliteCloudCloudy => '有雲'; @override - String get weatherPrecipitation => '降水量'; + String get mapLayerSatelliteTransparentWarm => '晴空(暖端) = 透明,顯示底圖'; @override - String get weatherHumidity => '濕度'; + String get mapLayerSatelliteTransparentReflectance => '低反射率/夜間 = 透明,顯示底圖'; @override - String get homeForecastTitle => '24小時預報'; + String get mapLayerSatelliteTransparentZero => '零差值 = 透明(無訊號)'; @override - String homeForecastHighLow(String high, String low) { - return '高 $high° · 低 $low°'; - } + String get mapLayerSatelliteTransparentNight => '夜間 = 透明,顯示底圖'; @override - String homeForecastPop(String pop) { - return '$pop%'; - } + String get mapLayerSatelliteTransparentNoData => '無資料(陸地) = 透明'; @override - String homeForecastFeelsLike(String temp) { - return '體感 $temp°'; - } + String get mapLayerSatelliteTransparentNoVegetation => '< 0.1 = 透明(無植被)'; @override - String homeForecastHumidity(String value) { - return '濕度 $value%'; - } + String get mapLayerSatelliteTransparentNoWater => '≤ 0 = 透明(無水體)'; @override - String homeForecastWind(String direction, String level) { - return '$direction · $level 級'; - } + String get mapLayerSatelliteTransparentClear => '晴空 = 透明,顯示底圖'; @override - String get homeForecastUnavailable => '選擇鄉鎮後可查看預報'; + String get mapLayerStyleSection => '顯示樣式'; @override - String get homeForecastEmpty => '目前沒有預報資料'; + String get mapLayerStyleTooltip => '顯示樣式'; @override - String get homeActiveEventsTitle => '生效中事件'; + String get mapLayerStyleGray => '灰階(JMA)'; @override - String get homeActiveEventsEmpty => '目前沒有生效中的事件'; + String get mapLayerStyleGrayTooltip => '氣象廳灰階慣例:溫度越低越白'; @override - String get homeRainTrendTitle => '近 1 小時降雨趨勢'; + String get mapLayerStyleJma => '雲頂強調(JMA)'; @override - String get mapLayers => '圖層'; + String get mapLayerStyleJmaTooltip => '灰階為底,−40 °C 以下上色,凸顯雲頂高度'; @override - String get mapLayerRadar => '雷達合成回波圖'; + String get mapLayerStyleBd => 'Dvorak BD'; + + @override + String get mapLayerStyleBdTooltip => 'Dvorak BD 曲線——熱帶氣旋強度分析的階梯灰階'; @override - String get mapLayerSatellite => 'ひまわり 紅外線圖'; + String get mapLayerQpesums => '未來 1 小時降水預報'; @override String get mapLayerLightning => '閃電'; @@ -4563,9 +5999,23 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapTimelineNow => '現在'; + @override + String get mapTimelinePast => '歷史'; + + @override + String get mapTimelineFuture => '未來'; + @override String get mapTimelineObserved => '觀測'; + @override + String get mapTimelineForecast => '預報'; + + @override + String mapTimelineDataTime(String time) { + return '資料時間 $time'; + } + @override String get notifySettingsMenu => '通知設定'; @@ -4779,6 +6229,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapNavRadar => '雷達'; + @override + String get mapNavQpesums => '預報'; + @override String get mapNavSatellite => '衛星'; @@ -5028,6 +6481,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '資料時間\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '強震監視器'; @@ -5091,6 +6550,70 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get disasterMapOverlayShelterTooltip => '顯示避難收容場所'; + @override + String get dpmOpenInMaps => '開啟地圖'; + + @override + String get mapAppGoogleMaps => 'Google Maps'; + + @override + String get mapAppAppleMaps => 'Apple Maps'; + + @override + String mapAppDefault(String app) { + return '$app(預設)'; + } + + @override + String get mapAppCopyCoordinates => '複製座標'; + + @override + String get mapAppCoordinatesCopied => '已複製座標'; + + @override + String mapAppOpenFailed(String app) { + return '無法開啟 $app'; + } + + @override + String get mapAppCallFailed => '此裝置無法撥打電話'; + + @override + String get mapOverlaySectionReference => '參考圖層'; + + @override + String get mapLayerCategoryEarthquake => '地震'; + + @override + String get mapLayerCategoryTyphoon => '颱風'; + + @override + String get mapLayerCategoryWeather => '氣象觀測'; + + @override + String get mapLayerCategorySatellite => '衛星'; + + @override + String get mapLayerCategoryRadar => '雷達'; + + @override + String get mapLayerCategoryLife => '生活'; + + @override + String get mapLayerCategoryForecast => '數值預報'; + + @override + String get mapOverlaySectionMap => '地圖'; + + @override + String get rainIntervalSection => '統計時間'; + + @override + String get mapTownLabels => '鄉鎮名稱'; + + @override + String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -5230,6 +6753,16 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '熱帶性低氣壓 TD $no'; } + @override + String typhoonTyNo(String no) { + return 'TY $no'; + } + + @override + String typhoonTdNo(String no) { + return 'TD $no'; + } + @override String get typhoonIntensityMild => '輕度颱風'; @@ -5260,6 +6793,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapMyLocation => '我的位置'; + @override + String get mapResetNorth => '回到北方'; + @override String get typhoonLegendCircle15 => '七級風暴風圈'; @@ -5382,4 +6918,100 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get dpmDisasterNuclear => '核子事故'; + + @override + String get skyTime => '天空時間'; + + @override + String get skyTimeAuto => '自動'; + + @override + String get skyTimeDawn => '黎明'; + + @override + String get skyTimeSunrise => '日出'; + + @override + String get skyTimeMorning => '上午'; + + @override + String get skyTimeNoon => '正午'; + + @override + String get skyTimeAfternoon => '下午'; + + @override + String get skyTimeGolden => '黃金時刻'; + + @override + String get skyTimeSunset => '日落'; + + @override + String get skyTimeDusk => '暮色'; + + @override + String get skyTimeNight => '夜晚'; + + @override + String get weatherModeCloudy => '多雲'; + + @override + String get weatherModeOvercast => '陰天'; + + @override + String get weatherModeSnow => '下雪'; + + @override + String get weatherModeSand => '沙塵'; + + @override + String get radarScanRange => '顯示掃描範圍'; + + @override + String get radarScanRangeSubtitle => '標示四座雷達實際觀測到的範圍。'; + + @override + String get radarScanRangeHint => '框外空白代表未觀測'; + + @override + String get radarOverlayMenuTooltip => '雷達圖層選項'; + + @override + String get radarCountyOutline => '縣市界線'; + + @override + String get radarGlobalOutline => '國界'; + + @override + String get radarGlobalOutlineHint => '各國國界外框'; + + @override + String get radarCountyOutlineHint => '畫在回波之上'; + + @override + String get radarCountyOutlineSubtitle => '讓縣市界線在雷達回波下仍然清楚。'; + + @override + String get radarTownOutline => '鄉鎮界線'; + + @override + String get radarTownOutlineHint => '較細的分區'; + + @override + String get radarTownOutlineSubtitle => '讓鄉鎮界線在雷達回波下仍然清楚。'; + + @override + String get qpesumsOverlayMenuTooltip => '定量降水預報圖層選項'; + + @override + String get windForecastOverlayMenuTooltip => '風場預報圖層選項'; + + @override + String get windForecastCountyOutlineHint => '繪製於風場之上'; + + @override + String get windForecastGlobalOutlineHint => '各國國界外框'; + + @override + String get windForecastTownOutlineHint => '更細的網格'; } diff --git a/lib/shared/color_hex.dart b/lib/shared/color_hex.dart index 1469534fa..97d4d31b0 100644 --- a/lib/shared/color_hex.dart +++ b/lib/shared/color_hex.dart @@ -18,3 +18,26 @@ Color? colorFromHexRgb(String hex) { final value = int.tryParse(digits, radix: 16); return value == null ? null : Color(0xFF000000 | value); } + +/// [value] interpolated on a `(at, hexColour)` ramp — the Dart twin of the +/// `interpolate` expression handed to MapLibre, so a value-coloured dot on the +/// map and its reading in the sheet agree by construction rather than by two +/// hand-kept colour tables. +/// +/// Values below the first stop clamp to it; above the last, to it. +Color? rampColor(List<(double, String)> stops, double value) { + if (stops.isEmpty) return null; + if (value <= stops.first.$1) return colorFromHexRgb(stops.first.$2); + if (value >= stops.last.$1) return colorFromHexRgb(stops.last.$2); + for (var i = 0; i < stops.length - 1; i++) { + final (lowAt, lowHex) = stops[i]; + final (highAt, highHex) = stops[i + 1]; + if (value < lowAt || value > highAt) continue; + final low = colorFromHexRgb(lowHex); + final high = colorFromHexRgb(highHex); + if (low == null || high == null) return low ?? high; + final span = highAt - lowAt; + return Color.lerp(low, high, span == 0 ? 0 : (value - lowAt) / span); + } + return colorFromHexRgb(stops.last.$2); +} diff --git a/lib/shared/map/admin_outline.dart b/lib/shared/map/admin_outline.dart new file mode 100644 index 000000000..ba12db3a1 --- /dev/null +++ b/lib/shared/map/admin_outline.dart @@ -0,0 +1,148 @@ +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// An administrative boundary set that can be redrawn over a weather raster. +enum AdminBoundary { + /// 國界 — the world's country borders. The outermost frame; drawn only when + /// explicitly asked for, because on a Taiwan-focused surface the neighbours' + /// outlines are usually noise. + global( + sourceLayer: 'global', + casingLayerId: 'admin-global-outline-casing', + lineLayerId: 'admin-global-outline', + casingWidth: 2.8, + casingOpacity: 0.4, + lineWidth: 1.1, + lineOpacity: 0.9, + ), + + /// 縣市 — the coarse frame. Heavier, because it is the one people navigate by. + county( + sourceLayer: 'city', + casingLayerId: 'admin-county-outline-casing', + lineLayerId: 'admin-county-outline', + casingWidth: 3.0, + casingOpacity: 0.45, + lineWidth: 1.2, + lineOpacity: 0.95, + ), + + /// 鄉鎮 — the fine mesh. Deliberately lighter: at 368 townships a stroke as + /// strong as the county one turns the island into a net and buries the echo + /// it is meant to sit over. + town( + sourceLayer: 'town', + casingLayerId: 'admin-town-outline-casing', + lineLayerId: 'admin-town-outline', + casingWidth: 1.8, + casingOpacity: 0.3, + lineWidth: 0.7, + lineOpacity: 0.7, + ); + + const AdminBoundary({ + required this.sourceLayer, + required this.casingLayerId, + required this.lineLayerId, + required this.casingWidth, + required this.casingOpacity, + required this.lineWidth, + required this.lineOpacity, + }); + + /// The vector tile layer in the base style's source. + final String sourceLayer; + + final String casingLayerId; + final String lineLayerId; + final double casingWidth; + final double casingOpacity; + final double lineWidth; + final double lineOpacity; +} + +/// Administrative borders drawn **over** a weather raster. +/// +/// The base style outlines counties and townships, but a full-saturation raster +/// covers them: radar sits above those borders precisely so the echo is not +/// interrupted, which leaves the map unreadable exactly where the weather is +/// worth looking at. This puts a second, louder set back on top — and because +/// it is a separate layer it can be switched off to get the clean raster back. +/// +/// Each set is drawn as a **casing**: a wide dark line under a narrow light one. +/// That is the standard cartographic answer to "must be legible over anything" — +/// the dark edge separates the border from bright echo, the light core separates +/// it from dark echo, and neither depends on knowing what is underneath. +abstract final class AdminOutline { + AdminOutline._(); + + /// The vector source the base style already declares. + static const String sourceId = 'exptech'; + + /// Wide dark under-stroke, so a border reads against pale echo. + static const String casingColor = '#000000'; + + /// Narrow light core, so it reads against dark echo and the basemap. + static const String lineColor = '#FFFFFF'; + + /// Adds [boundary]'s two strokes. Casing first so the core paints over it. + /// + /// [lineColor] overrides the default light core — the typhoon surface's + /// satellite underlay passes bright yellow there, because on fully-opaque + /// imagery the default white does not survive. [belowLayerId] omitted puts + /// them on top, which is what the radar surface wants; the typhoon surface + /// passes an anchor instead: there the borders still need to clear the + /// echo, but the track and storm bands are the subject and must not be + /// crossed by a white line. + static Future add( + MapLibreMapController controller, + AdminBoundary boundary, { + String lineColor = AdminOutline.lineColor, + String? belowLayerId, + }) async { + await controller.addLineLayer( + sourceId, + boundary.casingLayerId, + LineLayerProperties( + lineColor: casingColor, + lineWidth: boundary.casingWidth, + lineOpacity: boundary.casingOpacity, + lineJoin: 'round', + lineCap: 'round', + ), + sourceLayer: boundary.sourceLayer, + belowLayerId: belowLayerId, + enableInteraction: false, + ); + await controller.addLineLayer( + sourceId, + boundary.lineLayerId, + LineLayerProperties( + lineColor: lineColor, + lineWidth: boundary.lineWidth, + lineOpacity: boundary.lineOpacity, + lineJoin: 'round', + lineCap: 'round', + ), + sourceLayer: boundary.sourceLayer, + belowLayerId: belowLayerId, + enableInteraction: false, + ); + } + + /// Removes [boundary]'s strokes, tolerating a partially-added state. + /// + /// Never touches [sourceId] — it belongs to the base style, and removing it + /// would wipe the map. + static Future remove( + MapLibreMapController controller, + AdminBoundary boundary, + ) async { + for (final id in [boundary.lineLayerId, boundary.casingLayerId]) { + try { + await controller.removeLayer(id); + } catch (_) { + // Not present — nothing to undo. + } + } + } +} diff --git a/lib/shared/map/base_map.dart b/lib/shared/map/base_map.dart index f3ddaab91..4d97fb8e3 100644 --- a/lib/shared/map/base_map.dart +++ b/lib/shared/map/base_map.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:math' show Point; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/logging/log.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -8,8 +10,9 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// Layer-chip band under the safe-area top: outer pad ([AppSpacing.lg]) + chip /// height (~36) + a tight gap. iOS MapLibre already anchors ornaments to the /// safe top, so this is the full Y margin there; Android still needs safe-area -/// padding added on top (see [BaseMap.build]). -const double _layerChipBand = AppSpacing.lg + 36 + AppSpacing.xs; +/// padding added on top (see [BaseMap.build]). MapScaffold reuses it to park its +/// Flutter [MapCompass] in the same spot the native compass occupied. +const double layerChipBand = AppSpacing.lg + 36 + AppSpacing.xs; /// The app's reusable base map — a MapLibre map centred on Taiwan, rendered from /// the ExpTech vector style tinted by [MapColors] for the active brightness. @@ -24,14 +27,22 @@ const double _layerChipBand = AppSpacing.lg + 36 + AppSpacing.xs; /// `false` for a display-only surface like the home backdrop, where the map is /// driven entirely from code and the page's own gestures (tap to open, swipe /// to switch) must pass straight through. Tilt is always off. -class BaseMap extends StatelessWidget { +/// +/// The map is [StatefulWidget] so it can self-heal a failed platform-view +/// mount: after an iOS hot restart the engine can still own the previous +/// run's view id, the framework re-issues it, and `UiKitView` creation fails +/// with `recreating_view` — the map remounts with a fresh id and recovers +/// (see [_BaseMapState]). +class BaseMap extends StatefulWidget { const BaseMap({ super.key, this.onMapCreated, this.onStyleLoaded, this.onMapClick, this.onCameraIdle, + this.onCameraMove, this.interactive = true, + this.compassEnabled = true, this.showUserLocation = true, this.minZoomPreference = defaultMinZoom, this.maxZoomPreference = maxZoom, @@ -89,10 +100,22 @@ class BaseMap extends StatelessWidget { /// that need a fresh `toScreenLocation` projection. final OnCameraIdleCallback? onCameraIdle; + /// Fires on every camera move with the new position — [MapCompass] uses the + /// bearing so the needle tracks rotation live. + final OnCameraMoveCallback? onCameraMove; + /// Whether the user can pan/zoom/rotate the map. `false` makes it display-only /// (all gestures + the compass off) so the surrounding page owns every gesture. final bool interactive; + /// Whether the **native** MapLibre compass shows (when [interactive]). + /// + /// The native compass lives inside the platform view, so any Flutter overlay + /// paints over it. MapScaffold turns it off and draws its own [MapCompass] + /// at the top of the chrome stack instead, keeping north reachable even under + /// screen-space callouts (typhoon forecast tips). + final bool compassEnabled; + /// Whether to draw the device-location puck. /// /// This is MapLibre's **native** location component — `MLNUserLocationAnnot‌‍ @@ -112,6 +135,63 @@ class BaseMap extends StatelessWidget { /// Per-surface zoom ceiling (DPM AED may go to 16). final double maxZoomPreference; + @override + State createState() => _BaseMapState(); +} + +class _BaseMapState extends State { + /// Set once the platform view reports in — the readiness gate for the retry. + MapLibreMapController? _controller; + + /// Bumped to remount the map's platform view after a failed first attempt + /// (see [_scheduleReadinessRetry]). + int _mountAttempt = 0; + + Timer? _readinessTimer; + + @override + void initState() { + super.initState(); + _scheduleReadinessRetry(); + } + + @override + void dispose() { + _readinessTimer?.cancel(); + super.dispose(); + } + + /// Forwards map readiness to the caller and stops the retry timer. + void _onMapCreated(MapLibreMapController controller) { + _controller = controller; + _readinessTimer?.cancel(); + // A remount (retry) may have superseded this element — never hand a stale + // controller, whose native view is being torn down, to the caller. + if (!mounted) return; + widget.onMapCreated?.call(controller); + } + + /// The engine doesn't tear down iOS platform views synchronously across a + /// hot restart, so a fresh isolate can collide with the previous run's view + /// (the framework re-issues id 0 while the engine still owns it) and the + /// map's UiKitView creation fails with `recreating_view` — [onMapCreated] + /// never fires and the surface would sit blank for the session. Remount with + /// a fresh key: the new attempt allocates a new platform-view id and + /// succeeds. Bounded — two remounts clears any async teardown, and a + /// persistent failure is a real problem that should surface, not loop. + void _scheduleReadinessRetry() { + _readinessTimer?.cancel(); + _readinessTimer = Timer(const Duration(seconds: 4), () { + if (!mounted || _controller != null) return; + if (_mountAttempt < 2) { + setState(() => _mountAttempt++); + _scheduleReadinessRetry(); + return; + } + Log.warning('Map platform view never became ready after remounts'); + }); + } + @override Widget build(BuildContext context) { final palette = MapColors.of(Theme.of(context).brightness); @@ -122,13 +202,16 @@ class BaseMap extends StatelessWidget { final safeTop = Theme.of(context).platform == TargetPlatform.iOS ? 0.0 : MediaQuery.paddingOf(context).top; - final compassTop = safeTop + _layerChipBand; - final floor = minZoomPreference; - final ceiling = maxZoomPreference; + final compassTop = safeTop + layerChipBand; + final floor = widget.minZoomPreference; + final ceiling = widget.maxZoomPreference; return MapLibreMap( // Pre-layout placeholder only; each surface fits [taiwanBounds] (or its // own selection) once the map is laid out, so no hardcoded framing zoom. - initialCameraPosition: CameraPosition(target: taiwanCenter, zoom: floor), + initialCameraPosition: CameraPosition( + target: BaseMap.taiwanCenter, + zoom: floor, + ), // Brightness flip / AED overlay changes this string → MapLibre reloads // style; layers re-attach via [onStyleLoaded] (see [MapScaffold]). styleString: exptechVectorStyle( @@ -136,26 +219,30 @@ class BaseMap extends StatelessWidget { basemapTileUrl: basemapOriginTileUrl, glyphsUrl: glyphsOriginUrl, ), + // A remount gets a fresh id, so a collided first attempt recovers (see + // [_scheduleReadinessRetry]). + key: ValueKey(_mountAttempt), minMaxZoomPreference: MinMaxZoomPreference(floor, ceiling), trackCameraPosition: true, - compassEnabled: interactive, + compassEnabled: widget.interactive && widget.compassEnabled, compassViewPosition: CompassViewPosition.topRight, compassViewMargins: Point(AppSpacing.lg, compassTop), - scrollGesturesEnabled: interactive, - zoomGesturesEnabled: interactive, - rotateGesturesEnabled: interactive, + scrollGesturesEnabled: widget.interactive, + zoomGesturesEnabled: widget.interactive, + rotateGesturesEnabled: widget.interactive, tiltGesturesEnabled: false, - dragEnabled: interactive, - myLocationEnabled: showUserLocation, + dragEnabled: widget.interactive, + myLocationEnabled: widget.showUserLocation, // `compass` is the heading-cone mode; MapLibreMap asserts anything other // than `normal` requires myLocationEnabled, so pair them. - myLocationRenderMode: showUserLocation + myLocationRenderMode: widget.showUserLocation ? MyLocationRenderMode.compass : MyLocationRenderMode.normal, - onMapClick: onMapClick, - onMapCreated: onMapCreated, - onStyleLoadedCallback: onStyleLoaded, - onCameraIdle: onCameraIdle, + onMapClick: widget.onMapClick, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: widget.onStyleLoaded, + onCameraMove: widget.onCameraMove, + onCameraIdle: widget.onCameraIdle, ); } } diff --git a/lib/shared/map/camera_fit.dart b/lib/shared/map/camera_fit.dart index a57fcb5a7..b18ab8d87 100644 --- a/lib/shared/map/camera_fit.dart +++ b/lib/shared/map/camera_fit.dart @@ -12,6 +12,7 @@ library; import 'dart:math' as math; import 'dart:ui' show Size; +import 'package:dpip/core/geo/geo_math.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; /// ~0.02° ≈ 2 km — the minimum span a fit box is widened to. Enough that the fit @@ -115,7 +116,7 @@ LatLngBounds? safeFitBounds(LatLngBounds bounds) { double _mercatorX(double lng) => (lng + 180) / 360; double _mercatorY(double lat) { - final s = math.sin(lat * math.pi / 180).clamp(-0.9999, 0.9999); + final s = math.sin(degToRad(lat)).clamp(-0.9999, 0.9999); return 0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi); } @@ -127,3 +128,22 @@ double _mercatorLat(double y) { } double _log2(double x) => math.log(x) / math.ln2; + +/// Axis-aligned bounds of [points], or `null` when empty / non-finite. +LatLngBounds? boundsFromPoints(Iterable points) { + double? minLat, maxLat, minLng, maxLng; + for (final p in points) { + final lat = p.latitude; + final lng = p.longitude; + if (!lat.isFinite || !lng.isFinite) continue; + minLat = minLat == null ? lat : math.min(minLat, lat); + maxLat = maxLat == null ? lat : math.max(maxLat, lat); + minLng = minLng == null ? lng : math.min(minLng, lng); + maxLng = maxLng == null ? lng : math.max(maxLng, lng); + } + if (minLat == null) return null; + return LatLngBounds( + southwest: LatLng(minLat, minLng!), + northeast: LatLng(maxLat!, maxLng!), + ); +} diff --git a/lib/shared/map/default_map_layer_ui.dart b/lib/shared/map/default_map_layer_ui.dart index da5e95f6a..0d7e64426 100644 --- a/lib/shared/map/default_map_layer_ui.dart +++ b/lib/shared/map/default_map_layer_ui.dart @@ -8,11 +8,13 @@ import 'package:flutter/material.dart'; /// Icons / labels shared by the bottom-nav Map tab and the settings picker. /// /// Lives in `shared/` so `app/shell` and `features/settings` don't each hardcode -/// the 11-way switch (and so `core` stays Flutter-l10n free beyond ChangeNotifier). +/// the per-layer switch (and so `core` stays Flutter-l10n free beyond +/// ChangeNotifier). extension DefaultMapLayerUi on DefaultMapLayer { /// Outlined icon for inactive bottom-nav / list leading. IconData get icon => switch (this) { DefaultMapLayer.radar => Icons.radar_outlined, + DefaultMapLayer.qpesums => Icons.cloud_outlined, DefaultMapLayer.satellite => Icons.satellite_alt_outlined, DefaultMapLayer.lightning => Icons.bolt_outlined, DefaultMapLayer.typhoon => Icons.cyclone_outlined, @@ -28,6 +30,7 @@ extension DefaultMapLayerUi on DefaultMapLayer { /// Filled icon for the selected bottom-nav destination. IconData get selectedIcon => switch (this) { DefaultMapLayer.radar => Icons.radar, + DefaultMapLayer.qpesums => Icons.cloud, DefaultMapLayer.satellite => Icons.satellite_alt, DefaultMapLayer.lightning => Icons.bolt, DefaultMapLayer.typhoon => Icons.cyclone, @@ -43,6 +46,7 @@ extension DefaultMapLayerUi on DefaultMapLayer { /// Short bottom-nav / settings-picker label (not the long layer-switcher name). String label(AppLocalizations l10n) => switch (this) { DefaultMapLayer.radar => l10n.mapNavRadar, + DefaultMapLayer.qpesums => l10n.mapNavQpesums, DefaultMapLayer.satellite => l10n.mapNavSatellite, DefaultMapLayer.lightning => l10n.mapNavLightning, DefaultMapLayer.typhoon => l10n.mapNavTyphoon, diff --git a/lib/shared/map/map_compass.dart b/lib/shared/map/map_compass.dart new file mode 100644 index 000000000..f6f7be1eb --- /dev/null +++ b/lib/shared/map/map_compass.dart @@ -0,0 +1,72 @@ +/// The map's north indicator, drawn by Flutter so it can never be covered. +/// +/// MapLibre's native compass lives *inside* the platform view, so any Flutter +/// overlay (typhoon forecast callouts, sheets, chrome) paints over it. This +/// replacement sits at the top of the scaffold's stack — the topmost map +/// chrome — and counter-rotates its needle against the camera bearing so it +/// keeps pointing north while the map turns. +library; + +import 'package:dpip/core/geo/geo_math.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/frosted_surface.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// A circular, frosted north button driven by the camera [bearing]. +/// +/// Hidden while north-up, matching the native compass, so a north-facing map +/// doesn't float an idle needle. [bearing] is listened to — only the compass +/// rebuilds while the map rotates, not the whole scaffold. +class MapCompass extends StatelessWidget { + const MapCompass({super.key, required this.bearing, required this.onPressed}); + + /// Camera heading in degrees clockwise from north. + final ValueListenable bearing; + + /// Resets the camera to north-up. + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: bearing, + builder: (context, value, _) { + // Native compasses hide when the map faces north; match that. + if (value.abs() < 0.5) return const SizedBox.shrink(); + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Tooltip( + message: AppLocalizations.of(context).mapResetNorth, + child: FrostedSurface( + borderRadius: BorderRadius.circular(24), + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: onPressed, + child: SizedBox( + width: 40, + height: 40, + child: Center( + // A north-up map has bearing 0; turning the camera east by + // `bearing` means north now sits `-bearing` clockwise from + // the screen top, so the needle rotates by `-bearing`. + child: Transform.rotate( + angle: -degToRad(value), + child: Icon( + Icons.navigation, + size: 22, + color: colors.primary, + ), + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart index 3d06dcba0..48fcdda40 100644 --- a/lib/shared/map/map_layer.dart +++ b/lib/shared/map/map_layer.dart @@ -1,4 +1,6 @@ import 'package:dpip/core/error/result.dart'; +import 'package:dpip/shared/map/base_map.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -24,6 +26,34 @@ class MapFrame { int get hashCode => Object.hash(id, time); } +/// Index of the newest frame that is not in the future, or 0 when the list is +/// empty or entirely forecast. +/// +/// Observed data only ever reaches the present, so for radar or satellite this +/// is the last frame and saying "the last one" would have done. A forecast runs +/// past it — GFS by sixteen days — and there the two answers are nothing alike: +/// treating the last frame as now opens the map on next week, labels it 現在, +/// and leaves the scrubber pinned at the right-hand end with the entire +/// forecast behind it and nothing ahead. The forecast is not missing at that +/// point, it is just all to the left of a scrubber that claims to be at the +/// present. +/// +/// So: ask the clock, not the list. The present is the newest frame **at or +/// before** now — never a future step. At 21:43 with 3-hourly ECMWF steps +/// landing on 20:00 and 23:00, the present is 20:00; labelling the 23:00 +/// forecast as now would present a prediction as though it had already +/// happened. For observations the answer is unchanged. +int nowFrameIndex(List frames, {DateTime? now}) { + if (frames.isEmpty) return 0; + final at = now ?? DateTime.now(); + // Frames are chronological, so scan from the newest backwards. + for (var i = frames.length - 1; i >= 0; i--) { + if (!frames[i].time.isAfter(at)) return i; + } + // Everything is in the future — the forecast opens on its first step. + return 0; +} + /// A pluggable overlay on the shared map — radar today, rain / lightning / /// typhoon / … as they land. /// @@ -45,6 +75,10 @@ abstract interface class MapLayer { /// The switcher label (localised by the implementation via [context]). String label(BuildContext context); + /// Secondary switcher line under [label] — formula, band composition, or + /// anything that differentiates the layer at a glance. `null` hides it. + String? subtitle(BuildContext context); + /// The switcher icon — outlined, per the app's icon convention. IconData get icon; @@ -102,7 +136,22 @@ abstract interface class MapLayer { /// /// Use for layer-specific toggles (e.g. typhoon overlay menu). Default is /// empty — most layers only need the shared switcher. - Widget buildTopTrailingChrome(BuildContext context); + /// + /// [showTownLabels] / [onShowTownLabelsChanged] expose the base map's shared + /// township-label setting, so a layer's menu can carry it alongside its own + /// options (one affordance, not a second chip). Layers whose chrome is not a + /// settings menu may ignore them — [MapScaffold] shows a standalone + /// township-label menu for layers that return no chrome. + /// + /// [onReloadActive] re-loads this layer from scratch — a chrome option that + /// changes what the layer renders (e.g. the satellite colour style) calls it + /// after mutating its source so the already-mounted tiles re-fetch. + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }); /// Flutter widgets painted over the map (screen-space callouts, etc.). /// @@ -154,3 +203,81 @@ abstract interface class MapLayer { /// re-adds from scratch instead of no-oping on stale "already added" state. void onStyleReset(); } + +/// No-op bodies for the [MapLayer] members a given layer type doesn't use. +/// +/// Timeline layers (radar, satellite, QPESUMS) draw nothing in [MapLayer.render] +/// and open no [MapLayer.buildSheet]; sheet layers (stations, typhoon) publish +/// no [MapLayer.frames]. A layer mixes this in and overrides only what it does — +/// the scaffold can then call the whole [MapLayer] surface without a chain of +/// empty implementations per layer. +mixin MapLayerDefaults implements MapLayer { + @override + String? subtitle(BuildContext context) => null; + + @override + Future>> frames() async => const Ok([]); + + @override + Future prepare( + MapLibreMapController controller, + List frames, + ) async {} + + @override + Future show( + MapLibreMapController controller, + MapFrame frame, { + bool scrubbing = false, + }) async {} + + @override + Future render(MapLibreMapController controller) async {} + + @override + Future onMapTap( + LatLng latLng, + MapLibreMapController controller, + ) async {} + + @override + void selectFeature(String id) {} + + @override + Widget buildSheet(BuildContext context) => const SizedBox.shrink(); + + @override + Widget buildLegend(BuildContext context) => const SizedBox.shrink(); + + @override + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }) => const SizedBox.shrink(); + + @override + Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); + + @override + Future onCameraIdle(MapLibreMapController controller) async {} + + @override + Future onAmbientCacheCleared(MapLibreMapController controller) async {} + + @override + void onMapGestureStart() {} + + @override + void onMapGestureEnd() {} + + @override + double get mapMinZoom => BaseMap.defaultMinZoom; + + @override + double get mapMaxZoom => BaseMap.maxZoom; + + @override + void onStyleReset() {} +} diff --git a/lib/shared/map/map_layer_category.dart b/lib/shared/map/map_layer_category.dart new file mode 100644 index 000000000..5ec7705d1 --- /dev/null +++ b/lib/shared/map/map_layer_category.dart @@ -0,0 +1,79 @@ +/// Broad groupings for the map overlay list — shared by the settings picker +/// and the in-map layer switcher so both list layers under the same headers. +library; + +import 'package:dpip/l10n/gen/app_localizations.dart'; + +/// The seven groups the overlays fall into, in display order. +enum MapLayerCategory { + /// Seismic-monitor events (RTS). + earthquake, + + /// Ground-radar imagery. + radar, + + /// Numerical weather prediction: the short-term precipitation forecast + /// (QPESUMS) and the ECMWF / GFS wind fields. + forecast, + + /// Typhoon tracks. + typhoon, + + /// Continuous meteorological fields + convective phenomena (temperature, + /// humidity, pressure, wind, rain stations, lightning). + weather, + + /// Geostationary-satellite imagery (Himawari infrared). + satellite, + + /// Everyday-life facilities (disaster-prevention map). + life, +} + +/// The group [layerId] belongs to. +/// +/// A switch (not a lookup table) so an id added after this function — a future +/// layer — still resolves deterministically to [MapLayerCategory.weather] +/// instead of throwing. The satellite split namespaced the single IR layer +/// into `satellite-` ids, so those match by prefix off the historical +/// `satellite` id. +MapLayerCategory categoryOf(String layerId) { + if (layerId == 'satellite' || layerId.startsWith('satellite-')) { + return MapLayerCategory.satellite; + } + // The wind forecast split namespaced the forecast layers into + // `wind-` ids, so those match by prefix off the historical `wind` + // station layer. + if (layerId.startsWith('wind-')) { + return MapLayerCategory.forecast; + } + return switch (layerId) { + 'monitor' => MapLayerCategory.earthquake, + 'typhoon' => MapLayerCategory.typhoon, + 'temperature' || + 'humidity' || + 'pressure' || + 'wind' || + 'rain' || + 'lightning' => MapLayerCategory.weather, + 'radar' => MapLayerCategory.radar, + // The QPESUMS next-1-hour forecast is a numerical model output like the + // wind fields, so it shares their group rather than radar's — which is + // purely an instrument. + 'qpesums' => MapLayerCategory.forecast, + 'dpm' => MapLayerCategory.life, + _ => MapLayerCategory.weather, + }; +} + +/// Localised header for a group. +String categoryLabel(MapLayerCategory category, AppLocalizations l10n) => + switch (category) { + MapLayerCategory.earthquake => l10n.mapLayerCategoryEarthquake, + MapLayerCategory.typhoon => l10n.mapLayerCategoryTyphoon, + MapLayerCategory.weather => l10n.mapLayerCategoryWeather, + MapLayerCategory.satellite => l10n.mapLayerCategorySatellite, + MapLayerCategory.forecast => l10n.mapLayerCategoryForecast, + MapLayerCategory.radar => l10n.mapLayerCategoryRadar, + MapLayerCategory.life => l10n.mapLayerCategoryLife, + }; diff --git a/lib/shared/map/map_layer_order.dart b/lib/shared/map/map_layer_order.dart new file mode 100644 index 000000000..7e9c29177 --- /dev/null +++ b/lib/shared/map/map_layer_order.dart @@ -0,0 +1,66 @@ +/// Safe ordering of a map surface's layers by a persisted preference. +library; + +import 'package:dpip/shared/map/map_layer.dart'; +import 'package:dpip/shared/map/map_layer_category.dart'; + +/// [layers] ordered by [order] (layer ids, most-preferred first). +/// +/// The saved order is a snapshot of ids that the surface can outgrow: a layer +/// added after the order was saved is **not** in it, and one that was removed +/// may still be. This merge is the tolerance for both: +/// - ids in [order] with no matching layer are ignored (a stale id can never +/// throw an index error); +/// - layers not in [order] keep the surface's declared relative order and are +/// appended after every ordered one, so a brand-new layer appears at the +/// bottom without the user being asked to place it; +/// - duplicate ids in [order] count once. +/// +/// Every layer in [layers] appears exactly once. +List orderedLayers(List layers, List order) { + if (layers.isEmpty) return layers; + final byId = {for (final layer in layers) layer.id: layer}; + final placed = {}; + final ordered = []; + for (final id in order) { + final layer = byId[id]; + if (layer == null || !placed.add(id)) continue; + ordered.add(layer); + } + return [ + ...ordered, + for (final layer in layers) + if (placed.add(layer.id)) layer, + ]; +} + +/// [all] (the declared category set, in declared order) re-ordered by [order] +/// (category names, most-preferred first). +/// +/// Mirrors [orderedLayers]' tolerance so the persisted category order can +/// outgrow the code that wrote it: +/// - a name in [order] with no matching category is ignored; +/// - a category not in [order] keeps its declared position and is appended +/// after every ordered one, so a category added later — or the very first +/// launch, when nothing was saved — still shows up; +/// - duplicate names count once. +/// +/// Every category in [all] appears exactly once. +List orderedCategories( + List all, + List order, +) { + final byName = {for (final category in all) category.name: category}; + final placed = {}; + final ordered = []; + for (final name in order) { + final category = byName[name]; + if (category == null || !placed.add(category)) continue; + ordered.add(category); + } + return [ + ...ordered, + for (final category in all) + if (placed.add(category)) category, + ]; +} diff --git a/lib/shared/map/map_layer_switcher.dart b/lib/shared/map/map_layer_switcher.dart index a153f4c15..a303abdd2 100644 --- a/lib/shared/map/map_layer_switcher.dart +++ b/lib/shared/map/map_layer_switcher.dart @@ -1,12 +1,20 @@ /// The map control that picks which [MapLayer] is shown. library; +import 'dart:async'; + import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/settings/map_layer_order_controller.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_layer.dart'; +import 'package:dpip/shared/map/map_layer_category.dart'; +import 'package:dpip/shared/map/map_layer_order.dart'; import 'package:dpip/shared/widgets/frosted_surface.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; /// Frosted chip (active layer name) that opens a layer-picker drag sheet. /// @@ -84,6 +92,7 @@ class MapLayerSwitcher extends StatelessWidget { } Future _pick(BuildContext context) async { + final orderController = context.read(); final selected = await showModalBottomSheet( context: context, isScrollControlled: true, @@ -106,40 +115,56 @@ class MapLayerSwitcher extends StatelessWidget { child: Column( children: [ const _Grip(), - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.md, - ), - child: SizedBox( - width: double.infinity, - child: Text( - l10n.mapLayers, - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium, - ), + _CenteredHeader( + title: l10n.mapLayers, + left: const SizedBox.shrink(), + right: IconButton( + icon: const Icon(Icons.tune), + tooltip: l10n.mapLayerOrderTitle, + visualDensity: VisualDensity.compact, + onPressed: () => + _editOrder(sheetContext, orderController), ), ), Expanded( - child: ListView( - controller: scrollController, - padding: EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.md + bottomInset, - ), - children: [ - for (final layer in layers) - _LayerTile( - layer: layer, - selected: layer.id == active.id, - colors: colors, - onTap: () => Navigator.of(sheetContext).pop(layer), + // Live-updates when the order editor above changes it, so + // the picker reflects a reorder the moment the editor + // closes back on top of it. + child: ListenableBuilder( + listenable: orderController, + builder: (context, _) { + final ordered = orderedLayers( + layers, + orderController.order, + ); + final categories = orderedCategories( + MapLayerCategory.values, + orderController.categoryOrder, + ); + return ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md + bottomInset, ), - ], + children: [ + for (final category in categories) ...[ + SectionHeader(categoryLabel(category, l10n)), + for (final layer in ordered) + if (categoryOf(layer.id) == category) + _LayerTile( + layer: layer, + selected: layer.id == active.id, + colors: colors, + onTap: () => + Navigator.of(sheetContext).pop(layer), + ), + ], + ], + ); + }, ), ), ], @@ -151,6 +176,22 @@ class MapLayerSwitcher extends StatelessWidget { ); if (selected != null && selected.id != active.id) onSelected(selected); } + + /// Opens the layer-order editor over the picker. Reordering persists to + /// [orderController] on every drop, so closing the editor (or the picker) + /// never discards a change. + Future _editOrder( + BuildContext sheetContext, + MapLayerOrderController orderController, + ) async { + await showModalBottomSheet( + context: sheetContext, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => + _LayerOrderSheet(layers: layers, controller: orderController), + ); + } } /// Sheet grab handle — same visual as the typhoon / station 拖盤. @@ -213,12 +254,30 @@ class _LayerTile extends StatelessWidget { ), const SizedBox(width: AppSpacing.md), Expanded( - child: Text( - layer.label(context), - style: theme.textTheme.bodyLarge?.copyWith( - color: colors.onSurface, - fontWeight: selected ? FontWeight.w600 : FontWeight.w400, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + layer.label(context), + style: theme.textTheme.bodyLarge?.copyWith( + color: colors.onSurface, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + if (layer.subtitle(context) case final subtitle?) ...[ + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ], ), ), if (selected) @@ -237,3 +296,383 @@ class _LayerTile extends StatelessWidget { ); } } + +/// Order editor for the layer picker — a fixed-height sheet (not a draggable +/// 拖盤), since it opens over the picker and two stacked drag sheets are +/// confusing. +/// +/// Two levels, so a category's list is never mixed into the reorder list: +/// level 1 reorders the categories (persisted to `categoryOrder`), and tapping +/// a category opens level 2 to reorder just that category's layers (persisted +/// to `order`). Both are seeded from the controller resolved against the +/// surface's actual layers (`orderedLayers` / `orderedCategories`), so a layer +/// or category added after the order was saved shows up here — appended at the +/// bottom, ready to be dragged up. Every drop persists immediately; the reset +/// button clears both saved orders so the list falls back to the declared +/// order. +class _LayerOrderSheet extends StatefulWidget { + const _LayerOrderSheet({required this.layers, required this.controller}); + + final List layers; + final MapLayerOrderController controller; + + @override + State<_LayerOrderSheet> createState() => _LayerOrderSheetState(); +} + +class _LayerOrderSheetState extends State<_LayerOrderSheet> { + // Category blocks — the category order plus each category's layer ids. + late List<_Block> _blocks = _buildBlocks( + widget.layers, + widget.controller.order, + widget.controller.categoryOrder, + ); + + /// The category whose layers are being edited; null shows the category list. + MapLayerCategory? _editing; + + /// Layer ids in current block order — what gets persisted. + List get _ids => [ + for (final block in _blocks) + for (final id in block.ids) id, + ]; + + /// Category names in current block order — persisted alongside the ids. + List get _categoryIds => [ + for (final block in _blocks) block.category.name, + ]; + + @override + Widget build(BuildContext context) { + final editing = _editing; + final editingBlock = editing == null ? null : _blockOf(editing); + final closeButton = IconButton( + icon: const Icon(Icons.close), + tooltip: AppLocalizations.of(context).commonClose, + onPressed: () => Navigator.of(context).pop(), + ); + return SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + child: FrostedSurface( + borderRadius: AppRadius.topSheet, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (editing == null) + _CenteredHeader( + title: AppLocalizations.of(context).mapLayerOrderTitle, + left: TextButton( + onPressed: _isPristine() ? null : _reset, + child: Text( + AppLocalizations.of(context).mapLayerOrderReset, + ), + ), + right: closeButton, + ) + else + _CenteredHeader( + title: categoryLabel(editing, AppLocalizations.of(context)), + left: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + onPressed: () => setState(() => _editing = null), + ), + right: closeButton, + ), + Flexible( + child: editing == null + ? _categoryList(context) + : _layerList(context, editingBlock!), + ), + ], + ), + ), + ), + ); + } + + Widget _categoryList(BuildContext context) { + return ReorderableListView.builder( + buildDefaultDragHandles: false, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + itemCount: _blocks.length, + onReorderItem: _reorderCategory, + itemBuilder: (context, index) { + final block = _blocks[index]; + final canOpen = block.ids.length > 1; + return _CategoryOrderTile( + key: ValueKey('category-${block.category.name}'), + category: block.category, + index: index, + canOpen: canOpen, + onTap: canOpen + ? () => setState(() => _editing = block.category) + : null, + ); + }, + ); + } + + Widget _layerList(BuildContext context, _Block block) { + return ReorderableListView.builder( + buildDefaultDragHandles: false, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + itemCount: block.ids.length, + onReorderItem: (oldIndex, newIndex) => + _reorderLayer(block, oldIndex, newIndex), + itemBuilder: (context, index) { + final id = block.ids[index]; + final layer = widget.layers.firstWhere((layer) => layer.id == id); + return _ReorderTile(key: ValueKey(id), layer: layer, index: index); + }, + ); + } + + void _reorderCategory(int oldIndex, int newIndex) { + setState(() { + final block = _blocks.removeAt(oldIndex); + _blocks.insert(newIndex, block); + }); + // Fire-and-forget: the next drop supersedes this write anyway, and the + // picker below reads the controller's latest orders when it rebuilds. + unawaited(widget.controller.setOrder(_ids)); + unawaited(widget.controller.setCategoryOrder(_categoryIds)); + } + + void _reorderLayer(_Block block, int oldIndex, int newIndex) { + setState(() { + final id = block.ids.removeAt(oldIndex); + block.ids.insert(newIndex, id); + }); + unawaited(widget.controller.setOrder(_ids)); + } + + void _reset() { + setState(() { + _blocks = _buildBlocks(widget.layers, const [], const []); + }); + unawaited(widget.controller.reset()); + } + + bool _isPristine() { + final defaults = _buildBlocks(widget.layers, const [], const []); + final defaultIds = [ + for (final block in defaults) + for (final id in block.ids) id, + ]; + final defaultCategoryIds = [ + for (final block in defaults) block.category.name, + ]; + return listEquals(_ids, defaultIds) && + listEquals(_categoryIds, defaultCategoryIds); + } + + _Block? _blockOf(MapLayerCategory category) { + for (final block in _blocks) { + if (block.category == category) return block; + } + return null; + } +} + +/// One category block in the order editor — the header plus its layer ids. +class _Block { + _Block(this.category, [List? ids]) : ids = ids ?? []; + + final MapLayerCategory category; + final List ids; +} + +/// Groups [layers] (in [order]'s relative sequence) by category, in +/// [categoryOrder]'s relative sequence — a block per non-empty group. +List<_Block> _buildBlocks( + List layers, + List order, + List categoryOrder, +) { + final grouped = >{}; + for (final layer in orderedLayers(layers, order)) { + grouped.putIfAbsent(categoryOf(layer.id), () => []).add(layer.id); + } + return [ + for (final category in orderedCategories( + MapLayerCategory.values, + categoryOrder, + )) + if (grouped[category] case final ids?) _Block(category, ids), + ]; +} + +/// One row of the level-1 category list. Dragging reorders the categories; +/// tapping a category with more than one layer opens its level-2 layer list. +class _CategoryOrderTile extends StatelessWidget { + const _CategoryOrderTile({ + super.key, + required this.category, + required this.index, + required this.canOpen, + required this.onTap, + }); + + final MapLayerCategory category; + final int index; + final bool canOpen; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Material( + color: colors.surfaceContainerHighest.withValues(alpha: 0.35), + borderRadius: AppRadius.small, + child: InkWell( + borderRadius: AppRadius.small, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + child: Row( + children: [ + Expanded( + child: Text( + categoryLabel(category, AppLocalizations.of(context)), + style: theme.textTheme.bodyLarge?.copyWith( + color: colors.onSurface, + ), + ), + ), + if (canOpen) + Icon(Icons.chevron_right, color: colors.onSurfaceVariant), + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.drag_handle), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Sheet header whose title is absolutely centred over [left]/[right]. +/// +/// A `Row` with an `Expanded` title would let an asymmetric action (e.g. a wide +/// "Reset order" button on the left and a square close button on the right) +/// shove the title off-centre. Overlaying the actions and letting the title own +/// the full width keeps it truly centred. +class _CenteredHeader extends StatelessWidget { + const _CenteredHeader({ + required this.title, + required this.left, + required this.right, + }); + + final String title; + final Widget left; + final Widget right; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xs, + 0, + AppSpacing.xs, + AppSpacing.xs, + ), + child: Stack( + alignment: Alignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 64), + child: Text( + title, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium, + ), + ), + Align(alignment: Alignment.centerLeft, child: left), + Align(alignment: Alignment.centerRight, child: right), + ], + ), + ); + } +} + +/// One row of the reorder editor: layer identity on the left, a drag handle on +/// the right. +class _ReorderTile extends StatelessWidget { + const _ReorderTile({super.key, required this.layer, required this.index}); + + final MapLayer layer; + final int index; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Material( + color: colors.surfaceContainerHighest.withValues(alpha: 0.35), + borderRadius: AppRadius.small, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + child: Row( + children: [ + Icon(layer.icon, color: colors.onSurfaceVariant), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + layer.label(context), + style: theme.textTheme.bodyLarge?.copyWith( + color: colors.onSurface, + ), + ), + ), + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.drag_handle), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index b59d159af..a4c309cb7 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -14,8 +14,11 @@ import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:dpip/shared/map/map_station_handoff.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_layer_switcher.dart'; +import 'package:dpip/shared/map/map_compass.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:dpip/shared/map/map_timeline.dart'; +import 'package:dpip/shared/map/map_town_labels.dart'; +import 'package:dpip/shared/map/raster_timeline_layer.dart'; import 'package:dpip/shared/widgets/collapsible_map_legend.dart'; import 'package:dpip/shared/widgets/frosted_surface.dart'; import 'package:flutter/material.dart'; @@ -111,6 +114,15 @@ class _MapScaffoldState extends State { /// The map view's own size, captured at layout — see [_applyFraming]. Size? _mapViewSize; + /// Current camera heading — drives the Flutter compass needle. A notifier so + /// only the compass rebuilds while the map rotates. + final ValueNotifier _bearing = ValueNotifier(0); + + /// Whether the base map's township-name labels are shown. A base-map + /// property, not a layer's, so it lives here (shared by every layer's menu) + /// instead of on one chrome mixin. Defaults on, per the layer docs. + final ValueNotifier _showTownLabels = ValueNotifier(true); + /// The geography the map is framed on, kept across layer switches so each /// layer re-frames the *same* place into its own visible band. LatLngBounds? _target; @@ -119,6 +131,14 @@ class _MapScaffoldState extends State { double _timelineHeight = 0; final GlobalKey _timelineKey = GlobalKey(); + /// A deliberate framing ([_frameBounds] — a nav-bar / Home entry, the first + /// load, a station focus) ran while the timeline's height was still zero — + /// switching into a timeline layer zeroes it and the fit placed the subject + /// behind the scrubber. Set by [_frameBounds], cleared once the measured + /// height re-fits the camera, and never set by a bare layer switch (which + /// must not move the camera at all). + bool _reframeOnMeasure = false; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -136,6 +156,8 @@ class _MapScaffoldState extends State { @override void dispose() { + _bearing.dispose(); + _showTownLabels.dispose(); _basemapWarmer?.cancel(); _handoff?.removeListener(_onHandoff); _stationHandoff?.removeListener(_onStationHandoff); @@ -211,6 +233,11 @@ class _MapScaffoldState extends State { /// Adopts [bounds] as the framing target and applies it. void _frameBounds(LatLngBounds bounds, {bool northUp = false}) { _target = safeFitBounds(bounds); + // The active layer's chrome height is measured, not predicted — a timeline + // layer's panel only exists after its frames load. A deliberate framing + // that runs before then fits against a zero-height band, so remember to + // re-fit once [_measureTimeline] learns the real height. + _reframeOnMeasure = _active.usesTimeline; _applyFraming(northUp: northUp); } @@ -268,8 +295,15 @@ class _MapScaffoldState extends State { double _bottomInset(Size size) => size.height * _active.bottomChromeFraction + _timelineHeight; - /// Measures the timeline panel after layout and re-frames if it changed, so a - /// timeline layer frames into the band above the scrubber rather than behind it. + /// Measures the timeline panel after layout. + /// + /// The height feeds [_bottomInset] for the *next* deliberate framing (a nav-bar + /// / Home entry), so that framing avoids the scrubber. It never moves the + /// camera on its own — re-framing on a layer switch is exactly what was + /// removed: switching overlays keeps the user's camera untouched. The one + /// exception is a deferred deliberate framing ([_reframeOnMeasure]): it ran + /// before this panel existed, so once the real height lands it re-fits the + /// same target into the corrected band. void _measureTimeline() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -277,7 +311,10 @@ class _MapScaffoldState extends State { final height = (box != null && box.hasSize) ? box.size.height : 0.0; if ((height - _timelineHeight).abs() < 0.5) return; _timelineHeight = height; - _applyFraming(); + if (_reframeOnMeasure) { + _reframeOnMeasure = false; + _applyFraming(); + } }); } @@ -287,6 +324,58 @@ class _MapScaffoldState extends State { unawaited(const MapCache().setMaximumSize()); } + /// Feeds the compass needle — camera heading, ° clockwise from north. + void _onCameraMove(CameraPosition position) { + _bearing.value = position.bearing; + } + + /// Re-points the camera north, keeping centre / zoom — the native compass's + /// tap action, reproduced for the Flutter replacement. + void _resetNorth() { + final controller = _controller; + if (controller == null) return; + final position = controller.cameraPosition; + if (position == null) return; + // The camera stream may not deliver a final north-up event for a + // programmatic move (a no-op bearing change can skip region updates), so + // settle the needle directly instead of waiting on it — the compass must + // hide the instant the map faces north. + _bearing.value = 0; + unawaited( + controller.moveCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: position.target, + zoom: position.zoom, + bearing: 0, + tilt: 0, + ), + ), + ), + ); + } + + void _setShowTownLabels(bool value) { + if (_showTownLabels.value == value) return; + _showTownLabels.value = value; + _applyTownLabelVisibility(); + } + + /// Pushes the township-label setting onto a live map. The base style's + /// `town-label` layer survives style reloads, which reset it to visible, so + /// this also runs after every [_onStyleLoaded] to re-assert the choice. + void _applyTownLabelVisibility() { + final controller = _controller; + if (controller == null) return; + unawaited( + controller + .setLayerVisibility(townLabelLayerId, _showTownLabels.value) + .catchError((Object e, StackTrace st) { + Log.handle(e, st, 'Failed to sync the township labels'); + }), + ); + } + Future _warmBasemap(MapLibreMapController controller) async { final warmer = _basemapWarmer; if (warmer == null) return; @@ -340,6 +429,8 @@ class _MapScaffoldState extends State { unawaited(_applyStationHandoff()); // Home/nav camera handoff that arrived before the style was ready. unawaited(_applyCameraHandoff()); + // A reload resets the base style's township-label layer to visible. + _applyTownLabelVisibility(); } /// Forwards a map tap to the active (sheet) layer — it selects the nearest @@ -369,10 +460,12 @@ class _MapScaffoldState extends State { ok: (frames) { setState(() { _frames = frames; - _selectedIndex = frames.isEmpty ? 0 : frames.length - 1; // newest + _selectedIndex = nowFrameIndex(frames); }); if (frames.isNotEmpty) { - // Register the set, then reveal the newest (a layer adds tiles lazily). + // Register the set, then reveal the present (a layer adds tiles + // lazily). For observed data that is the newest frame; a forecast + // opens on the newest already-due step, future steps to the right. final controller = _controller!; final layer = _active; _queue(() => layer.prepare(controller, frames)); @@ -478,13 +571,31 @@ class _MapScaffoldState extends State { _timelineHeight = 0; }); if (controller != null) _queue(() => previous.clear(controller)); - // Re-frame the same target into the new layer's band — switching to radar - // after picking a township keeps the township framed, and each layer's - // different chrome height is accounted for instead of reusing the old one. - _applyFraming(); + // A bare switch is never a deliberate framing — the camera stays exactly as + // the user left it, and the [_reframeOnMeasure] deferred re-fit must not + // fire off it either (the height will change as the new layer's frames + // load, and that is a reason to *not* move the camera). + _reframeOnMeasure = false; + // Keep the camera exactly as the user left it — switching overlays must not + // zoom, re-centre, or rotate the map. Only a deliberate framing entry (the + // nav bar / Home hand-off, or a ranking station focus) moves the camera. _loadActive(); } + /// Re-loads the active layer from scratch: clear its on-map state (sources + /// mounted under the old URLs), then re-fetch frames and re-mount. A chrome + /// option that changes what a layer renders (e.g. the satellite colour style) + /// calls this after mutating its source. + Future _reloadActive() async { + final controller = _controller; + if (controller == null || !_styleLoaded) return; + final layer = _active; + // Clear first — the queued re-mount ops below land after it on the serial + // chain, so the map never shows new-style tiles over old ones. + _queue(() => layer.clear(controller)); + await _loadActive(); + } + /// Appends [op] to the serial controller-op chain, logging any failure — a /// failed overlay op degrades the map, it never throws into the tree. void _queue(Future Function() op) { @@ -534,6 +645,7 @@ class _MapScaffoldState extends State { onMapCreated: _onMapCreated, onStyleLoaded: _onStyleLoaded, onMapClick: (_, latLng) => _onMapClick(latLng), + onCameraMove: _onCameraMove, onCameraIdle: () { _active.onMapGestureEnd(); final controller = _controller; @@ -544,6 +656,10 @@ class _MapScaffoldState extends State { if (!mounted) return; setState(() => _cameraEpoch++); }, + // The native compass lives inside the platform view, so any + // Flutter overlay paints over it — MapScaffold draws its own + // [MapCompass] at the top of the stack instead. + compassEnabled: false, ), ), ), @@ -581,8 +697,16 @@ class _MapScaffoldState extends State { padding: const EdgeInsets.all(AppSpacing.lg), child: Builder( builder: (context) { - // Non-typhoon layers return [SizedBox.shrink] — skip the gap. - final chrome = _active.buildTopTrailingChrome(context); + // Layers with a settings menu (radar / QPESUMS / DPM / typhoon + // / rain) receive the shared township-label toggle to carry; + // the rest return [SizedBox.shrink], so show the standalone + // township-label menu instead — every layer can set it. + final chrome = _active.buildTopTrailingChrome( + context, + showTownLabels: _showTownLabels, + onShowTownLabelsChanged: _setShowTownLabels, + onReloadActive: _reloadActive, + ); final hasChrome = chrome is! SizedBox; return Row( mainAxisSize: MainAxisSize.min, @@ -590,6 +714,12 @@ class _MapScaffoldState extends State { if (hasChrome) ...[ chrome, const SizedBox(width: AppSpacing.sm), + ] else ...[ + MapTownLabelsMenu( + showTownLabels: _showTownLabels, + onShowTownLabelsChanged: _setShowTownLabels, + ), + const SizedBox(width: AppSpacing.sm), ], MapLayerSwitcher( layers: widget.layers, @@ -621,6 +751,22 @@ class _MapScaffoldState extends State { : const SizedBox.shrink(), ), ), + // Compass on top of *everything* — a screen-space callout (typhoon + // forecast tips) or an expanded sheet must never hide north. Parked + // just under the layer switcher chip, with a touch of breathing room. + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: EdgeInsets.only( + top: layerChipBand + AppSpacing.sm, + right: AppSpacing.lg, + ), + child: MapCompass(bearing: _bearing, onPressed: _resetNorth), + ), + ), + ), ], ); } @@ -667,6 +813,16 @@ class _MapScaffoldState extends State { } Widget _timelinePanel(BuildContext context) { + // Only raster-timeline layers reach here (the usesTimeline gate); the + // caption is theirs to say — observed vs forecast — so forecast frames are + // never read as measurements. Anything else falls back to "observed". + final active = _active; + final isRaster = active is RasterTimelineLayer; + final caption = isRaster + ? active.timelineCaption(context) + : AppLocalizations.of(context).mapTimelineObserved; + final framePeriod = isRaster ? active.framePeriod : null; + final dataTime = isRaster ? active.modelRunTime : null; return FrostedSurface( borderRadius: AppRadius.large, child: Padding( @@ -678,6 +834,9 @@ class _MapScaffoldState extends State { selectedIndex: _selectedIndex, onSelected: _onFrameSelected, onScrubbing: _onScrubbing, + caption: caption, + framePeriod: framePeriod, + dataTime: dataTime, ), ), ); diff --git a/lib/shared/map/map_style.dart b/lib/shared/map/map_style.dart index 2d99976b5..36155bd9a 100644 --- a/lib/shared/map/map_style.dart +++ b/lib/shared/map/map_style.dart @@ -6,6 +6,8 @@ library; import 'dart:ui' show Brightness; +import 'package:dpip/core/network/api_paths.dart'; + /// One brightness's cartographic hex colours — MapLibre paint strings only. /// /// Do not invent map hexes at call sites; resolve via [MapColors.of]. @@ -15,6 +17,8 @@ final class MapPalette { required this.fill, required this.outline, required this.townOutline, + required this.label, + required this.labelHalo, }); /// Sea / canvas behind the land fills. @@ -28,6 +32,12 @@ final class MapPalette { /// Township borders — quieter than [outline], close to [fill]. final String townOutline; + + /// Township name text. Contrast against [fill]'s side of the map. + final String label; + + /// Township name halo — the outline that lifts [label] off the fill. + final String labelHalo; } /// Sole registry of base-map paint colours (light + dark). @@ -38,6 +48,8 @@ abstract final class MapColors { fill: '#3F4045', outline: '#a9b4bc', townOutline: '#6A6B72', + label: '#FFFFFF', + labelHalo: '#000000', ); /// Light-mode cartography — pale sea, mid-grey land. @@ -46,6 +58,8 @@ abstract final class MapColors { fill: '#ADADAD', outline: '#6B6B6B', townOutline: '#9A9A9A', + label: '#141414', + labelHalo: '#FFFFFF', ); /// Palette for the given UI [brightness]. @@ -65,6 +79,18 @@ const String outlineLayerId = 'county-outline'; /// Id of the faint township-outline layer (below the county borders). const String townOutlineLayerId = 'town-outline'; +/// Id of the township-name label layer. Invisible until [townLabelMinZoom] +/// when the 368-town mesh is dense enough that names can place without a pile-up. +const String townLabelLayerId = 'town-label'; + +/// Township labels start placing at this zoom; [townLabelFadeZoom] finishes the +/// fade-in. Below it the layer is not placed at all (a layer of near-invisible +/// symbols would still fight for placement space). +const double townLabelMinZoom = 8.5; + +/// Zoom at which township labels reach full opacity (see [townLabelMinZoom]). +const double townLabelFadeZoom = 9.5; + /// Baked DPM source / layer ids — must match [DisasterMapLayer]. const String dpmAedSourceId = 'dpm-aed-src'; const String dpmAedPointsLayerId = 'dpm-aed-points'; @@ -77,7 +103,7 @@ const String dpmShelterPointsLayerId = 'dpm-shelter-points'; /// store through the Dart bridge, and warmed by `MapTileWarmer` — the same /// three tiers as every other ExpTech tile. const String basemapOriginTileUrl = - 'https://lb.exptech.dev/api/v1/map/tiles/{z}/{x}/{y}.pbf'; + 'https://lb.exptech.dev${ApiPaths.mapTilesV1}{z}/{x}/{y}.pbf'; /// Origin glyph template — MapLibre HTTPS. const String glyphsOriginUrl = @@ -86,9 +112,11 @@ const String glyphsOriginUrl = /// Builds the ExpTech vector base-map style as a MapLibre style JSON string. /// /// Pass [MapColors.of] for the active brightness — never ad-hoc hexes. The base -/// draws no labels itself, but declares a `glyphs` endpoint (the ExpTech -/// map-assets CDN) so overlay layers can render `text-field` symbols. Overlays -/// (radar) anchor below [outlineLayerId] so the county borders stay legible. +/// declares a `glyphs` endpoint (the ExpTech map-assets CDN) so overlay layers +/// can render `text-field` symbols, and draws township names itself once the +/// map is zoomed in past [townLabelMinZoom] (the [TOWN] property of the `town` +/// source-layer). Overlays (radar) anchor below [outlineLayerId] so the county +/// borders stay legible. /// /// [basemapTileUrl] / [glyphsUrl] are origin HTTPS templates fetched by /// MapLibre and served from the app's tile store through the Dart bridge. @@ -101,6 +129,8 @@ String exptechVectorStyle( final fill = palette.fill; final outline = palette.outline; final townOutline = palette.townOutline; + final label = palette.label; + final labelHalo = palette.labelHalo; return ''' { "version": 8, @@ -114,7 +144,20 @@ String exptechVectorStyle( { "id": "county", "type": "fill", "source": "exptech", "source-layer": "city", "paint": { "fill-color": "$fill" } }, { "id": "town", "type": "fill", "source": "exptech", "source-layer": "town", "paint": { "fill-color": "$fill" } }, { "id": "$townOutlineLayerId", "type": "line", "source": "exptech", "source-layer": "town", "paint": { "line-color": "$townOutline", "line-width": 0.4, "line-opacity": 0.7 } }, - { "id": "$outlineLayerId", "type": "line", "source": "exptech", "source-layer": "city", "paint": { "line-color": "$outline", "line-width": 1.0 } } + { "id": "$outlineLayerId", "type": "line", "source": "exptech", "source-layer": "city", "paint": { "line-color": "$outline", "line-width": 1.0 } }, + { "id": "$townLabelLayerId", "type": "symbol", "source": "exptech", "source-layer": "town", "minzoom": $townLabelMinZoom, "layout": { + "text-field": ["get", "TOWN"], + "text-font": ["Noto Sans TC Regular"], + "text-size": ["interpolate", ["linear"], ["zoom"], $townLabelMinZoom, 10, 12, 12.5], + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-padding": 2 + }, "paint": { + "text-color": "$label", + "text-halo-color": "$labelHalo", + "text-halo-width": 1.2, + "text-opacity": ["interpolate", ["linear"], ["zoom"], $townLabelMinZoom, 0, $townLabelFadeZoom, 1] + } } ] }'''; } @@ -122,12 +165,22 @@ String exptechVectorStyle( /// Purple used to highlight the selected township (fill + border). const String selectedColor = '#7C4DFF'; -/// Borders drawn on top of IR satellite imagery — black so they stay readable -/// on greyscale Himawari tiles (themed [MapPalette.outline] does not). -const String satelliteOutlineColor = '#000000'; +/// Bright yellow country / county borders drawn **over** satellite imagery. +/// +/// The hue that stays legible on true-colour satellite (which is overall +/// dark); tune it per channel if another imagery needs it. [AdminBoundary] +/// overlays keep their own white core — only satellite is yellow. +const String satelliteOutlineColor = '#FFD400'; + +/// Satellite township borders — dark yellow ([satelliteOutlineColor]'s +/// secondary), the fine mesh beneath the county/country frame. +const String satelliteTownOutlineColor = '#B79A00'; /// Runtime line layer: world land / country edges (`global` source-layer). const String satelliteGlobalOutlineLayerId = 'satellite-global-outline'; /// Runtime line layer: Taiwan county edges (`city` source-layer). const String satelliteCountyOutlineLayerId = 'satellite-county-outline'; + +/// Runtime line layer: Taiwan township edges (`town` source-layer). +const String satelliteTownOutlineLayerId = 'satellite-town-outline'; diff --git a/lib/shared/map/map_tile_cache.dart b/lib/shared/map/map_tile_cache.dart index 3791d9e10..96c48faa4 100644 --- a/lib/shared/map/map_tile_cache.dart +++ b/lib/shared/map/map_tile_cache.dart @@ -2,6 +2,7 @@ library; import 'dart:async'; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:dpip/core/logging/log.dart'; @@ -45,19 +46,22 @@ class MapTileCache { final EtagCacheStore _store; final NetworkUsageStore? _usage; - /// Native's in-process mirror budget — deliberately **small**. + /// Native's in-process mirror budget. /// /// This tier is a staging buffer for the warm path, not a second copy of the /// store: the store stays the source of truth for every byte, and anything - /// the mirror does not hold is read back from it. Sizing it generously would - /// quietly turn it into the real cache and leave [EtagCacheStore] doing - /// nothing but the cold start. + /// the mirror does not hold is read back from it. Sizing it is a trade-off — + /// too small and a warm band ([RasterTimelineLayer.warmRadius]) evicts its + /// own earlier tiles mid-injection and re-reads them on every settle; too + /// large and the mirror quietly becomes the real cache, leaving + /// [EtagCacheStore] doing nothing but the cold start. /// - /// A few viewports' worth is enough to cover the frames a drag is actually - /// crossing. Keep [RasterTimelineLayer.warmRadius] within what this holds — - /// warming a band wider than the budget just evicts its own earlier tiles and - /// re-reads them on every settle. - static const int defaultMemoryBytes = 2 * 1024 * 1024; + /// 24 MB holds a full ±12-frame scrub band of webp tiles *plus* the basemap + /// viewport, so a fast timeline drag stays on memory hits even while the map + /// itself downloads. [warm]'s fill mode ([warm]) tops it up outward from the + /// current frame until it is near this cap, then stops — the mirror trims + /// LRU beyond it, dropping the frames a scrub swept past. + static const int defaultMemoryBytes = 24 * 1024 * 1024; /// Tiles per `injectTiles` message — roughly one frame's viewport. static const int _injectChunk = 24; @@ -128,7 +132,13 @@ class MapTileCache { /// Returns how many tiles were injected. Safe to call often — the /// already-resident check is a strings-only round-trip, so a repeat warm of /// the same frame costs almost nothing. - Future warm(List urls) async { + /// + /// When [fillUntil] is non-zero, injection runs in [fillUntil]'s **fill + /// mode**: [urls] must be ordered most-wanted first, and the loop stops once + /// the native mirror is at `fillUntil × limit` — so a timeline can top the + /// mirror up outward from the current frame until it is nearly full and then + /// stop, instead of over-filling and churning LRU (or re-reading on settle). + Future warm(List urls, {double fillUntil = 0}) async { final wanted = urls.where(_isTile).toList(growable: false); if (wanted.isEmpty) return 0; try { @@ -149,12 +159,18 @@ class MapTileCache { ]; // Chunked: warming a wide band of frames is megabytes of image data, and // one giant message would occupy the platform channel long enough to be - // felt by whatever gesture is in progress. + // felt by whatever gesture is in progress. Each inject echoes the + // mirror's post-injection usage, so a fill warm can bail as it nears the + // cap — the remaining (more distant) urls simply stay cold. for (var i = 0; i < tiles.length; i += _injectChunk) { - final end = i + _injectChunk; - await injectMapLibreTiles( - end < tiles.length ? tiles.sublist(i, end) : tiles.sublist(i), - ); + final end = math.min(i + _injectChunk, tiles.length); + final usage = await injectMapLibreTiles(tiles.sublist(i, end)); + if (fillUntil > 0 && + usage != null && + usage.limit > 0 && + usage.used >= usage.limit * fillUntil) { + break; + } } return hits.length; } catch (error, stackTrace) { diff --git a/lib/shared/map/map_tile_warmer.dart b/lib/shared/map/map_tile_warmer.dart index 57e093e8e..57a90f422 100644 --- a/lib/shared/map/map_tile_warmer.dart +++ b/lib/shared/map/map_tile_warmer.dart @@ -70,7 +70,15 @@ class MapTileWarmer { /// Warms absolute tile [urls] (already resolved, e.g. from a layer's /// `tileUrl` template). - Future warmUrls(Iterable urls, {String? logLabel}) async { + /// + /// [fillUntil] (0 = off) puts the cache in fill mode: [urls] must be ordered + /// most-wanted first, and injection stops once the native mirror is near its + /// cap — the caller tops it up outward from the current frame. + Future warmUrls( + Iterable urls, { + String? logLabel, + double fillUntil = 0, + }) async { final cache = _cache; if (cache == null) return; final list = urls.toList(growable: false); @@ -78,7 +86,7 @@ class MapTileWarmer { final gen = ++_generation; await Future.delayed(settleDelay); if (gen != _generation) return; - final injected = await cache.warm(list); + final injected = await cache.warm(list, fillUntil: fillUntil); if (gen == _generation && logLabel != null && injected > 0) { Log.debug('MapTileWarmer $logLabel injected=$injected/${list.length}'); } diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index 6fed21c2e..4268152f8 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -12,7 +12,9 @@ import 'package:intl/intl.dart'; /// live and reports each crossed frame via [onSelected]. [onScrubbing] is true /// while the finger is down / the fling is in flight and false on settle — raster /// layers use that to opacity-GIF among residents without mounting cold frames. -/// The newest frame is labelled "now". +/// The frame labelled "now" is the newest one at or before the present — the +/// last frame for observed data, and the latest already-due step of a forecast. +/// Forecast steps after it are "future", never presented as current. /// /// Stateless about the map — it only turns [frames] + [selectedIndex] into a /// scrub gesture, so `MapScaffold` can drive any layer's frames through it. @@ -23,6 +25,9 @@ class MapTimeline extends StatefulWidget { required this.selectedIndex, required this.onSelected, this.onScrubbing, + this.caption, + this.framePeriod, + this.dataTime, }); /// Frames in chronological order (oldest first); must be non-empty. @@ -37,6 +42,21 @@ class MapTimeline extends StatefulWidget { /// `true` on scrub start / during drag; `false` when the scrub settles. final ValueChanged? onScrubbing; + /// What the frame times are, shown above the date — "observed" by default, + /// "forecast" for a forecast layer. + final String? caption; + + /// How long one frame's data represents, when it is a period rather than a + /// point. A next-hour forecast's frame at 21:00 covers 21:00–22:00, so the + /// big time label renders that range instead of a bare instant. `null` keeps + /// the point-in-time label. + final Duration? framePeriod; + + /// When the frames behind this timeline were produced — a forecast model + /// run's issue time (資料時間), distinct from the valid times the ruler + /// scrubs. `null` hides the data-time line. + final DateTime? dataTime; + @override State createState() => _MapTimelineState(); } @@ -45,6 +65,8 @@ class _MapTimelineState extends State { static final DateFormat _time = DateFormat('HH:mm'); // Numeric so no locale symbol data is needed (as with [_time]). static final DateFormat _date = DateFormat('yyyy/MM/dd'); + // The data-time line: the model run's issue time, `8/11 14:00`. + static final DateFormat _data = DateFormat('M/d HH:mm'); /// Slot width per frame — the scroll offset that centres frame `i` is /// `i * _slotWidth` (the leading/trailing pads are symmetric). @@ -65,6 +87,17 @@ class _MapTimelineState extends State { _dates = [for (final frame in widget.frames) _date.format(frame.time)]; } + /// The big time label: the selected instant, or — when the layer's frames + /// each cover a period — the range that instant starts, so "21:00" is read + /// as "21:00–22:00" rather than a point measurement. + String get _bigLabel { + final start = _times[_liveIndex]; + final period = widget.framePeriod; + if (period == null) return start; + final end = _time.format(widget.frames[_liveIndex].time.add(period)); + return '$start – $end'; + } + @override void initState() { super.initState(); @@ -163,15 +196,18 @@ class _MapTimelineState extends State { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - final isNow = _liveIndex == widget.frames.length - 1; + // Asked of the clock rather than of the list, so a forecast's "現在" lands + // on the present rather than on its furthest step. + final nowIndex = nowFrameIndex(widget.frames); + final era = _eraOf(_liveIndex, nowIndex); final labelStep = (48 / _slotWidth).ceil(); return Column( mainAxisSize: MainAxisSize.min, children: [ - // Left: 觀測 (label) over the selected date; right: the big time - // ("now" when the newest frame is under the scrubber). FittedBox keeps - // the row on one line on narrow screens instead of overflowing. + // Left: 觀測 (label) over the selected date; right: the big time with + // its era label (現在/歷史/未來) + HH:mm. FittedBox keeps the row on + // one line on narrow screens instead of overflowing. FittedBox( fit: BoxFit.scaleDown, child: Row( @@ -182,12 +218,20 @@ class _MapTimelineState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.mapTimelineObserved, + widget.caption ?? l10n.mapTimelineObserved, style: theme.textTheme.titleMedium?.copyWith( color: colors.onSurface, fontWeight: FontWeight.w600, ), ), + if (widget.dataTime case final dataTime?) + Text( + l10n.mapTimelineDataTime(_data.format(dataTime)), + style: theme.textTheme.labelSmall?.copyWith( + color: colors.tertiary, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), Text( _dates[_liveIndex], style: theme.textTheme.labelSmall?.copyWith( @@ -203,19 +247,21 @@ class _MapTimelineState extends State { crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ - if (isNow) ...[ - Text( - l10n.mapTimelineNow, - style: theme.textTheme.titleMedium?.copyWith( - color: colors.primary, - ), + Text( + switch (era) { + TimelineEra.past => l10n.mapTimelinePast, + TimelineEra.now => l10n.mapTimelineNow, + TimelineEra.future => l10n.mapTimelineFuture, + }, + style: theme.textTheme.titleMedium?.copyWith( + color: _eraColor(era, theme.brightness), ), - const SizedBox(width: AppSpacing.sm), - ], + ), + const SizedBox(width: AppSpacing.sm), Text( - _times[_liveIndex], + _bigLabel, style: theme.textTheme.headlineSmall?.copyWith( - color: colors.primary, + color: _eraColor(era, theme.brightness), fontFeatures: const [FontFeature.tabularFigures()], ), ), @@ -248,6 +294,10 @@ class _MapTimelineState extends State { itemBuilder: (context, i) => _Tick( width: _slotWidth, label: i % labelStep == 0 ? _times[i] : null, + labelColor: _eraColor( + _eraOf(i, nowIndex), + theme.brightness, + ), emphasised: i == _liveIndex, colors: colors, textStyle: theme.textTheme.labelSmall, @@ -275,11 +325,41 @@ class _MapTimelineState extends State { } } +/// Which side of the present a frame sits on. +enum TimelineEra { past, now, future } + +TimelineEra _eraOf(int index, int nowIndex) => + switch (index.compareTo(nowIndex)) { + -1 => TimelineEra.past, + 0 => TimelineEra.now, + _ => TimelineEra.future, + }; + +/// The era colours, one pair per brightness so each reads on both themes. +/// +/// The hues are fixed (not theme roles) because they are the semantics: 歷史 +/// blue, 現在 green, 未來 purple — a dark surface gets the lighter shade of the +/// same hue, a light surface the deeper one. +const Color _pastLight = Color(0xFF1565C0); +const Color _pastDark = Color(0xFF64B5F6); +const Color _nowLight = Color(0xFF2E7D32); +const Color _nowDark = Color(0xFF81C784); +const Color _futureLight = Color(0xFF7B1FA2); +const Color _futureDark = Color(0xFFBA68C8); + +Color _eraColor(TimelineEra era, Brightness brightness) => switch (era) { + TimelineEra.past => brightness == Brightness.dark ? _pastDark : _pastLight, + TimelineEra.now => brightness == Brightness.dark ? _nowDark : _nowLight, + TimelineEra.future => + brightness == Brightness.dark ? _futureDark : _futureLight, +}; + /// One ruler tick — a mark plus an optional time [label] beneath it. class _Tick extends StatelessWidget { const _Tick({ required this.width, required this.label, + required this.labelColor, required this.emphasised, required this.colors, required this.textStyle, @@ -287,6 +367,7 @@ class _Tick extends StatelessWidget { final double width; final String? label; + final Color labelColor; final bool emphasised; final ColorScheme colors; final TextStyle? textStyle; @@ -317,9 +398,7 @@ class _Tick extends StatelessWidget { label!, maxLines: 1, softWrap: false, - style: textStyle?.copyWith( - color: colors.onSurfaceVariant, - ), + style: textStyle?.copyWith(color: labelColor), ), ), ), diff --git a/lib/shared/map/map_town_labels.dart b/lib/shared/map/map_town_labels.dart new file mode 100644 index 000000000..4fc9a489a --- /dev/null +++ b/lib/shared/map/map_town_labels.dart @@ -0,0 +1,88 @@ +/// The base map's township-name toggle, shared by every layer's settings menu. +/// +/// [MapTownLabelsRow] slots into an existing overlay menu so the map-level +/// toggle never needs its own chip; [MapTownLabelsMenu] is the standalone +/// dropdown for layers that have no other settings (the state still lives in +/// [MapScaffold], this just renders it). +library; + +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_style.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// One checkbox row for the township-name setting — drops into any overlay +/// menu's children. +class MapTownLabelsRow extends StatelessWidget { + const MapTownLabelsRow({ + super.key, + required this.showTownLabels, + required this.onShowTownLabelsChanged, + }); + + /// Whether township-name labels are on (see [townLabelLayerId]). + final ValueListenable showTownLabels; + + final ValueChanged onShowTownLabelsChanged; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListenableBuilder( + listenable: showTownLabels, + builder: (context, _) => MapMenuToggleRow( + selected: showTownLabels.value, + icon: Icons.label_outlined, + title: l10n.mapTownLabels, + subtitle: l10n.mapTownLabelsHint, + tooltip: l10n.mapTownLabelsHint, + onTap: () => onShowTownLabelsChanged(!showTownLabels.value), + ), + ); + } +} + +/// Standalone township-label dropdown for layers that ship no other chrome — +/// same chip affordance as the layer menus, so a map without a tune button +/// still exposes the map's one optional setting. +class MapTownLabelsMenu extends StatelessWidget { + const MapTownLabelsMenu({ + super.key, + required this.showTownLabels, + required this.onShowTownLabelsChanged, + }); + + final ValueListenable showTownLabels; + final ValueChanged onShowTownLabelsChanged; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListenableBuilder( + listenable: showTownLabels, + builder: (context, _) => MenuAnchor( + alignmentOffset: const Offset(0, 4), + style: MapChipButton.menuStyle(context), + builder: (context, controller, _) => MapChipButton( + icon: Icons.tune, + tooltip: l10n.mapTownLabels, + active: !showTownLabels.value, + onTap: () => + controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + MapMenuScrollView( + children: [ + MapTownLabelsRow( + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/shared/map/maps_launcher.dart b/lib/shared/map/maps_launcher.dart new file mode 100644 index 000000000..552d71cfe --- /dev/null +++ b/lib/shared/map/maps_launcher.dart @@ -0,0 +1,183 @@ +/// Opening a map point in an external map app (Google Maps / Apple Maps), +/// with a platform-default shortcut and a user-facing app picker. +/// +/// Scheme knowledge lives here (and nowhere else): the native app deep links, +/// the platform default, and the choice sheet. It only takes a coordinate + +/// optional label, so any surface (the DPM detail sheet today, a station sheet +/// tomorrow) can hand a point over without knowing a thing about URI schemes. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// A map point to hand to an external map app. +@immutable +class MapLaunchTarget { + const MapLaunchTarget({required this.lat, required this.lng, this.label}); + + final double lat; + final double lng; + + /// Shown as the dropped-pin label (the facility name), when known. + final String? label; +} + +/// The two native map apps we can deep-link to on both platforms. +enum MapApp { + google, + apple; + + /// The OS's home map app — Apple Maps on iOS, Google Maps elsewhere. + /// The default for a one-tap open, so a tap goes to the app the user + /// actually thinks of as "the map" on their device. + static MapApp ofPlatform([TargetPlatform? platform]) => + switch (platform ?? defaultTargetPlatform) { + TargetPlatform.iOS || TargetPlatform.macOS => MapApp.apple, + _ => MapApp.google, + }; + + /// The localised app name, e.g. for the choice sheet. + String label(AppLocalizations l10n) => switch (this) { + MapApp.google => l10n.mapAppGoogleMaps, + MapApp.apple => l10n.mapAppAppleMaps, + }; +} + +/// Deep-link URL for [app] at [target]. +/// +/// Both are cross-platform web endpoints that hand off to the native app when +/// installed, so no `comgooglemaps://` / Apple-scheme fallback dance is needed. +/// Google gets the `lat,lng(label)` pin syntax (a named marker at the exact +/// point); Apple gets `q` plus an explicit `ll` + `z` so the map frames the +/// point even before the query resolves. +String mapsAppUrl(MapApp app, MapLaunchTarget target) { + final center = '${target.lat},${target.lng}'; + return switch (app) { + MapApp.google => + 'https://www.google.com/maps/search/?api=1&query=${_enc(target.label == null ? center : '$center(${target.label})')}', + MapApp.apple => + 'https://maps.apple.com/?q=${_enc(target.label ?? center)}&ll=$center&z=17', + }; +} + +/// Opens [target] in [app], leaving the app. Returns false when no handler +/// exists (e.g. a desktop without the app). +Future openInMapApp(MapApp app, MapLaunchTarget target) => launchUrl( + Uri.parse(mapsAppUrl(app, target)), + mode: LaunchMode.externalApplication, +); + +/// Dials [phone] in the platform's phone app via a `tel:` URI. Returns false +/// when the device has no handler (no SIM / a desktop). +/// +/// The string is a raw display number (`02-12345678`, `+886 9xx`), so it is +/// stripped down to the characters a phone dialler accepts rather than handed +/// to the OS verbatim — an encoded space or stray bracket is what turns a tap +/// into "no app can open this". +Future callPhoneNumber(String phone) { + final digits = phone.replaceAll(RegExp(r'[^\d+#*]'), ''); + if (digits.isEmpty) return Future.value(false); + // `Uri.parse` keeps the extension marker (`#`/`*`) and `+` verbatim — + // `Uri(scheme: 'tel', path: …)` would percent-encode `#` into `%23`. + return launchUrl( + Uri.parse('tel:$digits'), + mode: LaunchMode.externalApplication, + ); +} + +/// One-tap open in the platform's home map app. +Future openInDefaultMapApp(MapLaunchTarget target) => + openInMapApp(MapApp.ofPlatform(), target); + +/// Bottom sheet letting the user pick a map app (or copy the coordinates) for +/// [target]. The platform's home app is listed first and marked 預設. Returns +/// the chosen app, or null if the sheet was dismissed or the user copied the +/// coordinates. +Future showMapAppPicker(BuildContext context, MapLaunchTarget target) { + final l10n = AppLocalizations.of(context); + final defaultApp = MapApp.ofPlatform(); + return showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.sm, + ), + child: Text( + l10n.dpmOpenInMaps, + style: Theme.of( + sheetContext, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + ), + for (final app in [ + defaultApp, + ...MapApp.values.where((a) => a != defaultApp), + ]) + ListTile( + leading: Icon( + app == MapApp.google + ? Icons.map_outlined + : Icons.navigation_outlined, + ), + title: Text( + app == defaultApp + ? l10n.mapAppDefault(app.label(l10n)) + : app.label(l10n), + ), + onTap: () => Navigator.pop(sheetContext, app), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.copy_outlined), + title: Text(l10n.mapAppCopyCoordinates), + onTap: () { + final copied = '${target.lat},${target.lng}'; + Clipboard.setData(ClipboardData(text: copied)); + Navigator.pop(sheetContext); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.mapAppCoordinatesCopied)), + ); + }, + ), + ], + ), + ), + ); +} + +/// The whole "open in a map app" flow: picker, then open, then report. +/// +/// [showMapAppPicker] only *returns* the chosen app — opening it is this +/// function's job, because a picker that forgets to open (as callers did when +/// the sheet was first wired up) reads as "maps don't work" to a user who just +/// picked one. Returns when the sheet was dismissed, the coordinates were +/// copied, the app opened, or the open failed with a snackbar. +Future pickAndOpenMapApp( + BuildContext context, + MapLaunchTarget target, +) async { + final app = await showMapAppPicker(context, target); + if (app == null || !context.mounted) return; + final opened = await openInMapApp(app, target); + if (!opened && context.mounted) { + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.mapAppOpenFailed(app.label(l10n)))), + ); + } +} + +String _enc(String s) => Uri.encodeComponent(s); diff --git a/lib/shared/map/raster_frame_source.dart b/lib/shared/map/raster_frame_source.dart index 38643beb5..22bd0043f 100644 --- a/lib/shared/map/raster_frame_source.dart +++ b/lib/shared/map/raster_frame_source.dart @@ -21,6 +21,11 @@ abstract interface class RasterFrameSource { /// /// Local only — a tile the app has never downloaded stays a miss, and /// MapLibre fetches it the ordinary way. + /// + /// When [fill] is true, [frames] must be ordered nearest-the-finger first and + /// the implementation tops the native mirror up until it is nearly full, + /// skipping nothing it holds already — so a timeline can warm far past its + /// mounted ring and only the most distant frames stay cold. Future warmFrameTiles({ required List frames, required double south, @@ -28,6 +33,7 @@ abstract interface class RasterFrameSource { required double north, required double east, required double zoom, + bool fill = false, }); /// Aborts in-flight tile HTTP for [frames] — a scrub swept past them and diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index e4d571b29..a12173ea5 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -6,10 +6,12 @@ import 'dart:collection'; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:dpip/shared/map/raster_frame_source.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -68,14 +70,22 @@ abstract class RasterTimelineLayer implements MapLayer { /// How many frames either side have their tiles pushed into MapLibre's /// memory ahead of demand. /// - /// Costs nothing to draw — it only decides how far a drag can go before a - /// reveal has to read back from the store. Bounded by - /// [MapTileCache.defaultMemoryBytes], not by taste: warming a band wider than - /// that mirror holds evicts its own earlier tiles mid-injection and re-reads - /// the same bytes on every settle, which is worse than not warming them. + /// This is the **guaranteed** band: [fill] warm always covers it. Beyond it, + /// [MapTileCache.warm]'s fill mode keeps topping the mirror up outward from + /// the current frame until it is nearly full — the actual reach is set by + /// the memory cap, not by taste, so a 24 MB mirror easily covers far more. @protected int get warmRadius => 4; + /// Farthest a fill warm reaches from the current frame, in either direction. + /// + /// A cap on the spread, not a target: a fill warm injects centre → ±1 → ±2 … + /// and stops at the native mirror's cap ([MapTileCache.defaultMemoryBytes]), + /// so beyond this only the most distant frames stay cold. Kept finite so a + /// hundreds-of-frames history never unfolds into one giant URL list. + @protected + int get maxWarmRadius => 24; + /// Mounted-source ceiling. Beyond this the least-recently-shown frame is /// removed outright — `visibility: none` keeps GPU textures for a fast /// reveal, so `removeSource` is the actual memory release. @@ -85,6 +95,47 @@ abstract class RasterTimelineLayer implements MapLayer { /// Hook for extra style work once this layer's first frame is on the map /// (e.g. an outline layer that only makes sense over this overlay). @protected + /// Where this layer's frames are anchored in the style. + /// + /// Defaults to under the base style's county borders, which keeps them legible + /// through the raster. A layer that supplies its **own** borders on top should + /// return null instead: leaving the base ones showing through as well draws + /// every boundary twice, at two weights, and the pair reads as a smear. + @protected + String? get rasterBelowLayerId => outlineLayerId; + + /// What the frame times *are*, for the timeline caption above the date. + /// + /// Observed data (radar, satellite) keeps the shared "observed" label; a + /// forecast layer says so instead, so its frames are never read as + /// measurements. + String timelineCaption(BuildContext context) => + AppLocalizations.of(context).mapTimelineObserved; + + /// How long one frame's data represents, when a frame is a period rather + /// than a point — `null` keeps the shared point-in-time timeline label. + /// + /// A next-hour forecast's frame at 21:00 estimates the 21:00–22:00 window, + /// so a layer like QPESUMS returns the hour and the timeline renders the + /// selected frame as a range instead of a bare instant. + Duration? get framePeriod => null; + + /// Whether [frames] are all one forecast-model run — the API serves only the + /// latest run, so the oldest frame is that run's issue time (資料時間). + /// + /// Observed layers (radar, satellite) leave this false; a wind-forecast + /// layer opts in so the timeline can name when the model was run. + @protected + bool get framesAreOneRun => false; + + /// The model-run issue time behind these frames, when they are one run — the + /// oldest frame *is* the run start. `null` before [prepare] fills the list. + DateTime? get modelRunTime { + final run = framesAreOneRun; + if (!run || _orderedIds.isEmpty) return null; + return parseFrameTime(_orderedIds.first); + } + Future onAttached(MapLibreMapController controller) async {} /// Undoes [onAttached]. Called from [clear]. @@ -96,6 +147,9 @@ abstract class RasterTimelineLayer implements MapLayer { @override bool get usesTimeline => true; + @override + String? subtitle(BuildContext context) => null; + @override double get bottomChromeFraction => 0; @@ -118,8 +172,12 @@ abstract class RasterTimelineLayer implements MapLayer { Widget buildSheet(BuildContext context) => const SizedBox.shrink(); @override - Widget buildTopTrailingChrome(BuildContext context) => - const SizedBox.shrink(); + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }) => const SizedBox.shrink(); @override Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); @@ -148,6 +206,13 @@ abstract class RasterTimelineLayer implements MapLayer { String? _shownFrameId; bool _attached = false; + /// The frame index the warm band is currently centred on — the gate that + /// keeps a scrub from re-warming (or re-fetching the visible region) for + /// every frame it crosses. Set synchronously by [_warmBand] before its first + /// await, so concurrent scrub reveals past the band edge coalesce onto one + /// re-warm instead of one per frame. + int? _warmCentre; + String _sourceId(String frameId) => '$id-src-$frameId'; String _layerId(String frameId) => '$id-lyr-$frameId'; @@ -157,27 +222,42 @@ abstract class RasterTimelineLayer implements MapLayer { /// smear: `raster-opacity` is an animated paint property whose style-spec /// default is a **300 ms** cross-fade, so every swap used to blend two frames /// for longer than the finger takes to cross the next one — the map could - /// never catch up, however fast the tiles resolved. Declared once at mount, it - /// governs every later opacity change for free. + /// never catch up, however fast the tiles resolved. static RasterLayerProperties _mountPaint(double value) => RasterLayerProperties( visibility: 'visible', rasterOpacity: value, - rasterOpacityTransition: const {'duration': 0, 'delay': 0}, + rasterOpacityTransition: _instantTransition, ); - /// Scrub-path paint: opacity only. + /// The zero cross-fade every opacity write must carry. /// - /// Sent with `skipNulls` so the platform call carries this one property - /// instead of every raster property the layer type has — the full form makes - /// the platform side re-assign eight values that did not change, on a path - /// that runs for every frame the finger crosses. - static RasterLayerProperties _opacity(double value) => - RasterLayerProperties(rasterOpacity: value); + /// Declared at mount, `raster-opacity-transition` usually governs later + /// changes for free — but only while the native layer keeps it. Some paths + /// (a style reload, the platform's layer re-add, an update that only carries + /// the changed property) can lose it, which silently restores the 300 ms + /// style-spec default and ghosts every old frame behind a fast scrub. Sending + /// it with every write makes the zero fade explicit instead of remembered. + static const Map _instantTransition = { + 'duration': 0, + 'delay': 0, + }; + + /// Scrub-path paint: opacity + its zero transition. + /// + /// Sent with `skipNulls` so the platform call carries just these two + /// properties instead of every raster property the layer type has — the full + /// form makes the platform side re-assign eight values that did not change, + /// on a path that runs for every frame the finger crosses. + static RasterLayerProperties _opacity(double value) => RasterLayerProperties( + rasterOpacity: value, + rasterOpacityTransition: _instantTransition, + ); static const RasterLayerProperties _hidden = RasterLayerProperties( visibility: 'none', rasterOpacity: 0, + rasterOpacityTransition: _instantTransition, ); @override @@ -254,26 +334,41 @@ abstract class RasterTimelineLayer implements MapLayer { _mount(controller, frameId, opacity), ]); await _evictOverflow(controller, keep: {..._ring, frameId}); + + // The warm band follows the finger: revealing a frame the last-warmed band + // did not cover re-warms around it, so the rest of a long drag stays on + // memory hits instead of re-reading every mount from the store. Warmed + // frames only cost the debounced local read + push in [warmFrameTiles] — + // nothing drawn, nothing loaded — so this is the cheap tier to keep ahead + // of the finger. + final index = _indexById[frameId]; + if (index != null && !_warmCovers(index)) { + unawaited(_warmBand(controller, index)); + } } - /// Scrub hot path — never mounts, never cancels, never touches `visibility`. + /// Scrub hot path — never mounts, never cancels, rarely touches `visibility`. /// /// The two writes go out **together**. Awaiting the hide before sending the /// show cost two serial platform round-trips per frame, which is the budget /// for the whole frame; pipelined they cost one, and MapLibre applies them in /// order within a single render pass, so no in-between state is ever drawn. + /// + /// The previous frame is hidden either way. A ring member only fades out — + /// dropping it to `none` would evict its tiles and make scrubbing back a + /// reload — but a frame revealed mid-drag sits **outside** the ring, and + /// leaving it at full opacity while the scrub bounces back over it ghosts + /// the old frame behind the ring (`visibility: none` hides it outright). Future _flip(MapLibreMapController controller, String frameId) async { final previous = _shownFrameId; _shownFrameId = frameId; _touch(frameId); await Future.wait([ - if (previous != null && previous != frameId && _ring.contains(previous)) - // Stays `visible` at zero opacity: dropping it to `none` would evict - // its tiles and make scrubbing back a reload. + if (previous != null && previous != frameId) controller.setLayerProperties( _layerId(previous), - _opacity(0), + _ring.contains(previous) ? _opacity(0) : _hidden, skipNulls: true, ), controller.setLayerProperties( @@ -330,7 +425,11 @@ abstract class RasterTimelineLayer implements MapLayer { // Back into the ring from `none`: restore visibility *and* the opacity. await controller.setLayerProperties( _layerId(id), - RasterLayerProperties(visibility: 'visible', rasterOpacity: value), + RasterLayerProperties( + visibility: 'visible', + rasterOpacity: value, + rasterOpacityTransition: _instantTransition, + ), skipNulls: true, ); return; @@ -343,7 +442,7 @@ abstract class RasterTimelineLayer implements MapLayer { _sourceId(id), _layerId(id), _mountPaint(value), - belowLayerId: outlineLayerId, + belowLayerId: rasterBelowLayerId, ); _resident.add(id); } @@ -367,32 +466,68 @@ abstract class RasterTimelineLayer implements MapLayer { await source.abandonFrames(retired); } - /// Pushes the viewport tiles of every frame within [warmRadius] of [centre] - /// out of the app's store and into MapLibre's memory. + /// Whether [index] falls inside the band last warmed around [_warmCentre]. + bool _warmCovers(int index) { + final centre = _warmCentre; + if (centre == null || _orderedIds.isEmpty) return false; + final low = (centre - warmRadius).clamp(0, _orderedIds.length - 1); + final high = (centre + warmRadius).clamp(0, _orderedIds.length - 1); + return index >= low && index <= high; + } + + /// Pushes the viewport tiles of every frame from [centre] outward into + /// MapLibre's memory, in **fill** mode: nearest-the-finger frames first, then + /// ±1, ±2, … until the mirror is nearly full. /// - /// Deliberately wider than the mounted ring: warming costs one local read and - /// a memory push — no draw call, no tile pass, no network — so the band that - /// a drag can cross **without touching disk** is far cheaper to widen than the - /// band kept mounted. + /// Deliberately far wider than the mounted ring: warming costs one local read + /// and a memory push — no draw call, no tile pass, no network — so the band + /// that a drag can cross **without touching disk** is far cheaper to widen + /// than the band kept mounted. The mirror's LRU evicts the frames the scrub + /// left behind (a fill warm reads without bumping recency, so old frames + /// lose to new ones), which keeps the memory budget full of *nearby* tiles. Future _warmBand(MapLibreMapController controller, int centre) async { if (_orderedIds.isEmpty) return; - final low = (centre - warmRadius).clamp(0, _orderedIds.length - 1); - final high = (centre + warmRadius).clamp(0, _orderedIds.length - 1); + // Adopt the new centre before the first await: concurrent scrub reveals + // that cross the old band edge coalesce onto this one re-warm instead of + // each firing its own visible-region round-trip. + _warmCentre = centre; + final frames = _spreadFrames(centre); + if (frames.length <= 1) return; try { final bounds = await controller.getVisibleRegion(); await source.warmFrameTiles( - frames: [for (var i = low; i <= high; i++) _orderedIds[i]], + frames: frames, south: bounds.southwest.latitude, west: bounds.southwest.longitude, north: bounds.northeast.latitude, east: bounds.northeast.longitude, zoom: controller.cameraPosition?.zoom ?? 8, + fill: true, ); } catch (error, stackTrace) { Log.handle(error, stackTrace, '$id band warm'); } } + /// Frame ids ordered nearest-the-finger first: the centre, then ±1, ±2, … + /// out to [maxWarmRadius] (or the series edge). Fill warm injects in this + /// order and stops at the mirror cap, so when memory is tight the most + /// distant frames are exactly the ones that stay cold. + List _spreadFrames(int centre) { + final n = _orderedIds.length; + final result = []; + void add(int i) { + if (i >= 0 && i < n) result.add(_orderedIds[i]); + } + + add(centre); + for (var r = 1; r <= maxWarmRadius; r++) { + add(centre + r); + add(centre - r); + } + return result; + } + Future _evictOverflow( MapLibreMapController controller, { required Set keep, @@ -468,6 +603,7 @@ abstract class RasterTimelineLayer implements MapLayer { _orderedIds = const []; _indexById = const {}; _shownFrameId = null; + _warmCentre = null; _attached = false; } } diff --git a/lib/shared/map/xyz_tiles.dart b/lib/shared/map/xyz_tiles.dart index 7a4613fe6..42e591801 100644 --- a/lib/shared/map/xyz_tiles.dart +++ b/lib/shared/map/xyz_tiles.dart @@ -3,6 +3,8 @@ library; import 'dart:math' as math; +import 'package:dpip/core/geo/geo_math.dart'; + /// One Web-Mercator tile at zoom [z]. typedef XyzTile = ({int z, int x, int y}); @@ -15,7 +17,7 @@ int lngToTileX(double lng, int z) { /// Integer tile Y for [lat] at zoom [z] (Web Mercator; north → smaller y). int latToTileY(double lat, int z) { final n = 1 << z; - final latRad = lat.clamp(-85.05112878, 85.05112878) * math.pi / 180.0; + final latRad = degToRad(lat.clamp(-85.05112878, 85.05112878)); final y = ((1.0 - math.log(math.tan(latRad) + 1.0 / math.cos(latRad)) / math.pi) / 2.0) * diff --git a/lib/shared/widgets/event_icon.dart b/lib/shared/widgets/event_icon.dart new file mode 100644 index 000000000..faace1ea7 --- /dev/null +++ b/lib/shared/widgets/event_icon.dart @@ -0,0 +1,20 @@ +/// Resolves the Material icon for an event kind — the leading mark on event +/// timeline rows and home cards. +library; + +import 'package:dpip/core/weather/weather_icons.dart'; +import 'package:flutter/material.dart'; + +/// The icon for an event kind, keyed off `EventType.iconKey` so the domain +/// enum stays Material-free. Falls back to a generic notification mark for an +/// unknown key. +IconData eventTypeIcon(String key) => switch (key) { + 'earthquake' => Icons.crisis_alert, + 'report' => Icons.description_outlined, + 'intensity' => Icons.graphic_eq, + 'thunderstorm' => Icons.thunderstorm_outlined, + 'heavy_rain' => rainyHeavy, + 'weather_warning' => Icons.warning_amber_rounded, + 'tsunami' => Icons.tsunami_outlined, + _ => Icons.notifications_outlined, +}; diff --git a/lib/shared/widgets/loading_view.dart b/lib/shared/widgets/loading_view.dart index 71711e2bd..fcc259ff6 100644 --- a/lib/shared/widgets/loading_view.dart +++ b/lib/shared/widgets/loading_view.dart @@ -2,6 +2,28 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/material.dart'; +/// A small inline spinner for list rows / cards — no label, sized for context. +/// Use instead of a hand-rolled `SizedBox` + `CircularProgressIndicator` so +/// every in-list waiting state shares the same weight. +class InlineLoading extends StatelessWidget { + const InlineLoading({super.key, this.size = 24, this.color}); + + /// Edge length in logical pixels. + final double size; + + final Color? color; + + @override + Widget build(BuildContext context) => SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: 2, + color: color ?? Theme.of(context).colorScheme.primary, + ), + ); +} + /// The loading state of the async-state contract: a centered spinner with a /// label, so every feature shows waiting the same way instead of a blank frame. class LoadingView extends StatelessWidget { diff --git a/lib/shared/widgets/map_chip_button.dart b/lib/shared/widgets/map_chip_button.dart index b45e99e66..2de4fb39a 100644 --- a/lib/shared/widgets/map_chip_button.dart +++ b/lib/shared/widgets/map_chip_button.dart @@ -20,13 +20,13 @@ class MapChipButton extends StatelessWidget { required this.onTap, }); - /// Glyph shown on the chip (outlined variant by convention). - final IconData icon; - /// Whether the menu's settings differ from the defaults: tints the icon /// primary and draws the marker dot. final bool active; + /// Glyph shown on the chip (outlined variant by convention). + final IconData icon; + final String tooltip; final VoidCallback onTap; @@ -103,3 +103,48 @@ class MapChipButton extends StatelessWidget { ); } } + +/// Scrollable container for an overlay menu's rows — a long menu (typhoon's +/// storm/weather/extra groups) must not fall off the bottom of the screen. The +/// menu anchors beside the top-right chip, so capping at half the surface +/// height keeps the far end reachable while the short menus just shrink-wrap. +class MapMenuScrollView extends StatelessWidget { + const MapMenuScrollView({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.5, + ), + child: SingleChildScrollView( + primary: false, + child: Column(mainAxisSize: MainAxisSize.min, children: children), + ), + ); + } +} + +/// Hairline divider between an overlay menu's sections — thin and tinted +/// [ColorScheme.outlineVariant] so it separates groups without shouting. +class MapMenuDivider extends StatelessWidget { + const MapMenuDivider({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + child: Divider( + height: 1, + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ); + } +} diff --git a/lib/shared/widgets/map_color_legend.dart b/lib/shared/widgets/map_color_legend.dart index a294d7fa2..ad6ec2558 100644 --- a/lib/shared/widgets/map_color_legend.dart +++ b/lib/shared/widgets/map_color_legend.dart @@ -5,6 +5,8 @@ /// Matches the RTS intensity legend's density so every layer reads the same. library; +import 'dart:math' as math; + import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; @@ -37,24 +39,18 @@ class MapLegendCard extends StatelessWidget { /// Vertical colour scale built from ascending [stops] — strongest (last stop) /// at the top, matching legacy `ColorLegend(reverse: true)`. +/// +/// The unit, when present, is always shown **below** the scale ([unit]); it is +/// never appended to every value label — a number column stays numbers. class ColorScaleLegend extends StatelessWidget { - const ColorScaleLegend({ - super.key, - required this.stops, - this.unit, - this.appendUnit = false, - }); + const ColorScaleLegend({super.key, required this.stops, this.unit}); /// Ascending value → hex colour pairs (same order as MapLibre ramps). final List stops; - /// Optional unit shown below the scale, or appended to each label when - /// [appendUnit] is true. + /// Unit shown below the scale, e.g. `m/s` or `°C`. final String? unit; - /// When true, append [unit] after each value instead of under the scale. - final bool appendUnit; - static const double _cell = 14; static const double _swatch = 8; static const double _corner = 4; @@ -102,18 +98,13 @@ class ColorScaleLegend extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final stop in rows) - Expanded( - child: Text( - _label(stop.$1, unit, appendUnit), - style: labelStyle, - ), - ), + Expanded(child: Text(_label(stop.$1), style: labelStyle)), ], ), ), ], ), - if (unit != null && !appendUnit) ...[ + if (unit != null) ...[ const SizedBox(height: AppSpacing.xs), Text(l10n.mapLegendUnit(unit!), style: labelStyle), ], @@ -121,12 +112,10 @@ class ColorScaleLegend extends StatelessWidget { ); } - static String _label(double value, String? unit, bool appendUnit) { - final number = value == value.roundToDouble() + static String _label(double value) { + return value == value.roundToDouble() ? value.toInt().toString() : value.toString(); - if (appendUnit && unit != null) return '$number $unit'; - return number; } } @@ -180,3 +169,155 @@ class SymbolLegend extends StatelessWidget { ); } } + +/// A solid circular colour swatch for a [SymbolLegendItem] — the dot mark for +/// discrete categories (feed status, storm intensity, station type). Drawn the +/// same way as the layer's map markers where applicable. +class LegendDot extends StatelessWidget { + const LegendDot({ + super.key, + required this.color, + this.size = 10, + this.borderWidth = 0, + }); + + final Color color; + final double size; + + /// White ring width — `1` matches a marker that's ringed on the map. + final double borderWidth; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: borderWidth > 0 + ? Border.all(color: Colors.white, width: borderWidth) + : null, + ), + ); + } +} + +/// A short line sample for a [SymbolLegendItem] — the swatch for an outline +/// overlay (an administrative border, a coverage boundary). +/// +/// Draws the *same* construction the map does, casing included, so the key can +/// be matched to the line on screen rather than merely described. +class LineSwatch extends StatelessWidget { + const LineSwatch({ + super.key, + required this.color, + this.width = 1.2, + this.opacity = 1, + this.casingColor, + this.casingWidth = 0, + this.casingOpacity = 1, + this.dash, + }); + + /// The core stroke. + final Color color; + final double width; + final double opacity; + + /// Optional wider stroke drawn beneath [color]. + final Color? casingColor; + final double casingWidth; + final double casingOpacity; + + /// `[dash, gap]` in logical pixels; null draws a solid line. + final List? dash; + + static const Size _size = Size(20, 12); + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: _size, + painter: _LineSwatchPainter( + color: color, + width: width, + opacity: opacity, + casingColor: casingColor, + casingWidth: casingWidth, + casingOpacity: casingOpacity, + dash: dash, + ), + ); + } +} + +class _LineSwatchPainter extends CustomPainter { + _LineSwatchPainter({ + required this.color, + required this.width, + required this.opacity, + required this.casingColor, + required this.casingWidth, + required this.casingOpacity, + required this.dash, + }); + + final Color color; + final double width; + final double opacity; + final Color? casingColor; + final double casingWidth; + final double casingOpacity; + final List? dash; + + @override + void paint(Canvas canvas, Size size) { + final y = size.height / 2; + final casing = casingColor; + if (casing != null && casingWidth > 0) { + _stroke( + canvas, + size, + y, + casing.withValues(alpha: casingOpacity), + casingWidth, + ); + } + _stroke(canvas, size, y, color.withValues(alpha: opacity), width); + } + + void _stroke(Canvas canvas, Size size, double y, Color c, double w) { + final paint = Paint() + ..color = c + ..strokeWidth = w + ..strokeCap = StrokeCap.round; + final pattern = dash; + if (pattern == null || pattern.length < 2) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + return; + } + // Dash lengths are in line-width multiples on the map, as MapLibre defines + // `line-dasharray`, so scale them the same way here. + final on = pattern[0] * w; + final off = pattern[1] * w; + var x = 0.0; + while (x < size.width) { + canvas.drawLine( + Offset(x, y), + Offset(math.min(x + on, size.width), y), + paint, + ); + x += on + off; + } + } + + @override + bool shouldRepaint(covariant _LineSwatchPainter old) => + old.color != color || + old.width != width || + old.opacity != opacity || + old.casingColor != casingColor || + old.casingWidth != casingWidth || + old.dash != dash; +} diff --git a/lib/shared/widgets/map_menu_toggle_row.dart b/lib/shared/widgets/map_menu_toggle_row.dart new file mode 100644 index 000000000..201f232d4 --- /dev/null +++ b/lib/shared/widgets/map_menu_toggle_row.dart @@ -0,0 +1,106 @@ +/// Checkbox row for a map layer's overlay-menu dropdown. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:flutter/material.dart'; + +/// One on/off overlay inside a [MapChipButton] dropdown: icon, title, optional +/// hint, and a trailing checkbox. +/// +/// 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. +class MapMenuToggleRow extends StatelessWidget { + const MapMenuToggleRow({ + super.key, + required this.selected, + required this.icon, + required this.title, + required this.tooltip, + required this.onTap, + this.subtitle, + }); + + /// Whether the overlay is currently on. + final bool selected; + + /// Leading glyph — outlined, per the app's icon convention. + final IconData icon; + + final String title; + + /// Optional second line explaining what the overlay adds. + final String? subtitle; + + final String tooltip; + final VoidCallback onTap; + + /// Row width — fixed so a dropdown's rows line up regardless of label length. + static const double width = 228; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Tooltip( + message: tooltip, + child: MenuItemButton( + onPressed: onTap, + style: MapChipButton.rowStyle( + selected + ? colors.primaryContainer.withValues(alpha: 0.45) + : Colors.transparent, + ), + child: SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + Icon( + icon, + size: 22, + color: selected ? colors.primary : colors.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurface, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + selected ? Icons.check_box : Icons.check_box_outline_blank, + size: 20, + color: selected ? colors.primary : colors.outlineVariant, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/sheet_extent.dart b/lib/shared/widgets/sheet_extent.dart new file mode 100644 index 000000000..13c3de2b1 --- /dev/null +++ b/lib/shared/widgets/sheet_extent.dart @@ -0,0 +1,213 @@ +/// Shared bottom-sheet plumbing: tracking a draggable sheet's live extent, +/// deciding when it is flush with the top, and the grab handle. Four sheets +/// (station / DPM / typhoon / report filter) used to each copy this. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/shared/widgets/sheet_surface.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Whether a sheet [extent] (0…1) counts as flush with the top — the grip +/// hides and the panel merges with the status bar. +bool sheetExtentIsAtTop(double extent) => extent >= 1.0 - 0.02; + +/// Forwards every [DraggableScrollableNotification] from [child] into [extent], +/// so the sheet's own widgets can listen to the live scroll position. +class SheetExtentListener extends StatelessWidget { + const SheetExtentListener({ + super.key, + required this.extent, + required this.child, + }); + + /// The live sheet extent (0…1), written on every scroll notification. + final ValueNotifier extent; + + /// The scrollable whose extent to track (usually a + /// [DraggableScrollableSheet]). + final Widget child; + + @override + Widget build(BuildContext context) => + NotificationListener( + onNotification: (notification) { + extent.value = notification.extent; + return false; + }, + child: child, + ); +} + +/// The sheet's grab handle — hidden once the sheet is flush with the top. +/// Listens to [extent] itself, flipping via a post-frame callback so the hide +/// never dirties DSS layout mid-scroll. +class SheetGrip extends StatefulWidget { + const SheetGrip({super.key, required this.extent}); + + final ValueListenable extent; + + @override + State createState() => _SheetGripState(); +} + +class _SheetGripState extends State with SheetExtentFlag { + @override + ValueListenable get sheetExtent => widget.extent; + + @override + bool sheetFlagFrom(double extent) => !sheetExtentIsAtTop(extent); + + @override + Widget build(BuildContext context) { + if (!sheetFlag) return const SizedBox.shrink(); + final colors = Theme.of(context).colorScheme; + return Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colors.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } +} + +/// The full chrome of a bottom draggable sheet: extent listener, key-remount +/// [DraggableScrollableSheet], status-bar fill, and frosted [SheetSurface]. +/// +/// The typhoon / station / DPM sheets all share this skeleton — only the +/// remount [keyValue] and the size constants differ. [content] builds the +/// scroll body (which must not rebuild on every drag pixel; the chrome's +/// `ValueListenableBuilder` keeps the extent-reactive restyle separate). +class ExtentSheetChrome extends StatelessWidget { + const ExtentSheetChrome({ + super.key, + required this.extent, + required this.keyValue, + required this.initial, + required this.min, + required this.max, + required this.snapSizes, + required this.content, + }); + + /// Live sheet fraction — drives the chrome restyles only. + final ValueNotifier extent; + + /// Remount key — changing it pops/collapses the sheet via a fresh + /// [DraggableScrollableSheet] seeded at [initial]. + final Object? keyValue; + + final double initial; + final double min; + final double max; + + /// Snap detents; enables snapping when non-empty. + final List snapSizes; + + /// Builds the sheet body from the scroll controller — once per sheet mount. + final Widget Function(BuildContext context, ScrollController controller) + content; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return SheetExtentListener( + extent: extent, + child: Align( + alignment: Alignment.bottomCenter, + child: DraggableScrollableSheet( + key: ValueKey(keyValue), + expand: false, + initialChildSize: initial, + minChildSize: min, + maxChildSize: max, + snap: snapSizes.isNotEmpty, + snapSizes: snapSizes, + builder: (context, scrollController) { + final body = content(context, scrollController); + return ValueListenableBuilder( + valueListenable: extent, + child: body, + builder: (context, value, body) { + final atTop = sheetExtentIsAtTop(value); + final topInset = MediaQuery.paddingOf(context).top; + return AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarColor: atTop ? colors.surface : Colors.transparent, + statusBarIconBrightness: theme.brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + statusBarBrightness: theme.brightness, + ), + child: SheetSurface( + flushTop: atTop, + child: Padding( + padding: EdgeInsets.only(top: atTop ? topInset : 0), + child: body!, + ), + ), + ); + }, + ); + }, + ), + ), + ); + } +} + +/// A State deriving a boolean from the live sheet extent, flipping it via a +/// post-frame callback — a same-frame flip mid-DSS-layout would otherwise dirty +/// parentData for semantics. Self-listening keeps a widget out of the extent's +/// `ListenableBuilder`, so it only rebuilds when the derived flag actually +/// changes, not on every drag pixel. +mixin SheetExtentFlag on State { + /// The live extent to listen to. + ValueListenable get sheetExtent; + + /// The flag value for [extent]. + bool sheetFlagFrom(double extent); + + /// The current derived flag. + bool get sheetFlag => _sheetFlag; + + late bool _sheetFlag = sheetFlagFrom(sheetExtent.value); + + @override + void initState() { + super.initState(); + sheetExtent.addListener(_onSheetExtent); + } + + @override + void didUpdateWidget(covariant T oldWidget) { + super.didUpdateWidget(oldWidget); + // Re-bind in case the parent swapped the listenable. + sheetExtent.removeListener(_onSheetExtent); + sheetExtent.addListener(_onSheetExtent); + _onSheetExtent(); + } + + @override + void dispose() { + sheetExtent.removeListener(_onSheetExtent); + super.dispose(); + } + + void _onSheetExtent() { + final next = sheetFlagFrom(sheetExtent.value); + if (next == _sheetFlag) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final current = sheetFlagFrom(sheetExtent.value); + if (current != _sheetFlag) setState(() => _sheetFlag = current); + }); + } +} diff --git a/lib/shared/widgets/sheet_surface.dart b/lib/shared/widgets/sheet_surface.dart new file mode 100644 index 000000000..c23d419b8 --- /dev/null +++ b/lib/shared/widgets/sheet_surface.dart @@ -0,0 +1,38 @@ +/// The frosted, rounded panel every draggable detail sheet slides in on — +/// rounded + shadowed mid-height, full-bleed under the status bar when flush. +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:flutter/material.dart'; + +class SheetSurface extends StatelessWidget { + const SheetSurface({super.key, required this.child, this.flushTop = false}); + + final Widget child; + + /// Full-bleed under the status bar — drop top radius / shadow so it reads as + /// one panel with the safe-area band, not a floating card. + final bool flushTop; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final radius = flushTop ? BorderRadius.zero : AppRadius.topSheet; + return DecoratedBox( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: radius, + boxShadow: flushTop + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 16, + offset: const Offset(0, -2), + ), + ], + ), + child: ClipRRect(borderRadius: radius, child: child), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 6adbcb056..a53ef4d30 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -649,8 +649,8 @@ packages: dependency: "direct main" description: path: maplibre_gl - ref: "2c515a3feaa7fee565e1ae03971aade841bec54f" - resolved-ref: "2c515a3feaa7fee565e1ae03971aade841bec54f" + ref: "7c2e249149ff3023c54c86a1f4c0dca5fb43d6a1" + resolved-ref: "7c2e249149ff3023c54c86a1f4c0dca5fb43d6a1" url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" source: git version: "0.26.2" @@ -1180,7 +1180,7 @@ packages: source: hosted version: "3.2.5" url_launcher_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" diff --git a/pubspec.yaml b/pubspec.yaml index 4aad52785..b5aa9db13 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,7 +26,7 @@ dependencies: in_app_purchase: ^3.3.0 intl: ^0.20.2 json_annotation: ^4.12.0 - # ExpTech fork — HTTPS intercept + Dart tile-cache bridge. + # ExpTech fork — HTTPS intercept + Dart tile-cache bridge + Android gzip fix. # Requires MapLibre Native 6.27.0 (ios/vendor/maplibre). # https://github.com/ExpTechTW/flutter-maplibre-gl # Pin a commit (not floating `main`) so CI/`pub get` can't rewrite @@ -36,7 +36,7 @@ dependencies: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl - ref: 2c515a3feaa7fee565e1ae03971aade841bec54f + ref: 7c2e249149ff3023c54c86a1f4c0dca5fb43d6a1 package_info_plus: ^10.2.0 path_provider: ^2.1.6 provider: ^6.1.5+1 @@ -53,6 +53,7 @@ dev_dependencies: freezed: ^3.1.0 json_serializable: ^6.14.0 sqflite_common_ffi: ^2.4.2 + url_launcher_platform_interface: ^2.3.2 flutter: config: @@ -60,12 +61,23 @@ flutter: uses-material-design: true generate: true shaders: - - shaders/fog.frag - - shaders/thunderstorm.frag - - shaders/weather_sky.frag + - shaders/sky/transmittance.frag + - shaders/sky/sky_lut.frag + - shaders/sky/sky_view.frag + - shaders/cloud/clouds.frag + - shaders/weather/sun_flare.frag + - shaders/weather/galaxy.frag + - shaders/weather/night.frag + - shaders/weather/rain_on_glass.frag + - shaders/weather/lightning.frag + - shaders/weather/rainbow.frag + - shaders/weather/card_water.frag assets: - assets/DPIP.png - assets/box.json + - assets/weather/clouds/ + - assets/weather/particles/ + - assets/weather/sky/ - assets/location.json.gz - assets/map/town_boundaries.bin.gz - assets/travel_time.json.gz diff --git a/shaders/README.md b/shaders/README.md new file mode 100644 index 000000000..d0aec408a --- /dev/null +++ b/shaders/README.md @@ -0,0 +1,112 @@ +# shaders — the weather scene + +A layered, GPU-rendered weather backdrop: an atmospheric sky baked into two +lookup tables, a deck of lit cloud sprites over it, and the precipitation, +lightning, flare and rainbow layers on top. + +The pipeline follows a reference implementation whose behaviour was measured +rather than copied; every texture it draws is generated by the scripts in +`tool/`, and no third-party artwork or shader source is shipped. + +## How the sky actually works + +The single most important finding, and the one that makes this work: + +> **There is no simulated sun.** The sky is a 2-D lookup table indexed by +> `(sunAngleY, view elevation)`. `sunAngleY` is an authored per-keyframe value +> and is *literally the u coordinate* into that table. The screen pass is a +> **pure vertical gradient** — every pixel in a row is the same colour — and +> the sun disc belongs to a separate layer, not to the sky. + +Three stages: + +| stage | file | size | when | +|---|---|---|---| +| transmittance LUT | `sky/transmittance.frag` | 256×256 | on atmosphere change | +| sky-view LUT | `sky/sky_lut.frag` | 256×141 | on atmosphere change | +| sky gradient | CPU-baked, `SkyLutCache` | 4×1024 | on atmosphere change | + +The screen pass is no longer a shader. The sky is a pure vertical gradient of +the fixed LUT column, so `SkyLutCache._bakeSkyGradient` evaluates the old +`sky/sky_view.frag` mapping on the CPU once per re-bake (1024 exact samples) +and the painter stretches that image each frame. `sky_view.frag` survives only +as the reference for `test/shaders/preview_render_test.dart`. + +This is Bruneton & Neyret's precomputed atmospheric scattering (EGSR 2008) in +the LUT arrangement of Hillaire's *A Scalable and Production Ready Sky and +Atmosphere Rendering Technique* (EGSR 2020), whose reference implementation is +MIT-licensed. The constants are that paper's standard Earth atmosphere, in +**metres** and **1/metre**: Rayleigh `(5.802e-6, 1.3558e-5, 3.31e-5)`, ozone +`(6.5e-7, 1.881e-6, 8.5e-8)`, planet 6 360 000 m, atmosphere 6 460 000 m, +30 march steps. The per-keyframe values live in +`lib/.../weather_sky/sky_keyframe_data.dart`. + +The visible sky is a deliberately narrow **~19° band** starting just above the +horizon, not a dome — the horizon band is the busiest part of the sky and a +card backdrop reads better a little above it. Widening it is the single biggest +change available to the look. + +## Layer stack (paint order) + +``` +sky CPU-baked gradient from the LUT column → sky_lut_cache (gradient) +night stars + Milky Way + moon → weather/night.frag +clouds lit sprites, drawn per instance → cloud/clouds.frag +rain multi-depth streak curtain → weather/rain.frag +snow multi-depth octagonal flakes → weather/snow.frag +lightning bolt + full-screen flash → weather/lightning.frag +sun_flare crepuscular rays, ghosts, ring → weather/sun_flare.frag +rainbow spectral primary + secondary bow → weather/rainbow.frag +fog raymarched drifting banks → weather/fog.frag +grade grain + scene cast (softLight) → post/grade.frag +``` + +## Clouds + +Clouds are **authored volumetric sprites with baked normal maps**, not +procedural noise, and the several files per family are shape *variants* rather +than animation frames. A procedural fbm field reads as marble and cannot +reproduce them; that was the first attempt at this and it failed. + +DPIP generates its own sprites in the same two-part layout +(`tool/gen_cloud_sprites.py`, an offline volumetric raymarch), so the *shading* +is a faithful port while the artwork is original: + +``` +top half RGB = normal * 0.5 + 0.5, A = 255 +bottom half R = depth from the silhouette edge + G = backness B = inner glow A = coverage +``` + +The channel order deviates from the reference on one point, and it matters: +the reference keeps coverage in B and uploads a raw compressed texture, but +**Flutter premultiplies every image at decode**, so an alpha channel holding +anything else silently multiplies RGB toward zero. Coverage lives in A; the +shader divides it back out. + +Lighting is the environment-light model: cloud colour is not authored, it is +*sampled from the sky LUT* at elevation probes. A cloud's shaded underside is +the colour of the sky near the horizon, its lit top the sky near the sun — +which is why the clouds agree with the sky at every hour with no palette. + +## Flutter constraints worth knowing + +- Fragment-shader samplers bind as **NEAREST** with no way to request linear, + so every LUT read does bilinear filtering by hand. Without it the sky bands. +- Shader output is **premultiplied** — `vec4(rgb * a, a)`. +- `FlutterFragCoord()` is local to the canvas transform, so a translated rect + gets coordinates from its own origin. +- SkSL rejects array initialisers and reserves some identifiers (`packed`). +- Uniforms are float slots in declaration order; each shader's header lists its + layout and `test/shaders/weather_sky_shader_test.dart` pins it. + +## Regenerating + +``` +tool/gen_cloud_sprites.py # cloud sprites -> assets/weather/clouds/ +tool/gen_particle_sprites.py # rain/snow/drop -> assets/weather/particles/ +tool/gen_sky_textures.py # starmap, flare -> assets/weather/sky/ +``` + +Preview renders: `flutter test --run-skipped --tags preview +test/shaders/preview_render_test.dart` → `build/sky_preview/`. diff --git a/shaders/cloud/clouds.frag b/shaders/cloud/clouds.frag new file mode 100644 index 000000000..ef3520465 --- /dev/null +++ b/shaders/cloud/clouds.frag @@ -0,0 +1,322 @@ +// Cloud sprite — a port of the reference engine's +// The reference shader. +// +// The reference's clouds are authored volumetric sprites with baked normal maps drawn +// as 3D quads, not procedural noise. Reproducing them procedurally is what the +// first attempt at this got wrong: an fbm field reads as marble, never as +// cauliflower. So this shader shades one sprite, and the Dart layer places and +// draws the instances — which is exactly the division the original engine has. +// +// The sprite is the reference's two-part layout (the reference shader, +// `TEXTURE_PART_NUM 2.0`): +// +// top half normal map RGB = normal * 0.5 + 0.5, A = 255 +// bottom half R = depth from the silhouette edge +// G = backness (far side of the cloud) +// B = inner-glow mask +// A = coverage +// +// The channel order deviates from the reference's on one point, and it matters: +// The reference keeps coverage in B and uploads raw ASTC, but Flutter **premultiplies +// every image at decode**. An alpha channel holding anything but coverage +// therefore silently multiplies RGB toward zero — which is exactly what made +// the first attempt at this render almost invisible. Coverage lives in A, and +// the other channels are divided back out below. +// +// The lighting is the environment-light model, and it is the part worth +// porting: cloud colour is not authored, it is *sampled from the sky LUT* at a +// set of elevation probes. A cloud's shaded underside is literally the colour +// of the sky near the horizon; its lit top is the sky near the sun. That is +// why the reference's clouds agree with the sky at every hour with no palette to +// maintain. +// +// Four directional lights are dotted against the normal, as in the original — +// the fourth being `directionalLights0` with its z mirrored, which is what +// lights the cloud from behind and produces silver linings on thin edges +// (`backMulti`). +// +// Uniform contract — slots are float indices in declaration order. +// iSpriteSize (0..1) sprite size in pixels (full, both halves) +// iLightDir0 (2..4) sun direction (view space) +// iLightDir1 (5..7) ground-bounce direction +// iLightDir2 (8..10) ambient direction +// iBaseCfg (11..13) base light: picker, multi, intensity +// iSunCfg (14..16) sun light: picker, multi, intensity +// iGroundCfg (17..19) ground light: picker, multi, intensity +// iAmbientCfg (20..22) ambient light: picker, multi, intensity +// iOpacity (23) instance opacity +// iStartEdge (24) soft-edge start (cloud profile `uStartEdge`) +// iEndEdge (25) soft-edge end (cloud profile `uEndEdge`) +// iProgress (26) deck reveal 0..2 (the reference's `uProgress`) +// iSmooth (27) reveal softness (the reference's `uSmooth`) +// iCloudDepth (28) depth weighting 0..1 +// iSunIntensity (29) direct-sun strength 0..1 +// iFogDensity (30) haze blending the sprite toward the sky +// iInner (31) internal illumination 0..1 (lightning inside) +// iWhitePer (32) noon whitening (the reference's `whitePer`) +// iLutSize (33..34) sky LUT column size — only `.y` (height) is used; +// the sampler is the CPU-extracted column at the +// keyframe's sun angle, so u is gone +// iTexSize (35..36) sprite size in texels +// iBaseSky (37..39) sky colour at the base light's probe — baked once +// per keyframe on the CPU (the probe is pixel- +// independent), so the GPU skips that LUT fetch +// iHazeSky (40..42) sky colour at the 0.95 haze probe, same reason +// sampler 0: iSprite the cloud sprite +// sampler 1: iSkyView the 1×141 sky-LUT column (`sky/sky_lut.frag`, +// extracted by `SkyLutCache._bakeSkyColumn`) +#include + +precision mediump float; + +uniform vec2 iSpriteSize; +uniform vec3 iLightDir0; +uniform vec3 iLightDir1; +uniform vec3 iLightDir2; +uniform vec3 iBaseCfg; +uniform vec3 iSunCfg; +uniform vec3 iGroundCfg; +uniform vec3 iAmbientCfg; +uniform float iOpacity; +uniform float iStartEdge; +uniform float iEndEdge; +uniform float iProgress; +uniform float iSmooth; +uniform float iCloudDepth; +uniform float iSunIntensity; +uniform float iFogDensity; +uniform float iInner; +uniform float iWhitePer; +uniform vec2 iLutSize; +uniform vec2 iTexSize; +uniform vec3 iBaseSky; +uniform vec3 iHazeSky; +uniform sampler2D iSprite; +uniform sampler2D iSkyView; + +out vec4 fragColor; + +#define PI 3.14159265359 +#define ONE_OVER_PI 0.3183098861837907 + +/// Bilinear sky-LUT fetch, reduced to a **vertical** 2-tap. +/// +/// The LUT sampler is the CPU-extracted column at the keyframe's sun angle — +/// every probe's u is that same constant, so the horizontal filtering already +/// happened at bake time and a probe here is a 1D vertical fetch. Flutter +/// binds samplers as NEAREST with no way to request linear, so the vertical +/// interpolation is done by hand; without it the cloud lighting steps as the +/// sun moves. +vec3 sampleLut(float v) { + float texel = 1.0 / iLutSize.y; + float st = v * iLutSize.y - 0.5; + float base = floor(st); + float f = st - base; + float uv0 = (base + 0.5) * texel; + vec3 c0 = texture(iSkyView, vec2(0.5, uv0)).rgb; + vec3 c1 = texture(iSkyView, vec2(0.5, uv0 + texel)).rgb; + return mix(c0, c1, f); +} + +/// Bilinear sprite fetch. +/// +/// Sprites are magnified roughly 2.7x on screen, and Flutter binds samplers as +/// NEAREST — so without this the cloud is drawn from visibly blocky texels. +/// It matters most for the normal map: per-texel normal steps swing the light +/// weights between the blue sky probe and the warm noon tint, which is what +/// speckles a sunlit cloud with stray hues. +/// +/// Filtering happens on the premultiplied values, which is the correct order — +/// that is what premultiplied alpha is for. The caller divides afterwards. +vec4 sampleSprite(vec2 uv) { + vec2 texel = 1.0 / iTexSize; + vec2 st = uv * iTexSize - 0.5; + vec2 base = floor(st); + vec2 f = st - base; + vec2 uv0 = (base + 0.5) * texel; + + vec4 c00 = texture(iSprite, uv0); + vec4 c10 = texture(iSprite, uv0 + vec2(texel.x, 0.0)); + vec4 c01 = texture(iSprite, uv0 + vec2(0.0, texel.y)); + vec4 c11 = texture(iSprite, uv0 + texel); + return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y); +} + +/// The sky's colour at probe [picker], 0 = horizon … 1 = zenith. +/// +/// NOT yet the environment-light model. The engine's probe walks the **camera +/// frustum** — `texCoord2uv` does `normalize(mix(FrustumA, FrustumC, 1 - +/// probe))`, a band of roughly -0.4 to +19 degrees with its 20-degree lens — +/// so every probe lands near the horizon, the brightest part of the sky. +/// +/// That is correct, and it was tried: with the frustum band all four lights +/// return bright haze, the four-light sum passes 1 and the sprites clamp to +/// solid white. The reference keeps it in range because it weights those lights with +/// the **authored per-keyframe `cloudConfig`** (picker/multi/intensity per +/// light), while this port still synthesises them from one `day` scalar and +/// runs far hotter. +/// +/// So this deliberately keeps the wider 0..90-degree mapping, which stays in +/// range at the cost of taking fill light from zenith blue rather than +/// horizon haze. Switching to the real band is blocked on consuming the +/// authored cloudConfig — the same prerequisite as the real `whitePer` curve. +vec3 skyAt(float picker) { + // elevation = picker·(π/2), so `sqrt(elevation/(π/2))` is just `sqrt(picker)`. + float m = sqrt(clamp(picker, 0.0, 1.0)); + float v = clamp((m + 0.1015625) / 1.1015625, 0.0, 1.0); + return sampleLut(v); +} + +/// The reference's `readColorConfig`: sample the sky at the config's probe, then gain. +vec3 readColorConfig(vec3 cfg) { + return skyAt(cfg.x) * cfg.y; +} + +/// The reference's `calcDepthAlpha` — the deck reveal sweeps a band through the +/// sprite's depth channel, so a cloud materialises front-to-back. +float calcDepthAlpha(float depth) { + float per = (1.0 + iSmooth) - clamp(iProgress, 0.0, 1.0); + return clamp((depth - (per - iSmooth)) / max(iSmooth, 1e-4), 0.0, 1.0); +} + +void main() { + // The sprite's two halves, addressed exactly as the reference's vertex shader does. + vec2 uv = FlutterFragCoord().xy / iSpriteSize; + // Keep each half's v inside its own band by a texel, so the bilinear taps + // never straddle the seam and pull the other half's data across it. + float guard = 1.0 / iTexSize.y; + vec2 albedoUv = vec2(uv.x, clamp(uv.y * 0.5 + 0.5, 0.5 + guard, 1.0 - guard)); + vec2 normalUv = vec2(uv.x, clamp(uv.y * 0.5, guard, 0.5 - guard)); + + // Flutter hands back premultiplied samples, so undo it to recover the + // packed channels. + vec4 raw = sampleSprite(albedoUv); + float coverage = raw.a; + if (coverage < 0.004) { + fragColor = vec4(0.0); + return; + } + // Guard the divisor: at the feathered rim coverage approaches zero and + // dividing by it amplifies 8-bit quantisation into visible speckle. + vec3 unpremul = raw.rgb / max(coverage, 0.10); + float texDepth = clamp(unpremul.r, 0.0, 1.0); + float texBack = clamp(unpremul.g, 0.0, 1.0); + float texInner = clamp(unpremul.b, 0.0, 1.0); + + float depthAlpha = calcDepthAlpha(texDepth); + if (depthAlpha < 0.01) { + fragColor = vec4(0.0); + return; + } + + // --- alpha ------------------------------------------------------------- + float alpha = iOpacity * coverage; + // Soft edge: the outer wisps (low depth) are eroded away. + float edge = smoothstep(iEndEdge, iStartEdge, texDepth); + alpha = max(alpha * 0.5, alpha - edge * edge * 0.5); + // The far side of the cloud is transparent. + alpha *= smoothstep(1.0, 0.5, texBack); + if (alpha < 0.004) { + fragColor = vec4(0.0); + return; + } + + // --- normal ------------------------------------------------------------ + // The reference stores an un-normalised normal and normalises after scaling xy, so + // the stored xy:z ratio is what carries the surface tilt. + // The normal half is stored with A = 255, so premultiply is a no-op. + vec3 mapN = sampleSprite(normalUv).rgb * 2.0 - 1.0; + // The encoded z straddles zero (the reference's own distribution does too, and its + // shader flips on gl_FrontFacing). A sprite is always front-facing here, so + // clamp z positive — letting it go negative flips the lighting on scattered + // pixels and speckles the cloud with stray hues. + mapN.z = max(mapN.z, 0.08); + vec3 normal = normalize(mapN); + + // The fourth light is light 0 with z mirrored — the reference's back light. + vec3 lightDir3 = vec3(iLightDir0.xy, -iLightDir0.z); + + float l0 = max(dot(iLightDir0, normal), 0.0) * ONE_OVER_PI; + float l1 = max(dot(iLightDir1, normal), 0.0) * ONE_OVER_PI; + float l2 = max(dot(iLightDir2, normal), 0.0) * ONE_OVER_PI; + float l3 = max(dot(lightDir3, normal), 0.0) * ONE_OVER_PI; + + // How thin the cloud is here. Thin cloud transmits, so it is brighter and + // takes the silver-lining rim. + float backMulti = + smoothstep(0.05, 1.0, mix(1.0 - iCloudDepth, 1.0, texBack)); + + // --- the five-light rig, all sampled from the sky ----------------------- + // The base probe is pixel-independent (its picker is built from uniforms + // only), so its sky colour is baked on the CPU next to the keyframe and + // arrives as `iBaseSky` — one fewer LUT fetch per pixel. The gain below is + // still per-pixel (it rides `backMulti`), so only the colour is precomputed. + vec3 baseCfg = iBaseCfg; + float baseEdgeMulti = mix(1.0, 2.1, backMulti * min(1.0, iSunCfg.z)); + baseEdgeMulti = mix(baseEdgeMulti, 1.0, iWhitePer * 0.9); + // Cap the combined gain: the reference's two multipliers can reach 3.1x, which + // drives the blue channel past clipping at high sun and leaves the + // unclipped channels behind as a colour cast. + baseCfg.y *= min(mix(1.0, 1.5, backMulti) * baseEdgeMulti, 1.8); + baseCfg.y = mix(1.0, baseCfg.y, iCloudDepth); + vec3 baseColor = iBaseSky * baseCfg.y; + + // Each light samples the sky between its own probe and the next one, blended + // by how strongly it hits — so a lit face reads the brighter sky. + // The probe is nudged by how strongly the light hits, but only gently: + // moving it the full way makes neighbouring pixels read different LUT rows + // and speckles the cloud with stray hues. + vec3 sunCfg = + vec3(mix(iSunCfg.x, iGroundCfg.x, l0 * 0.35), iSunCfg.y, iSunCfg.z); + vec3 sunColor = readColorConfig(sunCfg); + float sunI = mix(0.3, sunCfg.z, iSunIntensity); + + vec3 groundCfg = vec3(mix(iGroundCfg.x, iAmbientCfg.x, l1 * 0.35), + iGroundCfg.y, iGroundCfg.z); + vec3 groundColor = readColorConfig(groundCfg); + float groundI = mix(0.3, groundCfg.z, iSunIntensity); + + vec3 ambientCfg = vec3(mix(iAmbientCfg.x, 1.0, l2 * 0.35), iAmbientCfg.y, + iAmbientCfg.z); + vec3 ambientColor = readColorConfig(ambientCfg); + float ambientI = mix(0.3, ambientCfg.z, iSunIntensity); + + vec3 backCfg = vec3(mix(iSunCfg.x, iGroundCfg.x, l3 * 0.35), + mix(0.0, 1.3, backMulti), iSunCfg.z); + vec3 backColor = readColorConfig(backCfg); + float backI = mix(1.5, 1.0, iSunIntensity) * backCfg.z; + + // The reference's authored noon tint — at high sun the sky probes go too blue, so it + // pulls the lights toward warm white. + sunColor = mix(sunColor, vec3(1.0, 0.74, 0.53), iWhitePer); + groundColor = mix(groundColor, vec3(0.85, 0.54, 0.37), iWhitePer); + ambientColor = mix(ambientColor, vec3(0.96, 0.63, 0.42), iWhitePer); + backColor = mix(backColor, vec3(1.0, 0.74, 0.53), iWhitePer); + + // --- composite ---------------------------------------------------------- + vec3 diffuse = mix(vec3(texDepth), vec3(1.0), 0.8); + diffuse = mix(diffuse, vec3(0.8), iFogDensity); + + float fog = max(0.0, iFogDensity); + float fogMulti = 1.0 - fog * fog; + + vec3 color = baseColor; + color += l0 * sunColor * sunI * fogMulti; + color += l1 * groundColor * groundI * fogMulti; + color += l2 * ambientColor * ambientI * fogMulti; + color += l3 * backColor * backI * fogMulti; + + // Lightning inside the cloud, masked by the sprite's inner-glow channel. + vec3 innerColor = mix(baseColor, vec3(1.0), 0.1); + color += innerColor * (0.1 + smoothstep(0.05, 1.0, texInner)) * iInner * 0.4; + color *= mix(1.0, 1.5, iInner); + + color *= diffuse; + // Haze dissolves the sprite into the sky behind it. The 0.95 haze probe is + // pixel-independent, so its colour arrives baked in `iHazeSky` (see + // `iBaseSky`). + color = mix(color, iHazeSky, iFogDensity); + + float a = clamp(alpha * depthAlpha, 0.0, 1.0); + fragColor = vec4(clamp(color, 0.0, 1.0) * a, a); // premultiplied +} diff --git a/shaders/fog.frag b/shaders/fog.frag deleted file mode 100644 index 16e8a660c..000000000 --- a/shaders/fog.frag +++ /dev/null @@ -1,81 +0,0 @@ -#include - -uniform float iTime; -uniform vec2 iResolution; -uniform sampler2D iChannel0; -uniform float iIntensity; -uniform float iSpeed; - -out vec4 fragColor; - -vec2 hash22(vec2 p) { - p = vec2(dot(p, vec2(127.1, 311.7)), - dot(p, vec2(269.5, 183.3))); - return fract(sin(p) * 43758.5453); -} - -float noise2D(vec2 p) { - vec2 i = floor(p); - vec2 f = fract(p); - - vec2 a = hash22(i); - vec2 b = hash22(i + vec2(1.0, 0.0)); - vec2 c = hash22(i + vec2(0.0, 1.0)); - vec2 d = hash22(i + vec2(1.0, 1.0)); - - vec2 u = f * f * (3.0 - 2.0 * f); - - return mix( - mix(a.x, b.x, u.x), - mix(c.x, d.x, u.x), - u.y - ); -} - -float fbm(vec2 p) { - float value = 0.0; - float amplitude = 0.5; - float frequency = 1.0; - - mat2 rot = mat2(0.8, 0.6, -0.6, 0.8); - - for (int i = 0; i < 6; i++) { - value += amplitude * noise2D(p * frequency); - amplitude *= 0.5; - frequency *= 2.0; - p = rot * p; - } - - return value; -} - -float fogMask(vec2 uv) { - vec2 p = uv * 3.0; - p.x -= iTime * iSpeed * 0.1; - p.y += iTime * iSpeed * 0.02; - - float fog = fbm(p); - - float fog2 = fbm(p * 1.5 - vec2(iTime * iSpeed * 0.12, 0.0)); - fog = mix(fog, fog2, 0.5); - - fog = smoothstep(0.2, 0.8, fog); - - return fog * iIntensity; -} - -void main() { - vec2 fragCoord = FlutterFragCoord(); - vec2 uv = fragCoord.xy / iResolution.xy; - - vec4 originalColor = texture(iChannel0, uv); - - float fog = fogMask(uv); - - vec3 fogColor = vec3(0.95, 0.97, 1.0); - - vec3 finalColor = mix(originalColor.rgb, fogColor, fog); - - fragColor = vec4(finalColor, originalColor.a); -} - diff --git a/shaders/sky/sky_lut.frag b/shaders/sky/sky_lut.frag new file mode 100644 index 000000000..5a264369a --- /dev/null +++ b/shaders/sky/sky_lut.frag @@ -0,0 +1,229 @@ +// Sky-view LUT — stage 2 of the atmospheric sky (baked at 256x141 RGB8). +// +// Single-scattering ray march in the arrangement of Hillaire 2020 (see +// `transmittance.frag`): step along the view ray, carry the eye-to-step +// transmittance, and at each step add the sunlight that survives to it — +// `sunTransmittance` read from stage 1 — times the phase function. +// +// Each texel ray-marches one view ray through the atmosphere and accumulates +// single scattering, giving the sky's radiance in that direction. The whole +// sky lands in one small texture that `sky_view.frag` samples per frame, so +// the integral runs on a bake rather than per pixel per frame. +// +// LUT axes, exactly the reference's: +// u → the sun's position along its arc, `mix(0°, 180°)`. This is indexed by +// the authored `sunAngleY` keyframe value — the engine has no solar +// ephemeris, and the sun never goes below the horizon here. Night comes +// from the keyframe's low `sunAngleY` plus its atmosphere, not from a +// negative elevation. +// v → view elevation, `sign(m) * (π/2) * m²` over `m ∈ [-0.102, 1]`. The +// square packs texels near the horizon where the gradient is steepest. +// +// The view ray is coplanar with the sun (the reference forces `texCoord.x = 0.5`), so +// the sky has no azimuthal variation — correct, because the screen pass is a +// pure vertical gradient. +// +// Distances are metres and coefficients 1/m, matching the engine's uploads. +// +// Uniform contract — slots are float indices in declaration order. +// uResolution (0..1) LUT size in pixels +// uRayleighScatter (2..4) Rayleigh scattering coefficient (1/m) +// uOzoneAbsorb (5..7) ozone absorption coefficient (1/m) +// uSunRadiance (8..10) sun radiance (config `sunIntensity`) +// uPostA..uPostE (11..15) tonemap coefficients (config `postColor`) +// uRayleighHeight (16) Rayleigh scale height (m) +// uMieScatter (17) Mie scattering coefficient (1/m) +// uMieAbsorb (18) Mie absorption coefficient (1/m) +// uMieHeight (19) Mie scale height (m) +// uMieAsymmetry (20) Mie phase asymmetry g +// uOzoneCentre (21) ozone layer centre altitude (m) +// uOzoneThickness (22) ozone layer half-thickness (m) +// uPlanetRadius (23) planet radius (m) +// uAtmosphereRadius (24) atmosphere outer radius (m) +// uEyeAltitude (25) viewer altitude (m) +// uTransSize (26..27) stage-1 LUT size in texels +// sampler 0: uTransmittance stage-1 LUT +#include + +precision highp float; + +uniform vec2 uResolution; +uniform vec3 uRayleighScatter; +uniform vec3 uOzoneAbsorb; +uniform vec3 uSunRadiance; +uniform float uPostA; +uniform float uPostB; +uniform float uPostC; +uniform float uPostD; +uniform float uPostE; +uniform float uRayleighHeight; +uniform float uMieScatter; +uniform float uMieAbsorb; +uniform float uMieHeight; +uniform float uMieAsymmetry; +uniform float uOzoneCentre; +uniform float uOzoneThickness; +uniform float uPlanetRadius; +uniform float uAtmosphereRadius; +uniform float uEyeAltitude; +uniform vec2 uTransSize; +uniform sampler2D uTransmittance; + +out vec4 fragColor; + +#define PI 3.14159265359 + +/// The reference's `MarchStepCount`. Constant so the loop unrolls on every backend. +const int kStepCount = 30; + +/// Scattering and extinction coefficients at altitude [h]. +void sigmaAt(float h, out vec3 scatter, out vec3 extinction) { + vec3 rayleigh = uRayleighScatter * exp(-h / uRayleighHeight); + float mieDensity = exp(-h / uMieHeight); + float mieScatter = uMieScatter * mieDensity; + float mieExtinct = (uMieScatter + uMieAbsorb) * mieDensity; + float ozone = + max(0.0, 1.0 - 0.5 * abs(h - uOzoneCentre) / uOzoneThickness); + + scatter = rayleigh + vec3(mieScatter); + extinction = rayleigh + vec3(mieExtinct) + uOzoneAbsorb * ozone; +} + +/// Per-channel phase function, weighted by each component's contribution. +vec3 phaseAt(float h, float cosTheta) { + vec3 rayleigh = uRayleighScatter * exp(-h / uRayleighHeight); + float mie = uMieScatter * exp(-h / uMieHeight); + + float cos2 = cosTheta * cosTheta; + float pRayleigh = 3.0 / (16.0 * PI) * (1.0 + cos2); + + float g = uMieAsymmetry; + float g2 = g * g; + float m = max(1.0 + g2 - 2.0 * g * cosTheta, 1e-4); + float pMie = + 3.0 / (8.0 * PI) * (1.0 - g2) * (1.0 + cos2) / ((2.0 + g2) * m * sqrt(m)); + + vec3 total = rayleigh + vec3(mie); + vec3 weighted = pRayleigh * rayleigh + pMie * vec3(mie); + return vec3( + total.x > 0.0 ? weighted.x / total.x : 0.0, + total.y > 0.0 ? weighted.y / total.y : 0.0, + total.z > 0.0 ? weighted.z / total.z : 0.0); +} + +/// Bilinear transmittance fetch. +/// +/// Flutter binds fragment-shader samplers as NEAREST with no way to request +/// linear, and the engine's own LUTs are LINEAR — so the interpolation has to +/// be done by hand or the sunset reddening quantises into bands. +vec3 sunTransmittance(float h, float cosZenith) { + float u = clamp(h / (uAtmosphereRadius - uPlanetRadius), 0.0, 1.0); + float v = clamp(0.5 + 0.5 * cosZenith, 0.0, 1.0); + + vec2 texel = 1.0 / uTransSize; + vec2 st = vec2(u, v) * uTransSize - 0.5; + vec2 base = floor(st); + vec2 f = st - base; + vec2 uv0 = (base + 0.5) * texel; + + vec3 c00 = texture(uTransmittance, uv0).rgb; + vec3 c10 = texture(uTransmittance, uv0 + vec2(texel.x, 0.0)).rgb; + vec3 c01 = texture(uTransmittance, uv0 + vec2(0.0, texel.y)).rgb; + vec3 c11 = texture(uTransmittance, uv0 + texel).rgb; + return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y); +} + +/// The reference's `tonemap`: the generic filmic curve with authored coefficients. +vec3 tonemap(vec3 x) { + return (x * (vec3(uPostA) * x + vec3(uPostB))) / + (x * (vec3(uPostC) * x + vec3(uPostD)) + vec3(uPostE)); +} + +/// The reference's `postProcessColor` dither: the LUT is 8-bit, so each channel is +/// nudged to a neighbouring level with probability equal to its fraction. +vec3 dither8Bit(vec2 seed, vec3 color) { + float rand = + fract(sin(dot(seed, vec2(12.9898, 78.233) * vec2(2.0))) * 43758.5453); + vec3 scaled = vec3(255.0) * clamp(pow(color, vec3(1.0 / 2.2)), 0.0, 1.0); + vec3 frac = scaled - floor(scaled); + return mix(floor(scaled), ceil(scaled), step(vec3(rand), frac)) / 255.0; +} + +/// Whether a ray from [o] along [d] meets the sphere of radius [r]. +bool hitsSphere(vec3 o, vec3 d, float r) { + float b = dot(o, d); + float c = dot(o, o) - r * r; + return (b * b - c >= 0.0) && (c <= 0.0 || b <= 0.0); +} + +/// Nearest forward intersection distance with a circle of radius [r]. +bool circleHit(vec2 o, vec2 d, float r, out float t) { + float b = dot(o, d); + float c = dot(o, o) - r * r; + float disc = b * b - c; + if (disc < 0.0) return false; + t = -b + (c <= 0.0 ? sqrt(disc) : -sqrt(disc)); + return (c <= 0.0) || (b <= 0.0); +} + +void main() { + vec2 uv = FlutterFragCoord().xy / uResolution; + + // --- view elevation (v axis) ------------------------------------------- + float m = uv.y * 1.1015625 - 0.1015625; + float theta = sign(m) * (PI / 2.0) * m * m; + float sinT = sin(theta); + float cosT = cos(theta); + vec3 dir = vec3(-cosT, sinT, 0.0); + + // --- sun position along its arc (u axis) -------------------------------- + // The reference's `calSunDir` negates elevation, and every use is of `-sunDir`; the + // direction *toward* the sun is therefore (-cos e, sin e, 0). + float e = radians(mix(0.0, 180.0, uv.x)); + vec3 toSun = vec3(-cos(e), sin(e), 0.0); + + float cosPhase = dot(dir, toSun); + + // --- ray extent --------------------------------------------------------- + vec2 planetOri = vec2(0.0, uEyeAltitude + uPlanetRadius); + vec2 planetDir = vec2(cosT, sinT); + float endT = 0.0; + if (!circleHit(planetOri, planetDir, uPlanetRadius, endT)) { + circleHit(planetOri, planetDir, uAtmosphereRadius, endT); + } + + // --- march -------------------------------------------------------------- + vec3 inScatter = vec3(0.0); + vec3 sumSigmaT = vec3(0.0); + float dt = endT / float(kStepCount); + + for (int i = 0; i < kStepCount; i++) { + float thisT = dt * float(i); + float midT = thisT + 0.5 * dt; + + vec3 posR = vec3(0.0, uEyeAltitude + uPlanetRadius, 0.0) + dir * midT; + float h = length(posR) - uPlanetRadius; + + vec3 scatter, extinction; + sigmaAt(h, scatter, extinction); + + vec3 deltaSigmaT = extinction * dt; + // Transmittance from the eye to the middle of this step. + vec3 eyeTrans = exp(-sumSigmaT - 0.5 * deltaSigmaT); + + if (!hitsSphere(posR, toSun, uPlanetRadius)) { + float cosZenith = dot(toSun, normalize(posR)); + vec3 sunTrans = sunTransmittance(h, cosZenith); + inScatter += dt * eyeTrans * scatter * phaseAt(h, cosPhase) * sunTrans; + } + + sumSigmaT += deltaSigmaT; + } + + // The LUT is 8-bit, so it stores display-ready values: the reference tonemaps, + // gamma-encodes and dithers here rather than at display time. + vec3 color = inScatter * uSunRadiance; + color = dither8Bit(vec2(0.5, uv.y), tonemap(color)); + + fragColor = vec4(color, 1.0); +} diff --git a/shaders/sky/sky_view.frag b/shaders/sky/sky_view.frag new file mode 100644 index 000000000..e1d759f91 --- /dev/null +++ b/shaders/sky/sky_view.frag @@ -0,0 +1,89 @@ +// Sky screen pass — stage 3. +// +// **Superseded at runtime:** the production backdrop no longer runs this shader. +// The pass is a pure vertical gradient of a fixed LUT column, so the painter +// now draws a CPU-baked ramp (`SkyLutCache._bakeSkyGradient`) stretched to the +// screen. This file is kept as the reference implementation for the preview +// tool (`test/shaders/preview_render_test.dart`) and its compile check. +// +// This is a **pure vertical gradient**: every pixel in a horizontal row gets +// the same colour. The sky has no azimuthal variation and no sun disc — the +// disc belongs to the separate sun layer. All this pass does is turn a screen +// row into a view elevation and read the colour stage 2 already integrated +// for it. +// +// The elevation comes from the frustum built in `sky_lut_cache.dart`: a narrow +// band of sky a little above the horizon — roughly 3.6° to 22.9° of elevation +// at the default yaw — rather than a full dome. Widening that band is the +// single biggest change available to the look. `uFrustumA` (top ray) and +// `uFrustumC` (bottom ray) are unit directions rebuilt on the CPU from the +// keyframe's `cameraYaw`. +// +// Uniform contract — slots are float indices in declaration order. +// uResolution (0..1) render size in pixels +// uFrustumA (2..4) top-of-frustum ray +// uFrustumC (5..7) bottom-of-frustum ray +// uSunAngleY (8) sky-LUT u coordinate (the keyframe's authored value) +// uTime (9) animation time (dither scroll) +// uLutSize (10..11) stage-2 LUT size in texels +// sampler 0: uSkyView stage-2 LUT +#include + +precision highp float; + +uniform vec2 uResolution; +uniform vec3 uFrustumA; +uniform vec3 uFrustumC; +uniform float uSunAngleY; +uniform float uTime; +uniform vec2 uLutSize; +uniform sampler2D uSkyView; + +out vec4 fragColor; + +#define PI 3.14159265359 + +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +/// Bilinear LUT fetch — Flutter binds samplers as NEAREST and offers no way +/// to ask for linear, while the engine's LUTs are LINEAR. Without this the +/// sky shows one visible step per LUT row. +vec3 sampleLut(vec2 uv) { + vec2 texel = 1.0 / uLutSize; + vec2 st = uv * uLutSize - 0.5; + vec2 base = floor(st); + vec2 f = st - base; + vec2 uv0 = (base + 0.5) * texel; + + vec3 c00 = texture(uSkyView, uv0).rgb; + vec3 c10 = texture(uSkyView, uv0 + vec2(texel.x, 0.0)).rgb; + vec3 c01 = texture(uSkyView, uv0 + vec2(0.0, texel.y)).rgb; + vec3 c11 = texture(uSkyView, uv0 + texel).rgb; + return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y); +} + +void main() { + vec2 fc = FlutterFragCoord().xy; + vec2 uv = fc / uResolution; + + // Screen top → FrustumA, bottom → FrustumC. Flutter's fragment coordinate + // already has y increasing downward, so no flip is needed here (the reference flips + // because its own vUv has y up). + vec3 dir = normalize(mix(uFrustumA, uFrustumC, uv.y)); + + float theta = asin(clamp(dir.y, -1.0, 1.0)); + float v = 0.5 + 0.5 * sign(theta) * sqrt(abs(theta) / (PI / 2.0)); + + // The reference's remap into the 141-row LUT, plus its sub-texel dither (0.00375) + // which hides the row seams in the steep gradient near the horizon. + float jitter = (hash12(fc + vec2(0.0, floor(uTime * 8.0))) - 0.5) * 0.00375; + float lutV = clamp((v * 256.0 - 115.0) / 141.0 + jitter, 0.0, 1.0); + + vec3 color = sampleLut(vec2(clamp(uSunAngleY, 0.0, 1.0), lutV)); + + fragColor = vec4(color, 1.0); +} diff --git a/shaders/sky/transmittance.frag b/shaders/sky/transmittance.frag new file mode 100644 index 000000000..0db96a38f --- /dev/null +++ b/shaders/sky/transmittance.frag @@ -0,0 +1,116 @@ +// Transmittance LUT — stage 1 of the atmospheric sky pipeline. +// +// The pipeline is the standard precomputed-scattering one: Bruneton & Neyret, +// "Precomputed Atmospheric Scattering" (EGSR 2008), in the LUT arrangement of +// Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering +// Technique" (EGSR 2020). The medium's coefficients in `SkyConstants` are that +// paper's standard Earth atmosphere. +// +// For every (altitude, sun-zenith) pair it integrates the optical depth of +// the atmosphere along the ray leaving that point, and stores +// `exp(-opticalDepth)` — the fraction of sunlight that survives the trip. +// Stage 2 (`sky_lut.frag`) samples this to know how much sun reaches each +// point it marches through, which is what makes sunsets redden correctly +// instead of being tinted by hand. +// +// The medium is the standard three-component model: Rayleigh (air molecules, +// exponential falloff, the blue), Mie (aerosols/haze, sharper falloff, the +// white glare), and an ozone absorption band (a tent centred in the +// stratosphere) which is what keeps a clear zenith from going muddy. +// Distances are metres and coefficients 1/m. +// +// LUT layout: +// u = altitude / atmosphereThickness +// v = 0.5 + 0.5 * cos(zenith) +// +// This is baked once per atmosphere change (never per frame) — see +// `sky_lut.dart`. +// +// Uniform contract — slots are float indices in declaration order +// (vec3 = 3 slots, vec2 = 2, float = 1). See shaders/README.md. +// uResolution (0..1) LUT size in pixels +// uRayleighScatter (2..4) Rayleigh scattering coefficient (1/m) +// uOzoneAbsorb (5..7) ozone absorption coefficient (1/m) +// uRayleighHeight (8) Rayleigh scale height (m) +// uMieScatter (9) Mie scattering coefficient (1/m) +// uMieAbsorb (10) Mie absorption coefficient (1/m) +// uMieHeight (11) Mie scale height (m) +// uOzoneCentre (12) ozone layer centre altitude (m) +// uOzoneThickness (13) ozone layer half-thickness (m) +// uPlanetRadius (14) planet radius (m) +// uAtmosphereRadius (15) atmosphere outer radius (m) +#include + +precision highp float; + +uniform vec2 uResolution; +uniform vec3 uRayleighScatter; +uniform vec3 uOzoneAbsorb; +uniform float uRayleighHeight; +uniform float uMieScatter; +uniform float uMieAbsorb; +uniform float uMieHeight; +uniform float uOzoneCentre; +uniform float uOzoneThickness; +uniform float uPlanetRadius; +uniform float uAtmosphereRadius; + +out vec4 fragColor; + +/// Samples along the ray. Constant so the loop unrolls on every backend +/// (Impeller rejects +/// uniform-bounded loops). +const int kStepCount = 20; + +/// Total extinction (out-scattering + absorption) at altitude [h]. +vec3 sigmaExtinction(float h) { + vec3 rayleigh = uRayleighScatter * exp(-h / uRayleighHeight); + float mie = (uMieScatter + uMieAbsorb) * exp(-h / uMieHeight); + // Tent profile: full strength at the centre, linearly to zero at the edges. + float ozoneFalloff = + max(0.0, 1.0 - 0.5 * abs(h - uOzoneCentre) / uOzoneThickness); + return rayleigh + vec3(mie) + uOzoneAbsorb * ozoneFalloff; +} + +/// Distance from [o] along [d] to the sphere of radius [r], or -1 if it is +/// missed / behind. Both are 2D because the problem is radially symmetric. +float raySphere(vec2 o, vec2 d, float r) { + float b = dot(o, d); + float c = dot(o, o) - r * r; + float disc = b * b - c; + if (disc < 0.0) return -1.0; + float sq = sqrt(disc); + float t0 = -b - sq; + float t1 = -b + sq; + if (t1 < 0.0) return -1.0; + return t0 >= 0.0 ? t0 : t1; +} + +void main() { + vec2 uv = FlutterFragCoord().xy / uResolution; + + float thickness = uAtmosphereRadius - uPlanetRadius; + float altitude = uv.x * thickness; + // v encodes cos(zenith): 0 → straight down, 1 → straight up. + float cosZenith = clamp(uv.y * 2.0 - 1.0, -1.0, 1.0); + float sinZenith = sqrt(max(0.0, 1.0 - cosZenith * cosZenith)); + + vec2 origin = vec2(0.0, uPlanetRadius + altitude); + vec2 dir = vec2(sinZenith, cosZenith); + + // A ray that hits the ground is fully blocked; otherwise it exits the top. + float ground = raySphere(origin, dir, uPlanetRadius); + float distance = ground > 0.0 + ? ground + : max(raySphere(origin, dir, uAtmosphereRadius), 0.0); + + vec3 opticalDepth = vec3(0.0); + float dt = distance / float(kStepCount); + for (int i = 0; i < kStepCount; i++) { + // Midpoint rule — a full step's worth of extinction at the step centre. + vec2 p = origin + dir * (dt * (float(i) + 0.5)); + opticalDepth += sigmaExtinction(length(p) - uPlanetRadius) * dt; + } + + fragColor = vec4(exp(-opticalDepth), 1.0); +} diff --git a/shaders/thunderstorm.frag b/shaders/thunderstorm.frag deleted file mode 100644 index 6a5d97be3..000000000 --- a/shaders/thunderstorm.frag +++ /dev/null @@ -1,95 +0,0 @@ -#include - -uniform float iTime; -uniform vec2 iResolution; -uniform float iLightningIntensity; -uniform float iRainAmount; -uniform sampler2D iChannel0; - -out vec4 fragColor; - -vec2 hash22(vec2 p) { - vec2 p2 = fract(p * vec2(0.1031, 0.1030)); - p2 += dot(p2, p2.yx + 19.19); - return fract((p2.x + p2.y) * p2); -} - -#define round(x) floor((x) + 0.5) - -float simplex2D(vec2 p) { - const float K1 = (sqrt(3.0) - 1.0) / 2.0; - const float K2 = (3.0 - sqrt(3.0)) / 6.0; - const float K3 = K2 * 2.0; - - vec2 i = floor(p + dot(p, vec2(K1))); - - vec2 a = p - i + dot(i, vec2(K2)); - vec2 o = 1.0 - clamp((a.yx - a) * 1.e35, 0.0, 1.0); - vec2 b = a - o + K2; - vec2 c = a - 1.0 + K3; - - vec3 h = clamp(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0, 1.0); - - h *= h; - h *= h; - - vec3 n = vec3( - dot(a, hash22(i) - 0.5), - dot(b, hash22(i + o) - 0.5), - dot(c, hash22(i + 1.0) - 0.5) - ); - - return dot(n, h) * 140.0; -} - -vec2 wetGlass(vec2 p) { - p += simplex2D(p * 0.1) * 3.0; - - float t = iTime; - - p *= vec2(0.025, 0.025 * 0.25); - - p.y -= t * 0.25; - - vec2 rp = round(p); - vec2 dropPos = p - rp; - vec2 noise = hash22(rp); - - dropPos.y *= 4.0; - - t = t * noise.y + (noise.x * 6.28); - - vec2 trailPos = vec2(dropPos.x, fract((dropPos.y + t) * 2.0) * 0.5 - 0.25); - - dropPos.y -= cos(t + cos(t)); - - float trailMask = clamp(-dropPos.y * 2.5 + 0.5, 0.0, 1.0); - - float dropSize = dot(dropPos, dropPos); - - float trailSize = clamp(trailMask * (-dropPos.y) - 0.5, 0.0, 1.0) + 0.5; - trailSize = dot(trailPos, trailPos) * trailSize * trailSize; - - float drop = clamp(dropSize * -60.0 + 3.0 * noise.y, 0.0, 1.0); - float trail = clamp(trailSize * -60.0 + 0.5 * noise.y, 0.0, 1.0); - - trail *= trailMask; - - return (drop * dropPos + trailPos * trail) * iRainAmount; -} - -void main() { - vec2 fragCoord = FlutterFragCoord().xy; - vec2 uv = fragCoord.xy / iResolution.xy; - - uv += wetGlass(fragCoord); - - vec3 col = texture(iChannel0, uv).rgb; - - float lt = (iTime + 3.0) * 0.5; - float lightning = sin(lt * sin(lt * 10.0)); - lightning *= pow(max(0.0, sin(lt + sin(lt))), 10.0); - col *= 1.0 + lightning * iLightningIntensity * 0.3; - - fragColor = vec4(col, 1.0); -} diff --git a/shaders/weather/card_water.frag b/shaders/weather/card_water.frag new file mode 100644 index 000000000..c9d068d6c --- /dev/null +++ b/shaders/weather/card_water.frag @@ -0,0 +1,135 @@ +// Water on the panel edge — the compositing half of the panel water, a port of +// the reference's rain branch (its snow and temperature branches are separate +// and are not used here). +// +// **The ambient is the sky.** The reference's ambient sampler is the 256×141 +// sky-view LUT: the cloud shader indexes the very same sampler with the LUT's +// own `(y·256−115)/141` layout, the city scene passes the LUT in directly, +// and `J`'s constructor carries the FrustumA/C constants that exist only for +// that texture's mapping. The rain branch samples it at the fullscreen `vUv` +// — the sky colour at the panel's height — and brightens it: +// +// ambient = sky × 1.3 +// +// which is what makes the water read as *transparent*: a sky-tinted 0.6-alpha +// film with a glint, over whatever it lies on. Every constant tried in its +// place — black, white, the buffer's own normal sum — rendered as grey or +// clamped white; adversarial pixel checks on the normal-sum variant showed +// interiors saturating to (1,1,1) exactly as observed on device. +// +// [iAmbient] receives the LUT row the card band covers (published by the sky +// cache at each re-bake) with the ×1.3 applied by the shader, so the water is +// lit by the same sky the backdrop shows. +// +// 8-bit adaptation: unsigned surfaces cannot hold signed sums, so the Dart +// side accumulates the **positive and negative parts in two buffers** (same +// draw, two channel-packed sprites), scaled by the anti-saturation factor the +// threshold is also scaled by. This pass reassembles `Σn = pos − neg`, +// giving both `normalize(Σn)` and the self-lit ambient the float original +// computes. +// +// Uniform contract — slots are float indices in declaration order. +// iResolution (0..1) field size in pixels +// iAmbient (2..4) the sky-view LUT sample at the panel band (see above) +// iSpecular (5) specular intensity (1.0 day / 1.2 night, panel branch) +// iThreshold (6) alpha cut — the grade's cut x the accumulation scale +// iAlpha (7) scene alpha (`uAlpha`), multiplied into the 0.6 +// iHour (8) local hour 0..23, steers the light sweep +// sampler 0: iFieldPos rgb = Σ(max(n,0)) — positive normal parts +// sampler 1: iFieldNeg rg = Σ(max(−n,0)), b = Σ kernel·0.784 (coverage) +// +// Alpha is deliberately unused in both: canvas surfaces store premultiplied +// colour, so any payload in an alpha-carrying channel would be scaled by it. +// Both sprites are opaque and every datum lives in a colour channel. +#include + +precision mediump float; + +uniform vec2 iResolution; +uniform vec3 iAmbient; +uniform float iSpecular; +uniform float iThreshold; +uniform float iAlpha; +uniform float iHour; +uniform sampler2D iFieldPos; +uniform sampler2D iFieldNeg; + +out vec4 fragColor; + +/// The reference shader's `sunColor` — a desaturated mauve, not white. +const vec3 kSunColor = vec3(0.59, 0.55, 0.61); + +/// `float gloss = 10.0;` on the shipped rain branch — written out as `s^10` +/// below, so no constant is needed. +const float kSurfaceAlpha = 0.6; + +// Bilinear fetch — samplers bind NEAREST, and the two buffers must be +// filtered identically or the reassembled sum tears at texel edges. Two +// copies because SkSL rejects samplers as function parameters. +vec4 fieldPosTex(vec2 uv) { + vec2 texel = 1.0 / iResolution; + vec2 p = uv * iResolution - 0.5; + vec2 f = fract(p); + vec2 base = (floor(p) + 0.5) * texel; + vec4 a = texture(iFieldPos, base); + vec4 b = texture(iFieldPos, base + vec2(texel.x, 0.0)); + vec4 c = texture(iFieldPos, base + vec2(0.0, texel.y)); + vec4 d = texture(iFieldPos, base + texel); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +vec4 fieldNegTex(vec2 uv) { + vec2 texel = 1.0 / iResolution; + vec2 p = uv * iResolution - 0.5; + vec2 f = fract(p); + vec2 base = (floor(p) + 0.5) * texel; + vec4 a = texture(iFieldNeg, base); + vec4 b = texture(iFieldNeg, base + vec2(texel.x, 0.0)); + vec4 c = texture(iFieldNeg, base + vec2(0.0, texel.y)); + vec4 d = texture(iFieldNeg, base + texel); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +void main() { + vec2 uv = FlutterFragCoord().xy / iResolution; + + vec3 pos = fieldPosTex(uv).rgb; + vec3 neg = fieldNegTex(uv).rgb; + + // `if (texColor.a < 0.01) return;` then the metaball cut — the threshold is + // far above the early-out, so one test covers both. + if (neg.b <= iThreshold) { + fragColor = vec4(0.0); + return; + } + + // Σn — only the direction is needed, so the accumulation scale cancels. + vec3 sum = vec3(pos.rg - neg.rg, pos.b); + float len = length(sum); + if (len < 1e-4) { + fragColor = vec4(0.0); + return; + } + vec3 normal = sum / len; + + // The day/night light sweep, verbatim. + float timePer = smoothstep(0.0, 12.0, iHour) - smoothstep(12.0, 23.0, iHour); + vec3 lightDir = + normalize(mix(vec3(-0.2, 0.4, 0.2), vec3(0.0, 1.0, 0.2), timePer)); + if (iHour >= 12.0) lightDir.x *= -1.0; + + vec3 viewDir = vec3(0.0, 0.0, 1.0); + vec3 halfDir = normalize(viewDir + lightDir); + + // x^10, written out — a constant-exponent `pow` would compile to log/exp; + // five multiplies are cheaper and bit-identical. + float s = max(dot(normal, halfDir), 0.0); + float s2 = s * s; + float specular = s2 * s2 * s2 * s2 * s2; + // `texture(uBgLightTex, vUv).rgb * 1.3` — the sky at the panel's height. + vec3 color = iAmbient * 1.3 + specular * kSunColor * iSpecular; + + // `oFragColor.a = (texColor.a > uAlphaThreshold) ? alpha : 0.0; a *= uAlpha` + float a = kSurfaceAlpha * clamp(iAlpha, 0.0, 1.0); + fragColor = vec4(clamp(color, 0.0, 1.0) * a, a); // premultiplied +} diff --git a/shaders/weather/galaxy.frag b/shaders/weather/galaxy.frag new file mode 100644 index 000000000..19719b472 --- /dev/null +++ b/shaders/weather/galaxy.frag @@ -0,0 +1,136 @@ +// Milky Way — a port of the reference engine +// (scene layer 1), drawn additively under the star field. +// +// This is a real sky, not a procedural band: an equirectangular photograph of +// the Milky Way is projected through a rotated camera, so the arch bends +// across the frame the way it actually does. A hand-drawn band cannot +// reproduce the dust lanes or the density falloff along the galactic plane. +// +// The projection, from the original: +// • each pixel's screen offset becomes a small rotation of the view ray +// `(0, 0, -1)` about y and then x, scaled by `uFov` +// • that ray is then rotated into galactic orientation by `rx`, `ry`, `rz` +// +// Every rotation is by the NEGATIVE of its angle. GLSL's `mat4(...)` takes +// **columns**, so the engine's matrices — written out to read like row-major +// rotations — are their own transposes, i.e. rotations by `-angle`. And +// `rotate0 = rotateX0 * rotateY0` applied as `rotate0 * v` runs Y first. +// Getting either wrong lands on a different patch of sky: the diagonal arch +// flattens into a horizontal band. +// • the result is converted to equirectangular UV with +// `vec2(atan(dir.z, dir.x), asin(dir.y)) * vec2(0.1591, 0.3183) + 0.5` +// (those constants are 1/2π and 1/π), then flipped on both axes because +// the starmap is stored upside-down +// +// Beware the naming: `uGalaxyAlpha` is **not** an opacity — it is the first +// rotation angle, `rx = uGalaxyAlpha * 2π`. The engine's real values are +// alpha 0.38, theta 0.64, phi 0.16, fov 0.27, and the +// blend is `SRC_ALPHA, ONE` — additive. +// +// Uniform contract — slots are float indices in declaration order. +// iResolution (0..1) render size in pixels +// iRotation (2..4) galaxy orientation (alpha, theta, phi), in turns +// iFov (5) field of view, in turns +// iStaticTime (6) slow drift, added to the roll angle +// iOpacity (7) overall opacity +// iAlpha (8) night visibility 0..1 +// sampler 0: iStarMap equirectangular Milky Way +#include + +precision highp float; + +uniform vec2 iResolution; +uniform vec3 iRotation; +uniform float iFov; +uniform float iStaticTime; +uniform float iOpacity; +uniform float iAlpha; +uniform sampler2D iStarMap; + +out vec4 fragColor; + +#define PI 3.141592653 +#define TWO_PI 6.283185307 + +float luminance(vec3 rgb) { + return dot(vec3(0.2126729, 0.7151522, 0.0721750), rgb); +} + +vec3 saturation(vec3 rgb, float amount) { + vec3 intensity = vec3(dot(rgb, vec3(0.2125, 0.7154, 0.0721))); + return mix(intensity, rgb, amount); +} + +float cpfv(float v, float lo, float hi) { + return clamp((v - lo) / (hi - lo), 0.0, 1.0); +} + +void main() { + vec2 uv = FlutterFragCoord().xy / iResolution; + // The engine's vUv has y up. + uv.y = 1.0 - uv.y; + + float aspect = iResolution.x / max(iResolution.y, 1.0); + vec2 st = (uv - 0.5) * 2.0; + st.x *= aspect; + // The reference's own framing tweaks for tall and wide screens. + if (aspect > 0.8) st /= 1.4; + if (aspect > 1.5) st.x += aspect / 2.0; + + float fov = iFov * TWO_PI; + float rx0 = st.y * fov; + float ry0 = st.x * fov; + + // Fade the far left and right, where the projection stretches badly. + float edgeFade = cpfv(abs(ry0), PI / 3.0, PI / 2.5); + + // Screen ray: Y first, then X, both negated (see the note above). + vec3 dir = vec3(0.0, 0.0, -1.0); + float c1 = cos(-ry0), s1 = sin(-ry0); + dir = vec3(dir.x * c1 + dir.z * s1, dir.y, -dir.x * s1 + dir.z * c1); + float c0 = cos(-rx0), s0 = sin(-rx0); + dir = vec3(dir.x, dir.y * c0 - dir.z * s0, dir.y * s0 + dir.z * c0); + + // Galactic orientation. `iRotation.x` is an angle despite being named alpha. + float rx = iRotation.x * TWO_PI; + float ry = iRotation.y * TWO_PI; + float rz = iRotation.z * TWO_PI + iStaticTime / 150.0; + + // `rotate = rotateZ * rotateY * rotateX` applied as `rotate * v`: X, then Y, + // then Z — again all negated. + float cx = cos(-rx), sx = sin(-rx); + dir = vec3(dir.x, dir.y * cx - dir.z * sx, dir.y * sx + dir.z * cx); + float cy = cos(-ry), sy = sin(-ry); + dir = vec3(dir.x * cy + dir.z * sy, dir.y, -dir.x * sy + dir.z * cy); + float cz = cos(-rz), sz = sin(-rz); + dir = vec3(dir.x * cz - dir.y * sz, dir.x * sz + dir.y * cz, dir.z); + + // Equirectangular lookup; 0.1591 = 1/2π and 0.3183 = 1/π. + vec2 tx = vec2(atan(dir.z, dir.x), asin(clamp(dir.y, -1.0, 1.0))); + tx *= vec2(0.1591, 0.3183); + tx += 0.5; + // The starmap is stored flipped on both axes. + tx = 1.0 - tx; + + vec4 color = texture(iStarMap, tx); + // The photograph is nearly monochrome; the reference pushes saturation hard so the + // warm core and the cool dust separate. + color.rgb = saturation(color.rgb, 4.0); + color.a *= smoothstep(0.1, 0.2, luminance(color.rgb)); + + // Fade out toward the horizon and the very top of the frame. + // `1/exp(k)` is `exp(-k)`, which skips the reciprocal. + float fb = clamp(1.0 - exp(-4.0 * uv.y * uv.y), 0.0, 1.0); + float ft = clamp(mix(1.0, 0.0, (uv.y - 0.7) / 0.3), 0.0, 1.0); + color.a *= ft * fb; + + color = mix(color, vec4(0.0), edgeFade); + + float a = clamp(color.a * iOpacity * clamp(iAlpha, 0.0, 1.0), 0.0, 1.0); + if (a < 0.003) { + fragColor = vec4(0.0); + return; + } + + fragColor = vec4(clamp(color.rgb, 0.0, 1.0) * a, a); // premultiplied +} diff --git a/shaders/weather/lightning.frag b/shaders/weather/lightning.frag new file mode 100644 index 000000000..d0ac3a7cc --- /dev/null +++ b/shaders/weather/lightning.frag @@ -0,0 +1,161 @@ +// Lightning layer — a port of the reference engine's +// The reference shader (the full-screen flash) and +// The reference shader (the bolt and its branch timing). +// +// The reference builds the bolt as CPU-generated branch geometry drawn as point +// sprites, with `vAttri` carrying "is this the main branch" and the point's +// normalised position along the path. A fragment shader has no geometry, so +// the channel is reconstructed as a distance field: the bolt's horizontal +// position at each height is a sum of noise octaves seeded per strike, and +// the pixel's distance to that path becomes the glow. Forks are the same +// function with a lateral bias, gated to start partway down. +// +// The timing envelope is the reference's, and it is the part that sells the effect: +// `uExpandPath` grows the channel downward (pixels below the growth front are +// discarded), the main branch then holds and fades over `uMainBranchFade`, +// and sub-branches fade faster over `uSubBranchFade`. The flash is a separate +// smoothstep pulse — `start → peek → fade` — tinted from `iBottomCol` to +// `iTopCol` up the screen. +// +// The flash is deliberately capped well below full white: this is a +// disaster-prevention app, and a full-screen strobe at high frequency is a +// photosensitivity hazard. The Dart side additionally rate-limits strikes. +// +// Uniform contract — slots are float indices in declaration order. +// iResolution (0..1) render size in pixels +// iTopCol (2..4) flash colour at the top of the screen +// iBottomCol (5..7) flash colour at the horizon +// iBoltCol (8..10) bolt core colour +// iTime (11) seconds since this strike began +// iFlash (12) flash intensity 0..1 +// iBoltAlpha (13) bolt opacity 0..1 +// iSeed (14) per-strike seed (path shape, position) +// iExpandPath (15) seconds for the channel to reach full length +// iBranchShow (16) seconds the main branch holds at full brightness +// iBranchFade (17) seconds for the main branch to fade +// iSubFade (18) seconds for sub-branches to fade +#include + +precision highp float; + +uniform vec2 iResolution; +uniform vec3 iTopCol; +uniform vec3 iBottomCol; +uniform vec3 iBoltCol; +uniform float iTime; +uniform float iFlash; +uniform float iBoltAlpha; +uniform float iSeed; +uniform float iExpandPath; +uniform float iBranchShow; +uniform float iBranchFade; +uniform float iSubFade; + +out vec4 fragColor; + +/// Hard ceiling on flash brightness — photosensitivity guard. +const float kMaxFlash = 0.62; + +float hash11(float n) { + return fract(sin(n * 127.1) * 43758.5453); +} + +/// Value noise in 1D, for the channel's lateral wander. +float vnoise1(float x) { + float i = floor(x); + float f = fract(x); + f = f * f * (3.0 - 2.0 * f); + return mix(hash11(i), hash11(i + 1.0), f); +} + +/// Horizontal position of a bolt channel at height [y] (0 top → 1 bottom). +/// +/// [seed] picks the strike, [wander] scales how far it strays. +float pathX(float y, float seed, float wander) { + float x = 0.0; + x += (vnoise1(y * 3.0 + seed) - 0.5) * 1.00; + x += (vnoise1(y * 7.0 + seed * 3.1) - 0.5) * 0.42; + x += (vnoise1(y * 17.0 + seed * 7.7) - 0.5) * 0.16; + x += (vnoise1(y * 41.0 + seed * 11.3) - 0.5) * 0.06; + return x * wander; +} + +/// Glow contribution of one channel, given the pixel's horizontal distance. +float channel(float dist, float core, float glow) { + float c = smoothstep(core, 0.0, dist); // hot core + float g = exp(-dist / max(glow, 1e-4)) * 0.55; // corona + return c + g; +} + +void main() { + vec2 uv = FlutterFragCoord().xy / iResolution; + float aspect = iResolution.x / max(iResolution.y, 1.0); + + vec3 color = vec3(0.0); + + // --- full-screen flash (thunder_light_frag) ----------------------------- + // A single pulse: rise to the peak, then decay. + float peek = iBranchShow; + float pulse = smoothstep(max(peek - 0.05, 0.0), peek, iTime) * + (1.0 - smoothstep(peek, peek + iBranchFade, iTime)); + float intensity = clamp(iFlash, 0.0, 1.0) * pulse; + intensity = min(intensity, kMaxFlash); + // The reference tints bottom → top; the flash is brightest where the cloud is. + color += mix(iBottomCol, iTopCol, 1.0 - uv.y) * intensity * 0.8; + + // --- bolt (thunder_frag) ------------------------------------------------ + float boltAlpha = clamp(iBoltAlpha, 0.0, 1.0); + if (boltAlpha > 0.005 && iTime < iExpandPath + iBranchShow + iBranchFade) { + // Growth front: nothing below it has been drawn yet. + float grown = iTime <= iExpandPath ? iTime / max(iExpandPath, 1e-4) : 1.0; + + // Strike origin, stable per seed, biased to the upper half. + float originX = mix(0.18, 0.82, hash11(iSeed * 1.7)); + // Bolts stop partway down — they strike toward, not into, the sheet. + float endY = mix(0.45, 0.78, hash11(iSeed * 5.3)); + + // Aspect-correct so the channel is not stretched on wide screens. + float px = (uv.x - originX) * aspect; + float y = uv.y; + + float visible = step(y, grown * endY); + float taper = 1.0 - smoothstep(endY * 0.7, endY, y); // thins toward the tip + + // Main channel. + float mainX = pathX(y, iSeed, 0.16); + float mainDist = abs(px - mainX); + float mainAge = clamp((iTime - iExpandPath - iBranchShow) / + max(iBranchFade, 1e-4), 0.0, 1.0); + float mainLife = 1.0 - mainAge; + color += iBoltCol * channel(mainDist, 0.0035, 0.020) * visible * taper * + mainLife * boltAlpha; + + // Two forks, each leaving the trunk at its own height. + float subAge = clamp((iTime - iExpandPath) / max(iSubFade, 1e-4), 0.0, 1.0); + float subLife = 1.0 - subAge; + for (int i = 0; i < 2; i++) { + float fs = iSeed + float(i) * 23.0 + 4.0; + float forkY = mix(0.18, 0.50, hash11(fs)); + if (y < forkY) continue; + float t = y - forkY; + // The fork inherits the trunk's position, then diverges. + float dir = hash11(fs * 3.0) > 0.5 ? 1.0 : -1.0; + float forkX = pathX(forkY, iSeed, 0.16) + + pathX(y + fs, fs, 0.10) - pathX(forkY + fs, fs, 0.10) + + dir * t * mix(0.35, 0.8, hash11(fs * 7.0)); + float forkEnd = forkY + mix(0.10, 0.26, hash11(fs * 11.0)); + float forkVis = step(y, min(grown * endY, forkEnd)); + float forkTaper = 1.0 - smoothstep(forkY, forkEnd, y); + color += iBoltCol * channel(abs(px - forkX), 0.0018, 0.011) * forkVis * + forkTaper * subLife * boltAlpha * 0.7; + } + } + + float a = clamp(max(color.r, max(color.g, color.b)), 0.0, 1.0); + if (a < 0.003) { + fragColor = vec4(0.0); + return; + } + + fragColor = vec4(clamp(color, 0.0, 1.0), a); // premultiplied +} diff --git a/shaders/weather/night.frag b/shaders/weather/night.frag new file mode 100644 index 000000000..72bd7d80e --- /dev/null +++ b/shaders/weather/night.frag @@ -0,0 +1,185 @@ +// Night layer — stars, Milky Way band and moon, porting the reference +// engine's the reference shader (stars) and the look of +// The reference shader (band). +// +// Stars follow the reference's `drawStars` closely: the sky is diced into `numCells` +// cells, each holding one star at a hashed offset, drawn as a smoothstep of +// the distance to that centre. Three passes at 4/8/16 cells give bright, +// medium and faint stars. The bright pass also gets the reference's glow stack — a +// wide halo, a four-point diffraction cross built from the angle modulo 90°, +// and a tight core — gated by a slow per-star twinkle so only a few sparkle +// at once instead of the whole field shimmering. +// +// The reference samples a starmap texture for the galaxy; this draws the band +// procedurally: a rotated, softly-bounded strip filled with fbm dust and +// scattered faint stars, which needs no 4K texture upload. +// +// There is no moon: the reference's scene has no moon layer at all, and the one this +// file used to draw was invented. +// +// Uniform contract — slots are float indices in declaration order. +// iResolution (0..1) render size in pixels +// iGalaxyTint (2..4) Milky Way colour +// iTime (7) animation time (twinkle) +// iAlpha (8) night visibility 0..1 (fades in after sunset) +// iScroll (9) parallax offset in pixels +// iCloudCover (10) 0..1 cloud cover, dims the field +// iGalaxy (11) Milky Way strength 0..1 +#include + +precision highp float; + +uniform vec2 iResolution; +uniform vec3 iGalaxyTint; +uniform float iTime; +uniform float iAlpha; +uniform float iScroll; +uniform float iCloudCover; +uniform float iGalaxy; + +out vec4 fragColor; + +#define PI 3.14159265 +#define TWO_PI 6.28318531 + +/// Gap between twinkles, in units of 2π (the reference's `TwinkleInterval`). +const float kTwinkleInterval = 40.0; + +vec2 hash22(vec2 p) { + p = vec2(dot(p, vec2(12.9898, 78.233)), dot(p, vec2(26.65125, 83.054543))); + return fract(sin(p) * 43758.5453); +} + +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash12(i), hash12(i + vec2(1.0, 0.0)), u.x), + mix(hash12(i + vec2(0.0, 1.0)), hash12(i + vec2(1.0, 1.0)), u.x), + u.y); +} + +float fbm(vec2 p) { + float v = 0.0; + float a = 0.5; + for (int i = 0; i < 5; i++) { + v += a * vnoise(p); + p = p * 2.03 + 11.3; + a *= 0.5; + } + return v; +} + +/// One star field pass. [numCells] sets density, [size] the radius, [br] the +/// brightness; [showGlow] adds the halo and diffraction spikes. +float drawStars(vec2 coord, float numCells, float size, float br, + bool showGlow) { + vec2 bigCoord = coord * numCells; + vec2 cellID = floor(bigCoord); + + vec2 rnd = hash22(mod(cellID, numCells)); + vec2 centerOffset = rnd * 0.7 + rnd.x * 0.3; + // Keep glowing stars away from the cell edge so their halo is not clipped. + vec2 center = cellID + (showGlow ? mix(vec2(0.1), vec2(0.9), centerOffset) + : mix(vec2(0.01), vec2(0.99), centerOffset)); + + vec2 offset = (bigCoord - center) / (numCells * size); + float d = dot(offset, offset); + if (d > 0.16 || (!showGlow && d > 0.0025)) return 0.0; + + float sqrtd = 1.0 - sqrt(d); + float star = br * smoothstep(0.95, 1.0, sqrtd); + // Base shimmer, always on and subtle. + star *= 1.2 + sin(center.y * 10.0 + center.x * 71.9 + iTime * 2.0) * 0.6; + + if (!showGlow) return star; + + // Wide halo. + float glow = pow(clamp(br * smoothstep(0.6, 1.0, sqrtd), 0.0, 2.0), 3.5) * + 0.016666667; + + // Four-point diffraction cross: the spike is longest at ±45°/±135°. + float angle = atan(offset.y, offset.x) + PI * 0.25; + float angleOffset = abs(mod(angle, PI * 0.5) - PI * 0.25); + float len = mix(0.76, 0.88, sqrt(angleOffset) / (PI * 0.25)); + glow += smoothstep(len + 0.05, 1.1, sqrtd) * 0.5; + + // Tight core. + if (sqrtd > 0.895) { + float core = clamp(br * smoothstep(0.895, 1.0, sqrtd), 0.0, 2.0); + glow += core * core * 0.33333333; + } + + // Twinkle gate: mostly dark, with a brief sine pulse every interval. + float t = center.y * 721.3 + center.x * 37.1 + iTime * 2.0; + float phase = mod(t, (2.0 + kTwinkleInterval) * PI) <= TWO_PI + ? mod(t, TWO_PI) + : TWO_PI; + glow *= 0.5 + sin(phase - PI * 0.5) * 0.5; + + return star + glow; +} + +void main() { + vec2 uv = FlutterFragCoord().xy / iResolution; + float aspect = iResolution.x / max(iResolution.y, 1.0); + + float alpha = clamp(iAlpha, 0.0, 1.0) * (1.0 - 0.85 * clamp(iCloudCover, 0.0, 1.0)); + if (alpha < 0.004) { + fragColor = vec4(0.0); + return; + } + + vec2 coord = (uv * 2.0 - 1.0) * vec2(aspect, 1.0); + // Very slow drift so the sky feels alive without reading as movement. + coord += vec2(iTime * 0.004, iScroll / max(iResolution.y, 1.0) * 0.6); + + // Stars thin out toward the horizon, where haze washes them out. + float horizonFade = smoothstep(0.05, 0.55, 1.0 - uv.y); + + vec3 col = vec3(0.0); + + // --- Milky Way band ---------------------------------------------------- + float galaxy = clamp(iGalaxy, 0.0, 1.0); + if (galaxy > 0.01) { + // Rotate into the band's frame, then measure distance from its spine. + float ca = cos(-0.42), sa = sin(-0.42); + vec2 g = vec2(coord.x * ca - coord.y * sa, coord.x * sa + coord.y * ca); + // Wobble the spine so the band is not a straight ruler. + float spine = g.y + 0.18 * sin(g.x * 1.3) + 0.07 * sin(g.x * 3.1 + 1.7); + float band = exp(-spine * spine * 7.0); + + float dust = fbm(g * 2.6 + 4.0); + float dark = smoothstep(0.35, 0.75, fbm(g * 4.1 + 20.0)); // dust lanes + float density = band * mix(0.35, 1.0, dust) * (1.0 - 0.65 * dark); + + col += iGalaxyTint * density * 0.22 * galaxy * horizonFade; + // Unresolved stars inside the band. + col += vec3(1.0) * drawStars(coord * 1.3, 26.0, 0.02, 0.35, false) * + band * galaxy * horizonFade; + } + + // --- star field -------------------------------------------------------- + col += vec3(0.74, 0.74, 0.74) * drawStars(coord, 4.0, 0.08, 2.0, true) * + horizonFade; + col += vec3(0.97, 0.85, 0.80) * drawStars(coord, 8.0, 0.05, 1.0, false) * + horizonFade; + col += vec3(0.85, 0.90, 1.00) * drawStars(coord, 16.0, 0.025, 0.5, false) * + horizonFade; + + col *= alpha; + float a = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0); + if (a < 0.003) { + fragColor = vec4(0.0); + return; + } + + // Additive-over-sky look via premultiplied output. + fragColor = vec4(col, a); +} diff --git a/shaders/weather/rain_on_glass.frag b/shaders/weather/rain_on_glass.frag new file mode 100644 index 000000000..a5dc87a6c --- /dev/null +++ b/shaders/weather/rain_on_glass.frag @@ -0,0 +1,204 @@ +// Rain on the glass — droplets clinging to a card's face, refracting it. +// +// Hung on the card as a `RenderEffect`, so what bends is the card's own +// pixels: text visibly warps behind a bead rather than having a droplet drawn +// flat on top of it. +// +// What it does, in order: +// • builds a droplet coverage field `c` from two layers — static beads that +// fade in and out in place, and running beads that slide down the glass +// • samples the field once more one step to the right, and takes the pair +// `(c - cx, cx - c)` as a surface gradient +// • offsets the texture lookup by that gradient — that displacement *is* the +// refraction; there is no lighting term, the specular block is dead code +// • `mix`es between the untouched pixel and the refracted one by `uAlpha` +// +// Two details that are easy to lose and both matter: +// • `if (c > 0.001)` — pixels with no droplet on them are passed through +// untouched rather than being refracted by a zero gradient. +// • `mod(uTime * 0.46, 5.0)` — the clock wraps every five seconds, so the +// field never drifts into the range where the hash loses precision. +// +// The coordinate frame is **not** the filtered view. `uResolution` is a +// hard-coded 1080×1080, set once and never updated for any view. Every bead +// size, the field's centring, and the refraction displacement are computed +// against that fixed frame in device pixels, which is why a bead is the same +// physical size on every card. Deriving the frame from the filtered widget's +// own size — as an earlier version did — shrinks the beads by the card/screen +// ratio. The engine-fed input size ([uSize]) is used only to normalise the +// texture lookups. +// +// Uniform contract — slots are float indices in declaration order. +// uSize (0..1) input texture size — SET BY THE ENGINE, not us +// uResolution (2..3) the fixed 1080×1080 frame, in device pixels +// uTime (4) animation time (seconds) +// uStaticDropSize (5) static bead scale (larger = fewer, bigger) +// uStaticDropAmount (6) static bead coverage +// uStaticDropSpeed (7) how fast static beads appear and fade +// uRunningDropSize (8) running bead scale +// uRunningDropAmount(9) running bead coverage +// uRunningDropSpeed (10) how fast running beads slide +// uAlpha (11) effect strength, 0 passes the content through +// sampler 0: uTex the content being filtered — SET BY THE ENGINE +#include + +precision mediump float; + +uniform vec2 uSize; +uniform vec2 uResolution; +uniform float uTime; +uniform float uStaticDropSize; +uniform float uStaticDropAmount; +uniform float uStaticDropSpeed; +uniform float uRunningDropSize; +uniform float uRunningDropAmount; +uniform float uRunningDropSpeed; +uniform float uAlpha; +uniform sampler2D uTex; + +out vec4 fragColor; + +/// The grid the running beads live on. Written out as a literal in the shipped +/// shader; it is the `vec2(6, 1) * 3` of the older revision. +const vec2 kGrid = vec2(18.0, 3.0); + +vec3 N13(float p) { + vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.11369, 0.13787)); + p3 += dot(p3, p3.yzx + 19.19); + return fract((p3.xxy + p3.yzz) * p3.zyx); +} + +float N(float t) { + return fract(sin(t * 12345.564) * 7658.76); +} + +float Saw(float b, float t) { + return smoothstep(0.0, b, t) * smoothstep(1.0, b, t); +} + +/// Per-cell noise for the running layer, hoisted out of the layer itself so the +/// gradient probe below re-uses it instead of re-hashing. +vec4 GenDropLayerN(vec2 uv, float t) { + t *= uRunningDropSpeed; + uv /= uRunningDropSize; + uv.y += t * 0.55; + + vec2 id = floor(uv * kGrid); + float colShift = N(id.x); + uv.y += colShift; + id = floor(uv * kGrid); + vec3 n = N13(id.x * 35.2 + id.y * 2376.1); + return vec4(n, colShift); +} + +float DropLayer2(vec2 uv, vec3 n, float t, float colShift) { + t *= uRunningDropSpeed; + float oldY = uv.y; + + uv /= uRunningDropSize; + uv.y += t * 0.55; + uv.y += colShift; + + vec2 a = vec2(6.0, 1.0); + vec2 st = fract(uv * kGrid) - vec2(0.5, 0.0); + float x = n.x - 0.5; + float y = oldY * 20.0; + x *= 0.7; + float ti = fract(t + n.z); + y = (Saw(0.9, ti) - 0.5) * 0.9 + 0.5; + vec2 p = vec2(x, y); + float d = length((st - p) * a.yx); + + float mainDrop = smoothstep(0.4, 0.0, d); + + float rr = smoothstep(1.0, y, st.y); + float r = sqrt(rr); + float cd = abs(st.x - x); + + // The trail is commented out in the shipped shader; only its front edge is + // still used, as a mask on the shed droplets. + float trailFront = smoothstep(-0.02, 0.02, st.y - y); + y = oldY; + + y = fract(y * 10.0) + (st.y - 0.5); + float dd = length(st - vec2(x, y)); + float droplets = smoothstep(0.3, 0.0, dd); + return mainDrop + droplets * r * trailFront; +} + +vec3 GenStaticLayerN(vec2 uv) { + uv *= 40.0 / uStaticDropSize; + vec2 id = floor(uv); + return N13(id.x * 107.45 + id.y * 3543.654); +} + +float StaticDrops(vec2 uv, vec3 n, float t) { + t *= uStaticDropSpeed; + uv *= 40.0 / uStaticDropSize; + uv = fract(uv) - 0.5; + + vec2 p = (n.xy - 0.5) * 0.7; + float d = length(uv - p); + float fade = Saw(0.025, fract(t + n.z)); + return smoothstep(0.3, 0.0, d) * fract(n.z * 10.0) * fade; +} + +float Drops(vec2 uv, vec3 dropN, vec3 staticN, float t, float rs, float ra, + float colShift) { + float s = StaticDrops(uv, staticN, t * 1.2) * rs * uStaticDropAmount; + float m1 = DropLayer2(uv, dropN, t * 1.85, colShift) * ra * uRunningDropAmount; + return smoothstep(0.3, 1.0, s + m1); +} + +void main() { + vec2 xy = FlutterFragCoord().xy; + // The OpenGL ES backend hands image filters a y-flipped texture, so the + // sampler needs the mirrored row. The **field** must not be mirrored with + // it: flipping `xy` before the field is built reverses `uv.y`, and the + // running beads — whose lattice scrolls with `uv.y += t * 0.55` — then slide + // *up* the glass. Worse, the mirror is about `uSize.y / 2` while the field + // is scaled by the fixed 1080 frame, so it also translates the lattice by + // `uSize.y - 1080`. Keep the two coordinates separate. + vec2 texXY = xy; +#ifdef IMPELLER_TARGET_OPENGLES + texXY.y = uSize.y - xy.y; +#endif + + // All in the fixed 1080-frame, exactly as shipped. + vec2 UV = xy / uResolution; + vec2 uv = (xy - 0.5 * uResolution) / uResolution.y; + uv.y = 1.0 - uv.y; + + float alpha = smoothstep(0.0, 2.0, uTime); + // Wraps every five seconds — see the note at the top. + float t = mod(uTime * 0.46, 5.0); + + float rainAmount = sin(UV.x * UV.y) * 0.3 + 0.7; + float staticDrops = smoothstep(-0.5, 1.0, rainAmount) * 2.0; + + vec4 dropN = GenDropLayerN(uv, t); + vec3 staticN = GenStaticLayerN(uv); + float c = Drops(uv, dropN.xyz, staticN, t, staticDrops, rainAmount, dropN.w); + + if (c > 0.001) { + vec2 e = vec2(0.0005, 0.0); + float cx = + Drops(uv + e, dropN.xyz, staticN, t, staticDrops, rainAmount, dropN.w); + vec2 n = vec2(c - cx, cx - c); + // `uTex.eval(texUV * uResolution)`: the displacement is `n` in the + // 1080-frame, i.e. `n * uResolution` device pixels. It is not small — the + // finite difference of a 0..1 mask reaches hundreds of pixels at a bead's + // rim, which is what makes this a lens rather than a nudge. + vec2 refracted = texXY + n * uResolution; + + vec4 uiColor = texture(uTex, texXY / uSize); + // Sampled straight, with no clamp and no decal. Punching the out-of-bounds + // lookup through to transparent was tried and looks worse here: this card + // is small enough that a bead's displacement leaves its bounds often, so + // the beads read as holes. + vec4 rainDropColor = texture(uTex, refracted / uSize); + fragColor = mix(uiColor, rainDropColor, uAlpha * alpha); + return; + } + fragColor = texture(uTex, texXY / uSize); +} diff --git a/shaders/weather/rainbow.frag b/shaders/weather/rainbow.frag new file mode 100644 index 000000000..111871f24 --- /dev/null +++ b/shaders/weather/rainbow.frag @@ -0,0 +1,145 @@ +// Rainbow layer — a port of the reference engine. +// +// The spectral maths is the reference's verbatim, and it is the reason this looks like +// a rainbow instead of a hue sweep. Real bows are defined by *deviation +// angles*: light leaves a droplet at ~137.7° for red and ~139.6° for violet +// (the primary bow), and at ~129.5°/126.1° for the secondary — which is why +// the secondary is both wider and colour-reversed. `GetSpectrum` places one +// smooth peak per channel at those angles, so the band structure falls out of +// the geometry rather than being painted in. +// +// `ApplyAtmosphere` is kept as well: the bow's strength comes from the +// optical depth through a sphere of moisture (`(r-l)/r` cubed, then +// `1 - exp2(-depth)`), so it fades naturally at the edges instead of being +// masked. `RainbowShadow` keeps it off the side of the sky the light comes +// from. +// +// The only structural change: the reference aims a camera with `uCamPos`/`uFov` and +// ray-marches; here the ray fan is built directly from screen UVs, which is +// equivalent for a fixed background camera and skips the matrix. +// +// Uniform contract — slots are float indices in declaration order. +// iResolution (0..1) render size in pixels +// iSunDir (2..4) direction toward the sun (the bow is opposite it) +// iOpacity (5) overall opacity 0..1 +// iRadius (6) bow radius trim (0.5 = textbook angles) +// iGap (7) primary→secondary separation (0.5 = textbook) +// iBrightness0 (8) primary bow brightness +// iBrightness1 (9) secondary bow brightness +// iVisRange (10) radius of the moisture volume +// iFov (11) vertical field of view (radians) +// iPitch (12) camera pitch (radians) +#include + +precision highp float; + +uniform vec2 iResolution; +uniform vec3 iSunDir; +uniform float iOpacity; +uniform float iRadius; +uniform float iGap; +uniform float iBrightness0; +uniform float iBrightness1; +uniform float iVisRange; +uniform float iFov; +uniform float iPitch; + +out vec4 fragColor; + +/// Smooth unit-height bump per channel, peaked at [peak] with width 1/[range]. +vec3 getSpectrum(float x, vec3 brightness, vec3 peak, vec3 range) { + vec3 t = clamp(1.0 - abs((peak - x) * range), 0.0, 1.0); + vec3 t2 = t * t; + // Smoothstep-shaped falloff (3t² - 2t³). + return (3.0 * t2 - 2.0 * t * t2) * brightness; +} + +/// Colour of the bow at scattering angle [theta] (degrees from the antisolar +/// point). +vec4 rainbowRGBA(float theta) { + vec4 result = vec4(0.0); + + // Primary bow: red at 137.7°, violet at 139.6°, measured from the sun. + vec3 peak0 = vec3(180.0 - 137.7, + 180.0 - (137.7 + 139.6) * 0.5, + 180.0 - 139.6) + + vec3((iRadius - 0.5) * 180.0); + vec3 range0 = vec3(1.0 / (peak0.b - peak0.r)); + result.xyz += getSpectrum(theta, vec3(iBrightness0), peak0, range0); + + // Secondary bow: wider, dimmer, and colour-reversed. + vec3 peak1 = vec3(180.0 - 129.5, + 180.0 - (129.5 + 126.1) * 0.5, + 180.0 - 126.1) + + vec3((iGap - 0.5) * 180.0) + vec3((iRadius - 0.5) * 180.0); + vec3 range1 = vec3(1.0 / (peak1.b - peak1.r)); + result.xyz += getSpectrum(theta, vec3(iBrightness1), peak1, range1); + + float aFactor = max(smoothstep(peak0.r + 1.0, peak0.b - 1.0, theta), + smoothstep(peak1.r - 1.0, peak1.b + 1.0, theta) * 0.2); + result.a = 1.0 - aFactor; + return result; +} + +/// Keeps the bow off the lit side of the sky. +float rainbowShadow(vec3 p) { + float f = -p.x * 1.1 + p.y - 1.5 - p.z; + return clamp(f * 0.05 + 0.1, 0.0, 1.0); +} + +void main() { + vec2 uv = FlutterFragCoord().xy / iResolution; + float aspect = iResolution.x / max(iResolution.y, 1.0); + + float opacity = clamp(iOpacity, 0.0, 1.0); + if (opacity < 0.004) { + fragColor = vec4(0.0); + return; + } + + // Same view ray construction as the sky, so the bow sits in the same space. + vec2 ndc = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0) * vec2(aspect, 1.0); + float tanHalfFov = tan(iFov * 0.5); + vec3 camRay = normalize(vec3(ndc.x * tanHalfFov, ndc.y * tanHalfFov, 1.0)); + float cp = cos(iPitch); + float sp = sin(iPitch); + vec3 rayDir = vec3(camRay.x, + camRay.y * cp + camRay.z * sp, + camRay.z * cp - camRay.y * sp); + + vec3 rayOrigin = vec3(0.0, 0.0, 0.0); + + // Optical depth through the moisture sphere centred on the viewer. + vec3 offset = -rayOrigin; + float d = dot(rayDir, offset); + vec3 closest = rayOrigin + rayDir * d; + float l = length(closest); + float r = max(iVisRange, 1e-3); + if (l >= r) { + fragColor = vec4(0.0); + return; + } + + float f = (r - l) / r; + f = f * f * f; + float opticalDepth = 10.0 * f * 0.1; + float amount = 1.0 - exp2(-opticalDepth); + + float shadow = rainbowShadow(rayOrigin) * 0.75 + 0.25; + float strength = clamp(amount * shadow, 0.0, 1.0); + + vec3 sunDir = normalize(iSunDir); + float dp = clamp(dot(sunDir, rayDir), -1.0, 1.0); + vec4 bow = rainbowRGBA(degrees(acos(dp)) + 0.5); + + // The reference's 0.6 brightness trim keeps the bow from reading as neon. + vec3 color = bow.xyz * strength * 0.6 * opacity; + + float a = clamp(max(color.r, max(color.g, color.b)), 0.0, 1.0); + if (a < 0.003) { + fragColor = vec4(0.0); + return; + } + + fragColor = vec4(color, a); // premultiplied +} diff --git a/shaders/weather/sun_flare.frag b/shaders/weather/sun_flare.frag new file mode 100644 index 000000000..ae7e85f14 --- /dev/null +++ b/shaders/weather/sun_flare.frag @@ -0,0 +1,237 @@ +// Sun — a port of the reference engine +// (scene layer 12), drawn additively over the sky and clouds. +// +// This layer owns the sun *entirely*. The reference's sky pass is a +// pure vertical gradient with no disc in it, so if this layer does not draw +// the disc, nothing does — which is exactly the bug this replaces: the disc +// was dropped when it moved out of the sky shader and only the rays survived, +// leaving clear days with no sun at all. +// +// Four pieces, all from the original: +// • the disc — `circleTex(uSunTex, …) * vec3(uR, uG, uB) * 1.02`, a radial +// colour ramp rather than a flat circle, which is what gives the limb its +// falloff and the core its warmth +// • static rays — `uObviousLineTex`, a fixed radial streak pattern, weighted +// by a forward-facing `fan` term +// • animated rays — three sine systems at 0, π/5 and π/2, the term the reference +// writes out by hand as angle-addition. Its gain is +// `LINE_COLOR * (uLineAlpha / 320)`, and `uLineAlpha` is **20.5** +// (`SunUniform.f16278e`) — a factor of 0.064. Assuming a gain near 1 +// makes the rays roughly fifteen times too strong and they swamp the sky. +// • the glow — `vec4(0.851, 0.604, 0.349, 0.5 / exp(x²))` +// +// Everything is then attenuated by `exp(1 - dist) / kDecay` and gated below +// the horizon, as in the original. +// +// The three lookups are radial: `circleTex(tex, st)` samples at +// `vec2(length(st * 2 - 1), 0.5)`, so `sun_profile` and `annulus` are 1-D +// ramps stored one row tall. +// +// Uniform contract — slots are float indices in declaration order. +// iResolution (0..1) render size in pixels +// iSunPos (2..3) normalized screen position of the sun (0..1) +// iRayColor (4..6) animated-ray tint (the reference's `LINE_COLOR` × light) +// iTime (7) animation time (seconds) +// iOpacity (8) overall opacity 0..1 +// iDiscAlpha (9) sun disc strength 0..1 +// iRayAlpha (10) animated ray strength 0..1 +// iAnnulusAlpha (11) chromatic ring strength (the reference's `uAnnulusAlpha`, +// 0.13 while the sun is up) +// iGhostAlpha (12) lens-ghost strength (the reference's `uCircleAlpha`, 1.03) +// iGhostOffset (13) ghost spacing along the sun axis (`uCircleOffset`) +// iSeed (14) per-transition ghost randomisation +// iGlowAlpha (15) broad glow strength 0..1 +// sampler 0: iSunTex radial colour ramp for the disc +// sampler 1: iRaysTex static radial streak pattern +// sampler 2: iAnnulus radial ramp for the ring +#include + +precision mediump float; + +uniform vec2 iResolution; +uniform vec2 iSunPos; +uniform vec3 iRayColor; +uniform float iTime; +uniform float iOpacity; +uniform float iDiscAlpha; +uniform float iRayAlpha; +uniform float iAnnulusAlpha; +uniform float iGhostAlpha; +uniform float iGhostOffset; +uniform float iSeed; +uniform float iGlowAlpha; +uniform sampler2D iSunTex; +uniform sampler2D iRaysTex; +uniform sampler2D iAnnulus; + +out vec4 fragColor; + +#define PI 3.14159265 + +/// Overall falloff with distance from the sun (the reference's `DECAY`). +const float kDecay = 6.0; +/// The reference's per-channel disc gain. +const vec3 kDiscGain = vec3(1.25, 1.61, 1.84); +/// Animated-ray gain: `uLineAlpha / 320` with the engine's `uLineAlpha` 20.5. +const float kLineGain = 20.5 / 320.0; +/// The reference's `CIRCLE_SIZE`. +const float kCircleSize = 0.95; + +/// The reference's ghost colour table. SkSL has no array initialisers. +vec3 ghostColor(int index) { + if (index == 0) return vec3(0.1686, 0.7412, 0.2353); + if (index == 1) return vec3(1.0000, 0.3843, 0.0000); + if (index == 2) return vec3(1.0000, 0.5333, 0.0000); + if (index == 3) return vec3(0.7804, 1.0000, 0.3725); + if (index == 4) return vec3(1.0000, 0.5333, 0.0000); + if (index == 5) return vec3(1.0000, 0.3843, 0.0000); + if (index == 6) return vec3(1.0000, 0.5333, 0.0000); + if (index == 7) return vec3(1.0000, 0.5333, 0.0000); + return vec3(1.0000, 0.2588, 0.1294); +} + +vec3 hash31(float n) { + vec3 p = fract(vec3(n) * vec3(0.1031, 0.11369, 0.13787)); + p += dot(p, p.yzx + 19.19); + return fract(vec3((p.x + p.y) * p.z, (p.x + p.z) * p.y, (p.y + p.z) * p.x)); +} + +/// The reference's `getCircle`: one lens ghost along the sun axis. +vec3 ghost(int index, vec2 uv, vec2 sunPos, float size, float dist, + float blur, float alpha) { + blur = mix(0.45, 1.0, blur); + float c = max(0.01 - pow(length(uv + sunPos * dist), size * 1.4), 0.0) * 50.0; + float c1 = (c > 0.1) ? (0.1 - (c - 0.1) / 5.0) : (c / 1.4) * blur * 2.0; + float c2 = mix(c1, c, (blur > 0.5) ? ((blur - 0.5) * 2.0) : 0.0); + float a = alpha * mix(1.0, 0.4, clamp((size - 1.0) * 3.0, 0.0, 1.0)) * + ((blur >= 0.82) ? 0.5 : 1.0); + return max(c2 * ghostColor(index) * a - 0.01, 0.0); +} + +/// The reference's `ring`: the chromatic halo, offset per channel. +vec3 ring(vec2 uv, vec2 pos, float dist) { + vec2 uvd = uv * length(uv); + float rl = length(uvd + (dist - 0.05) * pos); + float gl = length(uvd + dist * pos); + float bl = length(uvd + (dist + 0.05) * pos); + float r = max(2.0 / (1.0 + 32.0 * rl * rl), 0.0) * 0.25; + float g = max(2.0 / (1.0 + 32.0 * gl * gl), 0.0) * 0.23; + float b = max(2.0 / (1.0 + 32.0 * bl * bl), 0.0) * 0.21; + return vec3(r, g, b); +} + +/// The reference's `circleTex` is a radial lookup — distance from the centre into a +/// ramp. SkSL forbids passing a sampler to a function, so the coordinate is +/// computed here and each lookup is written out at its use site. +vec2 circleUv(vec2 st) { + return vec2(length(st * 2.0 - 1.0), 0.5); +} + +/// One animated ray system (the reference's `dimLine` term). +float rayAt(float theta) { + return abs(sin(3.0 * theta + cos(9.0 * theta))) * abs(sin(9.0 * theta)); +} + +float perFromVal(float v, float from, float to) { + return (v - from) / (to - from); +} + +void main() { + vec2 uvRaw = FlutterFragCoord().xy / iResolution; + float aspect = iResolution.x / max(iResolution.y, 1.0); + + float opacity = clamp(iOpacity, 0.0, 1.0); + if (opacity < 0.004) { + fragColor = vec4(0.0); + return; + } + + // The reference's frame: origin at screen centre, x scaled by aspect, y up. + vec2 uv = (uvRaw - 0.5) * vec2(aspect, -1.0); + vec2 sunPos = (iSunPos - 0.5) * vec2(aspect, -1.0); + + vec2 diff = uv - sunPos; + float dist = length(diff); + vec3 color = vec3(0.0); + + // --- static rays -------------------------------------------------------- + // Sampled in a frame centred on the sun, a third of the way out, so the + // pattern radiates from it. + vec2 st2 = clamp(diff / 3.0 + 0.5, 0.0, 1.0); + vec3 staticRays = texture(iRaysTex, st2).rgb; + + // Forward-facing weight: brightest looking straight into the sun. + float facing = dot(normalize(sunPos + 1e-5), normalize(sunPos - uv + 1e-5)); + facing = clamp(perFromVal(facing, -1.0, 1.0), 0.0, 1.0); + float fan = min(max(0.3, facing * facing * facing), 0.8); + float distFall = max(1.0 - dist, 0.0); + fan = max(distFall * distFall * distFall, fan); + color += staticRays * fan; + + // --- animated rays ------------------------------------------------------ + float rayAlpha = clamp(iRayAlpha, 0.0, 1.0); + if (rayAlpha > 0.01) { + float theta = atan(diff.y, diff.x); + // Three systems, each breathing at its own rate. + float a0 = (sin(iTime * 2.0) + 1.0) * 0.5; + float a1 = (sin(iTime * 2.0 + PI * 1.3333) + 1.0) * 0.5; + float a2 = (sin(iTime * 2.0 + PI * 2.6667) + 1.0) * 0.5; + float rays = rayAt(theta) * a0 + rayAt(theta + PI / 5.0) * a1 + + rayAt(theta + PI / 2.0) * a2; + // `uLineAlpha / 320` with uLineAlpha = 20.5. + color += iRayColor * kLineGain * rays * 1.2 * (1.0 - st2.x) * rayAlpha; + } + + // --- lens ghosts and ring (the reference's `u22Open` block) ----------------------- + // Enabled by `sunUniform.m(...)` in the main scene, so the flare is part of + // the stock look — the 0 in the constructor is only its initial value. + float ghostAlpha = clamp(iGhostAlpha, 0.0, 2.0); + if (ghostAlpha > 0.01) { + // `light.a` in the original: a slow breath on ghost size and spacing. + float breath = sin(iTime * 0.3333 + 5.0) * 0.05; + float count = floor(mix(4.0, 8.0, hash31(17.0 + iSeed).x)); + for (int i = 0; i < 8; i++) { + if (float(i) >= count) break; + vec3 rnd = hash31(float(i) + iSeed * 13.0); + float size = (rnd.x * 0.33 + kCircleSize) + breath; + float d = (iGhostOffset + rnd.y * 0.35 + + float(i) / 30.0 + float(i) * float(i) * 0.01) + breath; + color += ghost(i, uv, sunPos, size, d, rnd.z, ghostAlpha); + } + // The reference's `lensflare`: two chromatic rings either side of the sun. + vec2 uvs = uv * 4.0; + vec2 ps = sunPos * 1.5; + color += (ring(uvs, ps, -1.0) * 0.5 * 4.0 + ring(uvs, ps, 1.0) * 0.5 * 4.0) * + 0.6 * vec3(1.4, 1.2, 1.0); + } + + float annulusAlpha = clamp(iAnnulusAlpha, 0.0, 1.0); + if (annulusAlpha > 0.01) { + vec2 st = uv * 3.0 - sunPos * 2.5; + color += texture(iAnnulus, circleUv(st * 0.5 + 0.5)).rgb * annulusAlpha * 4.0; + } + + // --- the disc ------------------------------------------------------------ + // The piece that was missing. A radial colour ramp, not a flat circle. + vec2 st3 = clamp(diff + 0.5, 0.0, 1.0); + color += texture(iSunTex, circleUv(st3)).rgb * kDiscGain * 1.02 * + clamp(iDiscAlpha, 0.0, 1.0); + + // --- glow ---------------------------------------------------------------- + float x = length(st3 * 2.0 - 1.0) * 2.8; + color += vec3(0.8509803922, 0.6039215686, 0.3490196078) * + (0.5 * exp(-x * x)) * clamp(iGlowAlpha, 0.0, 1.0); + + // --- falloff ------------------------------------------------------------- + color *= exp(1.0 - dist) / kDecay; + // Gate below the horizon, as the reference does. + color = clamp(color, 0.0, 1.0) * smoothstep(-0.5, 0.1, uv.y + 0.5) * opacity; + + float a = clamp(max(color.r, max(color.g, color.b)), 0.0, 1.0); + if (a < 0.003) { + fragColor = vec4(0.0); + return; + } + + fragColor = vec4(color, a); // premultiplied +} diff --git a/shaders/weather_sky.frag b/shaders/weather_sky.frag deleted file mode 100644 index 19f115a2d..000000000 --- a/shaders/weather_sky.frag +++ /dev/null @@ -1,358 +0,0 @@ -// Physically-motivated weather backdrop for the home sheet. -// -// Clear skies use a proper blue gradient + localized sun. Clouds, rain, fog -// and lightning layer on top and scale with their uniforms so clear (low -// iCloud) does NOT pick up overcast wash or a full cloud deck. -// Uniform layout unchanged — Dart sets floats by index. -#include -#define PI 3.14159265 - -uniform float iTime; -uniform vec2 iResolution; -uniform float iScene; // 0 day / 1 night / 2 dawn / 3 sunset -uniform float iScroll; -uniform float iCloud; // 0..1 coverage control -uniform float iRain; -uniform float iWind; -uniform float iSunPhase; -uniform float iLight; -uniform float iFog; -uniform float iLightning; - -out vec4 fragColor; - -float hash12(vec2 p) { - vec3 p3 = fract(vec3(p.xyx) * 0.1031); - p3 += dot(p3, p3.yzx + 33.33); - return fract((p3.x + p3.y) * p3.z); -} - -vec2 hash22(vec2 p) { - vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); - p3 += dot(p3, p3.yzx + 33.33); - return fract((p3.xx + p3.yz) * p3.zy); -} - -float vnoise(vec2 p) { - vec2 i = floor(p); - vec2 f = fract(p); - vec2 u = f * f * (3.0 - 2.0 * f); - return mix(mix(hash12(i), hash12(i + vec2(1.0, 0.0)), u.x), - mix(hash12(i + vec2(0.0, 1.0)), hash12(i + vec2(1.0, 1.0)), u.x), - u.y); -} - -const mat2 M2 = mat2(0.80, 0.60, -0.60, 0.80); - -float fbm(vec2 p) { - float v = 0.0; - float a = 0.5; - for (int i = 0; i < 5; i++) { - v += a * vnoise(p); - p = M2 * p * 2.02 + 13.7; - a *= 0.5; - } - return v; -} - -float rfbm(vec2 p) { - float v = 0.0; - float a = 0.55; - for (int i = 0; i < 4; i++) { - float n = 1.0 - abs(vnoise(p) * 2.0 - 1.0); - v += a * n * n; - p = M2 * p * 2.03 + 7.9; - a *= 0.5; - } - return v; -} - -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -float hg(float mu, float g) { - float g2 = g * g; - return (1.0 - g2) / (4.0 * PI * pow(max(1.0 + g2 - 2.0 * g * mu, 1e-4), 1.5)); -} - -float rainLayer(vec2 uv, float t, float density, float spd, float shear, - float soft, float seed) { - vec2 warp = vec2(vnoise(uv * 3.0 + seed), vnoise(uv * 3.0 + seed + 17.0)); - uv += (warp - 0.5) * 0.06; - uv.x += uv.y * shear; - - vec2 p = vec2(uv.x * density, uv.y * density * 0.42); - vec2 cell = floor(p); - vec2 f = fract(p); - - float h = hash12(cell + seed); - float g = hash12(cell + seed + 41.7); - float alive = smoothstep(0.45, 0.92, g); - - float cx = 0.20 + 0.60 * h; - float fall = fract(h * 5.91 + t * spd * (0.85 + 0.50 * g)); - float len = 0.30 + 0.45 * g; - - float dx = f.x - cx; - float dy = f.y - fall; - dy -= floor(dy + 0.5); - - float w = 0.016 + 0.014 * h; - float lateral = smoothstep(w + soft, w * 0.10, abs(dx)); - float along = smoothstep(-len, -len * 0.18, dy) * smoothstep(0.035, -0.006, dy); - float tip = smoothstep(-0.02, -len * 0.55, dy) * (0.35 + 0.65 * g); - return lateral * along * alive * (0.35 + 0.45 * h + tip); -} - -// Clear-day sky: zenith→horizon blue with Rayleigh-ish falloff + Mie sun bloom. -// `sunDir2` is screen-space direction from pixel toward the sun (aspect-corrected). -vec3 clearSky(vec2 uv, float sy, vec2 sunOff, float sunVis, float wDay, - float wDawn, float wSun, float wNight, float iLight) { - // Base gradients per scene. - vec3 zen = wDay * mix(vec3(0.10, 0.28, 0.62), vec3(0.30, 0.56, 0.90), iLight) - + wNight * vec3(0.015, 0.022, 0.060) - + wDawn * vec3(0.14, 0.16, 0.38) - + wSun * vec3(0.12, 0.14, 0.36); - vec3 mid = wDay * mix(vec3(0.22, 0.48, 0.78), vec3(0.48, 0.72, 0.94), iLight) - + wNight * vec3(0.040, 0.055, 0.120) - + wDawn * vec3(0.45, 0.32, 0.52) - + wSun * vec3(0.55, 0.30, 0.40); - vec3 hor = wDay * mix(vec3(0.55, 0.72, 0.88), vec3(0.82, 0.90, 0.97), iLight) - + wNight * vec3(0.10, 0.13, 0.22) - + wDawn * vec3(0.98, 0.70, 0.45) - + wSun * vec3(1.00, 0.58, 0.30); - - vec3 col = mix(zen, mid, smoothstep(0.00, 0.55, sy)); - col = mix(col, hor, smoothstep(0.45, 1.05, sy)); - - // Atmospheric perspective brightening near horizon (day only). - float haze = pow(smoothstep(0.35, 1.05, sy), 2.0); - col = mix(col, mix(hor, vec3(1.0), 0.15), haze * 0.35 * (1.0 - wNight)); - - // Sun: localized disc + Mie corona (NOT whole-sky wash). - float ang = length(sunOff); - vec3 sunCol = wDay * vec3(1.00, 0.97, 0.88) - + wDawn * vec3(1.00, 0.72, 0.40) - + wSun * vec3(1.00, 0.55, 0.28); - float bloom = exp(-ang * ang * 8.0); - float glow = exp(-ang * ang * 28.0); - float core = exp(-ang * ang * 90.0); - float disc = smoothstep(0.034, 0.022, ang); - col += sunCol * (0.45 * bloom + 0.55 * glow + 0.85 * core + 1.1 * disc) * - sunVis; - - // Soft sun-side warm fill (narrow, not global). - col += sunCol * exp(-ang * 3.5) * 0.12 * sunVis * (wDawn + wSun + 0.35 * wDay); - - return col; -} - -void main() { - vec2 fc = FlutterFragCoord(); - vec2 uv = fc.xy / iResolution.xy; // y: 0 top → 1 bottom - float aspect = iResolution.x / max(iResolution.y, 1.0); - float t = mod(iTime, 600.0); - float scrollN = iScroll / max(iResolution.y, 1.0); - - float wDay = 1.0 - min(abs(iScene - 0.0), 1.0); - float wNight = 1.0 - min(abs(iScene - 1.0), 1.0); - float wDawn = 1.0 - min(abs(iScene - 2.0), 1.0); - float wSun = 1.0 - min(abs(iScene - 3.0), 1.0); - - // Remap iCloud: clear(0.10) → ~0 coverage; rain(0.85) → full. - // Do NOT treat raw iCloud as overcast wash (that greys clear skies). - float cloudCtrl = clamp(iCloud, 0.0, 1.0); - float coverageAmt = smoothstep(0.12, 0.78, cloudCtrl); // deck amount - float overcastWash = smoothstep(0.40, 0.92, cloudCtrl); // grey wash - float rainAmt = clamp(iRain, 0.0, 1.0); - float fogAmt = clamp(iFog, 0.0, 1.0); - - float breathe = 0.012 * sin(t * 0.05 + uv.x * 1.5); - float sy = clamp(uv.y + scrollN * 0.22 + breathe, 0.0, 1.0); - - // Screen-space sun on a day arc (matches iSunPhase from Dart). - float phase = clamp(iSunPhase, 0.0, 1.0); - float sunH = sin(phase * PI); - // Dawn/sunset: keep sun low even if phase is mid-ramp. - sunH = mix(sunH, sunH * 0.45, wDawn * 0.8 + wSun * 0.8); - vec2 sunUV = vec2(mix(0.14, 0.86, phase), mix(0.92, 0.18, sunH)); - sunUV.y += scrollN * 0.35; - vec2 sunOff = (uv - sunUV) * vec2(aspect, 1.0); - float sunVis = (1.0 - wNight) * (1.0 - 0.75 * overcastWash); - - vec3 col = clearSky(uv, sy, sunOff, sunVis, wDay, wDawn, wSun, wNight, iLight); - - // --- night layer --- - if (wNight > 0.0) { - vec3 nightSky = mix(vec3(0.012, 0.018, 0.055), vec3(0.06, 0.08, 0.14), - pow(sy, 2.5)); - nightSky += vec3(0.02, 0.05, 0.08) * exp(-abs(sy - 0.82) * 14.0) * 0.3; - col = mix(col, nightSky, wNight); - - vec2 md = uv - vec2(0.74, 0.17 + scrollN * 0.32); - md.x *= aspect; - float mdl = length(md); - float moon = smoothstep(0.050, 0.036, mdl); - float bite = smoothstep(0.044, 0.028, length(md - vec2(0.018, -0.007))); - col += vec3(0.80, 0.86, 0.98) * exp(-mdl * 8.0) * 0.20 * wNight; - col = mix(col, vec3(0.93, 0.95, 0.99), - clamp(moon - bite, 0.0, 1.0) * wNight); - - vec2 sg = vec2(uv.x * aspect, uv.y + scrollN * 0.06) * 100.0; - vec2 id = floor(sg); - vec2 f = fract(sg) - 0.5; - vec2 rnd = hash22(id); - float d = length(f - (rnd - 0.5) * 0.65); - float star = step(0.915, rnd.x) * exp(-d * d * 850.0); - star *= 0.45 + 0.55 * sin(t * 2.0 + rnd.y * 6.28); - col += vec3(star) * wNight * (1.0 - coverageAmt) * - smoothstep(0.60, 0.08, uv.y); - } - - // Overcast wash — only when actually cloudy. - if (overcastWash > 0.0) { - vec3 slate = mix(vec3(0.30, 0.34, 0.40), vec3(0.60, 0.65, 0.72), iLight); - slate = mix(slate, vec3(0.08, 0.10, 0.14), wNight); - col = mix(col, slate, overcastWash * (0.50 + 0.25 * rainAmt)); - } - - // --- cloud deck (gated hard when clear) --- - float cover = 0.0; - if (coverageAmt > 0.001) { - const float HZ = 2.15; - float depth = clamp(HZ - uv.y, 0.85, HZ); - vec2 sky = vec2((uv.x - 0.5) * aspect, 0.78) / depth; - sky += vec2(0.025 + 0.12 * iWind, 0.006) * t + vec2(0.0, scrollN * 0.5); - - vec2 q = vec2(fbm(sky * 0.85), fbm(sky * 0.85 + vec2(5.2, 1.3))); - vec2 r = vec2(fbm(sky * 1.7 + 1.6 * q + vec2(1.7, 9.2)), - fbm(sky * 1.7 + 1.6 * q + vec2(8.3, 2.8))); - float dens = rfbm(sky * 1.05 + 1.35 * q + 0.65 * r); - dens = mix(dens, fbm(sky * 1.55 + q), 0.40); - dens -= (1.0 - coverageAmt) * 0.18 * fbm(sky * 3.0 + r); - - // Clear → high threshold (almost no lobes); storm → low threshold. - float thresh = mix(0.78, 0.28, coverageAmt); - cover = smoothstep(thresh, thresh + 0.30, dens); - cover *= smoothstep(1.05, 0.72, uv.y); - cover *= mix(smoothstep(0.0, 0.14, uv.y), 1.0, coverageAmt); - cover *= coverageAmt; // hard gate for clear - cover = min(cover, 0.94); - - float tau = clamp((dens - thresh) * 2.8, 0.0, 2.5); - float transmit = exp(-tau * 0.85); - float ang = length(sunOff); - float scatter = exp(-max(dens - thresh, 0.0) * 1.4); - scatter *= exp(-ang * 1.2) * 0.5 + 0.5; // brighter toward sun - - vec3 sunLight = mix(vec3(0.55, 0.65, 0.85), vec3(1.0, 0.96, 0.88), - (1.0 - wNight) * sunVis); - sunLight = mix(sunLight, vec3(1.0, 0.65, 0.40), (wDawn + wSun) * 0.55); - sunLight *= (1.0 - wNight * 0.85); - - vec3 ambient = mix(vec3(0.28, 0.34, 0.48), vec3(0.72, 0.80, 0.90), iLight); - ambient = mix(ambient, vec3(0.08, 0.10, 0.16), wNight); - ambient = mix(ambient, vec3(0.55, 0.40, 0.42), (wDawn + wSun) * 0.35); - - float powder = 1.0 - exp(-tau * 2.0); - float phaseCloud = mix(0.4, 1.0, hg(clamp(1.0 - ang * 0.8, -1.0, 1.0), 0.4)); - vec3 cloudLit = ambient * (0.35 + 0.45 * transmit) - + sunLight * scatter * phaseCloud * (0.55 + 0.45 * powder); - cloudLit *= 1.0 - 0.35 * overcastWash; - cloudLit *= mix(0.55, 1.0, smoothstep(thresh, thresh + 0.55, dens)); - // Silver lining. - cloudLit += sunLight * exp(-ang * 4.0) * 0.35 * sunVis * cover; - - col = mix(col, cloudLit, cover); - } - - // Thin cirrus — only on mostly clear / partly cloudy days. - float cirAllow = (1.0 - overcastWash) * (1.0 - wNight * 0.7); - if (cirAllow > 0.01) { - vec2 cirP = vec2(uv.x * aspect * 1.2 - t * (0.01 + 0.018 * iWind), - uv.y * 6.5); - float cir = fbm(cirP + vec2(fbm(cirP * 0.5) * 1.5, 0.0)); - float cirBand = smoothstep(0.02, 0.16, uv.y) * smoothstep(0.42, 0.18, uv.y); - // Clear: subtle wisps; mid coverage: a bit more; heavy overcast: off. - float cirStrength = mix(0.10, 0.04, coverageAmt) * cirAllow; - float cirA = smoothstep(0.58, 0.90, cir) * cirBand * cirStrength; - vec3 cirCol = mix(col, vec3(1.0), 0.55 + 0.2 * iLight); - col = mix(col, cirCol, cirA * (1.0 - cover)); - } - - // --- rain --- - if (rainAmt > 0.001) { - float gust = vnoise(vec2(uv.x * aspect * 0.55 - t * 0.15, t * 0.07)); - float R = rainAmt * (0.40 + 1.30 * gust); - float gs = 0.08 * (R - rainAmt); - - vec2 ruv = vec2(uv.x * aspect, uv.y + scrollN * 0.35); - float rain = - rainLayer(ruv, t, 28.0, 0.95, 0.05 + 0.12 * iWind + gs, 0.040, 2.0) * - 0.30 + - rainLayer(ruv, t, 18.0, 1.35, 0.07 + 0.15 * iWind + gs, 0.022, 59.0) * - 0.60 + - rainLayer(ruv, t, 11.0, 1.90, 0.09 + 0.18 * iWind + gs, 0.010, 127.0) * - 0.95; - rain *= R * smoothstep(0.0, 0.18, uv.y); - - vec3 dropCol = mix(col, vec3(0.85, 0.90, 0.98), 0.55); - dropCol += vec3(0.75, 0.85, 1.0) * clamp(iLightning, 0.0, 1.0); - col = mix(col, dropCol, clamp(rain * 0.70, 0.0, 1.0)); - - float luma = dot(col, vec3(0.2126, 0.7152, 0.0722)); - col = mix(col, vec3(luma) * vec3(0.88, 0.93, 1.02), 0.28 * rainAmt); - col *= 1.0 - 0.14 * rainAmt; - } - - // --- fog --- - if (fogAmt > 0.001) { - vec2 fp = vec2(uv.x * aspect * 0.9, uv.y * 1.5); - float bank = vnoise(fp * 1.1 + vec2(0.035, 0.0) * t) * 0.40 - + vnoise(fp * 2.2 + vec2(0.08, -0.045) * t) * 0.35 - + vnoise(fp * 4.4 + vec2(-0.02, 0.055) * t) * 0.25; - bank = smoothstep(0.32, 0.70, bank); - - float height = smoothstep(0.02, 0.85, uv.y); - float density = fogAmt * height * (0.25 + 1.80 * bank) * (0.9 + 2.6 * fogAmt); - - vec3 air = mix(mix(vec3(0.72, 0.76, 0.82), vec3(0.90, 0.93, 0.96), iLight), - vec3(0.10, 0.12, 0.18), wNight); - float ang = length(sunOff); - air += vec3(1.0, 0.92, 0.80) * exp(-ang * 2.0) * 0.15 * sunVis; - col = mix(air, col, exp(-density)); - } - - // --- lightning (only when flashing) --- - float flash = clamp(iLightning, 0.0, 1.0); - if (flash > 0.001) { - col += vec3(0.65, 0.75, 0.95) * flash * (0.12 + 0.35 * cover); - col = mix(col, vec3(0.78, 0.84, 0.96), flash * 0.14); - vec2 buv = uv - vec2(0.5 + 0.1 * sin(floor(t * 3.0)), 0.0); - buv.x *= aspect; - float seed = floor(t * 3.0); - float jig = (hash12(vec2(seed, 2.0)) - 0.5) * 0.5; - float disp = 0.0; - float amp = 0.13; - float fr = 3.0; - for (int i = 0; i < 5; i++) { - disp += amp * sin(buv.y * fr + t * 2.0 + float(i) * 9.0 + seed); - amp *= 0.5; - fr *= 2.1; - } - float bolt = (0.0055 / (abs(buv.x - jig - disp) + 0.002)) * - smoothstep(0.95, 0.08, uv.y); - float branch = (0.002 / (abs(buv.x - jig - disp * 0.6 + 0.04) + 0.004)) * - smoothstep(0.7, 0.15, uv.y); - col += vec3(0.85, 0.92, 1.0) * (bolt + 0.45 * branch) * flash; - } - - // Tonemap + dither. - col = max(col, vec3(0.0)); - vec3 mapped = (col * (2.51 * col + 0.03)) / - (col * (2.43 * col + 0.59) + 0.14); - col = clamp(mapped, 0.0, 1.0); - col += (ign(fc.xy) - 0.5) / 255.0; - fragColor = vec4(col, 1.0); -} diff --git a/test/app/router/notification_routes_test.dart b/test/app/router/notification_routes_test.dart index adfcf1eef..0e6f7442c 100644 --- a/test/app/router/notification_routes_test.dart +++ b/test/app/router/notification_routes_test.dart @@ -1,5 +1,6 @@ import 'package:dpip/app/router/notification_routes.dart'; import 'package:dpip/core/notifications/notification_channels.dart'; +import 'package:dpip/core/notifications/notification_tap.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -70,4 +71,35 @@ void main() { expect(routeForNotificationChannel('does-not-exist'), AppRoutes.home); expect(routeForNotificationChannel(null), AppRoutes.home); }); + + test('routeNotificationTap navigates to the resolved route', () { + final named = []; + void record( + String name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + String? fragment, + Object? extra, + }) { + named.add(name); + } + + // EEW channel → the live monitor tab. + routeNotificationTap( + const NotificationTap(channelKey: 'eew_alert-important-v2'), + navigate: record, + ); + // Report family → the report list tab. + routeNotificationTap( + const NotificationTap(channelKey: 'report-general-v2'), + navigate: record, + ); + // An unmapped channel falls back to home rather than throwing. + routeNotificationTap( + const NotificationTap(channelKey: 'does-not-exist'), + navigate: record, + ); + + expect(named, [AppRoutes.eew, AppRoutes.earthquake, AppRoutes.home]); + }); } diff --git a/test/core/geo/location_monitor_test.dart b/test/core/geo/location_monitor_test.dart index 1c7b5d6b3..d667b3805 100644 --- a/test/core/geo/location_monitor_test.dart +++ b/test/core/geo/location_monitor_test.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'package:dpip/core/geo/device_location_reporter.dart'; import 'package:dpip/core/geo/location_monitor.dart'; import 'package:dpip/core/geo/location_service.dart'; +import 'package:dpip/core/geo/location_status.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/prefs.dart'; import 'package:dpip/core/settings/region_store.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -47,7 +49,9 @@ void main() { SharedPreferences.setMockInitialValues({}); final prefs = Prefs(await SharedPreferences.getInstance()); regions = RegionStore(prefs); - positions = StreamController(); + // Broadcast so a recovery-driven `reporter.restart()` can re-subscribe (a + // single-subscription stream throws on the second listen). + positions = StreamController.broadcast(); reporter = DeviceLocationReporter( positions: () => positions.stream, prefs: prefs, @@ -133,4 +137,66 @@ void main() { await pumpEventQueue(); expect(regions.currentCode, _tainanCode); }); + + test('GPS loss clears the township so 所在地 degrades to nationwide', () async { + var available = true; + final location = LocationService( + _directory(), + isAvailable: () async => available, + fix: () async => _tainanFix, + status: () async => + available ? LocationStatus.ready : LocationStatus.serviceOff, + ); + final monitor = LocationMonitor( + location: location, + reporter: reporter, + regions: regions, + )..start(); + reporter.start(); + await pumpEventQueue(); + + positions.add((lat: _tainanFix.lat, lng: _tainanFix.lng)); + await pumpEventQueue(); + expect(regions.currentCode, _tainanCode); + + // Services off → the monitor re-checks on resume and must not keep the + // last-known township as the current one (the backdrop would frame it). + available = false; + WidgetsBinding.instance.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await pumpEventQueue(); + expect(regions.currentCode, isNull); + + monitor.dispose(); + }); + + test('GPS returning re-resolves the current township', () async { + var available = false; + final location = LocationService( + _directory(), + isAvailable: () async => available, + fix: () async => _taipeiFix, + status: () async => + available ? LocationStatus.ready : LocationStatus.serviceOff, + ); + final monitor = LocationMonitor( + location: location, + reporter: reporter, + regions: regions, + )..start(); + reporter.start(); + await pumpEventQueue(); + expect(regions.currentCode, isNull); + + // Recovery re-resolves 所在地 instead of leaving it nationwide. + available = true; + WidgetsBinding.instance.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await pumpEventQueue(); + expect(regions.currentCode, _taipeiCode); + + monitor.dispose(); + }); } diff --git a/test/core/geo/location_service_test.dart b/test/core/geo/location_service_test.dart index d629dbd6e..8e5065989 100644 --- a/test/core/geo/location_service_test.dart +++ b/test/core/geo/location_service_test.dart @@ -57,4 +57,35 @@ void main() { ); expect(await service.currentTown(), isNull); }); + + test('lastKnownFix serves the cached fix without a live read', () async { + var liveReads = 0; + final service = LocationService( + _dir(), + lastKnown: () async => (lat: 25.05, lng: 121.55), + fix: () async { + liveReads++; + return (lat: 25.05, lng: 121.55); + }, + ); + expect(await service.lastKnownFix(), (lat: 25.05, lng: 121.55)); + expect(liveReads, 0, reason: 'the live read must not run'); + }); + + test('lastKnownFix is null when no cached fix exists', () async { + final service = LocationService( + _dir(), + lastKnown: () async => null, + fix: () async => (lat: 25.05, lng: 121.55), + ); + expect(await service.lastKnownFix(), isNull); + }); + + test('a lastKnownFix fault degrades to null, never throws', () async { + final service = LocationService( + _dir(), + lastKnown: () async => throw Exception('platform fault'), + ); + expect(await service.lastKnownFix(), isNull); + }); } diff --git a/test/core/network/etag_binary_test.dart b/test/core/network/etag_binary_test.dart index a2ff2d192..0f2f06d7c 100644 --- a/test/core/network/etag_binary_test.dart +++ b/test/core/network/etag_binary_test.dart @@ -139,6 +139,38 @@ void main() { }, ); + test( + 'immutable wind tile stores under URL-hash even without server ETag', + () async { + final payload = Uint8List.fromList([0x57, 0x49, 0x4e, 0x44]); + final adapter = _BinaryAdapter(bytes: payload); // no etag + final dio = createDio(etagCache: store)..httpClientAdapter = adapter; + const url = + 'https://static.core-tnn1.exptech.dev/api/v2/tiles/wind/1786384800/5/26/14.webp?model=ecmwf'; + final expectedEtag = EtagInterceptor.etagFromUrl(Uri.parse(url)); + + await dio.get>( + url, + options: Options(responseType: ResponseType.bytes), + ); + CachedBytes? entry; + for (var i = 0; i < 50 && entry == null; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + entry = await store.readBytes(url); + } + expect(entry, isNotNull); + expect(entry!.etag, expectedEtag); + expect(entry.bytes, payload); + + final second = await dio.get>( + url, + options: Options(responseType: ResponseType.bytes), + ); + expect(adapter.calls, 1, reason: 'local URL hit, no revalidation'); + expect(second.data, payload); + }, + ); + test('basemap PBF uses URL-hash ETag and hits locally on repeat', () async { final payload = Uint8List.fromList([0x1a, 0x2b, 0x3c]); final adapter = _BinaryAdapter(bytes: payload); // no server etag diff --git a/test/core/network/network_usage_store_test.dart b/test/core/network/network_usage_store_test.dart index 706c56450..c955b80ad 100644 --- a/test/core/network/network_usage_store_test.dart +++ b/test/core/network/network_usage_store_test.dart @@ -163,4 +163,62 @@ void main() { expect(stats.saved24h, 250); expect(stats.last24h, 500); }); + + test('history returns every hour of the window, zero-filled', () async { + now = DateTime.utc(2026, 1, 10, 12, 30); // current hour bucket = 12 + final s = store(); + await s.record(down: 1000, hit: false, saved: 0); // hour 12 + now = now.subtract(const Duration(hours: 3)); // 09:30 → hour 9 + await s.record(down: 0, hit: true, saved: 800); // hour 9 + now = now.add(const Duration(hours: 3)); + + final history = await s.history(hours: 4); + expect(history, hasLength(4)); + expect(history[0].saved, 800, reason: 'the 09:00 hit lands in its slot'); + expect(history[0].down, 0); + expect(history[1].down, 0, reason: 'the 10:00 gap hour is a zero bar'); + expect(history[1].saved, 0); + expect(history[2].down, 0, reason: 'the 11:00 gap hour is a zero bar'); + expect(history[3].down, 1000, reason: 'the 12:00 miss lands in its slot'); + expect(history[3].saved, 0); + // Oldest first, ascending UTC epoch hours. + expect([ + for (final h in history) h.hour, + ], List.generate(4, (i) => history.first.hour + i)); + }); + + test('history aggregates into coarser buckets for the 7-day view', () async { + now = DateTime.utc(2026, 1, 10, 12, 30); // current hour bucket = 12 + final s = store(); + await s.record(down: 300, hit: false, saved: 0); // hour 12 + await s.record(down: 100, hit: true, saved: 50); // hour 12 → same 6h slot + now = now.subtract(const Duration(hours: 2)); // 10:30 → hour 10 + await s.record(down: 600, hit: false, saved: 0); // hour 10 → 6h slot 6 + now = now.add(const Duration(hours: 2)); + + // A 12-hour window bucketed by 6: two samples per slot boundary. + final epochHour = now.millisecondsSinceEpoch ~/ 3600000; + final history = await s.history(hours: 12, bucketHours: 6); + expect(history, hasLength(2)); + expect( + history[0].hour, + (epochHour - 2) ~/ 6 * 6, + reason: 'slot starts at the epoch hour its record falls into', + ); + expect( + history[0].down, + 600, + reason: 'hour-10 bytes land in the first slot', + ); + expect(history[0].saved, 0); + expect( + history[1].hour, + epochHour ~/ 6 * 6, + reason: 'slot starts at the epoch hour now falls into', + ); + expect(history[1].down, 400, reason: 'both hour-12 records aggregate'); + expect(history[1].saved, 50); + expect(history[1].hits, 1); + expect(history[1].misses, 1); + }); } diff --git a/test/core/settings/map_layer_order_controller_test.dart b/test/core/settings/map_layer_order_controller_test.dart new file mode 100644 index 000000000..81bd976ab --- /dev/null +++ b/test/core/settings/map_layer_order_controller_test.dart @@ -0,0 +1,122 @@ +import 'package:dpip/core/settings/map_layer_order_controller.dart'; +import 'package:dpip/core/settings/prefs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future controllerWith( + Map initial, +) async { + SharedPreferences.setMockInitialValues(initial); + return MapLayerOrderController(Prefs(await SharedPreferences.getInstance())); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('starts empty when nothing was saved', () async { + final controller = await controllerWith({}); + expect(controller.order, isEmpty); + }); + + test('reads a previously saved order', () async { + final controller = await controllerWith({ + 'map.layerOrder': ['rain', 'radar'], + 'map.layerCategoryOrder': ['radar', 'typhoon'], + }); + expect(controller.order, ['rain', 'radar']); + expect(controller.categoryOrder, ['radar', 'typhoon']); + }); + + test('setOrder persists and notifies, and is idempotent', () async { + final controller = await controllerWith({}); + var notified = 0; + controller.addListener(() => notified++); + + await controller.setOrder(['typhoon', 'radar']); + expect(controller.order, ['typhoon', 'radar']); + expect(notified, 1); + + await controller.setOrder(['typhoon', 'radar']); + expect(notified, 1, reason: 'an unchanged order must not notify'); + + // A fresh controller reads the persisted value. + final reloaded = await controllerWith({'map.layerOrder': []}); + await reloaded.setOrder(['typhoon', 'radar']); + expect(controller.order, reloaded.order); + }); + + test('setOrder never leaks its input list', () async { + final controller = await controllerWith({}); + final input = ['radar', 'rain']; + await controller.setOrder(input); + input.add('typhoon'); + expect(controller.order, ['radar', 'rain']); + }); + + test('reset clears the saved order and notifies once', () async { + final controller = await controllerWith({ + 'map.layerOrder': ['rain', 'radar'], + }); + var notified = 0; + controller.addListener(() => notified++); + + await controller.reset(); + expect(controller.order, isEmpty); + expect(notified, 1); + + // Resetting an already-empty order is a no-op. + await controller.reset(); + expect(notified, 1); + + final reloaded = await controllerWith({}); + expect(reloaded.order, isEmpty); + }); + + test('category order starts empty and persists idempotently', () async { + final controller = await controllerWith({}); + expect(controller.categoryOrder, isEmpty); + var notified = 0; + controller.addListener(() => notified++); + + await controller.setCategoryOrder(['radar', 'typhoon']); + expect(controller.categoryOrder, ['radar', 'typhoon']); + expect(notified, 1); + + await controller.setCategoryOrder(['radar', 'typhoon']); + expect(notified, 1, reason: 'an unchanged order must not notify'); + + final reloaded = await controllerWith({}); + await reloaded.setCategoryOrder(['radar', 'typhoon']); + expect(controller.categoryOrder, reloaded.categoryOrder); + }); + + test('setCategoryOrder never leaks its input list', () async { + final controller = await controllerWith({}); + final input = ['radar']; + await controller.setCategoryOrder(input); + input.add('typhoon'); + expect(controller.categoryOrder, ['radar']); + }); + + test('reset clears both orders and notifies once', () async { + final controller = await controllerWith({ + 'map.layerOrder': ['rain', 'radar'], + 'map.layerCategoryOrder': ['radar', 'typhoon'], + }); + var notified = 0; + controller.addListener(() => notified++); + + await controller.reset(); + expect(controller.order, isEmpty); + expect(controller.categoryOrder, isEmpty); + expect(notified, 1); + + // Resetting an already-empty pair is a no-op. + await controller.reset(); + expect(notified, 1); + + final reloaded = await controllerWith({}); + expect(reloaded.order, isEmpty); + expect(reloaded.categoryOrder, isEmpty); + }); +} diff --git a/test/core/weather/weather_condition_test.dart b/test/core/weather/weather_condition_test.dart new file mode 100644 index 000000000..889083429 --- /dev/null +++ b/test/core/weather/weather_condition_test.dart @@ -0,0 +1,97 @@ +import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/core/weather/weather_condition.dart'; +import 'package:dpip/core/weather/weather_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const colors = ColorScheme.light(); + + group('weatherModeFor', () { + test('plain families map clear / cloudy / overcast', () { + expect(weatherModeFor(100), WeatherMode.clear); + expect(weatherModeFor(200), WeatherMode.cloudy); + expect(weatherModeFor(300), WeatherMode.overcast); + }); + + test('phenomenon ones digit wins over the family sky', () { + expect(weatherModeFor(106), WeatherMode.rain); + expect(weatherModeFor(214), WeatherMode.thunderstorm); + expect(weatherModeFor(308), WeatherMode.snow); + expect(weatherModeFor(105), WeatherMode.fog); + }); + + test('0 and unknown families fall back to auto', () { + expect(weatherModeFor(0), WeatherMode.auto); + expect(weatherModeFor(400), WeatherMode.auto); + }); + }); + + group('intensity channels', () { + test('rain intensity scales with the phenomenon', () { + expect(weatherRainIntensity(106), 0.35); // 有雨 + expect(weatherRainIntensity(111), 0.35); // 有陣雨 + expect(weatherRainIntensity(114), 0.70); // 有雷雨 + expect(weatherRainIntensity(117), 1.00); // 大雷雨 + expect(weatherRainIntensity(119), 0.15); // 有雷 — lightning only + }); + + test('rain-free codes carry no rain intensity', () { + expect(weatherRainIntensity(100), isNull); + expect(weatherRainIntensity(105), isNull); // 有霧 + expect(weatherRainIntensity(0), isNull); + }); + + test('snow intensity distinguishes heavy from light', () { + expect(weatherSnowIntensity(108), 1.0); // 有大雪 + expect(weatherSnowIntensity(109), 0.5); // 有雪珠 + expect(weatherSnowIntensity(106), isNull); // 有雨 + }); + }); + + group('weatherVisual', () { + test('code phenomenon drives the icon regardless of the label', () { + // 200 有雷雨 — the code, not the 多雲 label, picks the storm. + expect(weatherVisual('多雲', 214, colors).$1, Icons.thunderstorm_outlined); + expect(weatherVisual('晴', 106, colors).$1, rainy); + expect(weatherVisual('陰', 208, colors).$1, Icons.snowing); + expect(weatherVisual('晴', 105, colors).$1, Icons.foggy); + }); + + test('phenomena refine the glyph beyond the eight modes', () { + // A shower reads differently from steady rain, 霾 from a fog bank, and + // lightning without rain from a thunderstorm — each its own glyph while + // the accent still follows the resolved backdrop mode. + expect(weatherVisual('多雲', 111, colors).$1, rainyLight); // 有陣雨 + expect(weatherVisual('晴', 103, colors).$1, Icons.bolt_outlined); // 有閃電 + expect(weatherVisual('多雲', 107, colors).$1, Icons.sunny_snowing); // 有雨雪 + expect(weatherVisual('多雲', 112, colors).$1, Icons.cloudy_snowing); // 陣雨雪 + expect(weatherVisual('陰', 113, colors).$1, Icons.grain_outlined); // 有雹 + // 大雷雨 is the heaviest rung — filled, so it reads heavier. + expect(weatherVisual('多雲', 117, colors).$1, Icons.thunderstorm); + }); + + test('plain family codes map clear / cloudy / overcast', () { + expect(weatherVisual('晴', 100, colors).$1, Icons.wb_sunny_outlined); + expect(weatherVisual('多雲', 200, colors).$1, Icons.wb_cloudy_outlined); + expect(weatherVisual('陰', 300, colors).$1, Icons.cloud_outlined); + }); + + test('the accent colour follows the mode, not the glyph', () { + // 陣雨 (rain) and 雷雨 (thunderstorm) both refine their glyph but keep + // their family accent — primary for rain, tertiary for storms. + expect(weatherVisual('多雲', 111, colors).$2, colors.primary); + expect(weatherVisual('多雲', 114, colors).$2, colors.tertiary); + expect(weatherVisual('多雲', 200, colors).$2, colors.onSurfaceVariant); + expect(weatherVisual('晴', 100, colors).$2, colors.tertiary); + }); + + test('a missing code falls back to substring matching', () { + expect(weatherVisual('午後雷陣雨', 0, colors).$1, Icons.thunderstorm_outlined); + expect(weatherVisual('短暫雨', 0, colors).$1, rainy); + expect(weatherVisual('雪', 0, colors).$1, Icons.ac_unit_outlined); + expect(weatherVisual('晴時多雲', 0, colors).$1, Icons.wb_sunny_outlined); + expect(weatherVisual('多雲', 0, colors).$1, Icons.cloud_outlined); + }); + }); +} diff --git a/test/features/events/events_page_test.dart b/test/features/events/events_page_test.dart new file mode 100644 index 000000000..c70657c31 --- /dev/null +++ b/test/features/events/events_page_test.dart @@ -0,0 +1,109 @@ +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/town.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/settings/prefs.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/features/events/domain/event.dart'; +import 'package:dpip/features/events/domain/event_repository.dart'; +import 'package:dpip/features/events/presentation/pages/events_page.dart'; +import 'package:dpip/features/events/presentation/widgets/event_timeline.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Records which areas the page actually asked for, so a fallback to the +/// nationwide feed is visible to the test rather than hidden behind an +/// identical-looking empty list. +class _RecordingEventRepository implements EventRepository { + final List requests = []; + + @override + Future>> events({String? regionCode}) async { + requests.add(regionCode); + return const Ok([]); + } + + @override + Future>> activeEvents({String? regionCode}) async => + const Ok([]); +} + +Widget _wrap(RegionStore store, EventRepository events) { + const directory = TownDirectory({}); + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: store), + Provider.value(value: directory), + Provider.value(value: events), + ], + child: const EventsPage(), + ), + ); +} + +/// 全國, 所在地 and one saved township. +Future _store() async { + SharedPreferences.setMockInitialValues({ + 'home.savedRegionCodes': ['100'], + }); + return RegionStore(Prefs(await SharedPreferences.getInstance())); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('所在地 with no GPS fix shows the notice, not a feed', ( + tester, + ) async { + final events = _RecordingEventRepository(); + final store = await _store() + ..select(1); // 所在地, currentCode still null + await tester.pumpWidget(_wrap(store, events)); + await tester.pumpAndSettle(); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(l10n.regionCurrentUnavailable), findsOneWidget); + expect(find.byIcon(Icons.location_off_outlined), findsOneWidget); + expect(find.byType(EventTimeline), findsNothing); + // The nationwide feed must not stand in — it would read as "these are the + // events where you are". + expect(events.requests, isEmpty); + }); + + testWidgets('a GPS fix replaces the notice with that township feed', ( + tester, + ) async { + final events = _RecordingEventRepository(); + final store = await _store() + ..select(1); + await tester.pumpWidget(_wrap(store, events)); + await tester.pumpAndSettle(); + + store.setCurrentCode('200'); + await tester.pumpAndSettle(); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(l10n.regionCurrentUnavailable), findsNothing); + expect(find.byType(EventTimeline), findsOneWidget); + expect(events.requests, contains('200')); + }); + + testWidgets('全國 fetches the nationwide feed', (tester) async { + final events = _RecordingEventRepository(); + final store = await _store() + ..select(0); + await tester.pumpWidget(_wrap(store, events)); + await tester.pumpAndSettle(); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(l10n.regionCurrentUnavailable), findsNothing); + expect(find.byType(EventTimeline), findsOneWidget); + expect(events.requests, [null]); + }); +} diff --git a/test/features/home/presentation/home_backdrop_resolve_test.dart b/test/features/home/presentation/home_backdrop_resolve_test.dart new file mode 100644 index 000000000..7fd540dbd --- /dev/null +++ b/test/features/home/presentation/home_backdrop_resolve_test.dart @@ -0,0 +1,48 @@ +import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/features/home/presentation/pages/home_page.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// `resolveBackdrop` — the home sheet's backdrop inputs must follow the live +/// CWB code under `auto`, and switch to a pure forced look when the +/// experimental settings pin one. The second half is the regression: a forced +/// 晴天 used to keep the live rain/snow overrides, so the sky rendered sunny +/// keyframes with rain drops on top. +void main() { + const raining = WeatherRealtimeData( + weather: '有雨', + weatherCode: 106, + humidity: 92, + wind: WeatherWind(), + gust: WeatherWind(), + ); + + test('auto mode follows the live code and its overrides', () { + final b = resolveBackdrop(WeatherMode.auto, raining); + expect(b.mode, WeatherMode.rain); + expect(b.rain, 0.35); // 有雨 → the 小雨 rung + expect(b.snow, isNull); + expect(b.humidity, 92); + }); + + test('a forced mode drops every live override', () { + final b = resolveBackdrop(WeatherMode.clear, raining); + expect(b.mode, WeatherMode.clear); + expect(b.rain, isNull); + expect(b.snow, isNull); + expect(b.humidity, isNull); + }); + + test('auto mode with no reading falls back to the auto look', () { + final b = resolveBackdrop(WeatherMode.auto, null); + expect(b.mode, WeatherMode.auto); + expect(b.rain, isNull); + expect(b.snow, isNull); + expect(b.humidity, isNull); + }); + + test('a forced auto is indistinguishable from auto (no data)', () { + final b = resolveBackdrop(WeatherMode.auto, null); + expect(b.mode, WeatherMode.auto); + }); +} diff --git a/test/features/home/presentation/widgets/forecast_weather_visual_test.dart b/test/features/home/presentation/widgets/forecast_weather_visual_test.dart deleted file mode 100644 index 547060474..000000000 --- a/test/features/home/presentation/widgets/forecast_weather_visual_test.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:dpip/features/home/presentation/widgets/forecast_weather_visual.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - const colors = ColorScheme.light(); - - test('rain / thunder / snow win over weatherCode hundreds', () { - expect( - forecastWeatherVisual('午後雷陣雨', 200, colors).$1, - Icons.thunderstorm_outlined, - ); - expect( - forecastWeatherVisual('短暫雨', 200, colors).$1, - Icons.water_drop_outlined, - ); - expect(forecastWeatherVisual('雪', 100, colors).$1, Icons.ac_unit_outlined); - }); - - test('weatherCode hundreds map clear / cloudy / overcast', () { - expect( - forecastWeatherVisual('晴時多雲', 100, colors).$1, - Icons.wb_sunny_outlined, - ); - // "晴" substring wins before the hundreds switch — use a non-晴 label. - expect( - forecastWeatherVisual('多雲', 200, colors).$1, - Icons.wb_cloudy_outlined, - ); - expect(forecastWeatherVisual('陰', 300, colors).$1, Icons.cloud_outlined); - }); -} diff --git a/test/features/home/presentation/widgets/home_content_test.dart b/test/features/home/presentation/widgets/home_content_test.dart index a44f36787..18d6415f7 100644 --- a/test/features/home/presentation/widgets/home_content_test.dart +++ b/test/features/home/presentation/widgets/home_content_test.dart @@ -7,9 +7,15 @@ import 'package:dpip/features/events/domain/event.dart'; import 'package:dpip/features/events/domain/event_repository.dart'; import 'package:dpip/features/home/presentation/home_active_events_controller.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; +import 'package:dpip/features/home/presentation/widgets/home_active_events_section.dart'; import 'package:dpip/features/home/presentation/widgets/home_content.dart'; +import 'package:dpip/features/home/presentation/widgets/home_forecast_section.dart'; +import 'package:dpip/features/home/presentation/widgets/home_rain_trend_section.dart'; import 'package:dpip/features/home/presentation/widgets/home_sheet_header.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/rain_on_card.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; import 'package:dpip/features/weather/domain/weather_forecast.dart'; import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; @@ -33,6 +39,20 @@ class _FakeWeatherRepository implements MeteorWeatherRepository { dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); } +/// Stub next-hour rain trend — dry (no rain) or a steady light rain. +class _FakeHourTrendRepository implements RainHourTrendRepository { + _FakeHourTrendRepository({this.dry = false}); + + final bool dry; + + @override + Future> hourTrend(String code) async => Ok( + dry + ? RainHourTrend.dry(startUtc: DateTime.utc(2026, 8, 11)) + : RainHourTrend(startSecond: 1786362600, mm: List.filled(60, 0.5)), + ); +} + class _FakeEventRepository implements EventRepository { @override Future>> events({String? regionCode}) async => @@ -45,8 +65,12 @@ class _FakeEventRepository implements EventRepository { /// Pumps [HomeContent] with everything it reads: a [RegionStore] to switch on /// and localizations for the body. -Widget _wrap(RegionStore store) { - const directory = TownDirectory({}); +Widget _wrap( + RegionStore store, { + bool expanded = false, + RainHourTrendRepository? hourTrend, + TownDirectory directory = const TownDirectory({}), +}) { final events = _FakeEventRepository(); return MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -58,14 +82,23 @@ Widget _wrap(RegionStore store) { Provider.value(value: directory), Provider.value(value: events), ChangeNotifierProvider( - create: (_) => - HomeWeatherController(_FakeWeatherRepository(), store, directory), + create: (_) => HomeWeatherController( + _FakeWeatherRepository(), + hourTrend ?? _FakeHourTrendRepository(), + store, + directory, + ), ), ChangeNotifierProvider( create: (_) => HomeActiveEventsController(events, store), ), ], - child: Scaffold(body: HomeContent(scrollController: ScrollController())), + child: Scaffold( + body: HomeContent( + scrollController: ScrollController(), + expanded: expanded, + ), + ), ), ); } @@ -180,4 +213,141 @@ void main() { expect(find.text(l10n.regionCurrentUnavailable), findsNothing); expect(find.byIcon(Icons.cloud_outlined), findsOneWidget); }); + + testWidgets('no GPS fix → the sheet carries no cards at all', (tester) async { + final store = await _store(); + store.select(1); + // Full-screen is where every card is in play — rain trend, forecast and + // active events. None of them has a place to report on without a fix. + await tester.pumpWidget(_wrap(store, expanded: true)); + await tester.pumpAndSettle(); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.byType(HomeRainTrendSection), findsNothing); + expect(find.byType(HomeForecastSection), findsNothing); + expect(find.byType(HomeActiveEventsSection), findsNothing); + // The header's notice is the whole of the sheet's answer. + expect(find.text(l10n.regionCurrentUnavailable), findsOneWidget); + }); + + testWidgets('a located area full-screen shows the cards', (tester) async { + final store = await _store(); + store + ..select(1) + ..setCurrentCode('100'); + await tester.pumpWidget(_wrap(store, expanded: true)); + await tester.pumpAndSettle(); + + expect(find.byType(HomeRainTrendSection), findsOneWidget); + expect(find.byType(HomeForecastSection), findsOneWidget); + expect(find.byType(HomeActiveEventsSection), findsOneWidget); + }); + + testWidgets('a dry hour hides the rain trend and raises a compact forecast', ( + tester, + ) async { + final store = await _store(); + store + ..select(1) + ..setCurrentCode('100'); + // A resolvable township, so the trend fetch actually runs and resolves dry. + const directory = TownDirectory({ + '100': Town( + code: '100', + city: 'Test', + town: 'North', + lat: 25.0, + lng: 121.5, + cityLevel: '市', + townLevel: '區', + ), + }); + await tester.pumpWidget( + _wrap( + store, + expanded: true, + hourTrend: _FakeHourTrendRepository(dry: true), + directory: directory, + ), + ); + await tester.pumpAndSettle(); + + // No rain forecast → the trend card is gone entirely. + expect(find.byType(HomeRainTrendSection), findsNothing); + // One forecast card only — the hero's summary, not a second full card + // below the fold. It starts collapsed and grows with scroll. + final sections = tester + .widgetList(find.byType(HomeForecastSection)) + .toList(); + expect(sections, hasLength(1)); + expect(sections.single.expansion, 0); + // The hero card gets the same rain-on-glass treatment as the trend card. + expect( + find.ancestor( + of: find.byType(HomeForecastSection), + matching: find.byType(RainOnCard), + ), + findsOneWidget, + ); + }); + + testWidgets( + 'pulling the sheet up grows the hero forecast into the full card', + (tester) async { + final store = await _store(); + store + ..select(1) + ..setCurrentCode('100'); + const directory = TownDirectory({ + '100': Town( + code: '100', + city: 'Test', + town: 'North', + lat: 25.0, + lng: 121.5, + cityLevel: '市', + townLevel: '區', + ), + }); + await tester.pumpWidget( + _wrap( + store, + expanded: true, + hourTrend: _FakeHourTrendRepository(dry: true), + directory: directory, + ), + ); + await tester.pumpAndSettle(); + + final section = tester.widget( + find.byType(HomeForecastSection), + ); + expect(section.expansion, 0); + + // Scroll the sheet's list upward — the one forecast card stretches toward + // its full form instead of a second full card appearing. + await tester.drag(find.byType(ListView).first, const Offset(0, -120)); + await tester.pump(); + final grown = tester.widget( + find.byType(HomeForecastSection), + ); + expect(grown.expansion, greaterThan(0)); + // Still exactly one forecast card — no duplicate full card joined in. + expect(find.byType(HomeForecastSection), findsOneWidget); + }, + ); + + testWidgets('全國 keeps its events card (it is not a missing location)', ( + tester, + ) async { + final store = await _store() + ..select(0); + await tester.pumpWidget(_wrap(store, expanded: true)); + await tester.pumpAndSettle(); + + // 全國 has no point weather, but its events feed is real. + expect(find.byType(HomeRainTrendSection), findsNothing); + expect(find.byType(HomeForecastSection), findsNothing); + expect(find.byType(HomeActiveEventsSection), findsOneWidget); + }); } diff --git a/test/features/home/presentation/widgets/home_hero_preview_test.dart b/test/features/home/presentation/widgets/home_hero_preview_test.dart new file mode 100644 index 000000000..5d281575a --- /dev/null +++ b/test/features/home/presentation/widgets/home_hero_preview_test.dart @@ -0,0 +1,200 @@ +@Tags(['preview']) +library; + +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/town.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/settings/prefs.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/features/events/domain/event.dart'; +import 'package:dpip/features/events/domain/event_repository.dart'; +import 'package:dpip/features/home/presentation/home_active_events_controller.dart'; +import 'package:dpip/features/home/presentation/home_weather_controller.dart'; +import 'package:dpip/features/home/presentation/widgets/home_content.dart'; +import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; +import 'package:dpip/features/weather/domain/weather_forecast.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Renders the hero block ([HomeContent] flush, on a located township) at a +/// real device size, so the "does the rain trend card actually reach the +/// bottom, and does anything overflow past it" question can be *looked at* +/// instead of re-derived from arithmetic a second time. +/// +/// `mise exec -- flutter test --run-skipped --tags preview +/// test/features/home/presentation/widgets/home_hero_preview_test.dart` +/// → `build/home_hero_preview/`. +class _FakeWeatherRepository implements MeteorWeatherRepository { + @override + Future> realtime(double lat, double lng) async => + const Ok( + WeatherRealtime( + id: '467410', + station: WeatherRealtimeStation( + name: '臺南', + latitude: 23.0, + longitude: 120.2, + altitude: 40, + distance: 1.2, + ), + time: 0, + data: WeatherRealtimeData( + weather: '多雲', + weatherCode: 200, + temperature: 28.7, + humidity: 89, + rain: 0.0, + wind: WeatherWind(), + gust: WeatherWind(), + ), + ), + ); + + @override + Future> forecast(String code) async => + Ok(WeatherForecast(updateTime: 0, forecast: const [])); + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +/// A mid-hour pulse so the preview chart has visible shape. +class _FakeHourTrendRepository implements RainHourTrendRepository { + @override + Future> hourTrend(String code) async => Ok( + RainHourTrend( + startSecond: 1786362600, + mm: [ + for (var i = 0; i < 60; i++) + (i >= 22 && i <= 40) + ? (0.5 + (0.9 * (1 - ((i - 31).abs() / 9)))) + : 0.4, + ], + ), + ); +} + +class _FakeEventRepository implements EventRepository { + @override + Future>> events({String? regionCode}) async => + const Ok([]); + + @override + Future>> activeEvents({String? regionCode}) async => + const Ok([]); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('render the hero block at a real device size', (tester) async { + // The screenshots this is chasing were 1206x2622 physical at what reads + // as 3x — 402x874 logical. iOS status bar + Dynamic Island ~59, home + // indicator ~34: the same figures `HomeSheet`/`HomeContent` compute from + // MediaQuery in the real app. + const dpr = 3.0; + tester.view.physicalSize = const Size(1206, 2622); + tester.view.devicePixelRatio = dpr; + tester.view.padding = const FakeViewPadding( + top: 59 * dpr, + bottom: 34 * dpr, + ); + addTearDown(tester.view.reset); + + SharedPreferences.setMockInitialValues({ + 'home.savedRegionCodes': ['100'], + }); + final store = RegionStore(Prefs(await SharedPreferences.getInstance())); + store + ..select(1) // 所在地 + ..setCurrentCode('100'); // located, so the hero block is in play + const directory = TownDirectory({ + '100': Town( + code: '100', + city: '臺南', + town: '永康', + lat: 23.0, + lng: 120.2, + cityLevel: '市', + townLevel: '區', + ), + }); + final events = _FakeEventRepository(); + + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh', 'TW'), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: store), + Provider.value(value: directory), + Provider.value(value: events), + ChangeNotifierProvider( + create: (_) => HomeWeatherController( + _FakeWeatherRepository(), + _FakeHourTrendRepository(), + store, + directory, + ), + ), + ChangeNotifierProvider( + create: (_) => HomeActiveEventsController(events, store), + ), + ], + // Mirrors HomePage's own structure — Scaffold → Stack → + // Positioned.fill — rather than handing HomeContent to Scaffold.body + // directly, so it sees the same StackFit.expand-derived constraints + // the real sheet gives it, not whatever Scaffold.body loosely offers + // on its own. + child: Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: RepaintBoundary( + key: key, + child: HomeContent( + scrollController: ScrollController(), + expanded: true, + reveal: 1, + topInset: 59 + 44, // regionBarInset at flush=1 + ), + ), + ), + ], + ), + ), + ), + ), + ); + // Not pumpAndSettle: RainOnCard's splash keeps its own ticker running for + // as long as the card is on screen, so nothing here ever "settles" — a + // fixed handful of frames is enough for images (weather icon, area name) + // to resolve and layout to finish. + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + final boundary = + key.currentContext!.findRenderObject() as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1.0); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + final dir = Directory('build/home_hero_preview') + ..createSync(recursive: true); + File('${dir.path}/hero.png').writeAsBytesSync(bytes!.buffer.asUint8List()); + // ignore: avoid_print + print('wrote ${dir.path}/hero.png at ${image.width}x${image.height}'); + }); +} diff --git a/test/features/home/presentation/widgets/home_map_backdrop_test.dart b/test/features/home/presentation/widgets/home_map_backdrop_test.dart new file mode 100644 index 000000000..feb4a726b --- /dev/null +++ b/test/features/home/presentation/widgets/home_map_backdrop_test.dart @@ -0,0 +1,30 @@ +import 'package:dpip/features/home/presentation/widgets/home_map_backdrop.dart'; +import 'package:dpip/shared/map/admin_outline.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('the nationwide view draws the county frame only', () { + // Framed on the whole island, 368 township outlines collapse into a grey + // wash over the echo — the coarse frame is the only boundary that still + // says anything at that zoom. + expect(backdropBoundaries(null), [AdminBoundary.county]); + }); + + test('a township draws both, county last so it stacks on top', () { + final boundaries = backdropBoundaries('100'); + expect(boundaries, [AdminBoundary.town, AdminBoundary.county]); + // Order is the stacking order: the coarse frame should win where the two + // run together along a coastline. + expect(boundaries.last, AdminBoundary.county); + }); + + test('the county frame is never dropped', () { + for (final code in [null, '100', '200']) { + expect( + backdropBoundaries(code), + contains(AdminBoundary.county), + reason: 'code $code', + ); + } + }); +} diff --git a/test/features/home/presentation/widgets/home_sheet_header_silhouette_test.dart b/test/features/home/presentation/widgets/home_sheet_header_silhouette_test.dart new file mode 100644 index 000000000..c56994590 --- /dev/null +++ b/test/features/home/presentation/widgets/home_sheet_header_silhouette_test.dart @@ -0,0 +1,184 @@ +/// Verifies the capture half of [RainOnCard.silhouette] end to end against +/// real [HomeSheetHeader] content — [silhouetteSurface] itself is unit-tested +/// against synthetic pixels in `card_water_field_test.dart`, but nothing else +/// confirms actual rendered text and icon glyphs clear the 0.35 alpha +/// threshold rather than anti-aliasing away to nothing. +/// +/// `RainOnCard` itself is deliberately not mounted here — its ticker never +/// settles, so `pumpAndSettle` hangs against it (see `home_hero_preview_test +/// .dart`'s own note). Capturing a plain `RepaintBoundary` around the header +/// and feeding the bytes through `silhouetteSurface` directly exercises the +/// same pipeline `RainOnCard._capture` runs, without needing the ticker at +/// all. +/// +/// Tagged `preview`, same as `home_hero_preview_test.dart`: this stalled for +/// minutes in this harness with no diagnosis yet (a second +/// `RenderRepaintBoundary.toImage()`-based test to do so), so it is excluded +/// from the default `flutter test` run rather than risking an unattended CI +/// hang. Run explicitly with `--run-skipped --tags preview`. +@Tags(['preview']) +library; + +import 'dart:ui' as ui; + +import 'package:dpip/core/geo/town.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/settings/prefs.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/home/presentation/home_weather_controller.dart'; +import 'package:dpip/features/home/presentation/widgets/home_sheet_header.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/card_water_field.dart'; +import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; +import 'package:dpip/features/weather/domain/weather_forecast.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeWeatherRepository implements MeteorWeatherRepository { + @override + Future> realtime(double lat, double lng) async => + const Ok( + WeatherRealtime( + id: '467410', + station: WeatherRealtimeStation( + name: '臺南', + latitude: 23.0, + longitude: 120.2, + altitude: 40, + distance: 1.2, + ), + time: 0, + data: WeatherRealtimeData( + weather: '短暫雨', + weatherCode: 200, + temperature: 28.7, + humidity: 89, + rain: 2.4, + wind: WeatherWind(), + gust: WeatherWind(), + ), + ), + ); + + @override + Future> forecast(String code) async => + Ok(WeatherForecast(updateTime: 0, forecast: const [])); + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +class _FakeHourTrendRepository implements RainHourTrendRepository { + @override + Future> hourTrend(String code) async => + Ok(RainHourTrend(startSecond: 1786362600, mm: List.filled(60, 0.5))); +} + +void main() { + testWidgets( + 'a located township header rasterises to a non-empty silhouette', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final store = RegionStore(Prefs(await SharedPreferences.getInstance())); + store + ..select(1) + ..setCurrentCode('100'); + const directory = TownDirectory({ + '100': Town( + code: '100', + city: '臺南', + town: '永康', + lat: 23.0, + lng: 120.2, + cityLevel: '市', + townLevel: '區', + ), + }); + + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh', 'TW'), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: store), + Provider.value(value: directory), + ChangeNotifierProvider( + create: (_) => HomeWeatherController( + _FakeWeatherRepository(), + _FakeHourTrendRepository(), + store, + directory, + ), + ), + ], + child: Scaffold( + // No background fill behind the boundary — matches production, + // where the header sits directly on the sky with nothing + // painting behind it (see HomeSheetHeader's own doc). Anything + // opaque here would alpha-flood the whole capture and make + // every column register as "solid" from row 0, defeating the + // point of the assertion below. + backgroundColor: Colors.transparent, + body: RepaintBoundary( + key: key, + // Roughly the sheet's own content width (screen width minus + // AppSpacing.lg margins either side) — silhouetteSurface reads + // heights in this same card-local coordinate space. Height is + // just generous headroom for the expanded layout's name + + // temp + metrics stack; extra blank space below only adds + // columns that correctly read as "nothing here." + child: SizedBox( + width: 350, + height: 420, + child: HomeSheetHeader( + reveal: 1, + expanded: true, + weatherMode: WeatherMode.rain, + ), + ), + ), + ), + ), + ), + ); + // No RainOnCard in this tree, so nothing keeps a ticker alive — + // pumpAndSettle terminates normally here. + await tester.pumpAndSettle(); + + final boundary = + key.currentContext!.findRenderObject() as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1.0); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final surface = silhouetteSurface(data!, image.width, image.height); + + // At least a meaningful fraction of sampled columns must have caught + // real ink — the area name, the temperature, or a metric — not just a + // stray anti-aliased pixel or two. + var solid = 0; + const columnWidth = 3.0; + final columns = (image.width / columnWidth).ceil(); + for (var i = 0; i < columns; i++) { + if (surface.heightAt((i + 0.5) * columnWidth).isFinite) solid++; + } + expect( + solid, + greaterThan(columns ~/ 20), + reason: + 'expected real header content (name/temp/metrics) to clear the ' + 'silhouette alpha threshold across a meaningful span of columns, ' + 'got $solid/$columns', + ); + }, + ); +} diff --git a/test/features/home/presentation/widgets/home_sheet_test.dart b/test/features/home/presentation/widgets/home_sheet_test.dart index f8f941587..ba69b5ebb 100644 --- a/test/features/home/presentation/widgets/home_sheet_test.dart +++ b/test/features/home/presentation/widgets/home_sheet_test.dart @@ -3,6 +3,7 @@ import 'package:dpip/core/geo/town.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/prefs.dart'; import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/core/settings/sky_time_mode.dart'; import 'package:dpip/core/settings/weather_mode.dart'; import 'package:dpip/features/events/domain/event.dart'; import 'package:dpip/features/events/domain/event_repository.dart'; @@ -11,7 +12,10 @@ import 'package:dpip/features/home/presentation/home_sheet_extent.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; import 'package:dpip/features/home/presentation/widgets/home_content.dart'; import 'package:dpip/features/home/presentation/widgets/home_sheet.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/weather_sky_background.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; import 'package:dpip/features/weather/domain/weather_forecast.dart'; import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; @@ -33,6 +37,12 @@ class _FakeWeatherRepository implements MeteorWeatherRepository { dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); } +class _FakeHourTrendRepository implements RainHourTrendRepository { + @override + Future> hourTrend(String code) async => + Ok(RainHourTrend(startSecond: 1786362600, mm: List.filled(60, 0.5))); +} + class _FakeEventRepository implements EventRepository { @override Future>> events({String? regionCode}) async => @@ -60,8 +70,12 @@ Widget _wrap(RegionStore store, HomeSheetExtent extent) { Provider.value(value: directory), Provider.value(value: events), ChangeNotifierProvider( - create: (_) => - HomeWeatherController(_FakeWeatherRepository(), store, directory), + create: (_) => HomeWeatherController( + _FakeWeatherRepository(), + _FakeHourTrendRepository(), + store, + directory, + ), ), ChangeNotifierProvider( create: (_) => HomeActiveEventsController(events, store), @@ -72,6 +86,7 @@ Widget _wrap(RegionStore store, HomeSheetExtent extent) { scrollController: ScrollController(), extent: extent, weatherMode: WeatherMode.auto, + skyTimeMode: SkyTimeMode.auto, ), ), ), @@ -132,4 +147,38 @@ void main() { expect(content().topInset, greaterThan(0)); }, ); + + testWidgets('scrolling freezes and unfreezes the weather backdrop', ( + tester, + ) async { + final extent = HomeSheetExtent(); + await tester.pumpWidget(_wrap(await _store(), extent)); + // Full-screen so the weather backdrop is live. `pump` (not + // `pumpAndSettle`): the live sky's own ticker never settles. + extent.value = HomeSheetExtent.max; + await tester.pump(); + expect(_sky(tester).active, isTrue); + + // Scrolling the content list freezes the backdrop — the blur about to + // cover it hides the hold, and the shader stops re-rendering every frame. + final scrollable = tester.state( + find + .descendant( + of: find.byType(HomeContent), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(120); + await tester.pump(); + expect(_sky(tester).active, isFalse); + + // Returning to the top resumes the animation where it left off. + scrollable.position.jumpTo(0); + await tester.pump(); + expect(_sky(tester).active, isTrue); + }); } + +WeatherSkyBackground _sky(WidgetTester tester) => + tester.widget(find.byType(WeatherSkyBackground)); diff --git a/test/features/home/weather_sky/card_water_field_test.dart b/test/features/home/weather_sky/card_water_field_test.dart new file mode 100644 index 000000000..01714cb11 --- /dev/null +++ b/test/features/home/weather_sky/card_water_field_test.dart @@ -0,0 +1,597 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:dpip/features/home/presentation/widgets/weather_sky/card_water_field.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The card and the screen it is scaled against, in logical pixels. +const double _width = 360; +const double _screen = 800; + +final (ui.Image, ui.Image) _sprites = buildParticleSprites(size: 8); + +CardWaterField _field({int capacity = 200}) => CardWaterField( + spritePos: _sprites.$1, + spriteNeg: _sprites.$2, + capacity: capacity, +); + +void _run( + CardWaterField field, + double seconds, { + double intensity = 1.0, + CardWaterPreset preset = CardWaterPreset.moderate, + double step = CardWaterField.emitterTick, + CardWaterSurface? surface, +}) { + for (var t = 0.0; t < seconds; t += step) { + field.update( + step, + width: _width, + screenHeight: _screen, + intensity: intensity, + preset: preset, + surface: surface, + ); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('drops are born just above the edge, not a third of a screen up', () { + // the emitter's spawn y is 0.91, but that is an absolute world y — the collision box + // puts the card's top face at +0.852, so the spawn line is only 0.058 above + // it. Reading 0.91 as a relative height starts the fall off-card entirely. + expect(CardWaterField.spawnHeight, closeTo(0.058, 1e-9)); + expect( + CardWaterField.spawnHeight * _screen / 3, + closeTo(15.5, 0.5), + reason: 'about 15 dp above the edge on an 800 dp screen', + ); + }); + + test('the headroom covers the fall and the spray above it', () { + final headroom = CardWaterField.spawnHeadroom(_screen); + expect(headroom, greaterThan(CardWaterField.spawnHeight * _screen / 3)); + // The splash goes higher than the spawn line, so a headroom sized to the + // fall alone would clip the top off it. + expect(headroom, greaterThanOrEqualTo(48)); + }); + + test('pooled drops push each other apart instead of piling up', () { + final field = _field(); + // Land several groups, then let them relax. + _run(field, 1.0, preset: CardWaterPreset.heavy); + expect(field.settledCount(_screen), greaterThan(4)); + + final xs = field.debugPositions.map((p) => p.dx).toList(growable: false) + ..sort(); + var tightest = double.infinity; + for (var i = 0; i < xs.length - 1; i++) { + tightest = math.min(tightest, xs[i + 1] - xs[i]); + } + // LiquidFun's pressure term spreads particles to about a diameter apart; + // without it every group stays stacked on its landing point and the edge + // reads as separate dots rather than a sheet of water. + expect(tightest, greaterThan(0.0)); + }); + + test('the solver never pushes a drop through the card', () { + final field = _field(); + _run(field, 2.0, preset: CardWaterPreset.heavy); + for (final p in field.debugPositions) { + expect(p.dy, lessThanOrEqualTo(0.01)); + } + }); + + test( + 'drops are launched downward — the spray is not an initial velocity', + () { + final field = _field(); + // Sample the emitter's own output: the emitter is + // `Vec2((rand - 0.5) * spread, -fallSpeed)`, so every drop *starts* + // downward. Water moves up later only because the pressure solver throws + // it there. This has to bypass the world step — the spawn line is 15 px + // above the edge and a drop covers that in a third of one step, so a full + // tick always returns the group already landed. + field.debugEmit( + width: _width, + screenHeight: _screen, + preset: CardWaterPreset.moderate, + ); + + final fresh = field.debugVelocities.where((v) => v.dy != 0); + expect(fresh, isNotEmpty); + expect(fresh.every((v) => v.dy > 0), isTrue); + }, + ); + + test('the first group appears at the spawn line', () { + final field = _field(); + field.debugEmit( + width: _width, + screenHeight: _screen, + preset: CardWaterPreset.moderate, + ); + + final born = field.debugPositions.map((p) => -p.dy); + expect(born, isNotEmpty); + for (final y in born) { + expect(y, closeTo(CardWaterField.spawnHeight * _screen / 3, 6)); + } + }); + + test('the fall speed is the engine scale, not the digit painter scale', () { + final field = _field(); + field.debugEmit( + width: _width, + screenHeight: _screen, + preset: CardWaterPreset.moderate, + ); + + // fallSpeed 8.0 world units/s at 3 units to the screen height = 2133 px/s. + // 8.0 is the **phone** branch of the reference; the reference picks it and the 6.0 + // beside it is the tablet/fold one. The digit painter's + // 40-units-to-the-*width* scale would give 72 px/s. + for (final v in field.debugVelocities) { + expect(v.dy, closeTo(2133, 50)); + } + }); + + test('drops pile up on the edge and stay there', () { + final field = _field(); + _run(field, 0.5, preset: CardWaterPreset.moderate); + + expect(field.settledCount(_screen), greaterThan(0)); + for (final p in field.debugPositions) { + expect(p.dy, lessThanOrEqualTo(0.01)); + } + }); + + test('a landing group throws water back up — the splash', () { + final field = _field(); + // Sample every frame: the spray is transient by nature, so looking only at + // the final frame can easily land on a moment where the pile has settled. + var fastestRise = 0.0; + for (var t = 0.0; t < 1.5; t += 1 / 60) { + field.update( + 1 / 60, + width: _width, + screenHeight: _screen, + intensity: 1, + preset: CardWaterPreset.heavy, + ); + for (final v in field.debugVelocities) { + if (-v.dy > fastestRise) fastestRise = -v.dy; + } + } + + // `restitution = 0` never bounces anything. The upward motion is the + // pressure term resolving the overlap and the velocity being read back out + // of the displacement — solve it in one dimension and this stays 0. + expect( + fastestRise, + greaterThan(20), + reason: 'nothing is thrown upward, so there is no splash at all', + ); + }); + + test('the solver caps speed — hops, never streaks', () { + final field = _field(); + var fastest = 0.0; + for (var t = 0.0; t < 1.5; t += 1 / 60) { + field.update( + 1 / 60, + width: _width, + screenHeight: _screen, + intensity: 1, + preset: CardWaterPreset.heavy, + ); + for (final v in field.debugVelocities) { + // Only solver-driven motion: fresh drops legitimately fall at full + // speed, so measure everything except the straight-down launch. + if (v.dy < 0 || v.dx.abs() > 0.5) { + fastest = math.max(fastest, v.distance); + } + } + } + // `LimitVelocity` caps at the critical velocity — one particle diameter + // of travel per *sub*-step: 0.02 * (5/dt) = 6.0 world units/s, which is + // 1600 px/s on this screen. (An earlier version invented 1.2 here, which + // is not a LiquidFun constant at all.) + const criticalVelocity = 0.02 * 5 * 60.0; // diameter * inv_dt(sub-step) + expect(fastest, lessThanOrEqualTo(criticalVelocity * _screen / 3 + 1)); + expect(fastest, greaterThan(0), reason: 'no solver motion at all'); + }); + + test('water never gets below the card top edge', () { + final field = _field(); + _run(field, 2.0, preset: CardWaterPreset.heavy); + // The pool sits *on* the edge; leaking past it would draw water across the + // chart underneath. + for (final p in field.debugPositions) { + expect(p.dy, lessThanOrEqualTo(0.01)); + } + }); + + test('the grades differ by spawn interval, as the engine table does', () { + // the emitter's interval: 8 steps light, 3 moderate, 1 heavy — the count on the card is + // set by how often groups arrive, not by how fast they fall. + expect(CardWaterPreset.light.interval, 8); + expect(CardWaterPreset.moderate.interval, 3); + expect(CardWaterPreset.heavy.interval, 1); + + final light = _field(); + final heavy = _field(); + _run(light, 0.6, preset: CardWaterPreset.light); + _run(heavy, 0.6, preset: CardWaterPreset.heavy); + expect(heavy.liveCount, greaterThan(light.liveCount)); + }); + + test('the heavy grade layers two different worlds', () { + // `x(2)`/`x(3)` configure world 0 and world 1 differently — slow fat drops + // under fast thin ones. Collapsing them into one preset loses the storm. + expect( + CardWaterPreset.heavySecondary.fallSpeed, + isNot(CardWaterPreset.heavy.fallSpeed), + ); + expect( + CardWaterPreset.heavySecondary.life, + lessThan(CardWaterPreset.heavy.life), + ); + }); + + test('falling groups vary in size, exactly as grid packing varies', () { + // LiquidFun fills each group's disc from a world-aligned grid, so the + // random centre phase decides how many particles a group gets (1..5, + // averaging ~3 at the 0.015 radius). Constant-size groups were why every + // falling drop looked identical. + final field = _field(); + final sizes = {}; + var before = 0; + for (var step = 0; step < 240; step++) { + field.update( + CardWaterField.emitterTick, + width: _width, + screenHeight: _screen, + intensity: 1, + preset: CardWaterPreset.light, // interval 8: one group per emission + ); + final born = field.liveCount - before; + if (born > 0) sizes.add(born); + before = field.liveCount; + // First few groups are enough (light: no deaths yet). + if (step > 20) break; + } + expect(sizes.length, greaterThanOrEqualTo(1)); + + // Across many groups the count must actually vary. + final field2 = _field(); + final seen = {}; + var prev = 0; + for (var step = 0; step < 8 * 12; step++) { + field2.update( + CardWaterField.emitterTick, + width: _width, + screenHeight: _screen, + intensity: 1, + preset: CardWaterPreset.light, + ); + final born = field2.liveCount - prev; + // Deaths start after ~0.45 s; only count clean birth steps. + if (born > 0 && step < 8 * 12) seen.add(born); + prev = field2.liveCount; + if (step >= 8 * 3 + 1) break; // three groups, all before first death + } + expect( + seen.length, + greaterThanOrEqualTo(2), + reason: + 'every group had the same particle count — falling drops will ' + 'all be the same size again', + ); + }); + + test('group lifetimes are jittered, so deaths do not beat in sync', () { + // the emitter's caller passes `life · ((rand−0.5)·0.2 + 1)` per group. + final field = _field(); + // Two groups, born on different emission steps, then no more rain. + var steps = 0; + while (steps < 8 * 2 + 1) { + field.update( + CardWaterField.emitterTick, + width: _width, + screenHeight: _screen, + intensity: 1, + preset: CardWaterPreset.light, + ); + steps++; + } + final born = field.liveCount; + expect(born, greaterThan(1)); + + // Step until the first death; with per-group jitter the survivors of the + // other group live on — a partial drop, never a single cliff. + var sawPartial = false; + for ( + var t = 0.0; + t < 2.0 && field.liveCount > 0; + t += CardWaterField.emitterTick + ) { + final beforeStep = field.liveCount; + field.update( + CardWaterField.emitterTick, + width: _width, + screenHeight: _screen, + intensity: 0, + preset: CardWaterPreset.light, + ); + if (field.liveCount < beforeStep && field.liveCount > 0) { + sawPartial = true; + } + } + expect(field.liveCount, 0, reason: 'everything should expire eventually'); + expect( + sawPartial, + isTrue, + reason: + 'all groups died on the same frame — the ±10% lifetime jitter ' + 'is missing and the pool will strobe', + ); + }); + + test('drops expire at the end of their life', () { + final field = _field(); + _run(field, 0.3, preset: CardWaterPreset.moderate); + expect(field.liveCount, greaterThan(0)); + + _run(field, 1.0, intensity: 0, preset: CardWaterPreset.moderate); + expect(field.liveCount, 0); + }); + + test( + 'emission is fixed-step, so the frame rate does not change the rain', + () { + final at60 = _field(); + final at120 = _field(); + _run(at60, 0.6, step: 1 / 60); + _run(at120, 0.6, step: 1 / 120); + expect( + at120.liveCount, + closeTo(at60.liveCount.toDouble(), at60.liveCount * 0.35), + ); + }, + ); + + test('never exceeds capacity', () { + final field = _field(capacity: 24); + _run(field, 3.0, preset: CardWaterPreset.heavy); + expect(field.liveCount, lessThanOrEqualTo(24)); + }); + + test('a zero-size card is a no-op, not a crash', () { + final field = _field(); + field.update( + 1 / 60, + width: 0, + screenHeight: _screen, + intensity: 1, + preset: CardWaterPreset.moderate, + ); + field.update( + 1 / 60, + width: _width, + screenHeight: 0, + intensity: 1, + preset: CardWaterPreset.moderate, + ); + expect(field.liveCount, 0); + }); + + group('CardWaterSurface', () { + test('reads back the column it was built from', () { + final surface = CardWaterSurface( + heights: [0, -10, -10, double.infinity, 5], + columnWidth: 4, + ); + expect(surface.heightAt(0), 0); // column 0: [0, 4) + expect(surface.heightAt(3.9), 0); + expect(surface.heightAt(4), -10); // column 1: [4, 8) + expect(surface.heightAt(7.9), -10); + expect(surface.heightAt(8), -10); // column 2 + expect(surface.heightAt(12), double.infinity); // column 3 — empty + expect(surface.heightAt(16), 5); // column 4 + }); + + test('is infinite past both ends of the sampled span', () { + final surface = CardWaterSurface(heights: [0, 0], columnWidth: 4); + expect(surface.heightAt(-1), double.infinity); + expect(surface.heightAt(100), double.infinity); + }); + + test('an all-empty field has no solid x at all', () { + final surface = CardWaterSurface( + heights: List.filled(10, double.infinity), + columnWidth: 4, + ); + expect(surface.randomSolidX(math.Random(1)), isNull); + }); + + test('a solid x always lands in a column that reads back finite', () { + final surface = CardWaterSurface( + heights: [double.infinity, 0, double.infinity, -10, double.infinity], + columnWidth: 4, + ); + final random = math.Random(7); + for (var i = 0; i < 50; i++) { + final x = surface.randomSolidX(random); + expect(x, isNotNull); + expect(surface.heightAt(x!), isNot(double.infinity)); + } + }); + }); + + group('CardWaterField with a CardWaterSurface', () { + test('a drop settles at its own column\'s height, not topEdge', () { + // Wide, and run for only a fraction of a second — not the single + // narrow column and full second an earlier version of this test used. + // A growing pile spreads sideways under its own pressure, and given + // enough width and enough time that spread reaches past a too-narrow + // sampled span: those particles read back `heightAt`'s infinity, lost + // their floor, and were still falling when the test read them back. + // Wide and short-lived enough that this can't happen here. + const raisedY = -40.0; + final surface = CardWaterSurface( + heights: List.filled(200, raisedY), + columnWidth: 4, + ); + final field = _field(); + _run(field, 0.3, preset: CardWaterPreset.moderate, surface: surface); + expect( + field.settledCount(_screen, surface: surface), + greaterThan(0), + reason: 'nothing settled at the surface at all', + ); + for (final p in field.debugPositions) { + // Never past the surface — exactly the flat-edge "never gets below + // the card top edge" invariant, at an offset that only the surface, + // not the flat topEdge=0 default, could have produced. No lower + // bound: a splash is expected to throw drops back up by a variable + // amount, which is what "a landing group throws water back up" + // already covers on its own. + expect(p.dy, lessThanOrEqualTo(raisedY + 0.01)); + } + }); + + test('nothing solid anywhere means nothing is emitted', () { + final empty = CardWaterSurface( + heights: List.filled(20, double.infinity), + columnWidth: 4, + ); + final field = _field(); + _run(field, 0.5, preset: CardWaterPreset.heavy, surface: empty); + expect(field.liveCount, 0, reason: 'nothing solid to spawn a group over'); + }); + + test('a drop already falling over an empty column is never caught', () { + // Seeded on a full-width solid surface (behaves like the flat topEdge=0 + // case) so the group actually lands somewhere, then handed to a solver + // step that sees nothing but empty columns — isolating the solver from + // emission, which by itself already refuses to spawn over nothing. + final field = _field(); + field.debugEmit( + width: _width, + screenHeight: _screen, + preset: CardWaterPreset.moderate, + surface: CardWaterSurface(heights: [0], columnWidth: _width), + ); + expect(field.liveCount, greaterThan(0)); + final startY = field.debugPositions.map((p) => p.dy).reduce(math.max); + + final empty = CardWaterSurface( + heights: List.filled(20, double.infinity), + columnWidth: 4, + ); + // Moderate's own life is 0.38s — well short of that, or every seeded + // drop has already expired (and been compacted away) by the time this + // reads them back, regardless of whether the solver ever caught them. + for (var t = 0.0; t < 0.2; t += 1 / 60) { + field.update( + 1 / 60, + width: _width, + screenHeight: _screen, + intensity: 0, // no new groups — just watch the seeded ones fall + preset: CardWaterPreset.moderate, + surface: empty, + ); + } + expect( + field.liveCount, + greaterThan(0), + reason: 'died before it could fall', + ); + final endY = field.debugPositions.map((p) => p.dy).reduce(math.max); + expect( + endY, + greaterThan(startY + 10), + reason: 'a drop over an empty column should keep falling, not park', + ); + }); + }); + + group('silhouetteSurface', () { + // width x height RGBA, alpha-only (rgb stays 0 throughout — only alpha + // decides what counts as solid). + ByteData image(int w, int h, List<(int x, int y, int alpha)> pixels) { + final bytes = Uint8List(w * h * 4); + for (final (x, y, a) in pixels) { + bytes[(y * w + x) * 4 + 3] = a; + } + return ByteData.sublistView(bytes); + } + + test('reports the topmost solid row per column band', () { + const w = 6, h = 5; + final data = image(w, h, [ + (0, 2, 255), (1, 2, 255), // columns 0-1: solid starting row 2 + (2, 0, 255), (3, 0, 255), // columns 2-3: solid starting row 0 + // columns 4-5: nothing at all + ]); + final surface = silhouetteSurface(data, w, h, columnWidth: 2); + expect(surface.heightAt(0.5), 2.0); + expect(surface.heightAt(1.9), 2.0); + expect(surface.heightAt(2.0), 0.0); + expect(surface.heightAt(3.9), 0.0); + expect(surface.heightAt(4.5), double.infinity); + expect(surface.heightAt(5.9), double.infinity); + }); + + test('a thin diagonal stroke is still caught by its column band', () { + // One solid pixel per row, stepping right — nothing fills a whole + // 2px-wide band on its own, but each band still has *a* solid pixel in + // it somewhere, which is the case a coarse column width exists to + // handle: real glyph strokes are often thinner than the sample spacing. + const w = 8, h = 4; + final data = image(w, h, [ + (0, 0, 255), + (2, 1, 255), + (4, 2, 255), + (6, 3, 255), + ]); + final surface = silhouetteSurface(data, w, h, columnWidth: 2); + expect(surface.heightAt(0.5), 0.0); // band [0,2): pixel at x=0,y=0 + expect(surface.heightAt(2.5), 1.0); // band [2,4): pixel at x=2,y=1 + expect(surface.heightAt(4.5), 2.0); // band [4,6): pixel at x=4,y=2 + expect(surface.heightAt(6.5), 3.0); // band [6,8): pixel at x=6,y=3 + }); + + test('the threshold is a real cutoff, not just a non-zero check', () { + const w = 2, h = 3; + final data = image(w, h, [ + (0, 0, 60), // 24%, below a 0.35 threshold + (0, 1, 200), // 78%, above it + ]); + final surface = silhouetteSurface( + data, + w, + h, + columnWidth: 2, + threshold: 0.35, + ); + expect( + surface.heightAt(0.5), + 1.0, + reason: 'the faint pixel at row 0 should not count as solid', + ); + }); + + test('an entirely empty image has no solid column anywhere', () { + const w = 10, h = 10; + final data = image(w, h, const []); + final surface = silhouetteSurface(data, w, h, columnWidth: 3); + expect(surface.randomSolidX(math.Random(1)), isNull); + }); + }); +} diff --git a/test/features/home/weather_sky/card_water_pipeline_test.dart b/test/features/home/weather_sky/card_water_pipeline_test.dart new file mode 100644 index 000000000..2a5947cf4 --- /dev/null +++ b/test/features/home/weather_sky/card_water_pipeline_test.dart @@ -0,0 +1,275 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:dpip/features/home/presentation/widgets/weather_sky/card_water_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_test/flutter_test.dart'; + +Future _decodeAsset(String key) async { + final data = await rootBundle.load(key); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + final frame = await codec.getNextFrame(); + codec.dispose(); + return frame.image; +} + +/// End-to-end pin on the panel-water pipeline, adversarially verified: the +/// GPU shader's output is compared per-pixel against an independent float +/// re-implementation of the reference shader's rain branch running on the same +/// accumulation buffers. A wrong uniform, a broken channel packing, or a +/// regression in the self-lit ambient shows up as a numeric divergence here — +/// not as a surprise on a device. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const width = 360.0; + const cardHeight = 120.0; + const screenHeight = 800.0; + const pointSize = 5.0; + const threshold = 0.6; + + ui.Image accumulate( + CardWaterField field, + Size fieldSize, + double headroom, { + required bool negative, + }) { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder) + ..saveLayer( + Offset.zero & fieldSize, + Paint()..imageFilter = ui.ImageFilter.blur(sigmaX: 0.46, sigmaY: 0.46), + ) + ..translate(0, headroom); + field.paintCoverage(canvas, dropSize: pointSize, negative: negative); + canvas.restore(); + return recorder.endRecording().toImageSync( + fieldSize.width.ceil(), + fieldSize.height.ceil(), + ); + } + + test('the shader matches a float reference of screen.glsl', () async { + final program = await ui.FragmentProgram.fromAsset( + 'shaders/weather/card_water.frag', + ); + final shader = program.fragmentShader(); + + final sprites = buildParticleSprites(); + final field = CardWaterField(spritePos: sprites.$1, spriteNeg: sprites.$2); + for (var t = 0.0; t < 1.5; t += 1 / 60) { + field.update( + 1 / 60, + width: width, + screenHeight: screenHeight, + intensity: 1, + preset: CardWaterPreset.heavy, + ); + } + expect(field.liveCount, greaterThan(10)); + + final headroom = CardWaterField.spawnHeadroom(screenHeight); + final fieldSize = Size(width, cardHeight + headroom); + final pos = accumulate(field, fieldSize, headroom, negative: false); + final neg = accumulate(field, fieldSize, headroom, negative: true); + + const scale = CardWaterField.accumulationScale; + const hour = 14.0; + // A rainy sky sample — what the LUT publishes during rain. + const ambient = [0.36, 0.42, 0.49]; + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(fieldSize.width); + set(fieldSize.height); + set(ambient[0]); + set(ambient[1]); + set(ambient[2]); + set(1.0); // specular (day) + set(threshold * scale); + set(1.0); // scene alpha + set(hour); + shader.setImageSampler(0, pos); + shader.setImageSampler(1, neg); + + final out = ui.PictureRecorder(); + ui.Canvas(out).drawRect(Offset.zero & fieldSize, Paint()..shader = shader); + final image = out.endRecording().toImageSync( + fieldSize.width.ceil(), + fieldSize.height.ceil(), + ); + + // rawRgba is premultiplied — the stored channel values, no division. + final outPx = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + final posPx = (await pos.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + final negPx = (await neg.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + + // Float reference — the reference shader's rain branch with the self-aliased + // ambient, evaluated at texel centres where the shader's bilinear + // degenerates to the texel itself. + const sunColor = [0.59, 0.55, 0.61]; + final timePer = _smoothstep(0, 12, hour) - _smoothstep(12, 23, hour); + var light = [_lerp(-0.2, 0.0, timePer), _lerp(0.4, 1.0, timePer), 0.2]; + if (hour >= 12.0) light = [-light[0], light[1], light[2]]; + final lightLen = math.sqrt( + light[0] * light[0] + light[1] * light[1] + light[2] * light[2], + ); + final halfV = [ + light[0] / lightLen, + light[1] / lightLen, + light[2] / lightLen + 1.0, + ]; + final halfLen = math.sqrt( + halfV[0] * halfV[0] + halfV[1] * halfV[1] + halfV[2] * halfV[2], + ); + + final w = fieldSize.width.ceil(); + final h = fieldSize.height.ceil(); + var water = 0; + var interior = 0; + var blueDominant = 0; + var greyish = 0; + var worst = 0.0; + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + final o = (y * w + x) * 4; + final coverage = negPx[o + 2] / 255.0; + if (coverage <= threshold * scale) { + expect(outPx[o + 3], 0, reason: 'water outside the cut at $x,$y'); + continue; + } + water++; + + final sum = [ + (posPx[o] - negPx[o]) / 255.0, + (posPx[o + 1] - negPx[o + 1]) / 255.0, + posPx[o + 2] / 255.0, + ]; + final len = math.sqrt( + sum[0] * sum[0] + sum[1] * sum[1] + sum[2] * sum[2], + ); + if (len < 1e-4) continue; + final normal = [sum[0] / len, sum[1] / len, sum[2] / len]; + final spec = math + .pow( + math.max( + normal[0] * halfV[0] / halfLen + + normal[1] * halfV[1] / halfLen + + normal[2] * halfV[2] / halfLen, + 0.0, + ), + 10.0, + ) + .toDouble(); + + final expected = List.generate(3, (c) { + final color = (ambient[c] * 1.3 + spec * sunColor[c]).clamp(0.0, 1.0); + return (color * 0.6 * 255).round(); + }); + + var diff = 0; + for (var c = 0; c < 3; c++) { + diff = math.max(diff, (outPx[o + c] - expected[c]).abs()); + } + worst = math.max(worst, diff.toDouble()); + + // The look pins: the water is a brightened *sky tint* — it keeps the + // sky's blue lean everywhere, and it must never clamp to white the + // way the normal-sum ambient provably did. + final r = outPx[o]; + final g = outPx[o + 1]; + final b = outPx[o + 2]; + // "Pooled" is 1.3x the cut, not 2x. An isolated the reference drop peaks at + // only 1.2x the cut (0.180 against 0.1506) — the depth the composite + // is asked to light comes from *overlap*, and a 2x bar was only ever + // reachable while a blend bug saturated the buffer. + if (coverage > 1.3 * threshold * scale) { + interior++; + if (b > r) blueDominant++; + } + if (r > 140 && (r - g).abs() < 12 && (g - b).abs() < 12) greyish++; + } + } + + expect(water, greaterThan(50), reason: 'no water survived the threshold'); + expect( + worst, + lessThanOrEqualTo(18), + reason: 'shader diverged from the float reference of screen.glsl', + ); + expect(interior, greaterThan(20), reason: 'no pooled interior formed'); + expect(blueDominant, greaterThan((interior * 0.85).floor())); + // A specular highlight is *meant* to be bright and near-neutral, so this + // cannot be a tight bound — it is a guard against the failure it names, + // where a bad ambient clamped the whole surface to (1,1,1) and every water + // pixel went grey. One eighth still catches that by a wide margin; the + // pool's colour is pinned by the blue-dominance check above. + expect( + greyish, + lessThan(water ~/ 8 + 1), + reason: 'the water went white-grey again', + ); + }); + + test( + 'the sprites baked from the shipped particle textures decode correctly', + () async { + final kernel = await _decodeAsset( + 'assets/weather/particles/particle_blurred.webp', + ); + final normalMap = await _decodeAsset( + 'assets/weather/particles/drop_normal.webp', + ); + // Both come from `tool/gen_particle_sprites.py`: a 64×64 blurred disc and + // a 128×128 analytic hemisphere. The numbers below are properties of + // those two functions, so they hold for the generator's output as they + // did for the textures it replaced. + expect((kernel.width, kernel.height), (64, 64)); + expect((normalMap.width, normalMap.height), (128, 128)); + + final (pos, neg) = await bakeParticleSprites( + kernel: kernel, + normalMap: normalMap, + ); + final posPx = (await pos.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + final negPx = (await neg.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + int at(Uint8List px, int x, int y, int c) => px[(y * 64 + x) * 4 + c]; + + const c = 32; + // Centre: straight-up normal — both signed halves of x are ≈ 0, z ≈ 1, + // and the coverage in neg.b is the kernel's 0.784. + expect(at(posPx, c, c, 2), greaterThan(240)); + expect(at(posPx, c, c, 0), lessThan(20)); + expect(at(negPx, c, c, 0), lessThan(20)); + expect(at(negPx, c, c, 2) / 255.0, closeTo(0.784, 0.04)); + + // Right edge: nx strongly positive → pos.r high, neg.r ≈ 0. Left edge is + // the mirror. The sign split is the whole point of the pair. + expect(at(posPx, 56, c, 0), greaterThan(150)); + expect(at(negPx, 56, c, 0), lessThan(20)); + expect(at(negPx, 8, c, 0), greaterThan(150)); + expect(at(posPx, 8, c, 0), lessThan(20)); + + // The measured flat-topped kernel: still above the 0.6 cut at 60% radius. + expect(at(negPx, c + 19, c, 2) / 255.0, greaterThan(0.6)); + }, + ); +} + +double _smoothstep(double a, double b, double x) { + final t = ((x - a) / (b - a)).clamp(0.0, 1.0); + return t * t * (3 - 2 * t); +} + +double _lerp(double a, double b, double t) => a + (b - a) * t; diff --git a/test/features/home/weather_sky/precipitation_field_test.dart b/test/features/home/weather_sky/precipitation_field_test.dart new file mode 100644 index 000000000..7d9304a89 --- /dev/null +++ b/test/features/home/weather_sky/precipitation_field_test.dart @@ -0,0 +1,178 @@ +import 'dart:ui' as ui; + +import 'package:dpip/features/home/presentation/widgets/weather_sky/precipitation_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The particle field reproduces the reference's emitter and compute shader, so the +/// properties that define its look are pinned here — most importantly that +/// precipitation falls *down*, which a still frame cannot show and which a +/// sign slip silently inverted once already. +void main() { + const size = Size(360, 620); + + PrecipitationField field({int capacity = 400, bool tumble = false}) => + PrecipitationField( + atlas: _stubAtlas(), + capacity: capacity, + variants: 4, + cell: const Size(32, 68), + tumble: tumble, + seed: 3, + ); + + /// Runs the field and returns the mean y translation of its sprites. + double meanY( + PrecipitationField f, { + required int frames, + double intensity = 1.0, + double wind = 0.0, + }) { + var last = 0.0; + for (var i = 0; i < frames; i++) { + final batch = f.step( + size: size, + dt: 1 / 60, + intensity: intensity, + fallSpeed: 1.0, + wind: wind, + opacity: 1.0, + tint: const Color(0xFFFFFFFF), + sizeMin: 0.013, + sizeMax: 0.042, + // Far longer than the run, so respawning cannot confound the + // measurement — the real field uses the reference's constant 3.0 s. + life: 500, + ); + var sum = 0.0; + for (var p = 0; p < batch.count; p++) { + sum += batch.transforms[p * 4 + 3]; + } + last = batch.count == 0 ? 0.0 : sum / batch.count; + } + return last; + } + + test('particles fall downward', () { + final f = field(); + final before = meanY(f, frames: 2); + final after = meanY(f, frames: 30); + expect( + after, + greaterThan(before), + reason: 'mean y went $before → $after; it must increase (downward)', + ); + }); + + test('wind carries particles sideways in its own direction', () { + double meanX(double wind) { + final f = field(); + var last = 0.0; + for (var i = 0; i < 30; i++) { + final batch = f.step( + size: size, + dt: 1 / 60, + intensity: 1.0, + fallSpeed: 1.0, + wind: wind, + opacity: 1.0, + tint: const Color(0xFFFFFFFF), + sizeMin: 0.013, + sizeMax: 0.042, + life: 500, + ); + var sum = 0.0; + for (var p = 0; p < batch.count; p++) { + sum += batch.transforms[p * 4 + 2]; + } + last = batch.count == 0 ? 0.0 : sum / batch.count; + } + return last; + } + + expect(meanX(0.8), greaterThan(meanX(-0.8))); + }); + + test('intensity scales how many particles are live', () { + final f = field(capacity: 400); + int countAt(double intensity) => f + .step( + size: size, + dt: 1 / 60, + intensity: intensity, + fallSpeed: 1.0, + wind: 0.0, + opacity: 1.0, + tint: const Color(0xFFFFFFFF), + sizeMin: 0.013, + sizeMax: 0.042, + life: 3.0, + ) + .count; + + expect(countAt(0.25), lessThan(countAt(1.0))); + }); + + test( + 'depth is biased toward distant particles, as the reference weights it', + () { + // The reference's spawn uses `easeInCubic(0,1,rand)`, which is `rand⁴` — so most + // particles are far and only a few are close. That shows up as most + // sprites being small: the near ones are rare by construction. + final f = field(capacity: 2000); + final batch = f.step( + size: size, + dt: 1 / 60, + intensity: 1.0, + fallSpeed: 1.0, + wind: 0.0, + opacity: 1.0, + tint: const Color(0xFFFFFFFF), + sizeMin: 0.0, + sizeMax: 1.0, // so the transform's scale reads back as depth directly + life: 3.0, + ); + + var small = 0; + for (var p = 0; p < batch.count; p++) { + // scos = transforms[0] is the uniform scale at zero rotation. + final scale = batch.transforms[p * 4]; + // depth < 0.5 maps to scale < 0.5 * size.height / cell.height. + if (scale < 0.5 * size.height / 68) small++; + } + // rand⁴ < 0.5 for rand < 0.84, so ~84% should be in the far half. + expect(small / batch.count, greaterThan(0.75)); + }, + ); + + test('a long stall does not teleport the field', () { + // The widget clamps dt, but the field must also behave if handed a large + // step — particles should stay finite and on a sane scale. + final f = field(); + final batch = f.step( + size: size, + dt: 5.0, + intensity: 1.0, + fallSpeed: 1.0, + wind: 0.0, + opacity: 1.0, + tint: const Color(0xFFFFFFFF), + sizeMin: 0.013, + sizeMax: 0.042, + life: 3.0, + ); + for (var p = 0; p < batch.count; p++) { + expect(batch.transforms[p * 4 + 3].isFinite, isTrue); + } + }); +} + +/// A 4-cell stand-in for the rain atlas; content is irrelevant to the maths. +ui.Image _stubAtlas() { + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 128, 68), + Paint()..color = const Color(0xFFFFFFFF), + ); + return recorder.endRecording().toImageSync(128, 68); +} diff --git a/test/features/home/weather_sky/rain_on_glass_test.dart b/test/features/home/weather_sky/rain_on_glass_test.dart new file mode 100644 index 000000000..823813f10 --- /dev/null +++ b/test/features/home/weather_sky/rain_on_glass_test.dart @@ -0,0 +1,63 @@ +import 'package:dpip/features/home/presentation/widgets/weather_sky/rain_on_glass.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// `RainOnGlass` wraps the home sheet's own content, which is the surface the +/// user drags to collapse the sheet. A decorative filter must never eat that +/// gesture. +void main() { + testWidgets('a scrollable inside RainOnGlass still scrolls', (tester) async { + final controller = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: RainOnGlass( + intensity: 0.8, + child: ListView.builder( + controller: controller, + itemCount: 60, + itemBuilder: (context, i) => + SizedBox(height: 50, child: Text('row $i')), + ), + ), + ), + ); + await tester.pump(); + + await tester.drag(find.text('row 1'), const Offset(0, -300)); + // The droplet ticker repeats forever, so `pumpAndSettle` would never + // return — pump a fixed number of frames instead. + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + expect( + controller.offset, + greaterThan(0), + reason: 'drag did not scroll: offset ${controller.offset}', + ); + controller.dispose(); + }); + + testWidgets('taps reach a button inside RainOnGlass', (tester) async { + var taps = 0; + await tester.pumpWidget( + MaterialApp( + home: RainOnGlass( + intensity: 0.8, + child: Center( + child: ElevatedButton( + onPressed: () => taps++, + child: const Text('tap me'), + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('tap me')); + await tester.pump(); + + expect(taps, 1, reason: 'tap did not reach the button'); + }); +} diff --git a/test/features/home/weather_sky/sky_keyframe_test.dart b/test/features/home/weather_sky/sky_keyframe_test.dart new file mode 100644 index 000000000..8eb72520e --- /dev/null +++ b/test/features/home/weather_sky/sky_keyframe_test.dart @@ -0,0 +1,228 @@ +import 'dart:math' as math; + +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/solar_time.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The solar ephemeris is what decides whether the backdrop looks like noon, +/// sunset or midnight, so it is pinned against real astronomy for Taipei +/// rather than eyeballed. Getting it wrong would not crash anything — it +/// would just quietly show the wrong sky, which is exactly the kind of bug a +/// test has to catch. +void main() { + // Taipei. + const lat = 25.033; + const lon = 121.565; + + double deg(double radians) => radians * 180.0 / math.pi; + + /// Local Taiwan time (UTC+8) as a UTC instant. + DateTime taipei(int y, int m, int d, int hour, [int minute = 0]) => + DateTime.utc(y, m, d, hour, minute).subtract(const Duration(hours: 8)); + + group('solarPosition', () { + test('equinox noon elevation matches 90° − latitude', () { + // At the March equinox the sun is over the equator, so its noon + // elevation is 90° − |latitude| ≈ 65° for Taipei. + final sun = solarPosition( + taipei(2026, 3, 20, 12, 10), + latitude: lat, + longitude: lon, + ); + expect(deg(sun.elevation), closeTo(65.0, 1.5)); + }); + + test('summer sun climbs far higher than winter sun', () { + final summer = solarPosition( + taipei(2026, 6, 21, 12, 10), + latitude: lat, + longitude: lon, + ); + final winter = solarPosition( + taipei(2026, 12, 21, 12, 10), + latitude: lat, + longitude: lon, + ); + + // Taipei sits just north of the Tropic of Cancer, so the June sun + // passes almost exactly overhead; in December it barely clears 41°. + expect(deg(summer.elevation), closeTo(88.3, 2.0)); + expect(deg(winter.elevation), closeTo(41.5, 2.0)); + }); + + test('sun is below the horizon at local midnight', () { + final sun = solarPosition( + taipei(2026, 6, 21, 0), + latitude: lat, + longitude: lon, + ); + expect(deg(sun.elevation), lessThan(-20.0)); + }); + + test('sun rises in the east and sets in the west', () { + // Azimuth is measured clockwise from true north. + final morning = solarPosition( + taipei(2026, 3, 20, 7), + latitude: lat, + longitude: lon, + ); + final evening = solarPosition( + taipei(2026, 3, 20, 17), + latitude: lat, + longitude: lon, + ); + expect(deg(morning.azimuth), closeTo(90.0, 15.0)); + expect(deg(evening.azimuth), closeTo(270.0, 15.0)); + }); + + test('daylight is longer in June than December', () { + int daylightMinutes(DateTime dayStartLocal) { + var count = 0; + for (var m = 0; m < 24 * 60; m += 5) { + final sun = solarPosition( + dayStartLocal.add(Duration(minutes: m)), + latitude: lat, + longitude: lon, + ); + if (sun.elevation > 0) count += 5; + } + return count; + } + + final june = daylightMinutes(taipei(2026, 6, 21, 0)); + final december = daylightMinutes(taipei(2026, 12, 21, 0)); + + // Real values for Taipei are roughly 13h40m and 10h35m. + expect(june, closeTo(13 * 60 + 40, 20)); + expect(december, closeTo(10 * 60 + 35, 20)); + expect(june, greaterThan(december)); + }); + }); + + group('sunTimes', () { + test('matches published Taipei sunrise/sunset', () { + // Real almanac values for Taipei: 21 Jun ~05:05/18:47, + // 21 Dec ~06:34/17:12. + final june = sunTimes( + taipei(2026, 6, 21, 12), + latitude: lat, + longitude: lon, + ); + final december = sunTimes( + taipei(2026, 12, 21, 12), + latitude: lat, + longitude: lon, + ); + + expect(june.sunrise, closeTo(5.09, 0.25)); + expect(june.sunset, closeTo(18.78, 0.25)); + expect(december.sunrise, closeTo(6.57, 0.25)); + expect(december.sunset, closeTo(17.20, 0.25)); + }); + + test('daylight is longer in June than December', () { + final june = sunTimes( + taipei(2026, 6, 21, 12), + latitude: lat, + longitude: lon, + ); + final december = sunTimes( + taipei(2026, 12, 21, 12), + latitude: lat, + longitude: lon, + ); + expect( + june.sunset - june.sunrise, + greaterThan(december.sunset - december.sunrise), + ); + }); + }); + + group('keyframePosition', () { + test('anchors sunrise and sunset to the dawn and dusk keyframes', () { + // With the default equinox-ish sun the ring is very nearly even. + double pos(double hour) => keyframePosition(hour, frameCount: 17); + + expect(pos(5.6), closeTo(17 * 5.6 / 24, 0.01)); // sunrise → dawn key + expect(pos(18.4), closeTo(17 * 18.4 / 24, 0.01)); // sunset → dusk key + // Keyframe 8 is the ring's peak, and on an even split that is 11.3 h — + // solar noon, not 12:00. + expect(pos(11.3), closeTo(8.0, 0.05)); + }); + + test('06:00 resolves to dawn, not to night', () { + // The regression this guards: anchoring the ring to sunrise put 06:00 + // near keyframe 0 — which is midnight — and rendered a dark sky at dawn. + final dawn = resolveSky( + sunnyKeyframes, + position: keyframePosition( + 6, + frameCount: sunnyKeyframes.length, + sunrise: 5.3, + sunset: 18.6, + ), + humidity: 0.5, + ); + final midnight = resolveSky( + sunnyKeyframes, + position: keyframePosition( + 0, + frameCount: sunnyKeyframes.length, + sunrise: 5.3, + sunset: 18.6, + ), + humidity: 0.5, + ); + expect(dawn.sunAngleY, greaterThan(midnight.sunAngleY * 2)); + }); + + test('the sun peaks near midday and bottoms out overnight', () { + double angleAt(double hour) => resolveSky( + sunnyKeyframes, + position: keyframePosition(hour, frameCount: sunnyKeyframes.length), + humidity: 0.5, + ).sunAngleY; + + expect(angleAt(12), greaterThan(angleAt(6))); + expect(angleAt(12), greaterThan(angleAt(18))); + expect(angleAt(3), lessThan(angleAt(9))); + }); + }); + + group('resolveSky', () { + test('interpolates between neighbouring keyframes', () { + final a = resolveSky(sunnyKeyframes, position: 8, humidity: 0); + final b = resolveSky(sunnyKeyframes, position: 9, humidity: 0); + final mid = resolveSky(sunnyKeyframes, position: 8.5, humidity: 0); + + expect(mid.sunAngleY, closeTo((a.sunAngleY + b.sunAngleY) / 2, 1e-9)); + }); + + test('wraps around the end of the ring', () { + final last = resolveSky(sunnyKeyframes, position: 16.5, humidity: 0); + expect(last.sunAngleY, isNot(isNaN)); + // Position 17 is position 0 again. + final wrapped = resolveSky(sunnyKeyframes, position: 17, humidity: 0); + final zero = resolveSky(sunnyKeyframes, position: 0, humidity: 0); + expect(wrapped.sunAngleY, closeTo(zero.sunAngleY, 1e-9)); + }); + + test('humidity blends the dry and wet atmosphere ramps', () { + final dry = resolveSky(sunnyKeyframes, position: 8, humidity: 0); + final wet = resolveSky(sunnyKeyframes, position: 8, humidity: 1); + // The keyframe's humidity ramp raises Mie scattering with moisture. + expect(wet.mieScatter, greaterThan(dry.mieScatter)); + }); + + test('every weather type has keyframes and resolves', () { + expect(weatherKeyframes.length, 14); + for (final frames in weatherKeyframes) { + expect(frames, isNotEmpty); + final sky = resolveSky(frames, position: 1.5, humidity: 0.5); + expect(sky.sunAngleY, greaterThan(0)); + expect(sky.sunIntensity, greaterThan(0)); + } + }); + }); +} diff --git a/test/features/map/presentation/widgets/station_sheet_wind_touch_test.dart b/test/features/map/presentation/widgets/station_sheet_wind_touch_test.dart new file mode 100644 index 000000000..84648a72c --- /dev/null +++ b/test/features/map/presentation/widgets/station_sheet_wind_touch_test.dart @@ -0,0 +1,64 @@ +import 'package:dpip/features/map/presentation/widgets/station_sheet.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Regression guard for the wind-chart crash +/// `tooltipItems and touchedSpots size should be same`. +/// +/// The wind curve is drawn as per-bucket segment bars that **share their +/// boundary spots**, so one touch near a threshold crossing lands on two bars +/// at once and fl_chart hands the tooltip builder two spots. fl_chart +/// hard-requires one `LineTooltipItem` per touched spot — the chart used to +/// dedupe them (same x → same point), which shrank the list and threw in +/// `drawTouchTooltip`. +void main() { + final colors = ThemeData.light().colorScheme; + final textTheme = ThemeData.light().textTheme; + String timeLabel(double x) => '${x.toInt()}h'; + int? directionAt(double _) => 45; + Color accentAt(double _) => const Color(0xFF03A9F4); + + test('a shared-x touch keeps one item per touched spot (no dedupe)', () { + // Two bars share the boundary spot at x=100 (the crash's precondition): + // the touch hits both, so fl_chart passes two spots with the same x. + final sharedBar = LineChartBarData( + spots: const [FlSpot(100, 4.0), FlSpot(101, 5.0)], + ); + final nextBar = LineChartBarData( + spots: const [FlSpot(100, 4.0), FlSpot(102, 9.0)], + ); + final items = windTooltipItems( + touched: [ + LineBarSpot(sharedBar, 0, const FlSpot(100, 4.0)), + LineBarSpot(nextBar, 1, const FlSpot(100, 4.0)), + ], + unit: 'm/s', + timeLabel: timeLabel, + directionAt: directionAt, + accentAt: accentAt, + colors: colors, + textTheme: textTheme, + ); + + // Before the fix the dedupe collapsed the two same-x spots into one item + // and drawTouchTooltip threw "tooltipItems and touchedSpots size should be + // same". The builder must return exactly one item per touched spot. + expect(items.length, 2); + }); + + test('a single-spot touch still yields one item', () { + final bar = LineChartBarData(spots: const [FlSpot(100, 4.0)]); + final items = windTooltipItems( + touched: [LineBarSpot(bar, 0, const FlSpot(100, 4.0))], + unit: 'm/s', + timeLabel: timeLabel, + directionAt: directionAt, + accentAt: accentAt, + colors: colors, + textTheme: textTheme, + ); + + expect(items.length, 1); + }); +} diff --git a/test/features/map/presentation/wind_speed_test.dart b/test/features/map/presentation/wind_speed_test.dart new file mode 100644 index 000000000..7bab8f1d1 --- /dev/null +++ b/test/features/map/presentation/wind_speed_test.dart @@ -0,0 +1,29 @@ +import 'package:dpip/features/map/presentation/wind_speed.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('windSpeedBucket', () { + test('maps speeds onto the five legacy buckets', () { + expect(windSpeedBucket(0), 0); + expect(windSpeedBucket(3.3), 0); + expect(windSpeedBucket(3.4), 1); + expect(windSpeedBucket(7.9), 1); + expect(windSpeedBucket(8.0), 2); + expect(windSpeedBucket(13.9), 3); + expect(windSpeedBucket(32.7), 4); + expect(windSpeedBucket(100), 4); + }); + + test('negative speed never escapes bucket 0', () { + expect(windSpeedBucket(-1), 0); + }); + }); + + group('windSpeedColor', () { + test('is the discrete bucket colour, not an interpolation', () { + expect(windSpeedColor(0), windBuckets.first.$2); + expect(windSpeedColor(8.0), windBuckets[2].$2); + expect(windSpeedColor(50), windBuckets.last.$2); + }); + }); +} diff --git a/test/features/map/qpesums_layer_test.dart b/test/features/map/qpesums_layer_test.dart new file mode 100644 index 000000000..eee8bcb8e --- /dev/null +++ b/test/features/map/qpesums_layer_test.dart @@ -0,0 +1,210 @@ +import 'package:dpip/features/map/presentation/layers/qpesums_layer.dart'; +import 'package:dpip/features/weather/domain/qpesums_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'raster_timeline_harness.dart'; + +class _FakeQpesumsRepository extends FakeRasterFrameSource + implements QpesumsRepository { + _FakeQpesumsRepository(super.frames); + + @override + String tileUrl(String frame) => + 'https://host/api/v2/tiles/qpesums/$frame/{z}/{x}/{y}.webp'; +} + +void main() { + test('frames chronological', () async { + final layer = QpesumsMapLayer( + _FakeQpesumsRepository(['1786209600000', '1786208400000']), + ); + final frames = (await layer.frames()).valueOrNull!; + expect(frames.map((f) => f.id), ['1786208400000', '1786209600000']); + }); + + test('a settle mounts the preload ring at forecast opacity', () async { + final layer = QpesumsMapLayer(_FakeQpesumsRepository(_ids(5))); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[2]); + + expect(controller.opacityOf('qpesums-lyr-${frames[2].id}'), '0.85'); + }); + + test( + 'scrubbing inside the ring is two opacity writes, nothing else', + () async { + final layer = QpesumsMapLayer(_FakeQpesumsRepository(_ids(9))); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[4]); + controller.calls.clear(); + controller.sentKeys.clear(); + + await layer.show(controller, frames[5], scrubbing: true); + + expect(controller.calls, [ + 'set:qpesums-lyr-${frames[4].id}:0.0', + 'set:qpesums-lyr-${frames[5].id}:0.85', + ]); + expect( + controller.sentKeys, + everyElement(equals({'raster-opacity', 'raster-opacity-transition'})), + ); + }, + ); + + test('clear releases tiles', () async { + final source = _FakeQpesumsRepository(_ids(5)); + final layer = QpesumsMapLayer(source); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[2]); + await layer.clear(controller); + + expect(source.released, 1); + }); + + testWidgets('timeline caption says forecast, not observed', (tester) async { + final layer = QpesumsMapLayer(_FakeQpesumsRepository(const [])); + String? caption; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + caption = layer.timelineCaption(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(caption, 'Forecast'); + }); + + testWidgets('legend renders the QPESUMS mm/h scale', (tester) async { + final layer = QpesumsMapLayer(_FakeQpesumsRepository(const [])); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder(builder: (context) => layer.buildLegend(context)), + ), + ), + ); + + // Strongest (300) at the top of the scale, weakest (1) at the bottom. + expect(find.textContaining('mm/h'), findsOneWidget); + expect(find.text('300'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + }); + + group('overlays', () { + /// A layer attached to a live map, ready for the toggles. + Future<(QpesumsMapLayer, RecordingMapController)> attached() async { + final layer = QpesumsMapLayer(_FakeQpesumsRepository(_ids(3))); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + await layer.prepare(controller, frames); + await layer.show(controller, frames[1]); + return (layer, controller); + } + + test('all three overlays are on by default and drawn on attach', () async { + final (layer, controller) = await attached(); + + expect(layer.showScanRange.value, isTrue); + expect(layer.showCountyOutline.value, isTrue); + expect(layer.showTownOutline.value, isTrue); + // QPESUMS shares the radar composite's geometry but draws its own + // outline under its own ids, so both layers can be on the map at once. + expect(controller.calls, contains('addSource:qpesums-scan-range')); + expect( + controller.calls, + contains('addLineLayer:qpesums-scan-range-outline'), + ); + expect( + controller.calls, + contains('addLineLayer:admin-county-outline-casing'), + ); + expect(controller.calls, contains('addLineLayer:admin-county-outline')); + expect(controller.calls, contains('addLineLayer:admin-town-outline')); + }); + + test( + 'turning the coverage outline off removes the qpesums ids only', + () async { + final (layer, controller) = await attached(); + controller.calls.clear(); + + layer.setShowScanRange(false); + await pumpEventQueue(); + + expect( + controller.calls, + contains('removeLayer:qpesums-scan-range-outline'), + ); + expect(controller.calls, contains('removeSource:qpesums-scan-range')); + expect( + controller.calls.where((c) => c.contains('radar-scan-range')), + isEmpty, + reason: 'radar\'s outline, if shown, must not be touched', + ); + }, + ); + + test('the toggles are independent', () async { + final (layer, controller) = await attached(); + controller.calls.clear(); + + layer.setShowTownOutline(false); + await pumpEventQueue(); + + expect(controller.calls, contains('removeLayer:admin-town-outline')); + expect( + controller.calls.where((c) => c.contains('admin-county-outline')), + isEmpty, + reason: 'dropping the fine mesh must not take the coarse frame with it', + ); + expect(layer.showCountyOutline.value, isTrue); + }); + + test('clear tears the chrome down with the layer', () async { + final (layer, controller) = await attached(); + controller.calls.clear(); + + await layer.clear(controller); + + expect(controller.calls, contains('removeSource:qpesums-scan-range')); + expect(controller.calls, contains('removeLayer:admin-county-outline')); + }); + + test('a style reset re-adds the chrome on the next attach', () async { + final (layer, _) = await attached(); + + layer.onStyleReset(); + final frames = (await layer.frames()).valueOrNull!; + final fresh = RecordingMapController(); + await layer.prepare(fresh, frames); + await layer.show(fresh, frames[1]); + + expect(fresh.calls, contains('addSource:qpesums-scan-range')); + expect(fresh.calls, contains('addLineLayer:admin-county-outline')); + }); + }); +} + +/// [count] forecast frame ids, newest first (the wire order), 10-min steps. +List _ids(int count) => [ + for (var i = count - 1; i >= 0; i--) '${1786208400000 + i * 600000}', +]; diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index 31e0cda98..27a326982 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -1,3 +1,4 @@ +import 'package:dpip/shared/map/admin_outline.dart'; import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -33,9 +34,11 @@ void main() { await layer.show(controller, frames[4]); expect( - controller.calls.where((c) => c.startsWith('addSource:')).length, + controller.calls + .where((c) => c.startsWith('addSource:radar-src-')) + .length, 5, - reason: 'the ring is current ±2', + reason: 'the ring is current ±2; the overlays add sources of their own', ); expect(controller.opacityOf('radar-lyr-${frames[4].id}'), '0.85'); for (final i in [2, 3, 5, 6]) { @@ -68,10 +71,11 @@ void main() { ]); expect( controller.sentKeys, - everyElement(equals({'raster-opacity'})), + everyElement(equals({'raster-opacity', 'raster-opacity-transition'})), reason: 'the scrub path must not re-send visibility or the seven other ' - 'raster properties the layer type has', + 'raster properties the layer type has — only the opacity and the ' + 'zero cross-fade that keeps a scrub a loop instead of a smear', ); }, ); @@ -137,6 +141,28 @@ void main() { ); }); + test('flipping back over a mid-drag-revealed frame hides it', () async { + final layer = RadarMapLayer(_FakeRadarRepository(_ids(9))); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[4]); // ring 2..6 + // Drag past the ring, then bounce back into it — a fast fling's shape. + await layer.show(controller, frames[0], scrubbing: true); + await layer.show(controller, frames[5], scrubbing: true); + + expect( + controller.visibilityOf('radar-lyr-${frames[0].id}'), + 'none', + reason: + 'the cold frame was never part of the ring, so the flip back into ' + 'it must hide it outright — an opacity-0 fade leaves it at full ' + 'strength ghosting behind the ring while the drag bounces around', + ); + expect(controller.opacityOf('radar-lyr-${frames[5].id}'), '0.85'); + }); + test('a settle abandons the frames the scrub swept past', () async { final source = _FakeRadarRepository(_ids(9)); final layer = RadarMapLayer(source); @@ -155,8 +181,8 @@ void main() { ); }); - test('a settle warms well beyond the mounted ring', () async { - // 25 frames so the ±4 warm band is a strict subset, not the whole list. + test('a settle warms outward from the frame, far beyond the ring', () async { + // 25 frames so the ±4 ring is a strict subset of the warm spread. final source = _FakeRadarRepository(_ids(25)); final layer = RadarMapLayer(source); final frames = (await layer.frames()).valueOrNull!; @@ -165,13 +191,53 @@ void main() { await layer.prepare(controller, frames); await layer.show(controller, frames[12]); + final warmed = source.warmed.last; + expect(warmed, hasLength(25), reason: 'the fill warm spreads to the edge'); + expect(warmed.first, frames[12].id, reason: 'nearest-the-finger first'); expect( - source.warmed.last, - unorderedEquals([for (var i = 8; i <= 16; i++) frames[i].id]), + [warmed[1], warmed[2]], + unorderedEquals([frames[13].id, frames[11].id]), reason: - 'warming is a local read plus a memory push, so the band a drag ' - 'can cross without touching disk is far cheaper to widen than the ' - 'band kept mounted', + 'the spread walks outward ±1, ±2, … so fill warm injects the ' + 'nearest frames first and only the most distant stay cold', + ); + }); + + test('a scrub reveal past the warm band re-warms around the finger', () async { + final source = _FakeRadarRepository(_ids(25)); + final layer = RadarMapLayer(source); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[12]); // settle → warm 12, ±1, ±2 … + source.warmed.clear(); + + // Still inside the ±4 guaranteed band: revealing must not re-warm (or + // re-fetch the visible region) — the band already covers this mount. + await layer.show(controller, frames[16], scrubbing: true); + await pumpEventQueue(); + expect( + source.warmed, + isEmpty, + reason: + 'every scrub frame inside the warmed band costs one mount and no ' + 'store re-read, so the warm does not chase the finger frame by frame', + ); + + // Crossed the guaranteed band edge: the warm re-centres on the finger and + // spreads outward again, so the rest of a long drag stays on memory hits + // instead of store reads. + await layer.show(controller, frames[20], scrubbing: true); + await pumpEventQueue(); + final reWarmed = source.warmed.last; + expect(reWarmed.first, frames[20].id, reason: 're-warms around the finger'); + expect( + [reWarmed[1], reWarmed[2]], + unorderedEquals([frames[21].id, frames[19].id]), + reason: + 'revealing a frame the last-warmed band did not cover re-warms ' + 'around it, so a drag never lands past a warmed mount', ); }); @@ -189,8 +255,11 @@ void main() { expect(source.released, 1); expect( - controller.calls.where((c) => c.startsWith('removeSource:')).length, + controller.calls + .where((c) => c.startsWith('removeSource:radar-src-')) + .length, 5, + reason: 'the frame sources; the overlays tear down separately', ); }); @@ -199,6 +268,214 @@ void main() { final layer = RadarMapLayer(_FakeRadarRepository(ids.reversed.toList())); expect((await layer.frames()).valueOrNull!.length, 500); }); + + group('overlays', () { + /// A layer attached to a live map, ready for the toggles. + Future<(RadarMapLayer, RecordingMapController)> attached() async { + final layer = RadarMapLayer(_FakeRadarRepository(_ids(3))); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + await layer.prepare(controller, frames); + await layer.show(controller, frames[1]); + return (layer, controller); + } + + test('both overlays are on by default and drawn on attach', () async { + final (layer, controller) = await attached(); + + // Blank outside the coverage means "not observed", and a county you + // cannot identify is a county you cannot act on. Neither should have to + // be found in a menu first. + expect(layer.showScanRange.value, isTrue); + expect(layer.showCountyOutline.value, isTrue); + expect(layer.showTownOutline.value, isTrue); + expect(controller.calls, contains('addSource:radar-scan-range')); + expect( + controller.calls, + contains('addLineLayer:radar-scan-range-outline'), + ); + expect( + controller.calls, + contains('addLineLayer:admin-county-outline-casing'), + ); + expect(controller.calls, contains('addLineLayer:admin-county-outline')); + expect( + controller.calls, + contains('addLineLayer:admin-town-outline-casing'), + ); + expect(controller.calls, contains('addLineLayer:admin-town-outline')); + }); + + test('the echo is mounted above the base style borders', () async { + final (_, controller) = await attached(); + // The base style draws its own borders under the raster. Leaving the echo + // beneath them meant they always showed through, so the switchable copies + // on top would have been a second set at a second weight — and switching + // them off would still not have given a clean raster. + for (final call in controller.calls.where( + (c) => c.startsWith('addRasterLayer:'), + )) { + expect(controller.belowOf(call.split(':').last), isNull, reason: call); + } + }); + + test('the county and township toggles are independent', () async { + final (layer, controller) = await attached(); + controller.calls.clear(); + + layer.setShowTownOutline(false); + await pumpEventQueue(); + + expect(controller.calls, contains('removeLayer:admin-town-outline')); + expect( + controller.calls.where((c) => c.contains('admin-county-outline')), + isEmpty, + reason: 'dropping the fine mesh must not take the coarse frame with it', + ); + expect(layer.showCountyOutline.value, isTrue); + }); + + test('the township mesh is drawn lighter than the county frame', () { + // 368 townships at the county stroke turns the island into a net and + // buries the echo the borders are meant to sit over. + expect( + AdminBoundary.town.lineWidth, + lessThan(AdminBoundary.county.lineWidth), + ); + expect( + AdminBoundary.town.lineOpacity, + lessThan(AdminBoundary.county.lineOpacity), + ); + }); + + test('the coverage outline has no fill', () async { + final (_, controller) = await attached(); + // A wash over the covered area would tint every dBZ colour under it. + expect( + controller.calls.where((c) => c.startsWith('addFillLayer:')), + isEmpty, + ); + }); + + test('the county border is drawn above the raster', () async { + final (_, controller) = await attached(); + // The base style already outlines counties *below* the echo, which is + // where they disappear. These must not be anchored under anything. + expect(controller.belowOf('admin-county-outline'), isNull); + expect(controller.belowOf('admin-county-outline-casing'), isNull); + }); + + test('turning the coverage outline off removes what it added', () async { + final (layer, controller) = await attached(); + controller.calls.clear(); + + layer.setShowScanRange(false); + await pumpEventQueue(); + + expect( + controller.calls, + contains('removeLayer:radar-scan-range-outline'), + ); + expect(controller.calls, contains('removeSource:radar-scan-range')); + }); + + test('turning the county border off removes both strokes', () async { + final (layer, controller) = await attached(); + controller.calls.clear(); + + layer + ..setShowCountyOutline(false) + ..setShowTownOutline(false); + await pumpEventQueue(); + + expect(controller.calls, contains('removeLayer:admin-county-outline')); + expect( + controller.calls, + contains('removeLayer:admin-county-outline-casing'), + ); + expect(controller.calls, contains('removeLayer:admin-town-outline')); + // The vector source is the base style's; removing it would wipe the map. + expect( + controller.calls.where((c) => c == 'removeSource:exptech'), + isEmpty, + ); + }); + + test('the toggles are independent', () async { + final (layer, controller) = await attached(); + layer.setShowScanRange(false); + await pumpEventQueue(); + controller.calls.clear(); + + layer.setShowCountyOutline(false); + await pumpEventQueue(); + + expect( + controller.calls.where((c) => c.contains('radar-scan-range')), + isEmpty, + ); + }); + + test('setting the same value twice touches the map once', () async { + final (layer, controller) = await attached(); + layer.setShowScanRange(false); + await pumpEventQueue(); + controller.calls.clear(); + + layer.setShowScanRange(false); + await pumpEventQueue(); + + expect(controller.calls, isEmpty); + }); + + test('clear tears both overlays down with the layer', () async { + final (layer, controller) = await attached(); + controller.calls.clear(); + + await layer.clear(controller); + + expect(controller.calls, contains('removeSource:radar-scan-range')); + expect(controller.calls, contains('removeLayer:admin-county-outline')); + }); + + test('clear with an overlay off issues no removals for it', () async { + final (layer, controller) = await attached(); + layer.setShowScanRange(false); + await pumpEventQueue(); + controller.calls.clear(); + + await layer.clear(controller); + + expect( + controller.calls.where((c) => c.contains('radar-scan-range')), + isEmpty, + reason: + 'tearing down an overlay that was never added is a no-op the map ' + 'should never be asked to perform', + ); + }); + + test('a style reset re-adds both on the next attach', () async { + final (layer, _) = await attached(); + + // The style reload wiped every runtime source/layer under the layer's + // feet; nothing may be removed, only forgotten. + layer.onStyleReset(); + final frames = (await layer.frames()).valueOrNull!; + final fresh = RecordingMapController(); + await layer.prepare(fresh, frames); + await layer.show(fresh, frames[1]); + + expect( + fresh.calls, + contains('addSource:radar-scan-range'), + reason: + 'without forgetting the old state the overlays would never ' + 'come back, because the layer still thought they were drawn', + ); + expect(fresh.calls, contains('addLineLayer:admin-county-outline')); + }); + }); } /// [count] frame ids, newest first (the wire order). diff --git a/test/features/map/radar_overlay_menu_test.dart b/test/features/map/radar_overlay_menu_test.dart new file mode 100644 index 000000000..7d3890f37 --- /dev/null +++ b/test/features/map/radar_overlay_menu_test.dart @@ -0,0 +1,199 @@ +import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; +import 'package:dpip/features/map/presentation/widgets/radar_overlay_menu.dart'; +import 'package:dpip/features/weather/domain/radar_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'raster_timeline_harness.dart'; + +class _FakeRadarRepository extends FakeRasterFrameSource + implements RadarRepository { + _FakeRadarRepository() : super(const ['1700000000']); + + @override + String tileUrl(String frame) => 'https://host/$frame/{z}/{x}/{y}.webp'; +} + +Widget _wrap( + RadarMapLayer layer, { + ValueListenable? showTownLabels, + ValueChanged? onShowTownLabelsChanged, +}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + // Top-right of the map, where the scaffold mounts it beside the switcher. + home: Scaffold( + body: Align( + alignment: Alignment.topRight, + child: RadarOverlayMenu( + layer: layer, + showTownLabels: showTownLabels ?? ValueNotifier(true), + onShowTownLabelsChanged: onShowTownLabelsChanged ?? (_) {}, + ), + ), + ), +); + +Future _l10n() => + AppLocalizations.delegate.load(const Locale('en')); + +/// The menus have grown past the 800x600 default test surface; a dropdown row +/// that lands off-screen cannot be tapped, which fails as a "widget cannot +/// receive pointer events" rather than as anything about the menu. +void _useTallSurface(WidgetTester tester) { + tester.view.physicalSize = const Size(1200, 2000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); +} + +void main() { + testWidgets('the chip opens a menu carrying all five overlay toggles', ( + tester, + ) async { + _useTallSurface(tester); + final layer = RadarMapLayer(_FakeRadarRepository()); + await tester.pumpWidget(_wrap(layer)); + + final l10n = await _l10n(); + // Closed: neither row is on screen — this is a dropdown, not a panel. + expect(find.text(l10n.radarScanRange), findsNothing); + + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.radarGlobalOutline), findsOneWidget); + expect(find.text(l10n.radarScanRange), findsOneWidget); + expect(find.text(l10n.radarCountyOutline), findsOneWidget); + expect(find.text(l10n.radarTownOutline), findsOneWidget); + expect(find.text(l10n.mapTownLabels), 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); + // Reference chrome (scan range, county, town) and the name toggle ship + // on; the national border ships off. + expect(find.byIcon(Icons.check_box), findsNWidgets(4)); + expect(find.byIcon(Icons.check_box_outline_blank), findsOneWidget); + }); + + testWidgets('tapping the national-border row turns it on', (tester) async { + _useTallSurface(tester); + final layer = RadarMapLayer(_FakeRadarRepository()); + await tester.pumpWidget(_wrap(layer)); + + final l10n = await _l10n(); + expect(layer.showGlobalOutline.value, isFalse); + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.radarGlobalOutline)); + await tester.pumpAndSettle(); + + expect(layer.showGlobalOutline.value, isTrue); + // Independent controls: one must not drag the others with it. + expect(layer.showCountyOutline.value, isTrue); + expect(layer.showTownOutline.value, isTrue); + }); + + testWidgets('tapping the coverage row turns it off', (tester) async { + _useTallSurface(tester); + final layer = RadarMapLayer(_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.radarScanRange)); + await tester.pumpAndSettle(); + + expect(layer.showScanRange.value, isFalse); + // Independent controls: one must not drag the others with it. + expect(layer.showCountyOutline.value, isTrue); + expect(layer.showTownOutline.value, isTrue); + }); + + testWidgets('tapping the county row turns it off', (tester) async { + _useTallSurface(tester); + final layer = RadarMapLayer(_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(); + + expect(layer.showCountyOutline.value, isFalse); + expect(layer.showScanRange.value, isTrue); + expect(layer.showTownOutline.value, isTrue); + }); + + testWidgets('tapping the township row turns only it off', (tester) async { + _useTallSurface(tester); + final layer = RadarMapLayer(_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.radarTownOutline)); + await tester.pumpAndSettle(); + + expect(layer.showTownOutline.value, isFalse); + expect(layer.showCountyOutline.value, isTrue); + }); + + testWidgets('tapping the township-label row reports the flip upward', ( + tester, + ) async { + _useTallSurface(tester); + final layer = RadarMapLayer(_FakeRadarRepository()); + final labels = ValueNotifier(true); + final flipped = []; + await tester.pumpWidget( + _wrap( + layer, + showTownLabels: labels, + onShowTownLabelsChanged: flipped.add, + ), + ); + + final l10n = await _l10n(); + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.mapTownLabels)); + await tester.pumpAndSettle(); + + // The toggle lives on the scaffold, not the layer — the row hands the + // new value back up instead of flipping layer state. + expect(flipped, [false]); + }); + + testWidgets('the chip marks itself active once a default is switched off', ( + tester, + ) async { + _useTallSurface(tester); + final layer = RadarMapLayer(_FakeRadarRepository()); + await tester.pumpWidget(_wrap(layer)); + + // Both overlays ship on, so at rest the chip is unmarked. + expect( + tester.widget(find.byType(MapChipButton)).active, + isFalse, + ); + + layer.setShowCountyOutline(false); + await tester.pumpAndSettle(); + + // The dot is the only cue that this layer is no longer showing everything + // it would by default, while the menu is closed. + expect( + tester.widget(find.byType(MapChipButton)).active, + isTrue, + ); + }); +} diff --git a/test/features/map/radar_scan_range_test.dart b/test/features/map/radar_scan_range_test.dart new file mode 100644 index 000000000..d2aa3c527 --- /dev/null +++ b/test/features/map/radar_scan_range_test.dart @@ -0,0 +1,154 @@ +import 'dart:math' as math; + +import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Great-circle distance in km, the unsimplified form the coverage test is +/// derived from. Used here to check the simplification, not by the app. +double _haversineKm(double lon1, double lat1, double lon2, double lat2) { + const deg = math.pi / 180.0; + final dPhi = (lat2 - lat1) * deg; + final dLam = (lon2 - lon1) * deg; + final a = + math.pow(math.sin(dPhi / 2), 2) + + math.cos(lat1 * deg) * + math.cos(lat2 * deg) * + math.pow(math.sin(dLam / 2), 2); + return 2 * RadarScanRange.earthRadiusKm * math.asin(math.sqrt(a)); +} + +/// Whether a point is within some radar's range, computed the long way. +bool _coveredByHaversine(double lon, double lat) { + for (final s in RadarScanRange.sites) { + if (_haversineKm(lon, lat, s.lon, s.lat) <= s.radiusKm) return true; + } + return false; +} + +void main() { + test('the simplified test agrees with raw haversine across the grid', () { + // `sin²(·/2Rₑ)` is applied to both sides of the distance condition, which + // is order-preserving on [0, π/2] — so the two must agree everywhere, not + // merely nearby. Anything else means the algebra is wrong. + var checked = 0; + for (var lat = 18.0; lat <= 29.0; lat += 0.05) { + for (var lon = 115.0; lon <= 126.5; lon += 0.05) { + final fast = RadarScanRange.covered(lon, lat); + final slow = _coveredByHaversine(lon, lat); + // Points within a metre of a circle's rim can land either side of the + // comparison in floating point; ignore that band. + var onRim = false; + for (final s in RadarScanRange.sites) { + if ((_haversineKm(lon, lat, s.lon, s.lat) - s.radiusKm).abs() < + 0.001) { + onRim = true; + } + } + if (onRim) continue; + expect(fast, slow, reason: 'at $lon, $lat'); + checked++; + } + } + expect(checked, greaterThan(50000)); + }); + + test('coverage is the union of the circles, not any single one', () { + // A point only Ishigaki can see — the nearest Taiwanese radar is 126 km + // beyond its own range. If the union collapsed to the three domestic sites + // this whole south-east corner would read as unobserved. + const lon = 126.5; + const lat = 21.6; + expect(RadarScanRange.covered(lon, lat), isTrue); + for (var i = 0; i < RadarScanRange.domesticSiteCount; i++) { + final s = RadarScanRange.sites[i]; + expect( + _haversineKm(lon, lat, s.lon, s.lat), + greaterThan(s.radiusKm), + reason: 'site $i', + ); + } + }); + + test('coverage is clipped to the declared grid', () { + // Kenting reaches well south of 18°N, but the composite has no rows there. + expect(_coveredByHaversine(120.85, 17.95), isTrue); + expect(RadarScanRange.covered(120.85, 17.95), isFalse); + expect(RadarScanRange.covered(120.85, 18.05), isTrue); + }); + + test('the grid corners are outside coverage', () { + // The whole point of the outline: most of the declared box is never + // sampled, so a blank corner is "not observed", not "no rain". + for (final corner in [ + [115.0, 18.0], + [126.5, 18.0], + [115.0, 29.0], + [126.5, 29.0], + ]) { + expect( + RadarScanRange.covered(corner[0], corner[1]), + isFalse, + reason: 'corner $corner', + ); + } + }); + + test('the ring is a closed polygon that stays inside the grid', () { + final ring = RadarScanRange.ring(); + expect(ring.length, greaterThan(100)); + expect(ring.first, ring.last, reason: 'an exterior ring must close'); + for (final p in ring) { + expect(p[0], inInclusiveRange(RadarScanRange.west, RadarScanRange.east)); + expect( + p[1], + inInclusiveRange(RadarScanRange.south, RadarScanRange.north), + ); + } + }); + + test('the ring is wound counter-clockwise', () { + // GeoJSON's rule for an exterior ring. Shoelace > 0 is CCW in lon/lat. + final ring = RadarScanRange.ring(); + var twiceArea = 0.0; + for (var i = 0; i < ring.length - 1; i++) { + twiceArea += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]; + } + expect(twiceArea, greaterThan(0)); + }); + + test('every ring vertex sits on the coverage boundary', () { + // The edge is the closed-form inverse of the same condition, so each vertex + // must be covered just inside and uncovered just outside. + final ring = RadarScanRange.ring(); + for (var i = 0; i < ring.length - 1; i += 37) { + final lon = ring[i][0]; + final lat = ring[i][1]; + // Skip vertices clamped to the grid edge — there the boundary is the box. + if (lon <= RadarScanRange.west + 1e-6 || + lon >= RadarScanRange.east - 1e-6) { + continue; + } + final inward = RadarScanRange.covered( + lon + (lon > 121 ? -0.02 : 0.02), + lat, + ); + expect(inward, isTrue, reason: 'inside of vertex $i ($lon, $lat)'); + } + }); + + test('the outline is a polygon feature, decoded not encoded', () { + final geo = RadarScanRange.geoJson(); + // Handing `addSource` a string aborts the process on iOS. + expect(geo, isA>()); + final feature = (geo['features']! as List).first as Map; + final geometry = feature['geometry']! as Map; + expect(geometry['type'], 'Polygon'); + expect((geometry['coordinates']! as List).first, isA>()); + }); + + test('a coarser step still produces a valid ring', () { + final coarse = RadarScanRange.ring(step: 0.1); + expect(coarse.first, coarse.last); + expect(coarse.length, lessThan(RadarScanRange.ring().length)); + }); +} diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index a338e325c..69cac0e2e 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -35,6 +35,7 @@ abstract class FakeRasterFrameSource implements RasterFrameSource { required double north, required double east, required double zoom, + bool fill = false, }) async => warmed.add(List.of(frames)); @override @@ -59,8 +60,22 @@ class RecordingMapController implements MapLibreMapController { String? opacityOf(String layerId) => _state[layerId]?.opacity; @override - Future addSource(String sourceId, SourceProperties properties) async => - calls.add('addSource:$sourceId'); + Future addSource(String sourceId, SourceProperties properties) async { + // The iOS plugin feeds `data` straight to NSJSONSerialization, which + // *throws* — aborting the process, not returning an error — on anything + // that is not a JSON object. Reproduce that here so a source built from an + // encoded string fails in a test instead of on a device. + final data = properties.toJson()['data']; + if (data != null && data is! Map) { + throw ArgumentError.value( + data, + 'data', + 'GeoJSON source data must be a Map, not ${data.runtimeType} — ' + 'a top-level string crashes NSJSONSerialization on iOS', + ); + } + calls.add('addSource:$sourceId'); + } /// `raster-opacity-transition` each layer was mounted with, by layer id. final Map mountTransitions = {}; @@ -92,7 +107,43 @@ class RecordingMapController implements MapLibreMapController { double? maxzoom, dynamic filter, bool enableInteraction = true, - }) async => calls.add('addLineLayer:$layerId'); + }) async { + below[layerId] = belowLayerId; + lineColor[layerId] = properties.toJson()['line-color']?.toString(); + calls.add('addLineLayer:$layerId'); + } + + /// What each line/fill layer was anchored below — null means "on top", which + /// is the difference between a border that survives a raster and one that + /// disappears under it. + final Map below = {}; + + /// The anchor recorded for [layerId]; also null when never added, so pair it + /// with a `calls` assertion. + String? belowOf(String layerId) => below[layerId]; + + /// The `line-color` each line layer was drawn with — lets a test pin the + /// white radar border vs the yellow satellite one. + final Map lineColor = {}; + + /// The colour recorded for [layerId], or null when never added. + String? lineColorOf(String layerId) => lineColor[layerId]; + + @override + Future addFillLayer( + String sourceId, + String layerId, + FillLayerProperties properties, { + String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true, + }) async { + below[layerId] = belowLayerId; + calls.add('addFillLayer:$layerId'); + } @override Future removeLayer(String layerId) async => @@ -125,6 +176,16 @@ class RecordingMapController implements MapLibreMapController { ); } + @override + Future setLayerVisibility(String layerId, bool visible) async { + calls.add('setLayerVisibility:$layerId:$visible'); + final previous = _state[layerId]; + _state[layerId] = ( + visibility: visible ? 'visible' : 'none', + opacity: previous?.opacity, + ); + } + @override Future getVisibleRegion() async => LatLngBounds( southwest: const LatLng(22, 120), diff --git a/test/features/map/satellite_layer_test.dart b/test/features/map/satellite_layer_test.dart index 24294eb7a..8f85618a2 100644 --- a/test/features/map/satellite_layer_test.dart +++ b/test/features/map/satellite_layer_test.dart @@ -1,4 +1,5 @@ import 'package:dpip/features/map/presentation/layers/satellite_layer.dart'; +import 'package:dpip/features/weather/domain/satellite_channel.dart'; import 'package:dpip/features/weather/domain/satellite_repository.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,19 +13,26 @@ class _FakeSatelliteRepository extends FakeRasterFrameSource @override String tileUrl(String frame) => 'https://host/api/v2/tiles/satellite/$frame/{z}/{x}/{y}.webp'; + + @override + void setStyle(String? style) {} } void main() { test('frames chronological', () async { final layer = SatelliteMapLayer( _FakeSatelliteRepository(['1700000600', '1700000000']), + channel: SatelliteChannel.irClean, ); final frames = (await layer.frames()).valueOrNull!; expect(frames.map((f) => f.id), ['1700000000', '1700000600']); }); test('the shown frame is fully opaque', () async { - final layer = SatelliteMapLayer(_FakeSatelliteRepository(_ids(5))); + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(_ids(5)), + channel: SatelliteChannel.irClean, + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -37,7 +45,10 @@ void main() { test( 'scrubbing inside the ring is two opacity writes, nothing else', () async { - final layer = SatelliteMapLayer(_FakeSatelliteRepository(_ids(9))); + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(_ids(9)), + channel: SatelliteChannel.irClean, + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -54,45 +65,90 @@ void main() { ]); expect( controller.sentKeys, - everyElement(equals({'raster-opacity'})), + everyElement(equals({'raster-opacity', 'raster-opacity-transition'})), reason: 'the scrub path must not re-send visibility or the seven other ' - 'raster properties the layer type has', + 'raster properties the layer type has — only the opacity and the ' + 'zero cross-fade that keeps a scrub a loop instead of a smear', ); }, ); - test('dark boundary outlines are added once and removed on clear', () async { - final layer = SatelliteMapLayer(_FakeSatelliteRepository(_ids(5))); + test( + 'bright yellow county and town outlines are added once and removed on clear', + () async { + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(_ids(5)), + channel: SatelliteChannel.irClean, + ); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[2]); + await layer.show(controller, frames[0]); + + expect( + controller.calls + .where((c) => c == 'addLineLayer:$satelliteCountyOutlineLayerId') + .length, + 1, + reason: 'a second settle must not re-add the outlines', + ); + expect( + controller.calls, + isNot(contains('addLineLayer:$satelliteGlobalOutlineLayerId')), + reason: 'the 國界 border ships off by default', + ); + + controller.calls.clear(); + await layer.clear(controller); + expect( + controller.calls, + containsAll([ + 'removeLayer:$satelliteTownOutlineLayerId', + 'removeLayer:$satelliteCountyOutlineLayerId', + 'removeLayer:$satelliteGlobalOutlineLayerId', + ]), + ); + }, + ); + + test('the 國界 border draws only when asked', () async { + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(_ids(5)), + channel: SatelliteChannel.irClean, + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); await layer.prepare(controller, frames); await layer.show(controller, frames[2]); - await layer.show(controller, frames[0]); + controller.calls.clear(); + layer.setShowGlobalOutline(true); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } expect( - controller.calls - .where((c) => c == 'addLineLayer:$satelliteGlobalOutlineLayerId') - .length, - 1, - reason: 'a second settle must not re-add the outlines', + controller.calls, + contains('addLineLayer:$satelliteGlobalOutlineLayerId'), ); controller.calls.clear(); - await layer.clear(controller); + layer.setShowGlobalOutline(false); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } expect( controller.calls, - containsAll([ - 'removeLayer:$satelliteCountyOutlineLayerId', - 'removeLayer:$satelliteGlobalOutlineLayerId', - ]), + contains('removeLayer:$satelliteGlobalOutlineLayerId'), ); }); test('clear releases tiles', () async { final source = _FakeSatelliteRepository(_ids(5)); - final layer = SatelliteMapLayer(source); + final layer = SatelliteMapLayer(source, channel: SatelliteChannel.irClean); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); diff --git a/test/features/map/satellite_legend_test.dart b/test/features/map/satellite_legend_test.dart new file mode 100644 index 000000000..384201fe8 --- /dev/null +++ b/test/features/map/satellite_legend_test.dart @@ -0,0 +1,177 @@ +import 'package:dpip/features/map/presentation/widgets/satellite_legend.dart'; +import 'package:dpip/features/weather/domain/satellite_channel.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold(body: Center(child: child)), +); + +Future _l10n() => + AppLocalizations.delegate.load(const Locale('en')); + +void main() { + testWidgets( + 'thermal band grayscale carries the K scale and the yellow border key', + (tester) async { + await tester.pumpWidget( + _wrap( + SatelliteLegend( + channel: SatelliteChannel.irClean, + style: ValueNotifier(SatelliteStyle.gray), + ), + ), + ); + + final l10n = await _l10n(); + // JMA convention: 190 K cold (white) → 320 K warm (black); the unit + // sits below the scale, not after every value. + expect(find.text('190'), findsOneWidget); + expect(find.text('320'), findsOneWidget); + expect(find.text(l10n.mapLegendUnit('K')), findsOneWidget); + // Every channel shares the bright-yellow country/county and dark-yellow + // township frame key. + expect(find.text(l10n.mapLayerSatelliteGlobalOutline), findsOneWidget); + expect(find.text(l10n.radarCountyOutline), findsOneWidget); + expect(find.text(l10n.radarTownOutline), findsOneWidget); + }, + ); + + testWidgets('switching the style swaps the thermal scale', (tester) async { + final style = ValueNotifier(SatelliteStyle.gray); + await tester.pumpWidget( + _wrap(SatelliteLegend(channel: SatelliteChannel.irClean, style: style)), + ); + expect(find.text('190'), findsOneWidget); + + style.value = SatelliteStyle.jma; + await tester.pumpAndSettle(); + + // The cloud-top enhancement colours only the coldest tops (below −40 °C). + expect(find.text('190'), findsNothing); + expect(find.text('-45'), findsOneWidget); + expect(find.text('-92'), findsOneWidget); + }); + + testWidgets('cloud mask legend lists the four categories', (tester) async { + await tester.pumpWidget( + _wrap( + SatelliteLegend( + channel: SatelliteChannel.cloudmask, + style: ValueNotifier(SatelliteStyle.gray), + ), + ), + ); + + final l10n = await _l10n(); + expect(find.text(l10n.mapLayerSatelliteCloudClear), findsOneWidget); + expect(find.text(l10n.mapLayerSatelliteCloudProbablyClear), findsOneWidget); + expect( + find.text(l10n.mapLayerSatelliteCloudProbablyCloudy), + findsOneWidget, + ); + expect(find.text(l10n.mapLayerSatelliteCloudCloudy), findsOneWidget); + }); + + testWidgets('RGB products carry a compositing note instead of a scale', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + SatelliteLegend( + channel: SatelliteChannel.truecolor, + style: ValueNotifier(SatelliteStyle.gray), + ), + ), + ); + + final l10n = await _l10n(); + expect(find.text(l10n.mapLayerSatelliteRgbComposite), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('every channel renders a legend without throwing', ( + tester, + ) async { + for (final channel in SatelliteChannel.values) { + await tester.pumpWidget( + _wrap( + SatelliteLegend( + channel: channel, + style: ValueNotifier(SatelliteStyle.gray), + ), + ), + ); + await tester.pump(); + expect( + tester.takeException(), + isNull, + reason: 'channel ${channel.key} must build a legend', + ); + } + }); + + testWidgets('each transparency case explains itself', (tester) async { + final l10n = await _l10n(); + final cases = <(SatelliteChannel, String)>[ + // Reflective bands: low reflectance / night. + ( + SatelliteChannel.visibleRed, + l10n.mapLayerSatelliteTransparentReflectance, + ), + // Thermal bands: the warm clear-sky end. + (SatelliteChannel.irClean, l10n.mapLayerSatelliteTransparentWarm), + // Daytime RGBs fade out at night. + (SatelliteChannel.truecolor, l10n.mapLayerSatelliteTransparentNight), + // Difference layers: zero = no absorber. + (SatelliteChannel.btdSplit, l10n.mapLayerSatelliteTransparentZero), + // Cloud mask: clear sky. + (SatelliteChannel.cloudmask, l10n.mapLayerSatelliteTransparentClear), + // SST has no land value. + (SatelliteChannel.sst, l10n.mapLayerSatelliteTransparentNoData), + // NDVI below bare soil. + (SatelliteChannel.ndvi, l10n.mapLayerSatelliteTransparentNoVegetation), + // NDWI/MNDWI at or below zero. + (SatelliteChannel.ndwi, l10n.mapLayerSatelliteTransparentNoWater), + ]; + for (final (channel, note) in cases) { + await tester.pumpWidget( + _wrap( + SatelliteLegend( + channel: channel, + style: ValueNotifier(SatelliteStyle.gray), + ), + ), + ); + await tester.pump(); + expect( + find.text(note), + findsOneWidget, + reason: 'channel ${channel.key} must explain its transparency', + ); + } + }); + + testWidgets('always-opaque thermal RGBs carry no transparency note', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + SatelliteLegend( + channel: SatelliteChannel.ash, + style: ValueNotifier(SatelliteStyle.gray), + ), + ), + ); + final l10n = await _l10n(); + expect( + find.text(l10n.mapLayerSatelliteTransparentWarm), + findsNothing, + reason: 'thermal RGB recipes cover the whole scene', + ); + }); +} diff --git a/test/features/map/satellite_style_menu_test.dart b/test/features/map/satellite_style_menu_test.dart new file mode 100644 index 000000000..d9bc2ad35 --- /dev/null +++ b/test/features/map/satellite_style_menu_test.dart @@ -0,0 +1,182 @@ +import 'package:dpip/features/map/presentation/layers/satellite_layer.dart'; +import 'package:dpip/features/map/presentation/widgets/satellite_style_menu.dart'; +import 'package:dpip/features/weather/domain/satellite_channel.dart'; +import 'package:dpip/features/weather/domain/satellite_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'raster_timeline_harness.dart'; + +class _FakeSatelliteRepository extends FakeRasterFrameSource + implements SatelliteRepository { + _FakeSatelliteRepository() : super(const []); + + String? style; + + @override + String tileUrl(String frame) => 'https://host/$frame/{z}/{x}/{y}.webp'; + + @override + void setStyle(String? value) => style = value; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // The menu opens a dropdown that needs vertical room; default 800×600 crops + // the bottom rows so a tap on them fails as "not hittable". + void useTallSurface(WidgetTester tester) { + tester.view.physicalSize = const Size(1200, 2000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + Future loadL10n() => + AppLocalizations.delegate.load(const Locale('en')); + + Widget wrap(SatelliteStyleMenu menu) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: Align(alignment: Alignment.topRight, child: menu), + ), + ); + + testWidgets('a raw band offers the three colour styles, gray selected', ( + tester, + ) async { + useTallSurface(tester); + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(), + channel: SatelliteChannel.irClean, + ); + await tester.pumpWidget( + wrap( + SatelliteStyleMenu( + layer: layer, + onReloadActive: () async {}, + showTownLabels: ValueNotifier(true), + onShowTownLabelsChanged: (_) {}, + ), + ), + ); + + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + + final l10n = await loadL10n(); + expect(find.text(l10n.mapLayerStyleSection), findsOneWidget); + expect(find.text(l10n.mapLayerStyleGray), findsOneWidget); + expect(find.text(l10n.mapLayerStyleJma), findsOneWidget); + expect(find.text(l10n.mapLayerStyleBd), findsOneWidget); + // Default is grayscale — JMA's standard is the no-wire-value default. + expect(find.byIcon(Icons.check_circle), findsOneWidget); + }); + + testWidgets( + 'picking a style updates the layer, the repository, and reloads', + (tester) async { + useTallSurface(tester); + final repository = _FakeSatelliteRepository(); + final layer = SatelliteMapLayer( + repository, + channel: SatelliteChannel.irClean, + ); + var reloads = 0; + await tester.pumpWidget( + wrap( + SatelliteStyleMenu( + layer: layer, + onReloadActive: () async => reloads++, + showTownLabels: ValueNotifier(true), + onShowTownLabelsChanged: (_) {}, + ), + ), + ); + + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + final l10n = await loadL10n(); + await tester.tap(find.text(l10n.mapLayerStyleJma)); + await tester.pumpAndSettle(); + + expect(layer.style.value, SatelliteStyle.jma); + expect(repository.style, 'jma'); + expect(reloads, 1); + // The chip marks non-default style as active. + expect(layer.style.value, isNot(SatelliteStyle.gray)); + }, + ); + + testWidgets('a named product offers the reference menu, not the style menu', ( + tester, + ) async { + useTallSurface(tester); + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(), + channel: SatelliteChannel.truecolor, + ); + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final chrome = layer.buildTopTrailingChrome( + tester.element(find.byType(Scaffold)), + showTownLabels: ValueNotifier(true), + onShowTownLabelsChanged: (_) {}, + onReloadActive: () async {}, + ); + expect(chrome, isA()); + }); + + testWidgets( + 'a reflectance band offers the reference menu, not the style menu', + (tester) async { + useTallSurface(tester); + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(), + channel: SatelliteChannel.visibleBlue, + ); + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final chrome = layer.buildTopTrailingChrome( + tester.element(find.byType(Scaffold)), + showTownLabels: ValueNotifier(true), + onShowTownLabelsChanged: (_) {}, + onReloadActive: () async {}, + ); + expect(chrome, isA()); + }, + ); + + testWidgets('a thermal band offers the style menu', (tester) async { + useTallSurface(tester); + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(), + channel: SatelliteChannel.irLong, // B14 — thermal + ); + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final chrome = layer.buildTopTrailingChrome( + tester.element(find.byType(Scaffold)), + showTownLabels: ValueNotifier(true), + onShowTownLabelsChanged: (_) {}, + onReloadActive: () async {}, + ); + expect(chrome, isNot(isA())); + expect(chrome, isA()); + }); + + testWidgets('setStyle rejects temperature styles on a reflectance band', ( + tester, + ) async { + useTallSurface(tester); + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(), + channel: SatelliteChannel.visibleBlue, // B01 — grayscale only + ); + var reloads = 0; + layer.setStyle(SatelliteStyle.jma, onReloadActive: () async => reloads++); + expect(layer.style.value, SatelliteStyle.gray); + expect(reloads, 0); + }); +} diff --git a/test/features/map/typhoon_overlay_menu_test.dart b/test/features/map/typhoon_overlay_menu_test.dart new file mode 100644 index 000000000..9c2604036 --- /dev/null +++ b/test/features/map/typhoon_overlay_menu_test.dart @@ -0,0 +1,292 @@ +import 'package:dpip/shared/map/admin_outline.dart'; +import 'package:dpip/features/map/presentation/layers/typhoon_layer.dart'; +import 'package:dpip/features/map/presentation/layers/typhoon_weather_overlay.dart'; +import 'package:dpip/features/map/presentation/widgets/typhoon_overlay_menu.dart'; +import 'package:dpip/features/typhoon/domain/meteor_typhoon_repository.dart'; +import 'package:dpip/features/weather/domain/radar_repository.dart'; +import 'package:dpip/features/weather/domain/satellite_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_style.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'raster_timeline_harness.dart'; + +/// The menu never touches the repositories — it only reads and flips notifiers. +class _FakeTyphoonRepository implements MeteorTyphoonRepository { + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +class _FakeRadarRepository extends FakeRasterFrameSource + implements RadarRepository { + _FakeRadarRepository() : super(const []); + + @override + String tileUrl(String frame) => 'https://host/$frame/{z}/{x}/{y}.webp'; +} + +class _FakeSatelliteRepository extends FakeRasterFrameSource + implements SatelliteRepository { + _FakeSatelliteRepository() : super(const []); + + @override + String tileUrl(String frame) => 'https://host/sat/$frame/{z}/{x}/{y}.png'; + + @override + void setStyle(String? style) {} +} + +TyphoonMapLayer _layer() => TyphoonMapLayer( + _FakeTyphoonRepository(), + radar: _FakeRadarRepository(), + satellite: _FakeSatelliteRepository(), +); + +Widget _wrap(TyphoonMapLayer layer) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: Align( + alignment: Alignment.topRight, + child: TyphoonOverlayMenu( + layer: layer, + showTownLabels: ValueNotifier(true), + onShowTownLabelsChanged: (_) {}, + ), + ), + ), +); + +Future _l10n() => + AppLocalizations.delegate.load(const Locale('en')); + +/// Opens the dropdown. +Future _open(WidgetTester tester) async { + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); +} + +/// The menus have grown past the 800x600 default test surface; a dropdown row +/// that lands off-screen cannot be tapped, which fails as a "widget cannot +/// receive pointer events" rather than as anything about the menu. +void _useTallSurface(WidgetTester tester) { + tester.view.physicalSize = const Size(1200, 2000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); +} + +void main() { + test('every radar overlay defaults on', () { + final layer = _layer(); + expect(layer.showScanRange.value, isTrue); + expect(layer.showCountyOutline.value, isTrue); + expect(layer.showTownOutline.value, isTrue); + // And radar is already the underlay, so they are live on first open. + expect(layer.weatherOverlay.value, TyphoonWeatherOverlay.radar); + }); + + testWidgets('the radar chrome rows show while radar is the underlay', ( + tester, + ) async { + _useTallSurface(tester); + final layer = _layer(); + await tester.pumpWidget(_wrap(layer)); + await _open(tester); + + final l10n = await _l10n(); + expect(find.text(l10n.radarScanRange), findsOneWidget); + expect(find.text(l10n.radarCountyOutline), findsOneWidget); + expect(find.text(l10n.radarTownOutline), findsOneWidget); + }); + + testWidgets('they disappear when the underlay is satellite', (tester) async { + _useTallSurface(tester); + final layer = _layer(); + await tester.pumpWidget(_wrap(layer)); + + layer.setWeatherOverlay(TyphoonWeatherOverlay.satellite); + await tester.pumpAndSettle(); + await _open(tester); + + final l10n = await _l10n(); + // The radar chrome toggles exist only while the radar underlay does — a + // coverage boundary over Himawari IR would bound an instrument that is not + // on screen, and the base style's borders only disappear under the echo. + expect(find.text(l10n.radarScanRange), findsNothing); + expect(find.text(l10n.radarCountyOutline), findsNothing); + expect(find.text(l10n.radarTownOutline), findsNothing); + }); + + testWidgets('they disappear when there is no underlay at all', ( + tester, + ) async { + _useTallSurface(tester); + final layer = _layer(); + await tester.pumpWidget(_wrap(layer)); + + layer.setWeatherOverlay(TyphoonWeatherOverlay.none); + await tester.pumpAndSettle(); + await _open(tester); + + final l10n = await _l10n(); + expect(find.text(l10n.radarScanRange), findsNothing); + expect(find.text(l10n.radarCountyOutline), findsNothing); + expect(find.text(l10n.radarTownOutline), findsNothing); + }); + + testWidgets('tapping a row flips only its own flag', (tester) async { + _useTallSurface(tester); + final layer = _layer(); + await tester.pumpWidget(_wrap(layer)); + await _open(tester); + + final l10n = await _l10n(); + await tester.tap(find.text(l10n.radarScanRange)); + await tester.pumpAndSettle(); + + expect(layer.showScanRange.value, isFalse); + expect(layer.showCountyOutline.value, isTrue); + expect(layer.showTownOutline.value, isTrue); + }); + + testWidgets('switching away from radar does not reset the toggles', ( + tester, + ) async { + _useTallSurface(tester); + final layer = _layer(); + await tester.pumpWidget(_wrap(layer)); + layer.setShowCountyOutline(false); + + layer.setWeatherOverlay(TyphoonWeatherOverlay.satellite); + layer.setWeatherOverlay(TyphoonWeatherOverlay.radar); + await tester.pumpAndSettle(); + await _open(tester); + + // The choice is the reader's; a round trip through another underlay must + // not silently hand it back. + expect(layer.showCountyOutline.value, isFalse); + // Exactly one box is clear: the county one. Everything else is still on. + expect(find.byIcon(Icons.check_box_outline_blank), findsNWidgets(3)); + }); + + testWidgets('the chip is marked whenever an underlay is showing', ( + tester, + ) async { + _useTallSurface(tester); + final layer = _layer(); + await tester.pumpWidget(_wrap(layer)); + + // Radar is the default underlay, and any underlay is itself a deviation + // from the bare track — so the chip starts marked. + expect( + tester.widget(find.byType(MapChipButton)).active, + isTrue, + ); + + layer.setWeatherOverlay(TyphoonWeatherOverlay.none); + await tester.pumpAndSettle(); + + // With no underlay the radar chrome is not drawn either, so nothing on + // screen differs from the defaults and the marker clears. + expect( + tester.widget(find.byType(MapChipButton)).active, + isFalse, + ); + + layer.setShowScanRange(false); + await tester.pumpAndSettle(); + + // A radar-only toggle must not light the marker while radar is off — it + // would be pointing at a line that is not on the map. + expect( + tester.widget(find.byType(MapChipButton)).active, + isFalse, + ); + }); + + testWidgets( + 'the legend carries the satellite frame while satellite is the underlay', + (tester) async { + _useTallSurface(tester); + final layer = _layer(); + layer.setWeatherOverlay(TyphoonWeatherOverlay.satellite); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: Builder(builder: (context) => layer.buildLegend(context)), + ), + ), + ); + await tester.pumpAndSettle(); + + final l10n = await _l10n(); + // The fully-opaque IR underlay buries the base style, so the bright-yellow + // country/county frame and the dark-yellow township mesh are keyed — but + // the radar scan range has no instrument on screen and is not. + expect(find.text(l10n.mapLayerSatelliteGlobalOutline), findsOneWidget); + expect(find.text(l10n.radarCountyOutline), findsOneWidget); + expect(find.text(l10n.radarTownOutline), findsOneWidget); + expect(find.text(l10n.radarScanRange), findsNothing); + }, + ); + + test('the county border can be anchored under the typhoon vectors', () async { + final controller = RecordingMapController(); + await AdminOutline.add( + controller, + AdminBoundary.county, + belowLayerId: 'typhoon-probability', + ); + + // On the radar surface these go on top; over a typhoon track they must not + // cross the thing being read, so the anchor has to reach the layer call. + expect( + controller.belowOf(AdminBoundary.county.lineLayerId), + 'typhoon-probability', + ); + expect( + controller.belowOf(AdminBoundary.county.casingLayerId), + 'typhoon-probability', + ); + }); + + test( + 'the default border core is white; satellite passes bright yellow', + () async { + final controller = RecordingMapController(); + await AdminOutline.add(controller, AdminBoundary.county); + expect( + controller.lineColorOf(AdminBoundary.county.lineLayerId), + '#FFFFFF', + ); + expect( + controller.lineColorOf(AdminBoundary.county.casingLayerId), + '#000000', + ); + + final satelliteController = RecordingMapController(); + await AdminOutline.add( + satelliteController, + AdminBoundary.town, + lineColor: satelliteOutlineColor, + ); + expect( + satelliteController.lineColorOf(AdminBoundary.town.lineLayerId), + '#FFD400', + ); + }, + ); + + test('omitting the anchor keeps the borders on top', () async { + final controller = RecordingMapController(); + await AdminOutline.add(controller, AdminBoundary.county); + expect(controller.belowOf(AdminBoundary.county.lineLayerId), isNull); + }); +} diff --git a/test/features/map/wind_forecast_layer_test.dart b/test/features/map/wind_forecast_layer_test.dart new file mode 100644 index 000000000..9aaf90e2c --- /dev/null +++ b/test/features/map/wind_forecast_layer_test.dart @@ -0,0 +1,483 @@ +import 'dart:typed_data'; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/map/presentation/layers/wind_forecast_layer.dart'; +import 'package:dpip/features/map/presentation/widgets/wind_particle_overlay.dart'; +import 'package:dpip/features/weather/domain/wind_field.dart'; +import 'package:dpip/features/weather/domain/wind_forecast_model.dart'; +import 'package:dpip/features/weather/domain/wind_forecast_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_layer_category.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'raster_timeline_harness.dart'; + +class _FakeWindRepository extends FakeRasterFrameSource + implements WindForecastRepository { + _FakeWindRepository(super.frames); + + @override + String tileUrl(String frame) => + 'https://host/api/v2/tiles/wind/$frame/{z}/{x}/{y}.webp'; + + @override + Future> fetchWindField(String frame) async => Ok( + WindField( + width: 2, + height: 2, + lat0: 90, + lon0: 0, + dLat: -0.25, + dLon: 0.25, + uMin: -1, + uMax: 1, + vMin: -1, + vMax: 1, + timeMs: 0, + model: 'gfs', + u: Uint8List.fromList([127, 127, 127, 127]), + v: Uint8List.fromList([127, 127, 127, 127]), + ), + ); +} + +void main() { + test('frames chronological', () async { + final layer = WindForecastMapLayer( + _FakeWindRepository(['1700000600', '1700000000']), + model: WindForecastModel.gfs, + ); + final frames = (await layer.frames()).valueOrNull!; + expect(frames.map((f) => f.id), ['1700000000', '1700000600']); + }); + + test('id, icon, and model subtitle identify the forecast layer', () { + final gfs = WindForecastMapLayer( + _FakeWindRepository(const []), + model: WindForecastModel.gfs, + ); + expect(gfs.id, 'wind-gfs'); + expect(gfs.icon, Icons.air); + expect(WindForecastModel.gfs.subtitle, '0.25° · 1 h'); + + final ecmwf = WindForecastMapLayer( + _FakeWindRepository(const []), + model: WindForecastModel.ecmwf, + ); + expect(ecmwf.id, 'wind-ecmwf'); + expect(WindForecastModel.ecmwf.subtitle, '0.25° · 3 h'); + }); + + test('both models resolve to the numerical-forecast category', () { + expect(categoryOf('wind-gfs'), MapLayerCategory.forecast); + expect(categoryOf('wind-ecmwf'), MapLayerCategory.forecast); + }); + + test( + 'county and township borders are added on attach and removed on clear', + () async { + final layer = WindForecastMapLayer( + _FakeWindRepository(_ids(5)), + model: WindForecastModel.ecmwf, + ); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[2]); + + expect( + controller.calls, + containsAll([ + 'addLineLayer:admin-county-outline', + 'addLineLayer:admin-town-outline', + ]), + reason: + 'the wind field covers the base style\'s borders, so its own ' + 'county and township frame is drawn over it on attach', + ); + expect( + controller.calls, + isNot(contains('addLineLayer:admin-global-outline')), + reason: 'the national border ships off and must not appear on attach', + ); + + controller.calls.clear(); + await layer.clear(controller); + expect( + controller.calls, + containsAll([ + 'removeLayer:admin-county-outline', + 'removeLayer:admin-town-outline', + ]), + ); + }, + ); + + test('turning the global borders on draws them over a live map', () async { + final layer = WindForecastMapLayer( + _FakeWindRepository(_ids(5)), + model: WindForecastModel.ecmwf, + ); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[2]); + controller.calls.clear(); + + layer.setShowGlobalOutline(true); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + expect(controller.calls, contains('addLineLayer:admin-global-outline')); + + controller.calls.clear(); + layer.setShowGlobalOutline(false); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + expect(controller.calls, contains('removeLayer:admin-global-outline')); + }); + + test('turning the town borders off removes them from a live map', () async { + final layer = WindForecastMapLayer( + _FakeWindRepository(_ids(5)), + model: WindForecastModel.gfs, + ); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[2]); + controller.calls.clear(); + + layer.setShowTownOutline(false); + // The toggle syncs the map in an unawaited async op; let it drain. + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + + expect(controller.calls, contains('removeLayer:admin-town-outline')); + expect( + controller.calls, + isNot(contains('removeLayer:admin-county-outline')), + reason: 'the county frame must stay when only the town toggle flips', + ); + }); + + test('clear releases tiles', () async { + final source = _FakeWindRepository(_ids(5)); + final layer = WindForecastMapLayer(source, model: WindForecastModel.gfs); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[2]); + await layer.clear(controller); + + expect(source.released, 1); + }); + + test('showing a frame loads its wind field into the layer', () async { + final layer = WindForecastMapLayer( + _FakeWindRepository(_ids(5)), + model: WindForecastModel.gfs, + ); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + expect(layer.field.value, isNull); + + await layer.show(controller, frames[0]); + expect(layer.field.value, isNotNull); + expect(layer.field.value!.model, 'gfs'); + + // Detach stops advertising the grid, so a re-attach animates a fresh field + // rather than the previous layer's stale one. + await layer.clear(controller); + expect(layer.field.value, isNull); + }); + + testWidgets('the overlay slot hosts the particle animation', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox())), + ); + final layer = WindForecastMapLayer( + _FakeWindRepository(const []), + model: WindForecastModel.ecmwf, + ); + final overlay = layer.buildMapOverlay( + tester.element(find.byType(Scaffold)), + ); + expect(overlay, isA()); + }); + + testWidgets('the ticker runs only while a wind field is loaded', ( + tester, + ) async { + final layer = WindForecastMapLayer( + _FakeWindRepository(const []), + model: WindForecastModel.gfs, + ); + // The harness camera is zoom 7 over Taiwan, so a seeded particle must sit + // inside the viewport and the ticker has somewhere to streak it. + await layer.onAttached(RecordingMapController()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: WindParticleOverlay(layer: layer)), + ), + ); + expect( + tester.binding.transientCallbackCount, + 0, + reason: 'an empty layer must not run the animation at all', + ); + + layer.field.value = WindField( + width: 2, + height: 2, + lat0: 90, + lon0: 0, + dLat: -90, + dLon: 180, + uMin: -20, + uMax: 20, + vMin: -1, + vMax: 1, + timeMs: 0, + model: 'gfs', + u: Uint8List.fromList([255, 255, 255, 255]), + v: Uint8List.fromList([128, 128, 128, 128]), + ); + await tester.pump(); + + expect( + tester.binding.transientCallbackCount, + greaterThan(0), + reason: 'a loaded field must start the ticker that redraws the streaks', + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('the overlay paints visible streaks, not nothing', ( + tester, + ) async { + tester.view.physicalSize = const Size(1170, 2532); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final layer = WindForecastMapLayer( + _FakeWindRepository(const []), + model: WindForecastModel.gfs, + ); + await layer.onAttached(RecordingMapController()); + layer.field.value = WindField( + width: 2, + height: 2, + lat0: 90, + lon0: 0, + dLat: -90, + dLon: 180, + uMin: -20, + uMax: 20, + vMin: -1, + vMax: 1, + timeMs: 0, + model: 'gfs', + u: Uint8List.fromList([255, 255, 255, 255]), + v: Uint8List.fromList([128, 128, 128, 128]), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + // Black underneath: the streaks are white, and the default Scaffold + // background is near-white, so on that a blank overlay counts as + // bright everywhere and the assertion below means nothing. + body: RepaintBoundary( + child: ColoredBox( + color: Colors.black, + child: WindParticleOverlay(layer: layer), + ), + ), + ), + ), + ); + // Let the seeded particles accumulate a few frames of trail. + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + final boundary = tester.renderObject( + find.byType(RepaintBoundary).first, + ); + // toImage is a real engine async — it must run outside the test's fake + // clock, or the future never completes. + ByteData? data; + await tester.runAsync(() async { + final image = await boundary.toImage(); + data = await image.toByteData(); + image.dispose(); + }); + expect(data, isNotNull); + final bytes = data!; + + // The painter draws white-on-dark streaks; at least a few sampled pixels + // must be bright — zero means the CustomPaint produced nothing visible. + var bright = 0; + for (var i = 0; i < bytes.lengthInBytes; i += 16) { + final r = bytes.getUint8(i); + final g = bytes.getUint8(i + 1); + final b = bytes.getUint8(i + 2); + if (r > 160 && g > 160 && b > 160) bright++; + } + expect( + bright, + greaterThan(10), + reason: 'the particle trails must paint as visible bright pixels', + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('streaks appear on a field that arrives after the first build', ( + tester, + ) async { + // The field is fetched asynchronously, so in the app it is always null when + // the overlay first builds — and the painter is handed the simulation by + // value at build time. Without a rebuild when the field lands, the overlay + // paints nothing until some unrelated cause rebuilds it, which in practice + // meant the first pan. Nothing here ever touches the camera. + tester.view.physicalSize = const Size(1170, 2532); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final layer = WindForecastMapLayer( + _FakeWindRepository(const []), + model: WindForecastModel.gfs, + ); + await layer.onAttached(RecordingMapController()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + // Black underneath: the streaks are white, and the default Scaffold + // background is near-white, so on that a blank overlay counts as + // bright everywhere and the assertion below means nothing. + body: RepaintBoundary( + child: ColoredBox( + color: Colors.black, + child: WindParticleOverlay(layer: layer), + ), + ), + ), + ), + ); + + layer.field.value = WindField( + width: 2, + height: 2, + lat0: 90, + lon0: 0, + dLat: -90, + dLon: 180, + uMin: -20, + uMax: 20, + vMin: -1, + vMax: 1, + timeMs: 0, + model: 'gfs', + u: Uint8List.fromList([255, 255, 255, 255]), + v: Uint8List.fromList([128, 128, 128, 128]), + ); + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + final boundary = tester.renderObject( + find.byType(RepaintBoundary).first, + ); + ByteData? data; + await tester.runAsync(() async { + final image = await boundary.toImage(); + data = await image.toByteData(); + image.dispose(); + }); + final bytes = data!; + var bright = 0; + for (var i = 0; i < bytes.lengthInBytes; i += 16) { + if (bytes.getUint8(i) > 160 && + bytes.getUint8(i + 1) > 160 && + bytes.getUint8(i + 2) > 160) { + bright++; + } + } + expect( + bright, + greaterThan(10), + reason: 'a field loaded after mount must still start the streaks', + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('the options chip offers county, township, and name toggles', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 2000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final layer = WindForecastMapLayer( + _FakeWindRepository(const []), + model: WindForecastModel.gfs, + ); + await tester.pumpWidget( + const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: Locale('en'), + home: Scaffold(), + ), + ); + final chrome = layer.buildTopTrailingChrome( + tester.element(find.byType(Scaffold)), + showTownLabels: ValueNotifier(true), + onShowTownLabelsChanged: (_) {}, + onReloadActive: () async {}, + ); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: Align(alignment: Alignment.topRight, child: chrome), + ), + ), + ); + + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(l10n.radarGlobalOutline), findsOneWidget); + expect(find.text(l10n.radarCountyOutline), findsOneWidget); + expect(find.text(l10n.radarTownOutline), findsOneWidget); + expect(find.text(l10n.mapTownLabels), findsOneWidget); + // No scan range — a forecast model has no instrument footprint. + expect(find.text(l10n.radarScanRange), findsNothing); + }); +} + +/// [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/wind_particle_sim_test.dart b/test/features/map/wind_particle_sim_test.dart new file mode 100644 index 000000000..4e1cf7414 --- /dev/null +++ b/test/features/map/wind_particle_sim_test.dart @@ -0,0 +1,251 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; +import 'package:dpip/features/weather/domain/wind_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A 2×2 global grid; [uValue] / [vValue] set every cell's quantised plane +/// (so all cells carry the same wind), or -1 for a full eastward gradient. +WindField _field({int uValue = 128, int vValue = 128, double lon0 = 0}) => + WindField( + width: 2, + height: 2, + lat0: 90, + lon0: lon0, + dLat: -90, + dLon: 180, + uMin: -1, + uMax: 1, + vMin: -1, + vMax: 1, + timeMs: 0, + model: 'gfs', + u: Uint8List.fromList([uValue, uValue, uValue, uValue]), + v: Uint8List.fromList([vValue, vValue, vValue, vValue]), + ); + +/// A wind field whose every cell blows due east at ~1 m/s. +WindField _eastField() => _field(uValue: 255, vValue: 128); + +const _camera = WindCamera(centerLat: 25, centerLng: 121, zoom: 5, bearing: 0); +const _size = Size(1000, 1000); + +void main() { + group('projectLatLng', () { + test('the camera target lands on the viewport centre', () { + final p = projectLatLng(_camera, 25, 121, _size); + expect(p.dx, closeTo(500, 0.001)); + expect(p.dy, closeTo(500, 0.001)); + }); + + test('a point north of the target sits above it', () { + final p = projectLatLng(_camera, 25.1, 121, _size); + expect(p.dy, lessThan(500)); + expect(p.dx, closeTo(500, 0.001)); + }); + + test('bearing 90 rotates north to the right of the viewport', () { + final cam = WindCamera(centerLat: 0, centerLng: 0, zoom: 2, bearing: 90); + final north = projectLatLng(cam, 1, 0, _size); + expect(north.dx, greaterThan(500)); + expect(north.dy, closeTo(500, 0.001)); + }); + + test('a longitude expressed past 180 lands where its wrap does', () { + // Particle longitudes are `lon0 + x · 360`, so a grid whose first column + // is at 180 — ECMWF's — never produces one inside [-180, 180]. + final home = projectLatLng(_camera, 25, 121, _size); + for (final turns in [-2.0, -1.0, 1.0, 2.0]) { + final p = projectLatLng(_camera, 25, 121 + 360 * turns, _size); + expect(p.dx, closeTo(home.dx, 1e-6), reason: '121° + $turns turns'); + expect(p.dy, closeTo(home.dy, 1e-6), reason: '121° + $turns turns'); + } + }); + + test('a point past the antimeridian projects onto the near edge', () { + // Seen from 121°E, 175°W is 64° east, not 296° west. + final cam = WindCamera(centerLat: 0, centerLng: 121, zoom: 2, bearing: 0); + final p = projectLatLng(cam, 0, -175, _size); + final world = 512 * 4.0; + expect(p.dx - 500, closeTo(64 / 360 * world, 1e-6)); + }); + }); + + group('viewportBounds', () { + test('an unbearing zoom-0 512×512 view shows the whole world', () { + final cam = WindCamera(centerLat: 0, centerLng: 0, zoom: 0, bearing: 0); + final vp = viewportBounds(cam, const Size(512, 512)); + expect(vp.westLng, closeTo(-180, 1e-9)); + expect(vp.eastLng, closeTo(180, 1e-9)); + expect(vp.northLat, closeTo(85.05, 0.01)); + expect(vp.southLat, closeTo(-85.05, 0.01)); + }); + + test('shrinks with the size and zoom', () { + final cam = WindCamera( + centerLat: 25, + centerLng: 121, + zoom: 8, + bearing: 0, + ); + final vp = viewportBounds(cam, const Size(1000, 1000)); + // world is 512·2⁸ = 131072 px wide; 500 px is 1.373° of longitude. + expect(vp.eastLng - vp.westLng, closeTo(1000 / 131072 * 360, 1e-9)); + }); + }); + + group('WindParticleSim', () { + test('near-calm air barely moves a particle but leaves trails', () { + final sim = WindParticleSim(_field(), count: 20, random: math.Random(7)); + sim.step(_camera, _size); + final x0 = sim.particles.first.x; + final y0 = sim.particles.first.y; + for (var i = 0; i < 10; i++) { + sim.step(_camera, _size); + } + expect(sim.particles.first.x - x0, lessThan(1e-3)); + expect(sim.particles.first.y - y0, lessThan(1e-3)); + expect( + sim.particles.every((p) => p.screen != null), + isTrue, + reason: 'every seeded particle sits in the viewport, so all stamp', + ); + }); + + test('an eastward field advects particles east', () { + final sim = WindParticleSim( + _eastField(), + count: 40, + random: math.Random(7), + ); + sim.step(_camera, _size); // seeds the viewport + final x0 = [for (final p in sim.particles) p.x]; + sim.step(_camera, _size); // one advection frame + // The median, not the mean: a recycled particle jumps anywhere in the + // viewport, and at the web's drop rate a few dozen of them do so every + // frame — enough to swamp the sub-pixel drift of everyone else. + final drift = []; + for (var i = 0; i < sim.particles.length; i++) { + var dx = sim.particles[i].x - x0[i]; + if (dx > 0.5) dx -= 1; // unwind a wrap past longitude 0 + if (dx < -0.5) dx += 1; + drift.add(dx); + } + drift.sort(); + expect(drift[drift.length ~/ 2], greaterThan(0)); + }); + + test('a particle drifting off the field respawns into the viewport', () { + final sim = WindParticleSim(_field(), count: 1, random: math.Random(3)); + sim.step(_camera, _size); + final out = sim.particles.first + ..y = -1.5; // above the field's north edge, off the grid + sim.step(_camera, _size); + expect( + out.screen, + isNull, + reason: 'off the grid is nothing to stamp, whatever happens next', + ); + // Recycling is rejection-sampled, so a candidate in still air is turned + // down nine times in ten and the particle waits where it is for the next + // frame — exactly what the web renderer does. Give it those frames. + for (var i = 0; i < 100; i++) { + sim.step(_camera, _size); + } + expect(out.y, inInclusiveRange(0, 1)); + // The respawned position lies inside the current viewport's field rect. + final vp = viewportBounds(_camera, _size); + final east = ((vp.eastLng - 0) / 360) % 1.0; + final west = ((vp.westLng - 0) / 360) % 1.0; + expect( + out.x, + inInclusiveRange(math.min(west, east), math.max(west, east)), + ); + }); + + test('a grid whose first column is at 180 streaks like one at 0', () { + // ECMWF ships its global grid starting at longitude 180 where GFS starts + // at 0. Nothing else about the two differs, so the same view must draw + // the same number of streaks — it once drew none at all, because every + // ECMWF particle projected tens of thousands of pixels off screen and was + // recycled before it could trace anything. + int streaks(double lon0) { + final sim = WindParticleSim( + _field(uValue: 255, vValue: 128, lon0: lon0), + count: 40, + random: math.Random(7), + ); + for (var i = 0; i < 5; i++) { + sim.step(_camera, _size); + } + return sim.particles.where((p) => p.screen != null).length; + } + + expect(streaks(180), streaks(0)); + expect(streaks(180), greaterThan(30)); + }); + + test('a viewport straddling the grid seam seeds inside itself', () { + // The seam is at lon0, which for ECMWF is the antimeridian. Wrapping the + // two edges into [0,1) independently would leave west > east and seed the + // complement of the view — everywhere the viewer is not looking. + final cam = WindCamera(centerLat: 0, centerLng: 179, zoom: 4, bearing: 0); + final sim = WindParticleSim( + _field(lon0: 180), + count: 200, + random: math.Random(11), + ); + sim.step(cam, _size); + final vp = viewportBounds(cam, _size); + for (final p in sim.particles) { + var lng = 180 + p.x * 360; + lng -= 360 * ((lng - vp.westLng) / 360).floorToDouble(); + expect(lng, lessThanOrEqualTo(vp.eastLng + 1e-9)); + } + }); + + test('on-screen pace stays put as the map zooms in', () { + // A field-space step covers twice the pixels for every zoom level in, so + // holding it fixed made particles 23× too fast at z7 — the "flying + // everywhere" that a fixed step always ends in. + double pixelsPerFrame(double zoom) { + final sim = WindParticleSim(_eastField(), random: math.Random(5)); + final cam = WindCamera( + centerLat: 25, + centerLng: 121, + zoom: zoom, + bearing: 0, + ); + sim.step(cam, _size); // seeds + Offset at(WindParticle p) => + projectLatLng(cam, 90 + p.y * -180, p.x * 360, _size); + final before = [for (final p in sim.particles) at(p)]; + sim.step(cam, _size); + // Median again: the handful recycled this frame moved by a respawn, + // not by the wind. + final moved = [ + for (var i = 0; i < sim.particles.length; i++) + (at(sim.particles[i]) - before[i]).distance, + ]..sort(); + return moved[moved.length ~/ 2]; + } + + final slow = pixelsPerFrame(3); + for (final zoom in [4.0, 5.0, 6.0, 7.0]) { + expect( + pixelsPerFrame(zoom), + closeTo(slow, slow * 0.6), + reason: 'z$zoom should read at much the same pace as z3', + ); + } + // Past the tuned stops the web holds its values flat rather than + // extrapolating — "outside the stops there is no judgement behind the + // number, only arithmetic" — so the pace does climb again out there. The + // wind layer caps the map at z7, so nobody sees it; asserted only so the + // clamp is not mistaken for a bug next time someone measures z10. + expect(pixelsPerFrame(10), greaterThan(slow * 4)); + }); + }); +} diff --git a/test/features/map/wind_web_parity_test.dart b/test/features/map/wind_web_parity_test.dart new file mode 100644 index 000000000..89bbc7a60 --- /dev/null +++ b/test/features/map/wind_web_parity_test.dart @@ -0,0 +1,102 @@ +import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Every number here was computed by running `effective()` from +/// `satellite-tiles-go/web/index.html` over its own `DEFAULTS` and `TUNE`. +/// +/// The two renderers only agree because one copies the other, and values tuned +/// by dragging a slider cannot be re-derived from anything. So the agreement is +/// pinned rather than trusted: if the web's tuning moves, these fail and say +/// which way to move with it. +void main() { + group('particle count (TUNE: zoom log, rounded to a square)', () { + test('matches the web at every stop', () { + expect(particleCountFor(3), 6400); + expect(particleCountFor(4), 4096); + expect(particleCountFor(5), 2601); + expect(particleCountFor(6), 1600); + expect(particleCountFor(7), 1024); + }); + + test('is held flat outside the stops, not extrapolated', () { + expect(particleCountFor(1), 6400); + expect(particleCountFor(12), 1024); + }); + + test('is always a perfect square', () { + for (var z = 3.0; z <= 7.0; z += 0.25) { + final n = particleCountFor(z); + final edge = _isqrt(n); + expect(edge * edge, n, reason: 'z$z gave $n'); + } + }); + }); + + test('point size matches the web (TUNE: zoom lin)', () { + expect(pointSizeFor(3), closeTo(1.5, 1e-9)); + expect(pointSizeFor(4), closeTo(1.575, 1e-9)); + expect(pointSizeFor(5), closeTo(1.65, 1e-9)); + expect(pointSizeFor(6), closeTo(1.725, 1e-9)); + expect(pointSizeFor(7), closeTo(1.8, 1e-9)); + }); + + test('field step matches the web (TUNE: zoom log)', () { + // Relative, because the reference values were printed to eight figures. + void expectStep(double zoom, double speedFactor) { + final want = 0.0001 * speedFactor; + expect(fieldStepFor(zoom), closeTo(want, want * 1e-7)); + } + + expectStep(3, 0.2); + expectStep(4, 0.10483752); + expectStep(5, 0.05495453); + expectStep(6, 0.02880648); + expectStep(7, 0.0151); + }); + + group('fade opacity (TUNE: zoom lin)', () { + test('matches the web at every stop', () { + expect(fadeOpacityFor(3), closeTo(0.95, 1e-9)); + expect(fadeOpacityFor(5), closeTo(0.9475, 1e-9)); + expect(fadeOpacityFor(7), closeTo(0.945, 1e-9)); + }); + + test('never reaches 1, at any zoom', () { + // A fade that does not fade accumulates every frame and the screen + // saturates to white — the web says so in TUNE and means it. + for (var z = 0.0; z <= 14.0; z += 0.5) { + expect(fadeOpacityFor(z), lessThan(1.0)); + expect(fadeOpacityFor(z), greaterThan(0.5)); + } + }); + }); + + group('density weighting', () { + test('matches the web ramp', () { + expect(densityWeight(0), closeTo(0.5, 1e-9)); + expect(densityWeight(4), closeTo(1.060619, 1e-6)); + expect(densityWeight(9.6), closeTo(3.0, 1e-9)); + expect(densityWeight(16), closeTo(5.129630, 1e-6)); + expect(densityWeight(19.2), closeTo(5.5, 1e-9)); + }); + + test('saturates rather than running away past the ramp', () { + expect(densityWeight(32), closeTo(5.5, 1e-9)); + expect(densityWeight(200), closeTo(5.5, 1e-9)); + }); + + test('favours strong wind by eleven to one', () { + // The direction matters and it is the opposite of what it once was: the + // Dart port still had the older ratio, six to one the other way. + expect(densityWeight(19.2) / densityWeight(0), closeTo(11, 1e-9)); + }); + }); +} + +int _isqrt(int n) { + var r = 0; + while ((r + 1) * (r + 1) <= n) { + r++; + } + return r; +} diff --git a/test/features/settings/presentation/widgets/network_usage_chart_test.dart b/test/features/settings/presentation/widgets/network_usage_chart_test.dart new file mode 100644 index 000000000..51192c48a --- /dev/null +++ b/test/features/settings/presentation/widgets/network_usage_chart_test.dart @@ -0,0 +1,132 @@ +import 'package:dpip/core/network/network_usage_store.dart'; +import 'package:dpip/features/settings/presentation/widgets/network_usage_chart.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + + HourUsage usage(int hour, {int down = 0, int saved = 0}) => HourUsage( + hour: hour, + down: down, + saved: saved, + hits: down > 0 ? 0 : 1, + misses: down > 0 ? 1 : 0, + ); + + /// The chart built for [usage] bars with real traffic. + Future pumpChart( + WidgetTester tester, { + int hours = 24, + int weekPoints = 28, + }) async { + await tester.pumpWidget( + wrap( + NetworkUsageChart( + history: [ + for (var h = 0; h < hours; h++) usage(h, down: 2048, saved: 1024), + ], + week: [ + for (var i = 0; i < weekPoints; i++) + usage(i * 6, down: 2048, saved: 1024), + ], + ), + ), + ); + return tester.widget(find.byType(BarChart)).data; + } + + /// Taps a segment inside the [SegmentedButton] of type [T] by label. + Future tapSegment(WidgetTester tester, String label) async { + await tester.tap( + find.descendant( + of: find.byType(SegmentedButton), + matching: find.text(label), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('empty history shows a placeholder, no chart', (tester) async { + await tester.pumpWidget( + wrap( + NetworkUsageChart( + history: [for (var h = 0; h < 24; h++) usage(h)], + week: [for (var i = 0; i < 28; i++) usage(i * 6)], + ), + ), + ); + + expect(find.byType(BarChart), findsNothing); + expect(find.textContaining('No traffic recorded yet'), findsOneWidget); + }); + + testWidgets('defaults to the 24h window with hourly points', (tester) async { + final data = await pumpChart(tester); + + expect(data.barGroups, hasLength(24)); + final stacks = data.barGroups.first.barRods.first.rodStackItems; + expect(stacks, hasLength(2)); + expect(stacks[0].color, const Color(0xFFE53935)); + expect(stacks[1].color, const Color(0xFF66BB6A)); + // Taipei labels: epoch hour 0 → 1970-01-01 08:00 +08 → 1日8時. + expect(find.text('1日8時'), findsOneWidget); + // Labels are spaced out, not one per bar: 24 h at every 6 → 4 labels. + expect(find.textContaining('日'), findsNWidgets(4)); + // The tooltip clamps inside the chart so an edge bar never spills off the + // phone screen. + final tooltip = data.barTouchData.touchTooltipData; + expect(tooltip.fitInsideHorizontally, isTrue); + expect(tooltip.fitInsideVertically, isTrue); + expect(tooltip.maxContentWidth, 200); + }); + + testWidgets('switching to 7d shows 28 six-hour buckets', (tester) async { + await pumpChart(tester); + await tapSegment(tester, '7d'); + + final data = tester.widget(find.byType(BarChart)).data; + expect(data.barGroups, hasLength(28)); + expect(data.barGroups.first.barRods.first.width, 8); + // 28 buckets at every 7 → 4 labels. + expect(find.textContaining('日'), findsNWidgets(4)); + }); + + testWidgets('switching to Download plots only the red series', ( + tester, + ) async { + await pumpChart(tester); + await tapSegment(tester, 'Download'); + + final stacks = tester + .widget(find.byType(BarChart)) + .data + .barGroups + .first + .barRods + .first + .rodStackItems; + expect(stacks, hasLength(1)); + expect(stacks.single.color, const Color(0xFFE53935)); + // The green legend dot row is gone; only the switch segment still says it. + expect(find.text('Saved'), findsOneWidget); + }); + + testWidgets('switching to Saved plots only the green series', (tester) async { + await pumpChart(tester); + await tapSegment(tester, 'Saved'); + + final stacks = tester + .widget(find.byType(BarChart)) + .data + .barGroups + .first + .barRods + .first + .rodStackItems; + expect(stacks, hasLength(1)); + expect(stacks.single.color, const Color(0xFF66BB6A)); + expect(find.text('Downloaded'), findsNothing); + }); +} diff --git a/test/features/weather/domain/rain_hour_trend_test.dart b/test/features/weather/domain/rain_hour_trend_test.dart new file mode 100644 index 000000000..8053a0f81 --- /dev/null +++ b/test/features/weather/domain/rain_hour_trend_test.dart @@ -0,0 +1,170 @@ +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A full 60-sample rain array (the wire contract), with the first few entries +/// set to [leading] so a decode can be inspected. +List _rain60(List leading) { + final rain = List.filled(60, 0); + for (var i = 0; i < leading.length; i++) { + rain[i] = leading[i]; + } + return rain; +} + +/// A trend whose first [n] minutes carry [values] then the rest of the hour is +/// dry — controls both the peak and where the rain stops. +RainHourTrend _trend(List values) => RainHourTrend( + startSecond: 0, + mm: [...values, ...List.filled(60 - values.length, 0)], +); + +void main() { + test('decode reads the first non-empty rainforecast series', () { + final trend = RainHourTrend.decode({ + 'rainfallWarnings-rkai': [ + { + 'start': 1786362600, + 'rain': _rain60(const [6, 5, 4]), + }, + ], + 'rainfallWarnings-other': [ + {'start': 1, 'rain': []}, + ], + }); + + expect(trend.startSecond, 1786362600); + expect(trend.mm, hasLength(60)); + expect(trend.mm.sublist(0, 3), [6.0, 5.0, 4.0]); + expect(trend.mm.sublist(3), everyElement(0.0)); + }); + + test('decode returns a dry trend for an empty response', () { + final trend = RainHourTrend.decode(const {}); + + expect(trend.isDry, isTrue); + expect(trend.mm, hasLength(60)); + expect(trend.mm, everyElement(0.0)); + }); + + test('decode returns a dry trend for an all-empty series (the API "[]")', () { + final trend = RainHourTrend.decode({'rainfallWarnings-rkai': []}); + + expect(trend.isDry, isTrue); + }); + + test('dry factory is minute-aligned and reads as no rain', () { + final trend = RainHourTrend.dry( + startUtc: DateTime.utc(2026, 8, 11, 2, 34, 59), + ); + + expect(trend.startSecond % 60, 0); + expect(trend.isDry, isTrue); + expect(trend.summary.grade, RainHourTrendGrade.none); + }); + + test('decode skips empty series before the real one', () { + final trend = RainHourTrend.decode({ + 'empty': [], + 'real': [ + { + 'start': 10, + 'rain': _rain60(const [1]), + }, + ], + }); + + expect(trend.startSecond, 10); + }); + + test('decode rejects a non-numeric start', () { + expect( + () => RainHourTrend.decode({ + 's': [ + { + 'start': 'now', + 'rain': _rain60(const [1]), + }, + ], + }), + throwsFormatException, + ); + }); + + test('decode rejects a wrong sample count', () { + expect( + () => RainHourTrend.decode(const { + 's': [ + { + 'start': 10, + 'rain': [1, 2], + }, + ], + }), + throwsFormatException, + ); + }); + + test('decode rejects a malformed series entry', () { + expect( + () => RainHourTrend.decode(const { + 's': [42], + }), + throwsFormatException, + ); + }); + + group('summary', () { + test('all-dry hour is none', () { + final s = _trend(List.filled(60, 0)).summary; + expect(s.grade, RainHourTrendGrade.none); + expect(s.sustained, isFalse); + expect(s.stopInMinutes, isNull); + }); + + test('peak below 5 mm is scattered', () { + final s = _trend([0, 4.9, 0, 0, 0, 2.5]).summary; + expect(s.grade, RainHourTrendGrade.scattered); + }); + + test('peak exactly 5 mm is light', () { + final s = _trend([5]).summary; + expect(s.grade, RainHourTrendGrade.light); + }); + + test('light rain that stops mid-hour reports the stop minute', () { + // Rain through minute 20, dry after — stop is the minute after the last + // wet sample. + final s = _trend([for (var i = 0; i < 21; i++) 6.0]).summary; + expect(s.grade, RainHourTrendGrade.light); + expect(s.sustained, isFalse); + expect(s.stopInMinutes, 21); + }); + + test('light rain still falling late in the hour is sustained', () { + final values = List.filled(51, 6.0); // wet through minute 50 + final s = _trend(values).summary; + expect(s.grade, RainHourTrendGrade.light); + expect(s.sustained, isTrue); + expect(s.stopInMinutes, isNull); + }); + + test('peak at the light threshold (15 mm) is heavy', () { + final s = _trend([15]).summary; + expect(s.grade, RainHourTrendGrade.heavy); + }); + + test('heavy rain stopping mid-hour reports the stop minute', () { + final s = _trend([for (var i = 0; i < 10; i++) 40.0]).summary; + expect(s.grade, RainHourTrendGrade.heavy); + expect(s.sustained, isFalse); + expect(s.stopInMinutes, 10); + }); + + test('heavy rain continuing through the hour is sustained', () { + final s = _trend(List.filled(60, 40.0)).summary; + expect(s.grade, RainHourTrendGrade.heavy); + expect(s.sustained, isTrue); + expect(s.stopInMinutes, isNull); + }); + }); +} diff --git a/test/features/weather/frame_tile_api_test.dart b/test/features/weather/frame_tile_api_test.dart new file mode 100644 index 000000000..8b26fdb96 --- /dev/null +++ b/test/features/weather/frame_tile_api_test.dart @@ -0,0 +1,142 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/region_selection.dart'; +import 'package:dpip/core/settings/prefs.dart'; +import 'package:dpip/features/weather/data/frame_tile_api.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// A Dio adapter that records the URLs it is asked to fetch and answers a +/// fixed delta-encoded list, so the list endpoint a channel resolves to is +/// observable without a network. +class _RecordingAdapter implements HttpClientAdapter { + final List urls = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + urls.add(options.uri.toString()); + return ResponseBody.fromString( + '[1783360200, 600]', + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +void main() { + group('FrameTileApi.framesFromList', () { + test('decodes delta-seconds and returns them newest first', () { + // [baseSec, +600, +600] → 1783360200, 1783360800, 1783361400 (10-min steps) + expect(FrameTileApi.framesFromList([1783360200, 600, 600]), [ + '1783361400', + '1783360800', + '1783360200', + ]); + }); + + test('decodes delta-millis (QPESUMS) the same way', () { + // [baseMs, +600000, +600000] → 1786208400000, 1786209000000, + // 1786209600000 (10-min steps) + expect(FrameTileApi.framesFromList([1786208400000, 600000, 600000]), [ + '1786209600000', + '1786209000000', + '1786208400000', + ]); + }); + + test('an empty list yields no frames', () { + expect(FrameTileApi.framesFromList(const []), isEmpty); + }); + + test('a single-frame list is just the base timestamp', () { + expect(FrameTileApi.framesFromList([1783360200]), ['1783360200']); + }); + + test('frame ids keep their digit width, usable straight in a tile URL', () { + final seconds = FrameTileApi.framesFromList([1783360200, 600]); + expect(seconds.first.length, 10); + final millis = FrameTileApi.framesFromList([1786208400000, 600000]); + expect(millis.first.length, 13); + }); + }); + + group('FrameTileApi channel-scoped list', () { + late RegionSelection regions; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + regions = RegionSelection(Prefs(await SharedPreferences.getInstance())); + }); + + test('each satellite channel hits its own list endpoint', () async { + final adapter = _RecordingAdapter(); + final client = ApiClient(Dio()..httpClientAdapter = adapter, regions); + final b13 = FrameTileApi(client, 'satellite', channel: '13'); + final b14 = FrameTileApi(client, 'satellite', channel: '14'); + + await b13.getFrames(); + await b14.getFrames(); + + expect(adapter.urls, hasLength(2)); + expect(adapter.urls[0], isNot(adapter.urls[1])); + expect(adapter.urls[0], contains('list?channel=13')); + expect(adapter.urls[1], contains('list?channel=14')); + }); + + test('channels with no query stay distinct from named channels', () async { + final adapter = _RecordingAdapter(); + final client = ApiClient(Dio()..httpClientAdapter = adapter, regions); + final b13 = FrameTileApi(client, 'satellite', channel: '13'); + final product = FrameTileApi(client, 'satellite', channel: 'btd_wvirw'); + + await b13.getFrames(); + await product.getFrames(); + + expect(adapter.urls, hasLength(2)); + expect(adapter.urls[0], isNot(adapter.urls[1])); + expect(adapter.urls[0], contains('channel=13')); + expect(adapter.urls[1], contains('channel=btd_wvirw')); + }); + + test('each wind model hits its own list endpoint', () async { + final adapter = _RecordingAdapter(); + final client = ApiClient(Dio()..httpClientAdapter = adapter, regions); + final gfs = FrameTileApi(client, 'wind', model: 'gfs'); + final ecmwf = FrameTileApi(client, 'wind', model: 'ecmwf'); + + await gfs.getFrames(); + await ecmwf.getFrames(); + + expect(adapter.urls, hasLength(2)); + expect(adapter.urls[0], isNot(adapter.urls[1])); + expect(adapter.urls[0], contains('list?model=gfs')); + expect(adapter.urls[1], contains('list?model=ecmwf')); + // The tile URL template carries the model too, so MapLibre and the + // warmer fetch the same field the list named. + expect(gfs.tileUrl('1783360200'), contains('model=gfs')); + expect(ecmwf.tileUrl('1783360200'), contains('model=ecmwf')); + }); + + test('fetchWindBin targets the v1 binary endpoint per model', () async { + final adapter = _RecordingAdapter(); + final client = ApiClient(Dio()..httpClientAdapter = adapter, regions); + final gfs = FrameTileApi(client, 'wind', model: 'gfs'); + + await gfs.fetchWindBin('1783360200'); + + expect(adapter.urls, hasLength(1)); + expect(adapter.urls.single, contains('/api/v1/wind/gfs/1783360200.bin')); + }); + }); +} diff --git a/test/features/weather/radar_api_test.dart b/test/features/weather/radar_api_test.dart deleted file mode 100644 index 8839e142f..000000000 --- a/test/features/weather/radar_api_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:dpip/features/weather/data/radar_api.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('RadarApi.framesFromList', () { - test('decodes delta-seconds and returns them newest first', () { - // [baseSec, +600, +600] → 1783360200, 1783360800, 1783361400 (10-min steps) - expect(RadarApi.framesFromList([1783360200, 600, 600]), [ - '1783361400', - '1783360800', - '1783360200', - ]); - }); - - test('an empty list yields no frames', () { - expect(RadarApi.framesFromList(const []), isEmpty); - }); - - test('a single-frame list is just the base second', () { - expect(RadarApi.framesFromList([1783360200]), ['1783360200']); - }); - - test('frame ids are 10-digit seconds, usable straight in a tile URL', () { - final frames = RadarApi.framesFromList([1783360200, 600]); - expect(frames.first.length, 10); - }); - }); -} diff --git a/test/features/weather/satellite_api_test.dart b/test/features/weather/satellite_api_test.dart deleted file mode 100644 index 49327f00c..000000000 --- a/test/features/weather/satellite_api_test.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:dpip/features/weather/data/satellite_api.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('SatelliteApi.framesFromList', () { - test('decodes delta-seconds and returns them newest first', () { - expect(SatelliteApi.framesFromList([1783360200, 600, 600]), [ - '1783361400', - '1783360800', - '1783360200', - ]); - }); - - test('an empty list yields no frames', () { - expect(SatelliteApi.framesFromList(const []), isEmpty); - }); - - test('a single-frame list is just the base second', () { - expect(SatelliteApi.framesFromList([1783360200]), ['1783360200']); - }); - - test('frame ids are 10-digit seconds, usable straight in a tile URL', () { - final frames = SatelliteApi.framesFromList([1783360200, 600]); - expect(frames.first.length, 10); - }); - }); -} diff --git a/test/features/weather/wind_field_test.dart b/test/features/weather/wind_field_test.dart new file mode 100644 index 000000000..c2a72e564 --- /dev/null +++ b/test/features/weather/wind_field_test.dart @@ -0,0 +1,98 @@ +import 'dart:typed_data'; + +import 'package:dpip/features/weather/domain/wind_field.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Builds a minimal 2×2 WND1 payload covering the whole globe (0..360° lon, +/// 90..-90 lat) with known quantised planes. +Uint8List _wnd1({ + String magic = 'WND1', + int version = 1, + String model = 'gfs', +}) { + final name = Uint8List.fromList(model.codeUnits); + final data = ByteData(67 + name.length + 4 * 2); + data.setUint8(0, 0x57); + data.setUint8(1, 0x4e); + data.setUint8(2, 0x44); + data.setUint8(3, 0x31); + if (magic != 'WND1') data.setUint8(0, 0x58); + data.setUint16(4, version, Endian.little); + data.setUint16(6, 2, Endian.little); // width + data.setUint16(8, 2, Endian.little); // height + data.setFloat64(10, 90, Endian.little); // lat0 + data.setFloat64(18, 0, Endian.little); // lon0 + data.setFloat64(26, -90, Endian.little); // dLat + data.setFloat64(34, 180, Endian.little); // dLon + data.setFloat32(42, -1, Endian.little); // uMin + data.setFloat32(46, 1, Endian.little); // uMax + data.setFloat32(50, -1, Endian.little); // vMin + data.setFloat32(54, 1, Endian.little); // vMax + data.setUint64(58, 1700000000000, Endian.little); // timeMs + data.setUint8(66, name.length); + data.buffer.asUint8List().setRange(67, 67 + name.length, name); + final u = data.buffer.asUint8List(); + final offset = 67 + name.length; + // u plane: [0, 255, 0, 255]; v plane: [255, 0, 255, 0] + for (final (i, value) in [(0, 0), (1, 255), (2, 0), (3, 255)]) { + u[offset + i] = value; + u[offset + 4 + i] = 255 - value; + } + return Uint8List.fromList(data.buffer.asUint8List()); +} + +void main() { + test('parses every header field and keeps the planes as views', () { + final field = WindField.fromWnd1(_wnd1()); + + expect(field.width, 2); + expect(field.height, 2); + expect(field.lat0, 90); + expect(field.lon0, 0); + expect(field.dLat, -90); + expect(field.dLon, 180); + expect(field.uMin, -1); + expect(field.uMax, 1); + expect(field.vMin, -1); + expect(field.vMax, 1); + expect(field.timeMs, 1700000000000); + expect(field.model, 'gfs'); + expect(field.u.length, 4); + expect(field.v.length, 4); + }); + + test('dequantises the planes onto the min..max range', () { + final field = WindField.fromWnd1(_wnd1()); + // u plane [0, 255, 0, 255] maps onto -1..1; v is the complement. + expect(field.uAt(0), -1); + expect(field.uAt(1), 1); + expect(field.vAt(0), 1); + expect(field.vAt(1), -1); + }); + + test('a variable-length model name shifts the plane offset', () { + final field = WindField.fromWnd1(_wnd1(model: 'ecmwf')); + expect(field.model, 'ecmwf'); + expect(field.uAt(0), -1); + expect(field.uAt(1), 1); + }); + + test('rejects a non-WND1 magic', () { + expect( + () => WindField.fromWnd1(_wnd1(magic: 'XND1')), + throwsFormatException, + ); + }); + + test('rejects an unsupported version', () { + expect(() => WindField.fromWnd1(_wnd1(version: 2)), throwsFormatException); + }); + + test('rejects a truncated payload', () { + final bytes = _wnd1(); + expect( + () => WindField.fromWnd1(Uint8List.sublistView(bytes, 0, 60)), + throwsFormatException, + ); + }); +} diff --git a/test/shaders/card_water_preview_test.dart b/test/shaders/card_water_preview_test.dart new file mode 100644 index 000000000..a8ad70e79 --- /dev/null +++ b/test/shaders/card_water_preview_test.dart @@ -0,0 +1,254 @@ +@Tags(['preview']) +library; + +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:dpip/features/home/presentation/widgets/weather_sky/card_water_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Renders the card-edge water over a synthetic card + sky so the pipeline can +/// be *looked at* and *measured* — `flutter test --run-skipped --tags preview`, +/// then open `build/card_water_preview/`. +/// +/// The measurement is the point. The reference's own edge, sampled over 338 frames of a +/// 弱い雨 capture, wets **4.5 % of the card's top-edge columns** in about two +/// discrete clusters — droplets caught on the lip, not a continuous band. This +/// harness reports the same statistic so the port can be compared against that +/// number instead of against an impression. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // A phone, in logical pixels: everything the engine sizes is a fraction of + // the screen, so measuring at a real device size is the only way the numbers + // mean anything. + const screen = Size(402, 874); + const width = 360.0; + const cardHeight = 100.0; + const threshold = 0.6; + + // The reference: 5 px drops on a tall screen, in the 800-tall buffer's pixels. + final pointSize = 5.0 * screen.height / CardWaterField.bufferHeight; + // The reference: a flat 1/355 **UV** step on both axes, which is anisotropic + // in pixels because the buffer is not square. + const sigmaUv = 0.46253 / 355; + final sigmaX = sigmaUv * screen.width; + final sigmaY = sigmaUv * screen.height; + + Future<({double coverage, int clusters, int live})> render({ + required String name, + required double seconds, + required CardWaterPreset preset, + required CardWaterPreset secondaryPreset, + required Color sky, + required Color card, + required Color ambient, + required double specular, + }) async { + final program = await ui.FragmentProgram.fromAsset( + 'shaders/weather/card_water.frag', + ); + final shader = program.fragmentShader(); + final sprites = buildParticleSprites(); + final a = CardWaterField(spritePos: sprites.$1, spriteNeg: sprites.$2); + final b = CardWaterField( + spritePos: sprites.$1, + spriteNeg: sprites.$2, + seed: 23, + ); + for (var t = 0.0; t < seconds; t += 1 / 60) { + a.update( + 1 / 60, + width: width, + screenHeight: screen.height, + intensity: 1, + preset: preset, + ); + b.update( + 1 / 60, + width: width, + screenHeight: screen.height, + intensity: 1, + preset: secondaryPreset, + ); + } + + final headroom = CardWaterField.spawnHeadroom(screen.height); + final fieldSize = Size(width, cardHeight + headroom); + + ui.Image accumulate({required bool negative}) { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder) + ..saveLayer( + Offset.zero & fieldSize, + Paint() + ..imageFilter = ui.ImageFilter.blur(sigmaX: sigmaX, sigmaY: sigmaY), + ) + ..translate(0, headroom); + a.paintCoverage(canvas, dropSize: pointSize, negative: negative); + b.paintCoverage(canvas, dropSize: pointSize, negative: negative); + canvas.restore(); + return recorder.endRecording().toImageSync( + fieldSize.width.ceil(), + fieldSize.height.ceil(), + ); + } + + final pos = accumulate(negative: false); + final neg = accumulate(negative: true); + + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(fieldSize.width); + set(fieldSize.height); + set(ambient.r); + set(ambient.g); + set(ambient.b); + set(specular); + set(threshold * CardWaterField.accumulationScale); + set(1.0); + set(14.0); + shader.setImageSampler(0, pos); + shader.setImageSampler(1, neg); + + // Compose the scene: sky, card, then the water overlay — the exact + // layering RainOnCard produces. + final scene = ui.PictureRecorder(); + final canvas = Canvas(scene); + canvas.scale(3); // inspect at 3x + canvas.drawRect( + Rect.fromLTWH(0, 0, width, fieldSize.height), + Paint()..color = sky, + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, headroom, width, cardHeight), + const Radius.circular(12), + ), + Paint()..color = card, + ); + canvas.drawRect( + Rect.fromLTWH(0, 0, fieldSize.width, fieldSize.height), + Paint()..shader = shader, + ); + final image = scene.endRecording().toImageSync( + (width * 3).ceil(), + (fieldSize.height * 3).ceil(), + ); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + final dir = Directory('build/card_water_preview') + ..createSync(recursive: true); + File('${dir.path}/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List()); + + // Measure the edge the way the reference capture was measured: over a band a + // few drops tall straddling the card's top lip, a column counts as wet if + // any pixel in it rises clearly above the dry card. + final raw = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final px = raw!.buffer.asUint8List(); + final w = (width * 3).ceil(); + final bandTop = ((headroom - 3 * pointSize) * 3).round(); + final bandBottom = ((headroom + 3 * pointSize) * 3).round(); + final dry = _luma(card); + var wet = 0; + var clusters = 0; + var run = false; + for (var x = 0; x < w; x++) { + var isWet = false; + for (var y = bandTop; y < bandBottom; y++) { + final o = (y * w + x) * 4; + if ((_lumaOf(px[o], px[o + 1], px[o + 2]) - dry).abs() > 20) { + isWet = true; + break; + } + } + if (isWet) { + wet++; + if (!run) clusters++; + run = true; + } else { + run = false; + } + } + image.dispose(); + pos.dispose(); + neg.dispose(); + return ( + coverage: wet / w, + clusters: clusters, + live: a.liveCount + b.liveCount, + ); + } + + test('render card water previews', () async { + // Day rain: grey-blue sky, light glass card. + for (final scene + in < + ({ + String name, + double seconds, + CardWaterPreset preset, + CardWaterPreset secondary, + Color sky, + Color card, + Color ambient, + double specular, + }) + >[ + ( + name: 'day_light_2s', + seconds: 2.0, + preset: CardWaterPreset.light, + secondary: CardWaterPreset.light, + sky: const Color(0xFF8FA3B8), + // The reference's card is a 20 % pane of the sky, so the water is lit by + // nearly the same colour it lands on — which is most of why its + // edge reads as a faint disturbance rather than a bright band. + card: const Color(0x338FA3B8), + ambient: const Color(0xFF5C6B7E), + specular: 1.0, + ), + ( + name: 'day_heavy_2s', + seconds: 2.0, + preset: CardWaterPreset.heavy, + secondary: CardWaterPreset.heavySecondary, + sky: const Color(0xFF77808C), + card: const Color(0x3377808C), + ambient: const Color(0xFF525E6D), + specular: 1.0, + ), + ( + name: 'night_light_2s', + seconds: 2.0, + preset: CardWaterPreset.light, + secondary: CardWaterPreset.light, + sky: const Color(0xFF10151E), + card: const Color(0x3310151E), + ambient: const Color(0xFF161C26), + specular: 1.2, + ), + ]) { + final m = await render( + name: scene.name, + seconds: scene.seconds, + preset: scene.preset, + secondaryPreset: scene.secondary, + sky: scene.sky, + card: scene.card, + ambient: scene.ambient, + specular: scene.specular, + ); + // ignore: avoid_print + print( + '${scene.name}: edge coverage ${(m.coverage * 100).toStringAsFixed(2)}%' + ' (the reference 4.5%), clusters ${m.clusters} (the reference ~2), live ${m.live}', + ); + } + }); +} + +double _luma(Color c) => + _lumaOf((c.r * 255).round(), (c.g * 255).round(), (c.b * 255).round()); + +double _lumaOf(int r, int g, int b) => 0.299 * r + 0.587 * g + 0.114 * b; diff --git a/test/shaders/preview_render_test.dart b/test/shaders/preview_render_test.dart new file mode 100644 index 000000000..ea5f3cd8a --- /dev/null +++ b/test/shaders/preview_render_test.dart @@ -0,0 +1,683 @@ +@Tags(['preview']) +library; + +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'dart:typed_data'; + +import 'package:dpip/features/home/presentation/widgets/weather_sky/precipitation_field.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_clouds.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Renders reference-driven sky to PNGs for eyeballing. +/// +/// Development aid, not an assertion — tagged `preview` and skipped by +/// default. Run with: +/// +/// ``` +/// mise exec -- flutter test --run-skipped --tags preview \ +/// test/shaders/preview_render_test.dart +/// ``` +void main() { + // rootBundle needs the binding for the cloud sprite assets. + TestWidgetsFlutterBinding.ensureInitialized(); + + test('render sky across the keyframe ring', () async { + final dir = Directory('build/sky_preview'); + if (!dir.existsSync()) dir.createSync(recursive: true); + + final transProgram = await ui.FragmentProgram.fromAsset( + 'shaders/sky/transmittance.frag', + ); + final lutProgram = await ui.FragmentProgram.fromAsset( + 'shaders/sky/sky_lut.frag', + ); + final viewProgram = await ui.FragmentProgram.fromAsset( + 'shaders/sky/sky_view.frag', + ); + + const size = Size(360, 620); + + final cases = + < + ({ + String name, + List frames, + double pos, + CloudLayout layout, + double coverage, + double rain, + double snow, + }) + >[ + ( + name: 'sunny_00_night', + frames: sunnyKeyframes, + pos: 0, + layout: CloudLayout.fair, + coverage: 0.4, + rain: 0.0, + snow: 0.0, + ), + ( + name: 'sunny_04_dawn', + frames: sunnyKeyframes, + pos: 4, + layout: CloudLayout.scattered, + coverage: 0.7, + rain: 0.0, + snow: 0.0, + ), + ( + name: 'sunny_06', + frames: sunnyKeyframes, + pos: 6, + layout: CloudLayout.fair, + coverage: 1.0, + rain: 0.0, + snow: 0.0, + ), + ( + name: 'sunny_08_noon', + frames: sunnyKeyframes, + pos: 8, + layout: CloudLayout.scattered, + coverage: 0.7, + rain: 0.0, + snow: 0.0, + ), + ( + name: 'sunny_10', + frames: sunnyKeyframes, + pos: 10, + layout: CloudLayout.fair, + coverage: 1.0, + rain: 0.0, + snow: 0.0, + ), + ( + name: 'sunny_12_dusk', + frames: sunnyKeyframes, + pos: 12, + layout: CloudLayout.scattered, + coverage: 1.0, + rain: 0.0, + snow: 0.0, + ), + ( + name: 'sunny_14', + frames: sunnyKeyframes, + pos: 14, + layout: CloudLayout.fair, + coverage: 0.6, + rain: 0.0, + snow: 0.0, + ), + ( + name: 'cloudy_08', + frames: cloudyKeyframes, + pos: 8, + layout: CloudLayout.scattered, + coverage: 1.0, + rain: 0.0, + snow: 0.0, + ), + ( + name: 'overcast_01', + frames: overcastKeyframes, + pos: 1, + layout: CloudLayout.overcast, + coverage: 1.0, + rain: 0.0, + snow: 0.0, + ), + ( + name: 'rainy_heavy_01', + frames: rainyHeavyKeyframes, + pos: 1, + layout: CloudLayout.rain, + coverage: 1.0, + rain: 0.85, + snow: 0.0, + ), + ( + name: 'snow_day', + frames: snowyMediumKeyframes, + pos: 8, + layout: CloudLayout.overcast, + coverage: 1.0, + rain: 0.0, + snow: 0.7, + ), + ]; + + Future loadPng(String path) async { + final data = await rootBundle.load(path); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + return (await codec.getNextFrame()).image; + } + + final starMap = await loadPng('assets/weather/sky/starmap.webp'); + final galaxyProg = await ui.FragmentProgram.fromAsset( + 'shaders/weather/galaxy.frag', + ); + final sunTex = [ + await loadPng('assets/weather/sky/sun_profile.webp'), + await loadPng('assets/weather/sky/sun_rays.webp'), + await loadPng('assets/weather/sky/annulus.webp'), + ]; + final sunProg = await ui.FragmentProgram.fromAsset( + 'shaders/weather/sun_flare.frag', + ); + final rainAtlas = await loadPng('assets/weather/particles/rain_drop.webp'); + final snowAtlas = await loadPng('assets/weather/particles/snow_flake.webp'); + final cloudProgram = await ui.FragmentProgram.fromAsset( + 'shaders/cloud/clouds.frag', + ); + final sprites = []; + for (var i = 0; i < 12; i++) { + final data = await rootBundle.load( + 'assets/weather/clouds/${i.toString().padLeft(2, '0')}.webp', + ); + sprites.add( + (await (await ui.instantiateImageCodec( + data.buffer.asUint8List(), + )).getNextFrame()).image, + ); + } + + for (final c in cases) { + final sky = resolveSky(c.frames, position: c.pos, humidity: 0.6); + + final trans = _bakeTransmittance(transProgram, sky); + final lut = _bakeSkyLut(lutProgram, sky, trans); + final image = await _renderScene( + viewProgram, + cloudProgram, + sprites, + sky, + lut, + size, + c.layout, + c.coverage, + rainAtlas, + c.rain, + snowAtlas, + c.snow, + sunProg, + sunTex, + c.pos, + galaxyProg, + starMap, + ); + + final png = await image.toByteData(format: ui.ImageByteFormat.png); + File( + '${dir.path}/${c.name}.png', + ).writeAsBytesSync(png!.buffer.asUint8List()); + + final raw = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final px = raw!.buffer.asUint8List(); + ({int r, int g, int b}) at(int y) { + final i = (y * size.width.toInt() + size.width ~/ 2) * 4; + return (r: px[i], g: px[i + 1], b: px[i + 2]); + } + + // ignore: avoid_print + print( + '${c.name.padRight(16)} sunAngleY=${sky.sunAngleY.toStringAsFixed(3)} ' + 'I=${sky.sunIntensity.toStringAsFixed(1).padLeft(5)} ' + 'top=${at(10)} mid=${at(300)} low=${at(600)}', + ); + + trans.dispose(); + lut.dispose(); + image.dispose(); + // ignore: unnecessary_statements + Uint8List; + } + + // ignore: avoid_print + print('wrote ${cases.length} frames to ${dir.absolute.path}'); + }); +} + +/// The reference's frustum construction: fixed 20° vertical FOV, 0.1 near +/// plane, camera at y = 0.017, pitched by the keyframe's `cameraYaw`. +({List a, List c}) _frustum(double cameraYawDeg) { + const nearZ = 0.1; + const eyeY = 0.017; + final fTan = nearZ * math.tan(10.0 * math.pi / 180.0); + final p = cameraYawDeg * math.pi / 180.0; + final cp = math.cos(p); + final sp = math.sin(p); + return ( + a: [nearZ * cp - fTan * sp, eyeY + nearZ * sp + fTan * cp, 0.0], + c: [nearZ * cp + fTan * sp, eyeY + nearZ * sp - fTan * cp, 0.0], + ); +} + +ui.Image _bakeTransmittance(ui.FragmentProgram program, ResolvedSky sky) { + final s = program.fragmentShader(); + var i = 0; + void f(double v) => s.setFloat(i++, v); + f(SkyConstants.transmittanceWidth.toDouble()); + f(SkyConstants.transmittanceHeight.toDouble()); + f(SkyConstants.rayleighScatter.$1); + f(SkyConstants.rayleighScatter.$2); + f(SkyConstants.rayleighScatter.$3); + f(SkyConstants.ozoneAbsorb.$1); + f(SkyConstants.ozoneAbsorb.$2); + f(SkyConstants.ozoneAbsorb.$3); + f(sky.rayleighHeight); + f(sky.mieScatter); + f(sky.mieAbsorb); + f(sky.mieHeight); + f(SkyConstants.ozoneCentre); + f(sky.ozoneThickness); + f(SkyConstants.planetRadius); + f(SkyConstants.atmosphereRadius); + return _rasterise( + s, + SkyConstants.transmittanceWidth, + SkyConstants.transmittanceHeight, + ); +} + +ui.Image _bakeSkyLut( + ui.FragmentProgram program, + ResolvedSky sky, + ui.Image trans, +) { + final s = program.fragmentShader(); + var i = 0; + void f(double v) => s.setFloat(i++, v); + f(SkyConstants.skyLutWidth.toDouble()); + f(SkyConstants.skyLutHeight.toDouble()); + f(SkyConstants.rayleighScatter.$1); + f(SkyConstants.rayleighScatter.$2); + f(SkyConstants.rayleighScatter.$3); + f(SkyConstants.ozoneAbsorb.$1); + f(SkyConstants.ozoneAbsorb.$2); + f(SkyConstants.ozoneAbsorb.$3); + f(sky.sunIntensity); + f(sky.sunIntensity); + f(sky.sunIntensity); + f(sky.postColor.$1); + f(sky.postColor.$2); + f(sky.postColor.$3); + f(sky.postColor.$4); + f(sky.postColor.$5); + f(sky.rayleighHeight); + f(sky.mieScatter); + f(sky.mieAbsorb); + f(sky.mieHeight); + f(sky.mieAsymmetry); + f(SkyConstants.ozoneCentre); + f(sky.ozoneThickness); + f(SkyConstants.planetRadius); + f(SkyConstants.atmosphereRadius); + f(SkyConstants.eyeAltitude); + f(SkyConstants.transmittanceWidth.toDouble()); + f(SkyConstants.transmittanceHeight.toDouble()); + s.setImageSampler(0, trans); + return _rasterise(s, SkyConstants.skyLutWidth, SkyConstants.skyLutHeight); +} + +Future _renderScene( + ui.FragmentProgram viewProgram, + ui.FragmentProgram cloudProgram, + List sprites, + ResolvedSky sky, + ui.Image lut, + Size size, + CloudLayout layout, + double coverage, + ui.Image rainAtlas, + double rain, + ui.Image snowAtlas, + double snow, + ui.FragmentProgram sunProgram, + List sunTextures, + double keyframePos, + ui.FragmentProgram galaxyProgram, + ui.Image starMapImage, +) async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + + canvas.drawRect( + Offset.zero & size, + Paint()..shader = _skyShader(viewProgram, sky, lut, size), + ); + + // Milky Way first, under everything else. + final night = (1.0 - ((sky.sunAngleY - 0.02) / 0.42).clamp(0.0, 1.0) / 0.25) + .clamp(0.0, 1.0); + if (night > 0.01) { + final sh = galaxyProgram.fragmentShader(); + var k = 0; + void f(double v) => sh.setFloat(k++, v); + f(size.width); + f(size.height); + f(0.38); + f(0.64); + f(0.16); + f(0.27); + f(20.0); + f(0.9); + f(night * (1.0 - 0.95 * coverage)); + sh.setImageSampler(0, starMapImage); + canvas.drawRect( + Offset.zero & size, + Paint() + ..shader = sh + ..blendMode = BlendMode.plus, + ); + } + + // The sun rides its arc with the keyframe's height. + final day = ((sky.sunAngleY - 0.02) / 0.42).clamp(0.0, 1.0); + if (day > 0.02) { + final sh = sunProgram.fragmentShader(); + var k = 0; + void f(double v) => sh.setFloat(k++, v); + // Quarter-resolution buffer, as the reference does — the softness comes from the + // 4x upscale afterwards. + final q = Size( + (size.width / 4).ceilToDouble(), + (size.height / 4).ceilToDouble(), + ); + f(q.width); + f(q.height); + final theta = ((keyframePos - 6.0) / 4.0).clamp(-0.5, 1.5); + final ang = (theta - 0.5) * math.pi / 2.0; + f(0.5 + math.sin(ang) * 0.5); + f(0.5 - (0.5 - (1.0 - math.cos(ang)) * 0.15 + (-0.06 + 0.08 * day))); + f(1.0); + f(0.80); + f(0.45); + f(9.0); + f(day); + f(1.0 - 0.55 * coverage); + f(0.45 * (1.0 - 0.7 * coverage)); + f(0.13 * (1.0 - coverage)); + f(1.03 * (1.0 - 0.8 * coverage)); + f(-0.5); + f(3.0); + f(0.8 * (1.0 - 0.5 * coverage)); + sh.setImageSampler(0, sunTextures[0]); + sh.setImageSampler(1, sunTextures[1]); + sh.setImageSampler(2, sunTextures[2]); + final rec = ui.PictureRecorder(); + ui.Canvas(rec).drawRect(Offset.zero & q, Paint()..shader = sh); + final small = rec.endRecording().toImageSync( + q.width.toInt(), + q.height.toInt(), + ); + canvas.drawImageRect( + small, + Offset.zero & q, + Offset.zero & size, + Paint() + ..filterQuality = FilterQuality.low + ..blendMode = BlendMode.plus, + ); + } + + final lighting = cloudLighting(sunAngleY: sky.sunAngleY); + final placed = placeClouds( + layout, + width: size.width, + height: size.height, + time: 40, + coverage: coverage, + wind: 0.2, + ); + + // The base/haze probes are pixel-independent, so — as in production — their + // colours are read from a CPU LUT readback rather than fetched per pixel. + final lutBytes = (await lut.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + final lutU = sky.sunAngleY.clamp(0.0, 1.0); + final baseSky = _sampleLut( + lutBytes, + lutU, + _skyAtV(0.05 + (lighting.base.$1 - 0.05) * 0.85), + ); + final hazeSky = _sampleLut(lutBytes, lutU, _skyAtV(0.95)); + + for (final p in placed) { + final sprite = sprites[p.sprite % sprites.length]; + final shader = cloudProgram.fragmentShader(); + var i = 0; + void f(double v) => shader.setFloat(i++, v); + f(p.width); + f(p.height); + // Sun direction: swings with the sun's height across the sky. + final el = (sky.sunAngleY / 0.57).clamp(0.0, 1.0); + f(-0.55 + 1.1 * el); + f(0.25 + 0.70 * el); + f(0.45); + f(0.0); + f(-1.0); + f(0.2); // ground bounce, from below + f(0.0); + f(1.0); + f(0.1); // ambient, from above + f(lighting.base.$1); + f(lighting.base.$2); + f(lighting.base.$3); + f(lighting.sun.$1); + f(lighting.sun.$2); + f(lighting.sun.$3); + f(lighting.ground.$1); + f(lighting.ground.$2); + f(lighting.ground.$3); + f(lighting.ambient.$1); + f(lighting.ambient.$2); + f(lighting.ambient.$3); + f(p.opacity); + f(0.05); // start edge + f(0.23); // end edge + f(1.0); // progress — fully revealed + f(0.35); // smooth + f(0.85); // cloud depth + f(el); // sun intensity + f(0.0); // fog + f(0.0); // inner + f(lighting.whitePer); + // The LUT sampler is the 1×N CPU-extracted column; `.y` is its height. + f(SkyConstants.skyLutHeight.toDouble()); + f(1.0); + f(sprite.width.toDouble()); + f(sprite.height.toDouble()); + f(baseSky.r / 255.0); + f(baseSky.g / 255.0); + f(baseSky.b / 255.0); + f(hazeSky.r / 255.0); + f(hazeSky.g / 255.0); + f(hazeSky.b / 255.0); + shader.setImageSampler(0, sprite); + shader.setImageSampler(1, lut); + + canvas.save(); + canvas.translate(p.left, p.top); + canvas.drawRect( + Rect.fromLTWH(0, 0, p.width, p.height), + Paint()..shader = shader, + ); + canvas.restore(); + } + + if (rain > 0.002) { + final field = PrecipitationField( + atlas: rainAtlas, + capacity: 1792, + variants: 4, + cell: const Size(32, 68), + seed: 7, + ); + late ({ + Float32List transforms, + Float32List rects, + Int32List colors, + int count, + }) + batch; + // Step a few frames so the field is in its steady state, not its seed. + for (var i = 0; i < 40; i++) { + // Mirrors `WeatherSkyPainter._paintRain` — keep the two in step or the + // preview stops previewing anything. + const lightRung = 0.321; + final tier = ((rain - lightRung) / (1 - lightRung)).clamp(0.0, 1.0); + batch = field.step( + size: size, + dt: 1 / 60, + intensity: rain, + fallSpeed: 2.25 + 0.35 * tier, + wind: 0.0, + opacity: 0.30, + tint: const Color(0xFFF8F8F8), + sizeMin: 0.018, + sizeMax: 0.036 + 0.024 * tier, + life: 3.0, + ); + } + canvas.drawRawAtlas( + rainAtlas, + batch.transforms, + batch.rects, + batch.colors, + BlendMode.modulate, + null, + Paint()..blendMode = BlendMode.plus, + ); + } + if (snow > 0.002) { + final field = PrecipitationField( + atlas: snowAtlas, + capacity: 900, + cell: const Size(40, 40), + tumble: true, + seed: 11, + ); + late ({ + Float32List transforms, + Float32List rects, + Int32List colors, + int count, + }) + batch; + for (var i = 0; i < 8; i++) { + batch = field.step( + size: size, + dt: 1 / 60, + intensity: snow, + fallSpeed: 0.10 + 0.10 * snow, + wind: 0.2, + opacity: 0.7 + 0.3 * snow, + tint: const Color(0xFFF2F4F8), + sizeMin: 0.004, + sizeMax: 0.016, + life: 3.0, + ); + } + canvas.drawRawAtlas( + snowAtlas, + batch.transforms, + batch.rects, + batch.colors, + BlendMode.modulate, + null, + Paint(), + ); + } + + return recorder.endRecording().toImageSync( + size.width.toInt(), + size.height.toInt(), + ); +} + +ui.FragmentShader _skyShader( + ui.FragmentProgram program, + ResolvedSky sky, + ui.Image lut, + Size size, +) { + final fr = _frustum(sky.cameraYaw); + final s = program.fragmentShader(); + var i = 0; + void f(double v) => s.setFloat(i++, v); + f(size.width); + f(size.height); + f(fr.a[0]); + f(fr.a[1]); + f(fr.a[2]); + f(fr.c[0]); + f(fr.c[1]); + f(fr.c[2]); + f(sky.sunAngleY); + f(4.0); // time + f(SkyConstants.skyLutWidth.toDouble()); + f(SkyConstants.skyLutHeight.toDouble()); + s.setImageSampler(0, lut); + return s; +} + +/// The cloud shader's `skyAt` picker→LUT-v mapping — mirrors the painter's +/// helper (`WeatherSkyPainter._skyAtV`) so the preview bakes the same probes. +double _skyAtV(double picker) => + (math.sqrt(picker.clamp(0.0, 1.0)) + 0.1015625) / 1.1015625; + +/// Bilinear LUT read on the CPU, in the shaders' texel-centre convention — +/// `uv · size − 0.5` with edge clamping. Mirrors `SkyLutCache.skyAt`. +({int r, int g, int b}) _sampleLut(Uint8List bytes, double u, double v) { + const width = SkyConstants.skyLutWidth; + const height = SkyConstants.skyLutHeight; + int px(int i) => i.clamp(0, width - 1); + int py(int j) => j.clamp(0, height - 1); + ({int r, int g, int b}) texel(int x, int y) { + final o = (y * width + x) * 4; + return (r: bytes[o], g: bytes[o + 1], b: bytes[o + 2]); + } + + final sx = u.clamp(0.0, 1.0) * width - 0.5; + final x0 = sx.floor(); + final fx = sx - x0; + final sy = v.clamp(0.0, 1.0) * height - 0.5; + final y0 = sy.floor(); + final fy = sy - y0; + final c00 = texel(px(x0), py(y0)); + final c10 = texel(px(x0 + 1), py(y0)); + final c01 = texel(px(x0), py(y0 + 1)); + final c11 = texel(px(x0 + 1), py(y0 + 1)); + final topR = c00.r + (c10.r - c00.r) * fx; + final topG = c00.g + (c10.g - c00.g) * fx; + final topB = c00.b + (c10.b - c00.b) * fx; + final botR = c01.r + (c11.r - c01.r) * fx; + final botG = c01.g + (c11.g - c01.g) * fx; + final botB = c01.b + (c11.b - c01.b) * fx; + return ( + r: (topR + (botR - topR) * fy).round(), + g: (topG + (botG - topG) * fy).round(), + b: (topB + (botB - topB) * fy).round(), + ); +} + +ui.Image _rasterise(ui.FragmentShader shader, int width, int height) { + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..shader = shader, + ); + return recorder.endRecording().toImageSync(width, height); +} diff --git a/test/shaders/rain_on_glass_test.dart b/test/shaders/rain_on_glass_test.dart new file mode 100644 index 000000000..efc0b7b2c --- /dev/null +++ b/test/shaders/rain_on_glass_test.dart @@ -0,0 +1,98 @@ +@Tags(['preview']) +library; + +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('rain-on-glass refracts its input', () async { + const w = 360, h = 300; + // Stand-in "card": text-like bars on a panel, so refraction is visible. + final rec0 = ui.PictureRecorder(); + final c0 = ui.Canvas(rec0); + c0.drawRect( + const Rect.fromLTWH(0, 0, 360, 300), + Paint()..color = const Color(0xFF2A3550), + ); + c0.drawRRect( + RRect.fromRectAndRadius( + const Rect.fromLTWH(20, 20, 320, 260), + const Radius.circular(18), + ), + Paint()..color = const Color(0xFF19233A), + ); + for (var i = 0; i < 7; i++) { + c0.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(44, 48.0 + i * 32, 200 - (i % 3) * 40, 12), + const Radius.circular(6), + ), + Paint()..color = const Color(0xFFBFD0E8), + ); + c0.drawCircle( + Offset(290, 54.0 + i * 32), + 10, + Paint()..color = const Color(0xFF62D0C0), + ); + } + final content = rec0.endRecording().toImageSync(w, h); + + final prog = await ui.FragmentProgram.fromAsset( + 'shaders/weather/rain_on_glass.frag', + ); + Future render(double refraction, double time) async { + final sh = prog.fragmentShader(); + var i = 0; + void f(double v) => sh.setFloat(i++, v); + f(w.toDouble()); + f(h.toDouble()); // engine normally sets this + f(time); + f(1.0); + f(1.0); + f(1.0); // static size/amount/speed + f(1.0); + f(1.0); + f(1.0); // running size/amount/speed + f(refraction); + sh.setImageSampler(0, content); + final r = ui.PictureRecorder(); + ui.Canvas( + r, + ).drawRect(const Rect.fromLTWH(0, 0, 360, 300), Paint()..shader = sh); + return r.endRecording().toImageSync(w, h); + } + + final off = await render(0.0, 6.0); + final on = await render(1.0, 6.0); + + Future> px(ui.Image im) async => (await im.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + final a = await px(off), b = await px(on); + var diff = 0; + for (var k = 0; k < a.length; k += 4) { + if ((a[k] - b[k]).abs() > 6) diff++; + } + // ignore: avoid_print + print('pixels displaced by refraction: $diff / ${a.length ~/ 4}'); + + final rec = ui.PictureRecorder(); + final cv = ui.Canvas(rec); + cv.drawImage(off, Offset.zero, Paint()); + cv.drawImage(on, const Offset(370, 0), Paint()); + final out = rec.endRecording().toImageSync(730, 300); + final png = await out.toByteData(format: ui.ImageByteFormat.png); + Directory('build/sky_preview').createSync(recursive: true); + File( + 'build/sky_preview/_rain_glass.png', + ).writeAsBytesSync(png!.buffer.asUint8List()); + expect( + diff, + greaterThan(500), + reason: 'refraction should displace content', + ); + }); +} diff --git a/test/shaders/sky_gradient_parity_test.dart b/test/shaders/sky_gradient_parity_test.dart new file mode 100644 index 000000000..ae55a1553 --- /dev/null +++ b/test/shaders/sky_gradient_parity_test.dart @@ -0,0 +1,97 @@ +import 'dart:ui' as ui; + +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart'; +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The CPU-baked sky gradient (`SkyLutCache._bakeSkyGradient`) must reproduce +/// what the `sky_view.frag` screen pass drew, within the shader's own +/// sub-texel dither. +/// +/// The gradient samples 1024 rows with the shader's exact frustum → elevation +/// → LUT-v mapping. The reference is rasterised at 4×2048 so its odd rows +/// align with the gradient's rows; every channel is compared against the +/// reference with the dither's worst-case half-row shift allowed for. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('baked sky gradient matches the sky_view reference pass', () async { + final trans = await ui.FragmentProgram.fromAsset( + 'shaders/sky/transmittance.frag', + ); + final lut = await ui.FragmentProgram.fromAsset('shaders/sky/sky_lut.frag'); + final view = await ui.FragmentProgram.fromAsset( + 'shaders/sky/sky_view.frag', + ); + + final cache = SkyLutCache(trans.fragmentShader(), lut.fragmentShader()); + final sky = resolveSky(sunnyKeyframes, position: 8, humidity: 0.6); + expect(cache.update(sky), isTrue); + + // The readback is async — poll for the baked gradient. + final stopwatch = Stopwatch()..start(); + ui.Image? gradient; + while (stopwatch.elapsed < const Duration(seconds: 2)) { + gradient = cache.skyGradient; + if (gradient != null) break; + await Future.delayed(const Duration(milliseconds: 5)); + } + expect(gradient, isNotNull, reason: 'LUT readback never completed'); + + const width = 4; + const rows = 1024; + final fr = buildFrustum(sky.cameraYaw); + final s = view.fragmentShader(); + var i = 0; + void set(double v) => s.setFloat(i++, v); + set(width.toDouble()); + set((rows * 2).toDouble()); + set(fr.top[0]); + set(fr.top[1]); + set(fr.top[2]); + set(fr.bottom[0]); + set(fr.bottom[1]); + set(fr.bottom[2]); + set(sky.sunAngleY); + set(4.0); // time — a fixed dither scroll + set(SkyConstants.skyLutWidth.toDouble()); + set(SkyConstants.skyLutHeight.toDouble()); + s.setImageSampler(0, cache.skyView!); + + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), (rows * 2).toDouble()), + Paint()..shader = s, + ); + final reference = recorder.endRecording().toImageSync(width, rows * 2); + + final gradBytes = (await gradient!.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + final refBytes = (await reference.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + + var maxDiff = 0; + for (var r = 0; r < rows; r++) { + for (var c = 0; c < width; c++) { + final go = (r * width + c) * 4; + final ro = ((r * 2 + 1) * width + c) * 4; + for (var ch = 0; ch < 3; ch++) { + final d = (gradBytes[go + ch] - refBytes[ro + ch]).abs(); + if (d > maxDiff) maxDiff = d; + } + } + } + + // Worst case the dither shifts lutV by ±0.26 of a LUT row (sub-texel by + // design); allow that plus a small margin for the piecewise-linear ramp. + expect( + maxDiff, + lessThanOrEqualTo(3), + reason: 'gradient diverged from the sky_view pass (maxDiff=$maxDiff)', + ); + }); +} diff --git a/test/shaders/weather_sky_shader_test.dart b/test/shaders/weather_sky_shader_test.dart new file mode 100644 index 000000000..3b14f0fb4 --- /dev/null +++ b/test/shaders/weather_sky_shader_test.dart @@ -0,0 +1,230 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Compile-and-paint check for every layer shader in `shaders/`. +/// +/// `ui.FragmentProgram.fromAsset` is the only true syntax check for these +/// files — `flutter analyze` never looks at them — so each one is loaded and +/// painted with a full set of uniforms mirroring its slot contract (see +/// `shaders/README.md`). A shader that fails to compile, or whose declared +/// uniform count no longer matches the painter's, fails here. +/// +/// Layers that take the sky LUT as a sampler are fed a stand-in image, so +/// this also pins the sampler bindings. +void main() { + late ui.Image lut; + + setUpAll(() { + lut = _solidImage(const Color(0xFF6E9BD1), 16, 16); + }); + + final cases = <({String asset, List floats, int samplers})>[ + ( + asset: 'shaders/sky/transmittance.frag', + floats: [ + 64, 64, // uResolution + 5.802, 13.558, 33.1, // uRayleighScatter + 0.650, 1.881, 0.085, // uOzoneAbsorb + 0.008, // uRayleighHeight + 3.996, 4.400, 0.0012, // uMie{Scatter,Absorb,Height} + 0.025, 0.015, // uOzone{Centre,Thickness} + 6.360, 6.460, // radii + ], + samplers: 0, + ), + ( + asset: 'shaders/sky/sky_lut.frag', + floats: [ + 256, 141, // uResolution + 5.802e-6, 1.3558e-5, 3.31e-5, // uRayleighScatter (1/m) + 6.5e-7, 1.881e-6, 8.5e-8, // uOzoneAbsorb (1/m) + 12, 12, 12, // uSunRadiance + 1, 0, 0, 0, 1, // uPostA..E + 3000, // uRayleighHeight (m) + 5e-6, 0, 3000, 0.05, // uMie{Scatter,Absorb,Height,Asymmetry} + 25000, 8000, // uOzone{Centre,Thickness} (m) + 6360000, 6460000, // radii (m) + 739.98, // uEyeAltitude + 256, 256, // uTransSize + ], + samplers: 1, + ), + ( + asset: 'shaders/sky/sky_view.frag', + floats: [ + 200, 300, // uResolution + 0.0985, 0.0416, 0.0, // uFrustumA + 0.1010, 0.0064, 0.0, // uFrustumC + 0.5, // uSunAngleY + 7.0, // uTime + 16, 16, // uLutSize + ], + samplers: 1, + ), + ( + asset: 'shaders/cloud/clouds.frag', + floats: [ + 256, 288, // iSpriteSize + 0.3, 0.7, 0.45, // iLightDir0 (sun) + 0.0, -1.0, 0.2, // iLightDir1 (ground bounce) + 0.0, 1.0, 0.1, // iLightDir2 (ambient) + 0.10, 0.90, 1.0, // iBaseCfg + 0.55, 1.20, 1.0, // iSunCfg + 0.02, 0.70, 0.5, // iGroundCfg + 0.90, 0.80, 0.6, // iAmbientCfg + 1.0, // iOpacity + 0.05, 0.23, // edges + 1.0, 0.35, 0.85, // progress, smooth, cloudDepth + 0.8, // iSunIntensity + 0.0, 0.0, // fog, inner + 0.1, // iWhitePer + 16, 16, // iLutSize — the sampler is the 1×N sky column; `.y` is height + 256, 288, // iTexSize + 0.44, 0.50, 0.56, // iBaseSky — CPU-baked base probe + 0.46, 0.52, 0.58, // iHazeSky — CPU-baked haze probe + ], + samplers: 2, + ), + ( + asset: 'shaders/weather/sun_flare.frag', + floats: [ + 200, 300, // iResolution + 0.7, 0.35, // iSunPos + 1.0, 0.80, 0.45, // iRayColor + 7.0, // iTime + 0.9, // iOpacity + 1.0, // iDiscAlpha + 0.6, // iRayAlpha + 0.13, // iAnnulusAlpha + 1.03, // iGhostAlpha + -0.5, // iGhostOffset + 3.0, // iSeed + 0.8, // iGlowAlpha + ], + samplers: 3, + ), + ( + asset: 'shaders/weather/galaxy.frag', + floats: [ + 200, 300, // iResolution + 0.38, 0.64, 0.16, // iRotation (alpha/theta/phi, in turns) + 0.27, // iFov + 20.0, // iStaticTime + 0.9, // iOpacity + 1.0, // iAlpha + ], + samplers: 1, + ), + ( + asset: 'shaders/weather/night.frag', + floats: [ + 200, 300, // iResolution + 0.62, 0.68, 0.92, // iGalaxyTint + 7.0, // iTime + 1.0, // iAlpha + 0.0, // iScroll + 0.1, // iCloudCover + 0.0, // iGalaxy + ], + samplers: 0, + ), + ( + asset: 'shaders/weather/lightning.frag', + floats: [ + 200, 300, // iResolution + 0.70, 0.80, 1.00, // iTopCol + 0.25, 0.32, 0.55, // iBottomCol + 1.0, 1.0, 1.0, // iBoltCol + 0.2, // iTime + 0.8, // iFlash + 1.0, // iBoltAlpha + 3.0, // iSeed + 0.16, // iExpandPath + 0.16, // iBranchShow + 0.50, // iBranchFade + 0.42, // iSubFade + ], + samplers: 0, + ), + ( + asset: 'shaders/weather/rainbow.frag', + floats: [ + 200, 300, // iResolution + 0.2, -0.4, 0.9, // iSunDir + 0.8, // iOpacity + 0.5, // iRadius + 0.5, // iGap + 0.85, // iBrightness0 + 0.22, // iBrightness1 + 4.0, // iVisRange + 1.05, 0.525, // iFov, iPitch + ], + samplers: 0, + ), + ( + asset: 'shaders/weather/rain_on_glass.frag', + floats: [ + 200, 160, // uSize (engine-set at runtime) + 1080, 1080, // uResolution — the reference's fixed frame + 3.0, // uTime + 1.0, 1.0, 2.0, // static size / amount / speed (the rain set, 4..9) + 1.4, 1.0, 1.3, // running size / amount / speed + 0.6, // uAlpha + ], + samplers: 1, + ), + ( + asset: 'shaders/weather/card_water.frag', + floats: [ + 200, 160, // iResolution + 0.36, 0.42, 0.49, // iAmbient — a rainy sky sample + 1.0, // iSpecular + 0.15, // iThreshold (0.6 × accum scale) + 1.0, // iAlpha + 14.0, // iHour + ], + samplers: 2, + ), + ]; + + for (final c in cases) { + test('${c.asset} compiles and paints', () async { + final program = await ui.FragmentProgram.fromAsset(c.asset); + final shader = program.fragmentShader(); + for (var i = 0; i < c.floats.length; i++) { + shader.setFloat(i, c.floats[i]); + } + for (var i = 0; i < c.samplers; i++) { + shader.setImageSampler(i, lut); + } + + final recorder = ui.PictureRecorder(); + ui.Canvas( + recorder, + ).drawRect(const Rect.fromLTWH(0, 0, 200, 300), Paint()..shader = shader); + final image = recorder.endRecording().toImageSync(200, 300); + expect(image.width, 200); + + // A layer that renders nothing at all usually means a uniform slot + // shifted; require at least one non-transparent pixel. + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final px = bytes!.buffer.asUint8List(); + var opaque = 0; + for (var i = 3; i < px.length; i += 4) { + if (px[i] > 0) opaque++; + } + expect(opaque, greaterThan(0), reason: '${c.asset} painted nothing'); + }); + } +} + +ui.Image _solidImage(Color color, int width, int height) { + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..color = color, + ); + return recorder.endRecording().toImageSync(width, height); +} diff --git a/test/shared/map/map_compass_test.dart b/test/shared/map/map_compass_test.dart new file mode 100644 index 000000000..1012c4cac --- /dev/null +++ b/test/shared/map/map_compass_test.dart @@ -0,0 +1,71 @@ +import 'dart:math' as math; + +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_compass.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget harness(double bearing, VoidCallback onPressed) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: MapCompass(bearing: ValueNotifier(bearing), onPressed: onPressed), + ), + ); + } + + testWidgets('hidden while the map faces north (bearing ≈ 0)', (tester) async { + await tester.pumpWidget(harness(0, () {})); + + expect( + find.byType(MapCompass), + findsOneWidget, + reason: 'the widget itself is always in the tree', + ); + expect( + find.byIcon(Icons.navigation), + findsNothing, + reason: 'a north-facing map must not float an idle needle', + ); + }); + + testWidgets('needle appears once the camera turns', (tester) async { + await tester.pumpWidget(harness(0, () {})); + expect(find.byIcon(Icons.navigation), findsNothing); + + // Rotate the camera — only the compass rebuilds. + await tester.pumpWidget(harness(45, () {})); + + expect(find.byIcon(Icons.navigation), findsOneWidget); + }); + + testWidgets('needle counter-rotates by the bearing', (tester) async { + await tester.pumpWidget(harness(90, () {})); + + final transform = tester.widget( + find.ancestor( + of: find.byIcon(Icons.navigation), + matching: find.byType(Transform), + ), + ); + // Bearing 90° (camera turned east) ⇒ north sits 90° counter-clockwise from + // the screen top, so the needle must rotate by -90° (pi / 2). + expect(transform.transform, isNot(Matrix4.identity())); + final angle = math.atan2( + transform.transform.entry(1, 0), + transform.transform.entry(0, 0), + ); + expect(angle, closeTo(-math.pi / 2, 1e-6)); + }); + + testWidgets('tap resets the camera', (tester) async { + var reset = 0; + await tester.pumpWidget(harness(120, () => reset++)); + + await tester.tap(find.byIcon(Icons.navigation)); + await tester.pump(); + + expect(reset, 1); + }); +} diff --git a/test/shared/map/map_layer_category_test.dart b/test/shared/map/map_layer_category_test.dart new file mode 100644 index 000000000..c9bc50b0c --- /dev/null +++ b/test/shared/map/map_layer_category_test.dart @@ -0,0 +1,62 @@ +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_layer_category.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('categoryOf', () { + test('seismic monitor is its own group', () { + expect(categoryOf('monitor'), MapLayerCategory.earthquake); + }); + + test('typhoon is its own group', () { + expect(categoryOf('typhoon'), MapLayerCategory.typhoon); + }); + + test('weather observations are the fields + lightning', () { + for (final id in [ + 'temperature', + 'humidity', + 'pressure', + 'wind', + 'rain', + 'lightning', + ]) { + expect(categoryOf(id), MapLayerCategory.weather, reason: id); + } + }); + + test('satellite imagery is its own group', () { + expect(categoryOf('satellite'), MapLayerCategory.satellite); + }); + + test('numerical weather prediction is its own group', () { + expect(categoryOf('qpesums'), MapLayerCategory.forecast); + expect(categoryOf('wind-gfs'), MapLayerCategory.forecast); + expect(categoryOf('wind-ecmwf'), MapLayerCategory.forecast); + }); + + test('radar is its own group', () { + expect(categoryOf('radar'), MapLayerCategory.radar); + }); + + test('the disaster-prevention map is the daily-life group', () { + expect(categoryOf('dpm'), MapLayerCategory.life); + }); + + test('an unknown id resolves deterministically instead of throwing', () { + // A layer added after the classification was written must not crash. + expect(categoryOf('next-weather-layer'), MapLayerCategory.weather); + }); + }); + + test('every category resolves a label from every locale', () { + for (final locale in AppLocalizations.supportedLocales) { + final l10n = lookupAppLocalizations(locale); + for (final category in MapLayerCategory.values) { + expect(categoryLabel(category, l10n), isNotEmpty, reason: '$locale'); + } + } + }); +} diff --git a/test/shared/map/map_layer_order_test.dart b/test/shared/map/map_layer_order_test.dart new file mode 100644 index 000000000..63a0a2616 --- /dev/null +++ b/test/shared/map/map_layer_order_test.dart @@ -0,0 +1,153 @@ +import 'package:dpip/shared/map/map_layer.dart'; +import 'package:dpip/shared/map/map_layer_category.dart'; +import 'package:dpip/shared/map/map_layer_order.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Minimal [MapLayer] for ordering tests — only [id] matters. +class _FakeLayer implements MapLayer { + _FakeLayer(this.id); + + @override + final String id; + + @override + String? subtitle(BuildContext context) => null; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + List ids(List layers) => [for (final l in layers) l.id]; + + final radar = _FakeLayer('radar'); + final rain = _FakeLayer('rain'); + final typhoon = _FakeLayer('typhoon'); + final layers = [radar, rain, typhoon]; + + group('orderedLayers', () { + test('an empty surface yields an empty list', () { + expect(orderedLayers(const [], const []), isEmpty); + }); + + test('an empty saved order keeps the declared order', () { + expect(ids(orderedLayers(layers, const [])), [ + 'radar', + 'rain', + 'typhoon', + ]); + }); + + test('a partial order moves those layers first, rest keep their order', () { + expect(ids(orderedLayers(layers, ['typhoon'])), [ + 'typhoon', + 'radar', + 'rain', + ]); + }); + + test('a full order is honoured exactly', () { + expect(ids(orderedLayers(layers, ['rain', 'typhoon', 'radar'])), [ + 'rain', + 'typhoon', + 'radar', + ]); + }); + + test('a layer added after the order was saved appends at the bottom', () { + // 'dpm' was introduced later and never placed in the saved order. + final grown = [...layers, _FakeLayer('dpm')]; + expect(ids(orderedLayers(grown, ['typhoon'])), [ + 'typhoon', + 'radar', + 'rain', + 'dpm', + ]); + }); + + test('a stale id is ignored without dropping any layer', () { + expect(ids(orderedLayers(layers, ['gone', 'rain', 'radar'])), [ + 'rain', + 'radar', + 'typhoon', + ]); + }); + + test('duplicate ids in the order count once', () { + expect(ids(orderedLayers(layers, ['rain', 'rain', 'typhoon', 'rain'])), [ + 'rain', + 'typhoon', + 'radar', + ]); + }); + }); + + group('orderedCategories', () { + List names(List categories) => [ + for (final c in categories) c.name, + ]; + + test('an empty order keeps the declared order', () { + expect(names(orderedCategories(MapLayerCategory.values, const [])), [ + for (final c in MapLayerCategory.values) c.name, + ]); + }); + + test( + 'a partial order moves those categories first, rest keep their order', + () { + expect( + names( + orderedCategories(MapLayerCategory.values, ['radar', 'typhoon']), + ), + [ + 'radar', + 'typhoon', + 'earthquake', + 'forecast', + 'weather', + 'satellite', + 'life', + ], + ); + }, + ); + + test('a stale name is ignored without dropping any category', () { + expect( + names(orderedCategories(MapLayerCategory.values, ['gone', 'life'])), + [ + 'life', + 'earthquake', + 'radar', + 'forecast', + 'typhoon', + 'weather', + 'satellite', + ], + ); + }); + + test('duplicate names count once', () { + expect( + names( + orderedCategories(MapLayerCategory.values, [ + 'typhoon', + 'typhoon', + 'radar', + ]), + ), + [ + 'typhoon', + 'radar', + 'earthquake', + 'forecast', + 'weather', + 'satellite', + 'life', + ], + ); + }); + }); +} diff --git a/test/shared/map/map_layer_switcher_test.dart b/test/shared/map/map_layer_switcher_test.dart new file mode 100644 index 000000000..882d873ca --- /dev/null +++ b/test/shared/map/map_layer_switcher_test.dart @@ -0,0 +1,362 @@ +import 'package:dpip/core/settings/map_layer_order_controller.dart'; +import 'package:dpip/core/settings/prefs.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_layer.dart'; +import 'package:dpip/shared/map/map_layer_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Minimal [MapLayer] for switcher tests — identity + label (+ optional +/// subtitle) only. +class _FakeLayer implements MapLayer { + _FakeLayer(this.id, this._label, [this._subtitle]); + + @override + final String id; + final String _label; + final String? _subtitle; + + @override + String label(BuildContext context) => _label; + + @override + String? subtitle(BuildContext context) => _subtitle; + + @override + IconData get icon => Icons.layers_outlined; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final radar = _FakeLayer('radar', 'Radar echo'); + final qpesums = _FakeLayer('qpesums', 'Precip'); + final ecmwf = _FakeLayer('wind-ecmwf', 'ECMWF', '0.25° · 3 h'); + final gfs = _FakeLayer('wind-gfs', 'GFS', '0.25° · 1 h'); + final satellite = _FakeLayer('satellite', 'Satellite IR', 'B13 · 10.4 µm'); + final rain = _FakeLayer('rain', 'Rain'); + final typhoon = _FakeLayer('typhoon', 'Typhoon path'); + final layers = [radar, qpesums, ecmwf, gfs, satellite, rain, typhoon]; + + Future pumpSwitcher( + WidgetTester tester, + Map initial, { + MapLayer? active, + }) async { + // Tall viewport so every category block is laid out inside the initial + // sheet height — a short screen would lazily skip the bottom group. + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + SharedPreferences.setMockInitialValues(initial); + final controller = MapLayerOrderController( + Prefs(await SharedPreferences.getInstance()), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: controller, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Center( + child: MapLayerSwitcher( + layers: layers, + active: active ?? radar, + onSelected: (_) {}, + ), + ), + ), + ), + ), + ); + return controller; + } + + double topOf(WidgetTester tester, String text) { + // The picker chip and a sheet tile can share a label; prefer the row inside + // the (reorderable) sheet's list. + final reorder = find.byType(ReorderableListView); + final tile = reorder.evaluate().isNotEmpty + ? find.descendant(of: reorder, matching: find.text(text)) + : find.descendant( + of: find.byType(ListView).last, + matching: find.text(text), + ); + return tester.getTopLeft(tile).dy; + } + + /// Horizontal centre of [finder] — for asserting a title is truly centred + /// over the sheet, regardless of asymmetric header actions. + double centreX(WidgetTester tester, Finder finder) => + tester.getCenter(finder).dx; + + double screenWidth(WidgetTester tester) => + tester.view.physicalSize.width / tester.view.devicePixelRatio; + + testWidgets('the picker groups layers by category, ordered within', ( + tester, + ) async { + await pumpSwitcher(tester, { + 'map.layerOrder': ['rain', 'typhoon', 'radar', 'qpesums', 'satellite'], + }); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of( + tester.element(find.byType(MapLayerSwitcher)), + ); + // Header title is centred even with the trailing reorder button. + expect( + centreX(tester, find.text(l10n.mapLayers)), + closeTo(screenWidth(tester) / 2, 1), + ); + // Group order: radar → forecast → typhoon → weather → satellite. The + // numerical-forecast group (QPESUMS, then the unsaved wind models appended + // in declared order) sits right under radar. + expect( + topOf(tester, l10n.mapLayerCategoryRadar), + lessThan(topOf(tester, l10n.mapLayerCategoryForecast)), + ); + expect( + topOf(tester, l10n.mapLayerCategoryForecast), + lessThan(topOf(tester, l10n.mapLayerCategoryTyphoon)), + ); + expect( + topOf(tester, l10n.mapLayerCategoryTyphoon), + lessThan(topOf(tester, l10n.mapLayerCategoryWeather)), + ); + expect( + topOf(tester, l10n.mapLayerCategoryWeather), + lessThan(topOf(tester, l10n.mapLayerCategorySatellite)), + ); + // Within the forecast group the saved precip keeps its place above the + // wind models, which follow the declared order (ECMWF before GFS). + expect(topOf(tester, 'Precip'), lessThan(topOf(tester, 'ECMWF'))); + expect(topOf(tester, 'ECMWF'), lessThan(topOf(tester, 'GFS'))); + // A layer's subtitle sits under its label in the same tile. + expect( + find.descendant( + of: find.byType(ListView), + matching: find.text('B13 · 10.4 µm'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(ListView), + matching: find.text('0.25° · 3 h'), + ), + findsOneWidget, + ); + }); + + testWidgets( + 'the picker puts a layer added after the saved order at the bottom of ' + 'its category', + (tester) async { + await pumpSwitcher(tester, { + 'map.layerOrder': ['typhoon'], + }); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + + // Unsaved layers keep the declared order within their groups: Radar + // group (radar echo) at the top, then forecast (precip, ECMWF, GFS) + // above weather (rain) and satellite (satellite IR). + expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Rain'))); + expect(topOf(tester, 'Rain'), lessThan(topOf(tester, 'Satellite IR'))); + expect(topOf(tester, 'Precip'), lessThan(topOf(tester, 'GFS'))); + }, + ); + + testWidgets('the reorder button opens the category editor', (tester) async { + await pumpSwitcher(tester, {}); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of( + tester.element(find.byType(MapLayerSwitcher)), + ); + // Editor title is centred over the reset + close actions. + expect( + centreX(tester, find.text(l10n.mapLayerOrderTitle)), + closeTo(screenWidth(tester) / 2, 1), + ); + // Level 1 is the category list: one drag handle per category, no layer + // rows, no chevron on a single-layer category (radar, typhoon). + expect(find.byIcon(Icons.drag_handle), findsNWidgets(5)); + Finder inEditor(String text) => find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(text), + ); + expect(inEditor(l10n.mapLayerCategoryRadar), findsOneWidget); + expect(inEditor(l10n.mapLayerCategoryForecast), findsOneWidget); + expect(inEditor(l10n.mapLayerCategoryTyphoon), findsOneWidget); + expect(inEditor(l10n.mapLayerCategoryWeather), findsOneWidget); + expect(inEditor(l10n.mapLayerCategorySatellite), findsOneWidget); + // The picker behind still shows the active layer's tile — the editor's + // level-1 list must not. Only forecast holds more than one layer, so just + // one category is drill-in-able. + expect(inEditor('Radar echo'), findsNothing); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('tapping a category opens its layer list and back returns', ( + tester, + ) async { + await pumpSwitcher(tester, {}); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of( + tester.element(find.byType(MapLayerSwitcher)), + ); + Finder editorText(String text) => find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(text), + ); + final forecastCategory = find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(l10n.mapLayerCategoryForecast), + ); + await tester.tap(forecastCategory); + await tester.pumpAndSettle(); + + // Level 2 shows just the forecast-group layers, each with a drag handle. + expect(editorText('Precip'), findsOneWidget); + expect(editorText('ECMWF'), findsOneWidget); + expect(editorText('GFS'), findsOneWidget); + expect(editorText('Rain'), findsNothing); + expect(find.byIcon(Icons.drag_handle), findsNWidgets(3)); + // The back affordance appears only on level 2. + expect(find.byIcon(Icons.arrow_back), findsOneWidget); + + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + expect(editorText('Precip'), findsNothing); + expect(editorText(l10n.mapLayerCategoryRadar), findsOneWidget); + }); + + testWidgets('dragging a category reorders the categories', (tester) async { + final controller = await pumpSwitcher(tester, {}); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + + // Satellite is the bottom category. Drag its handle far up to the top. + final handles = find.descendant( + of: find.byType(ReorderableListView), + matching: find.byIcon(Icons.drag_handle), + ); + await tester.drag(handles.last, const Offset(0, -600)); + await tester.pumpAndSettle(); + + // Category order moved satellite to the top; the layer snapshot follows + // the block order (satellite group, then the rest in declared order). + expect(controller.categoryOrder, [ + 'satellite', + 'radar', + 'forecast', + 'typhoon', + 'weather', + ]); + expect(controller.order, [ + 'satellite', + 'radar', + 'qpesums', + 'wind-ecmwf', + 'wind-gfs', + 'typhoon', + 'rain', + ]); + }); + + testWidgets('dragging a layer reorders within its category', (tester) async { + final controller = await pumpSwitcher(tester, {}); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of( + tester.element(find.byType(MapLayerSwitcher)), + ); + await tester.tap( + find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(l10n.mapLayerCategoryForecast), + ), + ); + await tester.pumpAndSettle(); + + // GFS sits below ECMWF. Drag its handle far up to flip them. + final handles = find.descendant( + of: find.byType(ReorderableListView), + matching: find.byIcon(Icons.drag_handle), + ); + await tester.drag(handles.last, const Offset(0, -300)); + await tester.pumpAndSettle(); + + // Only the layer ids changed — GFS now first in the forecast group; the + // category order is untouched (declared order, so the flattened snapshot + // leads with the radar block). + expect(controller.order, [ + 'radar', + 'wind-gfs', + 'qpesums', + 'wind-ecmwf', + 'typhoon', + 'rain', + 'satellite', + ]); + expect(controller.categoryOrder, isEmpty); + }); + + testWidgets('reset restores the grouped default and clears both preferences', ( + tester, + ) async { + // A saved order that flips the forecast pair and moves a category — a + // grouped-default order would disable the reset button (nothing to restore). + final controller = await pumpSwitcher(tester, { + 'map.layerOrder': ['qpesums', 'satellite', 'rain', 'typhoon', 'radar'], + 'map.layerCategoryOrder': ['radar', 'typhoon'], + }); + await tester.tap(find.text('Radar echo')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of( + tester.element(find.byType(MapLayerSwitcher)), + ); + await tester.tap(find.text(l10n.mapLayerOrderReset)); + await tester.pumpAndSettle(); + + // The category list falls back to the grouped default… + final inEditor = find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(l10n.mapLayerCategoryForecast), + ); + await tester.tap(inEditor); + await tester.pumpAndSettle(); + // …and precip is back before the wind models within the forecast group. + expect(topOf(tester, 'Precip'), lessThan(topOf(tester, 'ECMWF'))); + expect(topOf(tester, 'ECMWF'), lessThan(topOf(tester, 'GFS'))); + // Both preferences are cleared. + expect(controller.order, isEmpty); + expect(controller.categoryOrder, isEmpty); + }); +} diff --git a/test/shared/map/map_style_test.dart b/test/shared/map/map_style_test.dart new file mode 100644 index 000000000..f10e6b649 --- /dev/null +++ b/test/shared/map/map_style_test.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; + +import 'package:dpip/shared/map/map_style.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('the vector style parses and layers stack in order', () { + final style = + jsonDecode( + exptechVectorStyle( + MapColors.dark, + basemapTileUrl: 'https://example.com/{z}/{x}/{y}.pbf', + glyphsUrl: 'https://example.com/{fontstack}/{range}.pbf', + ), + ) + as Map; + + final layers = style['layers'] as List; + final ids = [for (final l in layers) (l as Map)['id']]; + expect(ids, [ + 'bg', + 'land', + 'county', + 'town', + 'town-outline', + 'county-outline', + 'town-label', + ]); + }); + + group('town-label layer', () { + Map townLabel() { + final style = + jsonDecode( + exptechVectorStyle( + MapColors.light, + basemapTileUrl: 'https://example.com/{z}/{x}/{y}.pbf', + glyphsUrl: 'https://example.com/{fontstack}/{range}.pbf', + ), + ) + as Map; + return (style['layers'] as List) + .cast>() + .firstWhere((l) => l['id'] == townLabelLayerId); + } + + test('reads the township TOWN property in Noto Sans TC', () { + final layout = townLabel()['layout'] as Map; + expect(layout['text-field'], ['get', 'TOWN']); + expect(layout['text-font'], ['Noto Sans TC Regular']); + expect(layout['text-allow-overlap'], false); + }); + + test('is gated: not placed before min zoom, faded in by the fade zoom', () { + final layer = townLabel(); + expect(layer['minzoom'], townLabelMinZoom); + final paint = layer['paint'] as Map; + final opacity = paint['text-opacity'] as List; + expect(opacity, contains(townLabelMinZoom)); + expect(opacity, contains(townLabelFadeZoom)); + }); + + test('label colours come from the palette, not hardcoded at the layer', () { + final paint = townLabel()['paint'] as Map; + expect(paint['text-color'], MapColors.light.label); + expect(paint['text-halo-color'], MapColors.light.labelHalo); + }); + }); +} diff --git a/test/shared/map/map_timeline_test.dart b/test/shared/map/map_timeline_test.dart index 1c15e2280..4a521c187 100644 --- a/test/shared/map/map_timeline_test.dart +++ b/test/shared/map/map_timeline_test.dart @@ -16,6 +16,8 @@ Widget _wrap({ required List frames, required int selectedIndex, required ValueChanged onSelected, + Duration? framePeriod, + DateTime? dataTime, }) => MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -28,13 +30,76 @@ Widget _wrap({ frames: frames, selectedIndex: selectedIndex, onSelected: onSelected, + framePeriod: framePeriod, + dataTime: dataTime, ), ), ), ), ); +/// A forecast's frames straddle the present: six hours of history and sixteen +/// ahead, hourly, so index 6 is now. Anchored on the real clock because the +/// widget asks the real clock. +List _forecastFrames() { + final now = DateTime.now(); + return [ + for (var h = -6; h <= 16; h++) + MapFrame( + id: '$h', + time: now.add(Duration(hours: h)), + ), + ]; +} + void main() { + testWidgets('a forecast labels the present, not its furthest step', ( + tester, + ) async { + final frames = _forecastFrames(); + await tester.pumpWidget( + _wrap(frames: frames, selectedIndex: 6, onSelected: (_) {}), + ); + await tester.pumpAndSettle(); + expect( + find.text('Now'), + findsOneWidget, + reason: 'the frame at the present moment is the one that is now', + ); + + // And the last step — sixteen hours out — must not claim to be now, which + // is what left the scrubber parked at the right-hand end with the whole + // forecast behind it and nothing ahead. + await tester.pumpWidget( + _wrap( + frames: frames, + selectedIndex: frames.length - 1, + onSelected: (_) {}, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Now'), findsNothing); + }); + + testWidgets('history and future frames carry their own era labels', ( + tester, + ) async { + final frames = _forecastFrames(); // 6 hours back, 16 ahead; index 6 is now + await tester.pumpWidget( + _wrap(frames: frames, selectedIndex: 2, onSelected: (_) {}), + ); + await tester.pumpAndSettle(); + expect(find.text('Past'), findsOneWidget); + + await tester.pumpWidget( + _wrap(frames: frames, selectedIndex: 12, onSelected: (_) {}), + ); + await tester.pumpAndSettle(); + expect(find.text('Future'), findsOneWidget); + expect(find.text('Past'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets( 'labels the newest selected frame as "now" without layout error', (tester) async { @@ -100,4 +165,72 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets( + 'a frame period renders the big label as a range, ticks stay start times', + (tester) async { + final frames = _frames(10); // 07:00 + 9×10 min → newest 08:30 + await tester.pumpWidget( + _wrap( + frames: frames, + selectedIndex: 9, + onSelected: (_) {}, + framePeriod: const Duration(hours: 1), + ), + ); + await tester.pumpAndSettle(); + + // The selected frame starts at 08:30 and covers the following hour. + expect(find.text('08:30 – 09:30'), findsOneWidget); + // Ticks keep their bare start times (every fourth slot is labelled). + expect(find.text('08:20'), findsOneWidget); + expect(find.text('08:30'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('a data time renders as a line under the caption', ( + tester, + ) async { + final frames = _frames(10); + await tester.pumpWidget( + _wrap( + frames: frames, + selectedIndex: 9, + onSelected: (_) {}, + dataTime: DateTime(2026, 7, 13, 6, 0), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Data 7/13 06:00'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'switching to another layer\u0027s frames re-centres on its newest frame', + (tester) async { + final first = _frames(10); + await tester.pumpWidget( + _wrap(frames: first, selectedIndex: 9, onSelected: (_) {}), + ); + await tester.pumpAndSettle(); + expect(find.text('08:30'), findsWidgets); // newest of the first set + + // The scaffold hands a brand-new list instance (the newly selected + // layer's frames) plus that layer's newest index. + final second = _frames(6); + await tester.pumpWidget( + _wrap(frames: second, selectedIndex: 5, onSelected: (_) {}), + ); + await tester.pumpAndSettle(); + + // The ruler re-labelled and re-centred — the old set is gone and the + // new newest frame sits under the scrubber as "now". + expect(find.text('Now'), findsOneWidget); + expect(find.text('07:50'), findsWidgets); // 07:00 + 5×10 min + expect(find.text('08:30'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); } diff --git a/test/shared/map/map_town_labels_test.dart b/test/shared/map/map_town_labels_test.dart new file mode 100644 index 000000000..c9ecb0f02 --- /dev/null +++ b/test/shared/map/map_town_labels_test.dart @@ -0,0 +1,83 @@ +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_town_labels.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Standalone township-label dropdown, as [MapScaffold] shows it for layers +/// with no settings menu of their own. +Widget _wrap(ValueNotifier labels, {ValueChanged? onChanged}) => + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: Align( + alignment: Alignment.topRight, + child: MapTownLabelsMenu( + showTownLabels: labels, + onShowTownLabelsChanged: onChanged ?? (_) {}, + ), + ), + ), + ); + +Future _l10n() => + AppLocalizations.delegate.load(const Locale('en')); + +void main() { + testWidgets('the chip is unmarked while the labels are on (the default)', ( + tester, + ) async { + final labels = ValueNotifier(true); + await tester.pumpWidget(_wrap(labels)); + + expect( + tester.widget(find.byType(MapChipButton)).active, + isFalse, + ); + }); + + testWidgets('the dropdown carries the township-name toggle', (tester) async { + final labels = ValueNotifier(true); + await tester.pumpWidget(_wrap(labels)); + + final l10n = await _l10n(); + expect(find.text(l10n.mapTownLabels), findsNothing); + + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.mapTownLabels), findsOneWidget); + // On by default: the box starts ticked. + expect(find.byIcon(Icons.check_box), findsOneWidget); + }); + + testWidgets('tapping the row flips the shared setting', (tester) async { + final labels = ValueNotifier(true); + final flipped = []; + await tester.pumpWidget(_wrap(labels, onChanged: flipped.add)); + + final l10n = await _l10n(); + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.mapTownLabels)); + await tester.pumpAndSettle(); + + // The scaffold owns the value; the menu only reports the flip. + expect(flipped, [false]); + }); + + testWidgets('the chip marks itself once the labels are switched off', ( + tester, + ) async { + final labels = ValueNotifier(false); + await tester.pumpWidget(_wrap(labels)); + + // Off is a deviation from the default, so the chip carries the dot. + expect( + tester.widget(find.byType(MapChipButton)).active, + isTrue, + ); + }); +} diff --git a/test/shared/map/maps_launcher_test.dart b/test/shared/map/maps_launcher_test.dart new file mode 100644 index 000000000..c595ff301 --- /dev/null +++ b/test/shared/map/maps_launcher_test.dart @@ -0,0 +1,202 @@ +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/maps_launcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +/// Records every URL handed to the platform so a test can assert the deep link +/// was actually opened — the exact thing the original picker forgot to do. +class _RecordingLauncher extends UrlLauncherPlatform { + final launched = []; + + @override + LinkDelegate? get linkDelegate => null; + + @override + Future canLaunch(String url) async => true; + + @override + Future launch( + String url, { + required bool useSafariVC, + required bool useWebView, + required bool enableJavaScript, + required bool enableDomStorage, + required bool universalLinksOnly, + required Map headers, + String? webOnlyWindowName, + }) async { + launched.add(url); + return true; + } +} + +void main() { + const target = MapLaunchTarget(lat: 25.0330, lng: 121.5654, label: '台北市'); + const bare = MapLaunchTarget(lat: 25.0330, lng: 121.5654); + + group('mapsAppUrl', () { + test('google with a label pins the label at the exact point', () { + final url = Uri.parse(mapsAppUrl(MapApp.google, target)); + expect(url.host, 'www.google.com'); + expect(url.queryParameters['query'], '25.033,121.5654(台北市)'); + }); + + test('google without a label uses the bare coordinate', () { + final url = Uri.parse(mapsAppUrl(MapApp.google, bare)); + expect(url.queryParameters['query'], '25.033,121.5654'); + }); + + test('apple carries q, ll and z so the map frames the point', () { + final url = Uri.parse(mapsAppUrl(MapApp.apple, bare)); + expect(url.host, 'maps.apple.com'); + expect(url.queryParameters['q'], '25.033,121.5654'); + expect(url.queryParameters['ll'], '25.033,121.5654'); + expect(url.queryParameters['z'], '17'); + }); + }); + + group('MapApp.ofPlatform', () { + test('iOS and macOS default to Apple Maps', () { + expect(MapApp.ofPlatform(TargetPlatform.iOS), MapApp.apple); + expect(MapApp.ofPlatform(TargetPlatform.macOS), MapApp.apple); + }); + + test('everything else defaults to Google Maps', () { + for (final platform in [ + TargetPlatform.android, + TargetPlatform.windows, + TargetPlatform.linux, + TargetPlatform.fuchsia, + ]) { + expect(MapApp.ofPlatform(platform), MapApp.google); + } + }); + }); + + group('callPhoneNumber', () { + test('dials a tel: URI stripped to dialler-safe characters', () async { + final launcher = _RecordingLauncher(); + final original = UrlLauncherPlatform.instance; + UrlLauncherPlatform.instance = launcher; + addTearDown(() => UrlLauncherPlatform.instance = original); + + expect(await callPhoneNumber('02-1234 5678'), isTrue); + expect(launcher.launched.single, 'tel:0212345678'); + }); + + test( + 'keeps plus, hash and star for extensions and international', + () async { + final launcher = _RecordingLauncher(); + final original = UrlLauncherPlatform.instance; + UrlLauncherPlatform.instance = launcher; + addTearDown(() => UrlLauncherPlatform.instance = original); + + expect(await callPhoneNumber('+886 9 1234 5678 #5'), isTrue); + expect(launcher.launched.single, 'tel:+886912345678#5'); + }, + ); + + test('never launches when nothing dialler-safe remains', () async { + final launcher = _RecordingLauncher(); + final original = UrlLauncherPlatform.instance; + UrlLauncherPlatform.instance = launcher; + addTearDown(() => UrlLauncherPlatform.instance = original); + + expect(await callPhoneNumber('(none)'), isFalse); + expect(launcher.launched, isEmpty); + }); + }); + + group('pickAndOpenMapApp', () { + testWidgets('opens the chosen app after the picker returns it', ( + tester, + ) async { + final launcher = _RecordingLauncher(); + final original = UrlLauncherPlatform.instance; + UrlLauncherPlatform.instance = launcher; + addTearDown(() => UrlLauncherPlatform.instance = original); + + const target = MapLaunchTarget(lat: 25.0330, lng: 121.5654); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => pickAndOpenMapApp(context, target), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + // The test platform defaults to Android, so Google Maps is the + // 預設-marked first row and Apple Maps the plain second one. + await tester.tap(find.text('Apple Maps')); + await tester.pumpAndSettle(); + + expect(launcher.launched, hasLength(1)); + expect( + Uri.parse(launcher.launched.single).host, + 'maps.apple.com', + reason: + 'the picker must hand the chosen app to the OS, not just ' + 'dismiss itself', + ); + }); + + testWidgets('a failed open surfaces a snackbar', (tester) async { + final original = UrlLauncherPlatform.instance; + UrlLauncherPlatform.instance = _DecliningLauncher(); + addTearDown(() => UrlLauncherPlatform.instance = original); + + const target = MapLaunchTarget(lat: 25.0330, lng: 121.5654); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => pickAndOpenMapApp(context, target), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apple Maps')); + await tester.pumpAndSettle(); + + expect(find.text('Could not open Apple Maps'), findsOneWidget); + }); + }); +} + +class _DecliningLauncher extends _RecordingLauncher { + @override + Future launch( + String url, { + required bool useSafariVC, + required bool useWebView, + required bool enableJavaScript, + required bool enableDomStorage, + required bool universalLinksOnly, + required Map headers, + String? webOnlyWindowName, + }) async => false; +} diff --git a/test/shared/map/now_frame_index_test.dart b/test/shared/map/now_frame_index_test.dart new file mode 100644 index 000000000..bfdfcd1c7 --- /dev/null +++ b/test/shared/map/now_frame_index_test.dart @@ -0,0 +1,66 @@ +import 'package:dpip/shared/map/map_layer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final _now = DateTime(2026, 8, 11, 11, 0); + +/// Frames every [stepHours] hours, from [fromHours] before now to [toHours] +/// after — negative for history, positive for forecast steps. +List _span(int fromHours, int toHours, {int stepHours = 1}) => [ + for (var h = fromHours; h <= toHours; h += stepHours) + MapFrame( + id: '$h', + time: _now.add(Duration(hours: h)), + ), +]; + +void main() { + test('observed frames: the present is the newest one', () { + // Radar and satellite stop at the present, so nothing changes for them. + final frames = _span(-24, 0, stepHours: 1); + expect(nowFrameIndex(frames, now: _now), frames.length - 1); + }); + + test('a forecast puts the present on the newest step that has arrived', () { + // GFS as served: two days back, sixteen days forward. + final frames = _span(-48, 384, stepHours: 3); + final i = nowFrameIndex(frames, now: _now); + expect(frames[i].time, _now); + expect(i, greaterThan(0), reason: 'history sits to the left'); + expect( + i, + lessThan(frames.length - 1), + reason: 'the forecast must remain to the right, or there is none to see', + ); + }); + + test('a forecast step in the future is never the present', () { + // 21:43 with 3-hourly steps on 20:00 / 23:00: the present is the already- + // due 20:00, not the nearer-in-absolute-terms 23:00 — a prediction must + // not read as current before it has happened. + final now = DateTime(2026, 8, 11, 21, 43); + final frames = [ + MapFrame(id: 'a', time: DateTime(2026, 8, 11, 20, 0)), + MapFrame(id: 'b', time: DateTime(2026, 8, 11, 23, 0)), + ]; + expect(nowFrameIndex(frames, now: now), 0); + }); + + test('picks the newest frame at or before now', () { + // Nearest in absolute terms is b (+1 h), but the present is the newest + // frame that has actually arrived — a (−2 h). + final frames = [ + MapFrame(id: 'a', time: _now.subtract(const Duration(hours: 2))), + MapFrame(id: 'b', time: _now.add(const Duration(hours: 1))), + MapFrame(id: 'c', time: _now.add(const Duration(hours: 4))), + ]; + expect(nowFrameIndex(frames, now: _now), 0); + }); + + test('an entirely future set answers with its first step', () { + expect(nowFrameIndex(_span(6, 48, stepHours: 6), now: _now), 0); + }); + + test('an empty set answers 0 rather than throwing', () { + expect(nowFrameIndex(const [], now: _now), 0); + }); +} diff --git a/tool/gen_asset_diff.py b/tool/gen_asset_diff.py new file mode 100644 index 000000000..5475e8db1 --- /dev/null +++ b/tool/gen_asset_diff.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Build side-by-side comparison sheets for the weather textures. + +The weather scene's textures are being replaced with generated originals, and +"close enough" is a judgement only an eye can make. This lays each replacement +next to the texture it replaces — same scale, same channel breakdown, labelled +— so the pair can be looked at rather than argued about. + + old | new | difference + +The difference panel is `|old - new|` amplified, on the channel that matters +for that texture. It is a *guide*, not a target: these are meant to be +different pictures. Read it as "did anything structural change", not as an +error metric. The header line under each pair carries the numbers that do +matter — size, and whatever statistic that texture is judged on. + +Usage: + python3 tool/gen_asset_diff.py --old --new + +Both directories are searched for the same filenames (extension-insensitive), +so a `.png` replaced by a `.webp` still pairs up. Sheets land in +`build/asset_diff/`. +""" + +import argparse +import os +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +# Per-texture presentation. `panels` names how to break a texture into rows so +# the comparison shows what the shader actually consumes: a cloud sprite's +# coverage and normal map say far more than its flat RGBA does. +LAYOUT = { + "clouds": dict(panels="cloud", scale=1.0), # matched by folder name + "particle_blurred": dict(panels="alpha", scale=4.0), + "drop_normal": dict(panels="rgb", scale=2.0), + "snow_flake": dict(panels="alpha", scale=6.0), + "rain_drop": dict(panels="alpha", scale=3.0), + "starmap": dict(panels="crop", scale=1.0), + "sun_rays": dict(panels="rgb_norm", scale=0.7), + "sun_profile": dict(panels="lut", scale=1.0), + "annulus": dict(panels="lut", scale=1.0), +} + +LABEL_H = 22 +GAP = 10 +BG = (24, 24, 28) + + +def _load(path): + return np.array(Image.open(path).convert("RGBA")).astype(np.float32) + + +def _gray(a): + return np.dstack([a] * 3) + + +def _panels(kind, rgba): + """Break one texture into the rows worth comparing.""" + rgb, alpha = rgba[..., :3], rgba[..., 3] + if kind == "cloud": + h = rgba.shape[0] // 2 + return [("coverage", _gray(rgba[h:, :, 3])), ("normal", rgba[:h, :, :3])] + if kind == "alpha": + return [("alpha", _gray(alpha))] + if kind == "rgb": + return [("rgb", rgb)] + if kind == "rgb_norm": + # Stored deliberately dim; normalise so the shape is visible at all. + return [("rgb (normalised)", rgb / max(rgb.max(), 1e-6) * 255.0)] + if kind == "crop": + # A star map is only meaningful at 1:1 — a downscale averages the + # stars away and every field looks identical. + h, w = rgba.shape[:2] + y, x = h // 3, w // 3 + return [("1:1 crop", rgb[y:y + 300, x:x + 760])] + if kind == "lut": + # A 1-D curve: show it as a wide swatch and as a plot of the three + # channels, because the swatch alone hides everything below ~1/20. + row = rgb[0] + swatch = np.repeat(row[None, :, :], 40, axis=0) + plot = np.zeros((120, row.shape[0], 3), dtype=np.float32) + for c, colour in enumerate(((255, 80, 80), (80, 255, 110), (110, 150, 255))): + ys = (119 - row[:, c] / 255.0 * 118).astype(int) + plot[ys, np.arange(row.shape[0])] = colour + return [("swatch", swatch), ("curve", plot)] + raise ValueError(kind) + + +def _to_img(arr, scale): + img = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGB") + if scale != 1.0: + img = img.resize( + (max(1, int(img.width * scale)), max(1, int(img.height * scale))), + Image.NEAREST if scale > 1 else Image.LANCZOS, + ) + return img + + +def _layout_for(name, path): + """Pick the panel treatment by filename, falling back to the directory. + + The cloud sprites are named `00`..`11`, so they can only be recognised by + the folder they live in — and getting that wrong shows the raw RGBA instead + of the coverage and normal map the shader actually reads. + """ + if name in LAYOUT: + return LAYOUT[name] + parent = Path(path).parent.name + return LAYOUT.get(parent, dict(panels="rgb", scale=1.0)) + + +def sheet(name, old_path, new_path, out_dir): + kind = _layout_for(name, old_path) + old, new = _load(old_path), _load(new_path) + if old.shape != new.shape: + new_img = Image.open(new_path).convert("RGBA").resize( + (old.shape[1], old.shape[0]), Image.LANCZOS + ) + new_cmp = np.array(new_img).astype(np.float32) + else: + new_cmp = new + + rows = [] + for (label, a), (_, b) in zip(_panels(kind["panels"], old), + _panels(kind["panels"], new_cmp)): + diff = np.clip(np.abs(a - b) * 3.0, 0, 255) + rows.append((label, [a, b, diff])) + + scale = kind["scale"] + tiles = [[_to_img(p, scale) for p in panels] for _, panels in rows] + col_w = max(t.width for row in tiles for t in row) + total_w = col_w * 3 + GAP * 2 + total_h = sum(row[0].height + LABEL_H for row in tiles) + GAP * (len(tiles) - 1) + + canvas = Image.new("RGB", (total_w, total_h + LABEL_H), BG) + draw = ImageDraw.Draw(canvas) + for i, header in enumerate(("original (the reference)", "generated", "|difference| x3")): + draw.text((i * (col_w + GAP) + 4, 5), header, fill=(210, 210, 215)) + + y = LABEL_H + for (label, _), row in zip(rows, tiles): + for i, tile in enumerate(row): + canvas.paste(tile, (i * (col_w + GAP), y)) + draw.text((4, y + row[0].height + 4), label, fill=(150, 150, 158)) + y += row[0].height + LABEL_H + GAP + + out = Path(out_dir) / f"{name}.png" + canvas.save(out) + return out, os.path.getsize(old_path), os.path.getsize(new_path) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--old", required=True, help="directory tree of the originals") + ap.add_argument("--new", required=True, help="directory tree of the replacements") + ap.add_argument("--out", default="build/asset_diff") + args = ap.parse_args() + os.makedirs(args.out, exist_ok=True) + + olds = {p.stem: p for p in Path(args.old).rglob("*") if p.suffix in {".png", ".webp"}} + news = {p.stem: p for p in Path(args.new).rglob("*") if p.suffix in {".png", ".webp"}} + + total_old = total_new = 0 + for stem in sorted(olds.keys() & news.keys()): + out, ob, nb = sheet(stem, olds[stem], news[stem], args.out) + total_old += ob + total_new += nb + print("%-22s %8d -> %8d bytes (%+.0f%%) %s" + % (stem, ob, nb, (nb - ob) / ob * 100, out)) + + missing = sorted(olds.keys() - news.keys()) + if missing: + print("no replacement yet:", ", ".join(missing)) + if total_old: + print("total %d -> %d bytes (%+.0f%%)" + % (total_old, total_new, (total_new - total_old) / total_old * 100)) + + +if __name__ == "__main__": + main() diff --git a/tool/gen_cloud_sprites.py b/tool/gen_cloud_sprites.py new file mode 100644 index 000000000..12ae36043 --- /dev/null +++ b/tool/gen_cloud_sprites.py @@ -0,0 +1,1559 @@ +#!/usr/bin/env python3 +"""Generate DPIP cloud sprites — original artwork, volumetric raymarch. + +WHY THIS FILE EXISTS +-------------------- +The sprites this replaces were decoded out of the reference engine and are the reference vendor's +copyrighted artwork. Everything here is synthesised from a PRNG: no reference +pixel is read, traced, re-encoded or fitted to at any point. The only thing +borrowed is the *texture contract* — the byte layout `shaders/cloud/clouds.frag` +reads — which is an interface, not artwork. + +TEXTURE CONTRACT (see `shaders/cloud/clouds.frag`) +-------------------------------------------------- +512 x 576 RGBA, two stacked 512 x 288 halves. + + top half RGB = normal * 0.5 + 0.5, A = 255 + The shader does `sampleSprite(normalUv).rgb * 2 - 1` and then + `mapN.z = max(mapN.z, 0.08); normalize(mapN)`. It stores an + UN-normalised normal, so only the xy:z ratio carries tilt — + and any pixel whose z lands negative gets its lighting + flattened by that clamp, so z must stay mostly positive. + A = 255 makes Flutter's decode-time premultiply a no-op, which + is the only reason the normal survives intact. + + bottom half R = depth from the silhouette edge. Read by `calcDepthAlpha` + (the deck reveal sweeps a band through it front-to-back) and + by `edge = smoothstep(iEndEdge, iStartEdge, texDepth)`, which + erodes low-depth pixels. A rim crushed to zero leaves both with + nothing to work with, so R must stay non-zero out to the + feathered edge and rise only ~2x into the core. + G = backness, "how far through to the far side". The shader + does `alpha *= smoothstep(1.0, 0.5, texBack)` and derives + `backMulti` (the silver lining) from it, so backness must be + HIGH WHERE THE CLOUD IS THIN. Getting this sign wrong fades the + core and puts the rim light in the middle of the cloud. + B = inner-glow mask, gated by `smoothstep(0.05, 1.0, texInner)` + — a ceiling below ~0.3 means lightning never registers. + A = coverage / opacity. Flutter premultiplies on decode and the + shader divides RGB back out by `max(coverage, 0.10)`, which is + why coverage lives in A and not, as the reference had it, in B. + +Files on disk are written UNpremultiplied; the decode does the multiply. + +HOW THE ART IS MADE +------------------- +An orthographic volumetric raymarch (the camera looks down -z, so the march is a +cumulative sum through a density volume), with the normal map built on a second, +independent path. Five things distinguish it from a naive march, each fixing a +defect that shows up as a number in the profile AND as something the eye can +name on a contact sheet: + + 1. ONE UPDRAUGHT, NOT A ROW OF BLOBS. The crown's position is drawn FIRST and + the body is built around it: a short chain of large, heavily overlapping + ellipsoids whose radii and centre height both peak under the crown, standing + on a soft-min half-space (the condensation level, hence the flat base). So + the mass already rolls and the crown continues its arc. Build the body first + and drop a crown wherever lobe placement lands, and the two disagree — every + sprite comes out as a block with a shelf beside it. The earlier arrangement + — one wide thin slab plus many small lobes — reads instead as a table with + popcorn on it, and says so in the numbers: lobe count 4 against a reference + median of 2, bbox fill 0.45 against 0.58. + + 2. A SOFT TRANSFER WITH A VERTICAL GRADIENT. Density is `logistic(F / s)`, so + the field decays EXPONENTIALLY outside the surface with a tunable length — + a real 20-px coverage ramp and an exponential wisp tail instead of a binary + silhouette. `s` grows toward the base, because a cumulus crown is condensing + hard and its edge is a few pixels wide while its base is evaporating and + trails off over tens; one tail length for both averages them into a + uniformly furry outline. + + 3. THE RIM IS TORN BY A SEPARATE FIELD, AND TORN ANISOTROPICALLY. A thresholded + mid-frequency mask multiplies density in the exponential tail (`tear_in` + sets how far inward it reaches: ~0 on a cumulus, ~1 on a veil, which IS torn + all the way through). The mask's noise is stretched 3-5x along the wind, so + a veil comes out as torn horizontal filaments rather than isotropic confetti + — the defect that made the previous veils read as a spray of dots. + + 4. THE TWO SCALES OF THE NORMAL ARE BUILT SEPARATELY. This is the whole point + of the file. Coverage is taken from the FULL-detail field, so the silhouette + keeps every erosion octave and stays cauliflower. The normal is taken from a + SMOOTHED thickness field — a dome per lobe, which is what makes big saturated + lobes — and fine relief is added back as a SEPARATE term. Taking one gradient + of the raw surface instead gives the two failure modes the judges named: + either smooth lobes with no relief, or sandpaper with no lobes. + + 5. THE NORMAL'S SPECTRUM IS A CONSTRUCTION, NOT A TUNING. The height field is + split into one-octave bands, each band's gradient re-weighted, and then — + the part that makes it a guarantee rather than a hope — the band amplitudes + are MEASURED ON THE OUTPUT and the weights corrected, twice. The realised + radial power spectrum is a power law of slope 2*log2(ratio) - 2 whatever the + raymarch happened to produce. All noise below the volume's resolution is + synthesised in the Fourier domain, because a cubic-zoomed lattice beats + against the pixel grid into 45-degree moire that the per-octave + re-normalisation then promotes into visible scratches. + + Finally the normal is passed through the same saturating map a real surface + normal gets (`n/sqrt(1+|n|^2)`), which is what makes its histogram a plateau + instead of a spike with outliers — reference p99/sigma is 2.0, a raw gradient + is 3.2 — and which is why the reference art reads as broad saturated colour + fields rather than pale mush with hot specks. + + 6. THE SILHOUETTE IS CRENELLATED AT FULL RESOLUTION. The volume is marched at + half the sprite's size, so its boundary upsamples to a smooth ramp, and a + real cumulus edge is a row of cauliflower scallops 20-60 px across. Because + optical depth decays exponentially outside the surface, multiplying it by + exp(k * detail) TRANSLATES the boundary by k*detail*s — scallops for one + 2-D field. Three details make it work rather than wreck it, and each was a + visible failure first: the factor is gated off the saturated core (ungated, + a -3 sigma excursion takes tau=8 to tau=0.3 and the cloud comes out as + lace); the field is band-limited AFTER rectification (the soft-abs that + rounds its peaks also manufactures 5-px harmonics, and those displace the + boundary into fur); and the displacement is asymmetric (a symmetric one + opens dark pockets just inside the rim, so the silhouette reads as sponge). + +CLI + gen_cloud_sprites.py --out DIR [--count 12] [--seed 7] [--sheet PNG] + [--compare REF_SPRITE:OUT_PNG] +""" + +from __future__ import annotations + +import argparse +import math +import os +import time + +import numpy as np +from PIL import Image, ImageDraw +from scipy.ndimage import distance_transform_edt, gaussian_filter, zoom +from scipy.special import ndtri + +# -------------------------------------------------------------------------- +# geometry constants +# -------------------------------------------------------------------------- +TILE_W, TILE_H = 512, 288 # one half of the sprite, in texels +PX = 128.0 # full-res pixels per world unit + +# The density volume is marched at half the sprite's xy resolution. Nothing in +# the profile asks for coverage detail below ~4 px (the thinnest filament the +# reference set carries is 5.7 px), and resolving finer would only spend bytes +# the lossless encoder cannot claw back. +VX, VY, VZ = 256, 144, 112 +WX, WY, WZ = TILE_W / PX, TILE_H / PX, 2.6 # world extents (4.0 x 2.25 x 2.6) + + +# -------------------------------------------------------------------------- +# noise — 3-D lattice (volume) and 2-D spectral (surface relief) +# -------------------------------------------------------------------------- +def _zoom_to(lat: np.ndarray, target: tuple) -> np.ndarray: + """Periodic upsample of a small lattice to `target`. + + Two stages on purpose: a cubic zoom to half the target is cheap (the output + is 1/8 the voxels) and removes the derivative creases that plague a linear + upsample of a coarse lattice; the final x2 is linear, where those creases + are a voxel apart and invisible. A single cubic zoom to full size costs ~8x + more for no visible gain. + """ + half = tuple(max(2, t // 2) for t in target) + a = zoom( + lat, + tuple(h / c for h, c in zip(half, lat.shape)), + order=3, + mode="grid-wrap", + grid_mode=True, + ) + out = zoom( + a, + tuple(t / s for t, s in zip(target, a.shape)), + order=1, + mode="grid-wrap", + grid_mode=True, + ) + return out[: target[0], : target[1], : target[2]].astype(np.float32) + + +def _decrease(vol): + """Kill the derivative creases the final linear zoom leaves behind. + + A linear upsample is C0 but not C1, and its kinks sit on axis-aligned + planes two voxels apart — at sprite resolution, straight 4-px lines. They + are invisible in the density (the transfer function smears them) but any + fixed-amplitude re-normalisation downstream promotes them into scratches. + One sub-voxel blur removes them. The 2-D relief that actually feeds the + normal map does not come from this lattice at all, for the same reason. + """ + return gaussian_filter(vol, 0.62) + + +def fbm3(rng, freq, octaves, gain, billow=False, target=(VZ, VY, VX), ax=1.0, az=1.0): + """Seamless 3-D fbm in roughly [0,1]; `freq` = lattice cells per world unit. + + `billow` rectifies each octave into rounded lobes before summing — that is + what reads as cauliflower. A plain fbm at the same spectrum reads as marble. + `ax` / `az` stretch the lattice along x / z: a wind-torn veil is not + isotropic, and an isotropic tear mask is exactly what makes a veil read as + confetti instead of filaments. + """ + out = np.zeros(target, dtype=np.float32) + amp, norm, f = 1.0, 0.0, float(freq) + for _ in range(octaves): + cells = ( + max(3, int(round(f * WZ / az))), + max(3, int(round(f * WY))), + max(3, int(round(f * WX / ax))), + ) + layer = _zoom_to(rng.random(cells).astype(np.float32), target) + if billow: + layer = 1.0 - np.abs(layer * 2.0 - 1.0) + out += amp * layer + norm += amp + amp *= gain + f *= 2.0 + out = _decrease(out / norm) + lo, hi = float(out.min()), float(out.max()) + return (out - lo) / max(hi - lo, 1e-6) + + +def band_noise(rng, lam, aniso=1.0, shape=(TILE_H, TILE_W)): + """One isotropic octave of band-limited 2-D noise, peak wavelength `lam` px. + + Synthesised in the Fourier domain rather than by upsampling a lattice. A + cubic `zoom` of a coarse lattice to a non-integer multiple beats against the + pixel grid, and the beat is diagonal, coherent and octave-wide — harmless in + a density field that a logistic then smears, fatal in a normal map whose + octaves are re-normalised to fixed amplitudes, where it surfaces as 45-degree + hatching across the whole tile. Building the octave as a radial filter on + white noise has no lattice to beat against, is exactly periodic, and is + isotropic by construction. + """ + fy = np.fft.fftfreq(shape[0])[:, None] + fx = np.fft.rfftfreq(shape[1])[None, :] * aniso + r = np.sqrt(fy * fy + fx * fx) + f0 = 1.0 / lam + w = np.exp(-0.5 * (np.log(np.maximum(r, 1e-9) / f0) / 0.62) ** 2) + w[0, 0] = 0.0 + out = np.fft.irfft2(np.fft.rfft2(rng.standard_normal(shape)) * w, s=shape) + out = out.astype(np.float32) + return out / max(float(out.std()), 1e-9) + + +def spec2(rng, lam0, octaves, gain, aniso=1.0, shape=(TILE_H, TILE_W)): + """Multi-octave spectral noise normalised to [0,1]. Drop-in for a 2-D fbm.""" + out = np.zeros(shape, dtype=np.float32) + amp, lam = 1.0, float(lam0) + for _ in range(octaves): + out += amp * band_noise(rng, lam, aniso, shape) + amp *= gain + lam *= 0.5 + lo, hi = float(out.min()), float(out.max()) + return (out - lo) / max(hi - lo, 1e-6) + + +def billow2(rng, lam0, octaves, gain, soft=0.42, shape=(TILE_H, TILE_W)): + """Rounded-lobe (cauliflower) relief, zero-mean, unit std. + + `soft` rounds the rectifier's valleys: a hard |n| makes creases whose + curvature is a delta, and a normal map built on those reads as faceted + crumpled foil — the failure the erosion generator hit. sqrt(n^2 + soft^2) + has bounded curvature everywhere, so the lobes stay lobes and the valleys + between them stay round. + """ + out = np.zeros(shape, dtype=np.float32) + amp, lam = 1.0, float(lam0) + for _ in range(octaves): + n = band_noise(rng, lam, 1.0, shape) + out -= amp * (np.sqrt(n * n + soft * soft) - soft) + amp *= gain + lam *= 0.5 + out -= out.mean() + return out / max(float(out.std()), 1e-9) + + +# -------------------------------------------------------------------------- +# shape grammar +# -------------------------------------------------------------------------- +def smax(a, b, k): + """Polynomial smooth maximum. Exactly max() once |a-b| > k, so blobs that + do not touch contribute nothing — a log-sum-exp instead keeps inflating the + crowded middle of the cloud into a peak, which is what makes a raymarched + cumulus read as one round blob.""" + h = np.clip(0.5 + 0.5 * (a - b) / k, 0.0, 1.0) + return b + (a - b) * h + k * h * (1.0 - h) + + +def smin(a, b, k): + """Polynomial smooth minimum — the flat base is an intersection, not a cut.""" + h = np.clip(0.5 - 0.5 * (a - b) / k, 0.0, 1.0) + return b + (a - b) * h - k * h * (1.0 - h) + + +# Twelve recipes. Each is a *family*, not a sprite: the numbers below are +# ranges the PRNG draws from, so re-seeding gives a different sky of the same +# character. Nine dense + three torn veils, because twelve cumulus is not what +# a sky looks like — and the reference set that has to be replaced splits 9/3 +# on exactly that line. +# +# n how many cauliflower lobes ride the top of the mass. SMALL on +# purpose: the reference median is 2. Many small lobes on a wide +# slab is the "popcorn on a plank" silhouette. +# half_w outer half-width of the cloud, world units (1 unit = 128 px) +# height base-to-crown height, world units +# core fraction of `height` taken by the mass the lobes sit on +# lobe_r lobe radius, world units — large, so a lobe is a shoulder of the +# cloud rather than a bobble on it +# masses how many separate masses the base is broken into (1 = one cloud, +# more = cumulus fractus, which is where the high lobe counts and +# ragged outlines in the set come from) +# over per-family parameter overrides. This is where the SET is tuned as +# a set rather than a bag of one-offs: the variety gate measures the +# spread of lobe count, edge width, roughness and normal wavelength +# ACROSS the twelve, so each family deliberately owns a different +# corner of those axes. `solid` marks the near-opaque cumulus — two +# of the nine reference cumulus are 30-70 % opaque core with a narrow +# rim, and without a couple of those the set has no spread in edge +# softness or mean coverage at all. +ARCHETYPES = [ + # name n half_w height core lobe_r masses + # 0 very wide, very low, near-opaque, crisp-edged: the "fair weather" plate + ("humilis", (1, 1), (1.50, 1.62), (0.79, 0.89), (0.66, 0.78), (0.52, 0.64), (1, 1), + dict(solid=1, erode=(0.20, 0.28), blend=(0.22, 0.29), band_ratio=(0.592, 0.620), fine_floor=(0.050, 0.090), + pend=(0.09, 0.15), nbody=(3, 4), base_wobble=(0.144, 0.240), + edge_detail=(0.85, 1.05), nrm_xy=(0.26, 0.30), relief=(0.20, 0.28), + dome_sig=(9.0, 12.0))), + # 1 medium two-crown cumulus, softest edge of the dense group + ("humilis", (2, 2), (1.27, 1.39), (1.07, 1.19), (0.48, 0.60), (0.48, 0.60), (1, 1), + dict(erode=(0.18, 0.26), blend=(0.21, 0.27), band_ratio=(0.600, 0.630), fine_floor=(0.063, 0.105), + pend=(0.10, 0.19), nbody=(3, 4), base_wobble=(0.160, 0.272), + nrm_xy=(0.175, 0.205))), + # 2 wide solid mediocris — the most "textbook" cumulus of the set + ("mediocris", (2, 2), (1.41, 1.55), (0.97, 1.07), (0.54, 0.66), (0.46, 0.58), (1, 1), + dict(solid=1, erode=(0.20, 0.28), blend=(0.20, 0.26), band_ratio=(0.615, 0.645), fine_floor=(0.067, 0.109), + depth_lo=(0.095, 0.135), grain=(0.080, 0.110), base_wobble=(0.192, 0.304), + pend=(0.18, 0.28), nbody=(3, 4), dome_mix=(0.55, 0.70), + nrm_xy=(0.22, 0.26), relief=(0.26, 0.34))), + # 3 TALL and narrow: the aspect-ratio outlier the variety gate needs + ("congestus", (2, 2), (1.06, 1.18), (1.25, 1.39), (0.38, 0.50), (0.52, 0.66), (1, 1), + dict(erode=(0.21, 0.29), blend=(0.20, 0.26), band_ratio=(0.590, 0.620), fine_floor=(0.052, 0.092), + depth_lo=(0.105, 0.145), base_wobble=(0.112, 0.208), pend=(0.11, 0.20), + nbody=(2, 3), edge_detail=(0.95, 1.15), dome_mix=(0.30, 0.45), + nrm_xy=(0.25, 0.29), relief_lam=(62.0, 84.0))), + # 4 the flattest plate in the set — wide, shallow, many shallow bumps + ("humilis", (2, 3), (1.52, 1.65), (0.77, 0.87), (0.64, 0.76), (0.34, 0.44), (1, 1), + dict(erode=(0.22, 0.30), blend=(0.18, 0.24), band_ratio=(0.645, 0.675), fine_floor=(0.085, 0.120), + base_wobble=(0.128, 0.224), pend=(0.10, 0.18), nbody=(4, 5), + relief=(0.34, 0.44), nrm_xy=(0.155, 0.185))), + # 5 stratocumulus roll: a long deck with a row of small crowns + ("stratocu", (3, 4), (1.44, 1.56), (0.85, 0.95), (0.60, 0.72), (0.30, 0.40), (1, 1), + dict(solid=1, erode=(0.24, 0.32), blend=(0.19, 0.25), band_ratio=(0.660, 0.690), fine_floor=(0.090, 0.125), + grain=(0.085, 0.115), depth_grain=(0.13, 0.19), pend=(0.14, 0.24), + depth_vert=(0.18, 0.32), + base_wobble=(0.112, 0.208), nbody=(4, 5), relief=(0.36, 0.48), + edge_detail=(1.00, 1.20), relief_lam=(34.0, 46.0), nrm_xy=(0.19, 0.23))), + # 6 two masses with real sky between them + ("fractus", (2, 3), (1.44, 1.56), (0.93, 1.05), (0.46, 0.58), (0.38, 0.50), (2, 2), + dict(erode=(0.22, 0.30), blend=(0.18, 0.24), band_ratio=(0.610, 0.640), fine_floor=(0.071, 0.118), + tau_peak=(4.2, 5.6), tail=(0.022, 0.030), pend=(0.11, 0.20), nbody=(2, 3), + depth_vert=(0.22, 0.38), + nrm_xy=(0.21, 0.25))), + # 7 broken cluster: three masses, high lobe count, ragged outline + ("fractus", (5, 7), (1.46, 1.58), (0.87, 0.99), (0.38, 0.50), (0.24, 0.32), (3, 3), + dict(erode=(0.24, 0.32), blend=(0.12, 0.17), band_ratio=(0.665, 0.695), fine_floor=(0.095, 0.130), + tau_peak=(4.6, 6.0), tail=(0.020, 0.027), pend=(0.20, 0.30), + depth_vert=(0.16, 0.30), + base_wobble=(0.160, 0.272), nbody=(2, 3), relief=(0.36, 0.48), + edge_detail=(1.05, 1.25), relief_lam=(30.0, 42.0), nrm_xy=(0.175, 0.205))), + # 8 tall-ish solid cumulus with a shoulder — the other end from #0 + ("pair", (2, 3), (1.20, 1.33), (1.15, 1.27), (0.46, 0.58), (0.50, 0.62), (1, 1), + dict(solid=1, erode=(0.19, 0.27), blend=(0.21, 0.27), band_ratio=(0.598, 0.626), fine_floor=(0.053, 0.095), + base_wobble=(0.096, 0.176), tail=(0.019, 0.025), pend=(0.16, 0.26), + nbody=(2, 3), dome_sig=(8.0, 11.0), nrm_xy=(0.25, 0.29), + relief=(0.24, 0.32))), + # 9-11 torn veils. Their normal map is a different animal from the cumulus: + # short centroid wavelength, high roughness, near-zero z — which is most of + # the set's spread in every normal-map statistic, so the ratios and relief + # scales here are deliberately far from the dense families'. + ("veil", (3, 5), (1.02, 1.19), (0.40, 0.52), (0.62, 0.82), (0.16, 0.24), (1, 2), + dict(band_ratio=(0.698, 0.730), base_wobble=(0.048, 0.128), tail=(0.105, 0.130), + tear_in=(0.70, 0.90), erode=(0.20, 0.32), back_hi=(0.42, 0.56), nbody=(3, 4), + relief_lam=(18.0, 26.0), tear_ax=(3.2, 4.4))), + ("veil", (4, 7), (0.98, 1.15), (0.36, 0.49), (0.58, 0.78), (0.13, 0.20), (1, 2), + dict(band_ratio=(0.700, 0.732), base_wobble=(0.048, 0.128), tail=(0.107, 0.132), + tear_in=(0.74, 0.94), erode=(0.20, 0.32), back_hi=(0.42, 0.56), nbody=(3, 5), + relief_lam=(16.0, 24.0), tear_ax=(3.6, 5.0))), + ("veil", (2, 4), (0.94, 1.11), (0.41, 0.56), (0.66, 0.86), (0.17, 0.26), (1, 1), + dict(band_ratio=(0.695, 0.730), base_wobble=(0.048, 0.128), tail=(0.103, 0.128), + tear_in=(0.64, 0.84), erode=(0.20, 0.32), back_hi=(0.42, 0.56), + depth_grain=(0.18, 0.24), nbody=(2, 4), relief_lam=(20.0, 30.0), + tear_ax=(2.8, 4.0))) +] + + +def build_shape(rng, spec): + """Draw one cloud's blob list from its archetype. Returns (blobs, pend, meta). + + A cumulus is a MASS with cauliflower riding its top, not a row of columns + and not one ellipsoid. The base is flat because that is the condensation + level; the character is in the lobes on the upper surface, each one a rising + thermal. + + The mass is NOT one wide flat ellipsoid. That was the previous version's + mistake and it is visible from across the room: a wide thin slab silhouettes + as a straight bar, so however good the lobes are the cloud reads as popcorn + on a plank (bbox fill 0.45 against the reference's 0.58, lobe count 4 against + 2). Here the mass is a short chain of `nbody` large ellipsoids whose radii + taper toward the ends and whose centres ride a shallow arc, heavily fused — + so the body already rolls, and the lobes only have to add a crown. + """ + name, nrange, wrange, hrange, corer, lober, massr, over = spec + n = int(rng.integers(nrange[0], nrange[1] + 1)) + W = float(rng.uniform(*wrange)) + H = float(rng.uniform(*hrange)) + core = float(rng.uniform(*corer)) + lobe_r = float(rng.uniform(*lober)) + nm = int(rng.integers(massr[0], massr[1] + 1)) + nbody = over.get("nbody", (2, 3)) + veil = name == "veil" + # A cumulus family is one updraught, so its top is a single dome and its + # lobes are scallops on that dome. A fractus or a stratocumulus deck is the + # opposite: separate masses, big independent lobes, no shared crown. The + # two shaping terms below are for the first kind only — applied to the + # second they iron the set's broken clouds into loaves. + domed = name in ("humilis", "mediocris", "congestus", "pair") + + # Frame the cloud about the middle of the tile: the profile wants 15-85 px + # of margin above and 25-80 below, and a base pinned to the tile floor + # reads as a cloud that fell out of frame. + base_y = -0.5 * H + 0.045 + float(rng.uniform(-0.05, 0.05)) + core_h = H * core + + # The crown's position is drawn FIRST, and everything else is built around + # it. A cumulus is one updraught: the whole mass bulges under the tower, so + # the body is highest exactly where the crown sits and the crown continues + # the body's arc rather than standing on it. Build the body first and drop a + # crown wherever the lobe placement happens to land, and the two disagree — + # a tall lobe next to a low shoulder joins in a vertical step, and every + # sprite in the set comes out as a block with a shelf beside it. + # Kept near the middle on the domed families. The arc falls away as the + # square of the distance from here, so a crown drawn out at a third of the + # half-width tips a wide low humilis into a wedge — high at one end, a + # straight slope to the other — instead of the loaf it should be. + crown_x = float(rng.uniform(-0.18, 0.18) if domed + else rng.uniform(-0.34, 0.34)) * W + + blobs = [] + masses = [] # (cx, rx, ry) of each cluster, for placing lobes later + if nm == 1: + masses.append((float(rng.uniform(-0.05, 0.05)), W, core_h * 0.5)) + else: + # Split the span into unequal chunks with gaps between them. + cuts = np.sort(rng.uniform(0.18, 1.0, nm - 1)) + edges = np.concatenate([[0.0], cuts, [1.0]]) * 2.0 * W - W + widest = float(np.max(np.diff(edges))) + for a, b in zip(edges[:-1], edges[1:]): + gap = float(rng.uniform(0.10, 0.20)) * (b - a) + rx = (b - a) * 0.5 - gap + if rx < lobe_r * 0.7: + continue + # Height scales with width, so a broken cloud is one mass with + # COMPANIONS rather than a row of equals. Equal masses silhouette as + # a camel — two humps with a dip between them — which is a shape the + # sky does not make and the eye rejects immediately. + frac = (b - a) / max(widest, 1e-6) + masses.append( + ( + float((a + b) * 0.5 + rng.uniform(-0.05, 0.05)), + rx, + core_h * 0.5 * (0.42 + 0.58 * frac) * float(rng.uniform(0.88, 1.08)), + ) + ) + if not masses: + masses = [(0.0, W, core_h * 0.5)] + + # Each mass becomes a fused chain. The sub-ellipsoids are deep in z + # (rz ~ 1.2-1.8 x ry): a cloud is about as deep as it is tall, and a mass + # that is thin in z never accumulates enough optical depth to bake an opaque + # core, which is most of why the previous set looked like grey mottle where + # The reference is solid white. + body = [] + for cx, rx, ry in masses: + nb = int(rng.integers(nbody[0], nbody[1] + 1)) + # A wide mass needs at least three links, or the "chain" is two blobs at + # the extremes with a saddle between them. + if rx > 0.85: + nb = max(nb, 3) + nb = max(1, min(nb, 5)) + # The chain's own top arc. This is what makes the BODY a domed loaf + # rather than a plate, and it has to be a real fraction of the mass's + # height: at a tenth of `ry` the chain's top is flat to the eye, the + # dome has to come from the lobes instead, and a lobe big enough to + # supply it is a ball sitting on a plate. + arc = ry * float(rng.uniform(0.45, 0.95) if domed else rng.uniform(0.10, 0.34)) + ts = np.linspace(-1.0, 1.0, nb) if nb > 1 else np.array([0.0]) + for t in ts: + # Width and height taper TOGETHER, about the crown. Shrinking only + # the height leaves a wide, very flat ellipsoid at the end of the + # chain, and a wide flat ellipsoid silhouettes as a horizontal shelf + # with a right-angle join — the one shape in this grammar that reads + # instantly as machinery rather than weather. + ex0 = cx + float(t) * rx * 0.55 + q = float(np.clip((ex0 - crown_x) / max(rx, 1e-6), -1.35, 1.35)) + taper = 1.0 - 0.28 * q * q + sub_rx = rx * float(rng.uniform(0.52, 0.74)) * (0.58 + 0.42 * taper) + sub_ry = ry * float(rng.uniform(0.92, 1.12)) * taper + sub_ry = max(sub_ry, ry * 0.58) + ex = cx + float(t) * max(rx - sub_rx, 0.0) * float(rng.uniform(0.90, 1.06)) + # The centre sits LESS than one radius above the condensation level, + # always, so the half-space intersection always bites. Let it float + # above and the ellipsoid keeps its own rounded underside: the cloud + # grows a belly, and a cumulus with a belly is instantly wrong. + # A shallow arc over the chain, so the body's own top is a dome. + # Without it a two-element chain with a strong taper puts a low wide + # ellipsoid beside a tall one and the join is a step. + # The arc's own abscissa spans the CHAIN, not the mass: the links only + # reach 0.55 of `rx`, so normalising by `rx` leaves `1 - q^2` between + # 0.7 and 1 across the whole body and the "dome" is a uniform lift + # with a straight top. + qa = float(np.clip((ex0 - crown_x) / max(rx * 0.62, 1e-6), -1.0, 1.0)) + cy = (base_y + sub_ry * float(rng.uniform(0.70, 0.92)) + + arc * max(0.0, 1.0 - qa * qa)) + rz = min(sub_ry * float(rng.uniform(1.25, 1.90)), 0.95) + rz = max(rz, sub_rx * 0.34) + body.append((ex, cy, float(rng.uniform(-0.10, 0.10)), sub_rx, sub_ry, rz)) + blobs.extend(body) + + def core_top(x): + """Top of the body under x — where a lobe would sit.""" + best = base_y + for ex, cy, _cz, sub_rx, sub_ry, _rz in body: + t = abs(x - ex) / max(sub_rx, 1e-6) + if t < 1.0: + best = max(best, cy + sub_ry * math.sqrt(1.0 - t * t)) + return best + + # Lobes ride the body's top ARC, each rising a fraction of its own radius + # above whatever is under it. Giving them absolute heights instead is a + # trap that looks fine in the numbers and terrible on screen: the arc falls + # away at the ends, so an edge lobe has to stack four or five ellipsoids to + # reach the same absolute top, and the cloud grows goalposts. The crown lobe + # is therefore chosen near the body's HIGHEST point, and a stack is capped at + # two, so the tall one is a shoulder and never a tower. + # Lobes cluster toward the middle when there are few of them. Spreading two + # lobes to the flanks leaves the centre lower than both ends and the cloud + # silhouettes as a W, which no cumulus does. + lobe_cap = core_h * 0.5 * float( + rng.uniform(1.00, 1.32) if domed else rng.uniform(2.4, 3.2)) + span = min(0.19 * (n - 1), 0.60) + slots = np.linspace(-span, span, n) if n > 1 else np.array([0.0]) + tall = int(np.argmin(np.abs(slots))) # the crown is the middle slot + for i in range(n): + # A lobe is a thermal RIDING the mass, so the mass sets its size. Left + # to `lobe_r` alone it comes out near twice the body's own half-height + # on a wide shallow humilis, and however well it is sunk into the top + # it still stands a full body-height proud: the cloud silhouettes as a + # mushroom, which is the most recognisable way a generated cumulus goes + # wrong and the one the reference set never does. + r = min(lobe_r * float(rng.uniform(0.85, 1.20)), lobe_cap) + cx = float(crown_x + slots[i] * W + + rng.uniform(-0.22, 0.22) * (1.2 * W / max(n, 1))) + cx = float(np.clip(cx, -W * 0.78, W * 0.78)) + top = core_top(cx) + # A lobe out near the flank rises less than one in the middle. Without + # that the body's arc falls away under an edge lobe, the lobe reaches + # for an absolute height anyway, and the cloud grows goalposts. + centre = 1.0 - 0.50 * abs(cx) / max(W, 1e-6) + rise = r * float(rng.uniform(0.45, 1.00)) * centre + if i == tall: + rise = max(rise, min(base_y + H - top, r * 1.05)) + ht = top + rise - base_y + y = top - r * 0.72 + r + stack = 0 + while True: + # A little wider than tall — but only a little. Push the ratio past + # ~1.3 and the lobe's own top goes flat over its middle, so the crown + # silhouettes as a rectangular block with a step down to the body + # instead of an arc rising out of it. + blobs.append( + ( + cx + float(rng.uniform(-0.05, 0.05)), + y, + float(rng.uniform(-0.30, 0.30)), + r * float(rng.uniform(1.00, 1.26)), + r * float(rng.uniform(0.88, 1.06)), + r * float(rng.uniform(0.95, 1.35)) * (0.7 if veil else 1.0), + ) + ) + stack += 1 + if y + r >= base_y + ht - 1e-3 or stack >= 2: + break + y += r * float(rng.uniform(0.80, 0.98)) + r *= float(rng.uniform(0.78, 0.92)) + + # Pendant shreds. The base plane is an intersection, so anything built + # below it is simply cut away — which is exactly why an earlier version + # baked a razor-flat base (sil_base_over_top_rms 0.14 against a reference + # range of 0.34-1.53; a base FLATTER than the reference is as much a tell + # as a lumpy one). These are unioned back on AFTER the cut, so they survive: + # physically, the ragged fractus that hangs beneath a cumulus base. + pend = [] + pend_r = over.get("pend", (0.14, 0.26)) + npd = int(rng.integers(1, 4)) + for _ in range(npd): + r = float(rng.uniform(*pend_r)) * (0.5 if veil else 1.0) + px_ = float(rng.uniform(-0.80, 0.80)) * W + if core_top(px_) <= base_y + 1e-6: + continue # nothing overhead to hang from + pend.append( + (px_, base_y + r * float(rng.uniform(-0.30, 0.05)), + float(rng.uniform(-0.15, 0.15)), + r * float(rng.uniform(0.9, 1.5)), r * 0.62, + r * float(rng.uniform(0.5, 0.9))) + ) + + return blobs, pend, {"name": name, "base_y": base_y, "half_w": W, + "height": H, "veil": veil} + + +# -------------------------------------------------------------------------- +# density field + march +# -------------------------------------------------------------------------- +def march(rng, blobs, pend, meta, p): + """Orthographic volumetric march. Returns the 2-D fields at volume res.""" + xs = np.linspace(-WX / 2, WX / 2, VX, dtype=np.float32) + ys = np.linspace(WY / 2, -WY / 2, VY, dtype=np.float32) # row 0 = top + zs = np.linspace(WZ / 2, -WZ / 2, VZ, dtype=np.float32) # slice 0 = near + + X = xs[None, None, :] + Y = ys[None, :, None] + Z = zs[:, None, None] + + # --- domain warp ------------------------------------------------------- + # Advecting the *coordinates* rather than adding noise to the field is what + # keeps lobes reading as lobes: a warped ellipsoid is still a closed, convex + # blob, where an additively perturbed one dissolves into fog. + wa = p["warp"] + wf = p["warp_freq"] + WXf = (fbm3(rng, wf, 3, 0.55) - 0.5) * wa + WYf = (fbm3(rng, wf, 3, 0.55) - 0.5) * wa * 0.62 + WZf = (fbm3(rng, wf, 3, 0.55) - 0.5) * wa + # Wind shear: the veils are torn streaks, so their warp is stretched + # horizontally and sheared with height. + xw = X + WXf + p["shear"] * (Y - meta["base_y"]) + yw = Y + WYf + zw = Z + WZf + del WXf, WYf, WZf + + # --- blob union -------------------------------------------------------- + # f_i is an approximate signed distance (positive inside) rather than the + # usual 1 - r^2, so the smooth-max blend radius `k` is a length in world + # units and the amount of fusion does not depend on blob size. + k = p["blend"] + F = None + for (cx, cy, cz, rx, ry, rz) in blobs: + d = np.sqrt( + ((xw - cx) / rx) ** 2 + ((yw - cy) / ry) ** 2 + ((zw - cz) / rz) ** 2 + ) + f = (1.0 - d) * ((rx * ry * rz) ** (1.0 / 3.0)) + F = f if F is None else smax(F, f, k) + del xw, yw, zw + + # --- flat base --------------------------------------------------------- + # A cumulus base is the condensation level: flat, but not machined. The + # half-space is displaced downward by a SPARSE wobble — zero over most of + # the span, dipping hard where a shaft of cloud hangs below the level. That + # is the shape of the reference statistic: ~2/3 of base columns within a few + # px of the median (base_flat_frac 0.68 on the dense set) yet an rms + # deviation of 6-25 px (base_rms), which no symmetric wobble reproduces. + wn = fbm3(rng, 1.7, 3, 0.5, target=(1, VY, VX))[0] + hang = np.clip((wn - 0.46) / 0.54, 0.0, 1.0) ** 1.3 + base = Y - (meta["base_y"] - p["base_wobble"] * hang[None, :, :]) + F = smin(F, base, p["base_blend"]) + del base, wn, hang + + # Pendants are unioned back AFTER the cut, so the condensation level does + # not shave them off. + for (cx, cy, cz, rx, ry, rz) in pend: + d = np.sqrt(((X - cx) / rx) ** 2 + ((Y - cy) / ry) ** 2 + ((Z - cz) / rz) ** 2) + F = smax(F, (1.0 - d) * ((rx * ry * rz) ** (1.0 / 3.0)), p["blend"]) + + # --- surface erosion --------------------------------------------------- + # Gated to a shell around the surface: 1 at F=0, falling as 1/(1+(F/w)^2) + # inward. Eroding the whole field instead hollows the core and destroys the + # internal density the profile measures as dens_cov_hp_std_thick. + # + # The dominant erosion wavelength is deliberately ~40-90 px: the coverage + # ramp is ~25 px wide, so anything finer is smeared out of the silhouette + # and only costs file size, while anything coarser just translates the whole + # surface instead of breaking it into lobes. The 0.30 offset is the field's + # working point — subtracting it keeps the erosion roughly mean-free so the + # cloud is scalloped, not shrunk. + # The shell must close on BOTH sides of the surface. Gating on max(F, 0) + # leaves the weight at exactly 1 everywhere OUTSIDE it, so the erosion runs + # at full strength through the whole exponential tail — and since the tail is + # 20-40 px of low coverage, a mid-frequency field chopping it up turns a soft + # halo into a mat of curly filaments. On a contact sheet that is a cloud + # wearing a fur coat, and it is also where the file-size budget goes, because + # lossless WebP cannot compress it. Outward the shell is deliberately + # narrower than inward: scalloping belongs at the boundary, not beyond it. + bill = fbm3(rng, p["erode_freq"], 4, 0.46, billow=True) + wsh = np.where(F >= 0.0, p["shell_w"], p["shell_w"] * 0.40) + shell = 1.0 / (1.0 + (F / wsh) ** 2) + F -= p["erode"] * shell * (bill - 0.30) + del bill, shell, wsh + + # --- density transfer -------------------------------------------------- + # THE soft-edge lever. logistic(F/s) is ~1 inside and decays as exp(F/s) + # outside, so the projected coverage has an exponential tail of tunable + # length instead of stopping dead at the geometric boundary. + # + # `s` is not a constant over the cloud. A cumulus crown is where the + # updraught is condensing hardest — a sharp boundary a few pixels wide — + # while the base is evaporating into the subcloud layer and trails off over + # tens. The reference art has exactly that asymmetry (its crown edges are + # 4 px, its bases 30+), and a single tail length can only average the two + # into a uniformly furry outline. + vsoft = np.clip( + (meta["base_y"] + meta["height"] * 0.50 - Y) / (0.45 * max(meta["height"], 1e-6)), + 0.0, 1.0, + ) ** 1.5 + s = p["tail"] * (1.0 + p["base_soft"] * vsoft) + rho = 1.0 / (1.0 + np.exp(np.clip(-F / s, -30.0, 30.0))) + + # Internal density variation — the profile's "no internal structure" + # defect. Low frequency, moderate amplitude, and it doubles as the pocket + # field the inner-glow channel is accumulated against, so the glow reads as + # illuminated cavities rather than a copy of coverage. + dens_n = fbm3(rng, p["inner_freq"], 3, 0.5) + rho *= 1.0 + p["inner_amp"] * (dens_n - 0.5) + + # --- tearing the tail into wisps --------------------------------------- + # The mask only acts where the logistic has already fallen off (tailw ramps + # in as F goes negative), so the body is untouched while the halo breaks + # into filaments. Values above 1 make streamers that reach further out than + # the plain exponential would; values near 0 punch the gaps between them. + # `tear_ax` stretches the mask along the wind: a torn veil is a set of + # horizontal filaments, and an isotropic mask makes confetti instead. + tear = fbm3(rng, p["tear_freq"], 3, 0.62, ax=p["tear_ax"], az=p["tear_ax"]) + # Thresholded, not merely scaled: a mask that only dims the tail gives a + # smooth halo, and a smooth halo has no filaments in it. Clipping the + # bottom of the noise away punches genuine holes, which is what turns the + # exponential skirt into separate streamers of measurable thickness. + tear = p["tear_hi"] * np.clip((tear - p["tear_cut"]) / 0.42, 0.0, 1.0) ** 1.3 + tear += p["tear_lo"] + # `tear_in` decides how far in the tearing reaches. Near 0 the body is + # untouched and only its skirt frays (cumulus); near 1 the whole sheet is + # shredded, which is what a torn stratus veil actually is. + tailw = np.clip( + p["tear_in"] + (1.0 - p["tear_in"]) * (-F / (p["tear_reach"] * s)), 0.0, 1.0 + ) + # Tearing is a base-and-flank phenomenon on a cumulus: the crown is the part + # actively condensing and it is where the outline stays smooth and scalloped. + # Applied uniformly, the same mask makes the crown hairy, which is the single + # loudest difference between a generated cloud and the reference at 1:1. + tailw = tailw * (p["tear_crown"] + (1.0 - p["tear_crown"]) * vsoft) + rho *= 1.0 - tailw + tailw * tear + del tear, tailw, F + + np.maximum(rho, 0.0, out=rho) + + # --- integrate --------------------------------------------------------- + dz = WZ / VZ + cum = np.cumsum(rho, axis=0) + cum *= p["sigma_e"] * dz + total = cum[-1].copy() + + # Visible surface, for the normal map: the transmittance-weighted expected + # depth of first scattering. A hard "where does tau cross T" crossing has to + # pick a fallback threshold wherever the ray never reaches T, and the seam + # between the two regimes is a cliff that the normal map turns into a + # scratch. This expectation is continuous everywhere by construction. + w = rho + w *= np.exp(-cum) + zi = np.arange(VZ, dtype=np.float32)[:, None, None] + wsum = w.sum(axis=0) + surf = (w * zi).sum(axis=0) / np.maximum(wsum, 1e-6) / float(VZ) + body = total > 0.35 + + # Inner glow: scattered light that escaped toward the viewer, weighted by + # the interior density noise so it pools in cavities. + glow = (w * dens_n).sum(axis=0) * dz + del w, dens_n, rho, cum + + return {"tau": total, "surf": surf, "body": body, "glow": glow} + + +# -------------------------------------------------------------------------- +# 2-D assembly: coverage, the three data channels, the normal map +# -------------------------------------------------------------------------- +def up(a, order=3): + """Volume resolution -> sprite resolution.""" + out = zoom(a.astype(np.float32), (TILE_H / a.shape[0], TILE_W / a.shape[1]), + order=order, mode="nearest") + return out[:TILE_H, :TILE_W] + + +def bbox_of(mask): + ys, xs = np.nonzero(mask) + if len(ys) == 0: + return 0, 0, mask.shape[1] - 1, mask.shape[0] - 1 + return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()) + + +def extend_outward(h, m): + """Continue `h` smoothly past `m` by diffusion (Laplacian inpainting). + + The obvious alternative — filling the outside with the value of the nearest + body pixel — is a trap. A nearest-value fill is piecewise constant on the + Voronoi cells of the boundary, so its seams are long straight discontinuities + radiating away from every concave notch in the silhouette. They are invisible + in the height field and lethal in the normal map, where they show up as + saturated 1-px scratches across the whole tile. + + Blur-and-restore converges to the harmonic extension, which has no seams: + coarse passes first so distant background gets a sensible value, fine passes + afterwards so the boundary stays sharp. + """ + known = h.astype(np.float32) * m + wt = m.astype(np.float32) + base = gaussian_filter(known, 48.0) / np.maximum(gaussian_filter(wt, 48.0), 1e-6) + out = np.where(m, h, base).astype(np.float32) + for sigma, n in ((16.0, 8), (8.0, 8), (4.0, 6), (2.0, 5), (1.0, 4)): + for _ in range(n): + out = gaussian_filter(out, sigma) + out[m] = h[m] + return out + + +# One-octave split of the height field. The coarsest band (everything above +# ~40 px) is the cloud's lobe structure and is what the eye reads first; the +# rest is relief riding on it. +NSIG = (1.2, 2.4, 4.8, 9.6, 19.2, 38.4) + + +def dog_bands(h, sigmas=NSIG): + g = [gaussian_filter(h, s) for s in sigmas] + b = [h - g[0]] + [g[i] - g[i + 1] for i in range(len(sigmas) - 1)] + [g[-1]] + return b[::-1] # coarsest first + + +def _ladder(ch, mask): + return np.array([float(np.std(b[mask])) for b in dog_bands(ch)]) + + +def shaped_normal(height, mask, ratio, fine_floor=0.095, coarse_boost=1.0, iters=3): + """Gradient of `height` with a band amplitude ladder enforced on the OUTPUT. + + THE fix for "sandpaper or blur, pick one". The height field is split into + one-octave bands and each band's gradient is re-weighted so its amplitude is + `ratio` times the next-coarser band's; the radial power spectrum of the + result is then a power law of slope 2*log2(ratio) - 2 (ratio 0.63 -> about + f^-3.3, where the reference art sits) whatever the raymarch produced. + + The re-weighting alone is not enough, and that is the difference between this + and the previous version. Normalising each band's gradient assumes the + gradient of band j lands in band j, which difference-of-gaussians bands only + roughly do — the leakage biased the realised ladder flat at the coarse end + (measured 0.78 per octave where 0.63 was asked for), which is exactly the + "large-scale gradient too weak, neither scale reads" complaint. So the + ladder is MEASURED on the assembled nx/ny and the weights corrected. Two + passes converge, and the guarantee is then on the thing the eye and the + profile both look at rather than on an intermediate. + + `fine_floor` stops the ladder below ~10 % of the coarsest band: the + reference's finest octave sits at 0.11 of its coarsest, not at 0.63^6, and + driving a band below the 8-bit quantum only spends file size on dither. + """ + bands = dog_bands(height) + gx, gy = [], [] + for b in bands: + by, bx = np.gradient(b) + gx.append(bx) + gy.append(by) + nb = len(bands) + want_rel = np.maximum(ratio ** np.arange(nb), fine_floor) + # The coarsest band gets a thumb on the scale, and it earns it. A clean + # geometric ladder in THESE bins does not come out as a clean ladder when + # re-measured on octave boundaries half a step away: band 0 spans everything + # above ~280 px and spills half its energy into the next bin down, so the + # realised coarse ratio lands near 0.84 where 0.63 was asked for. That is + # the "large-scale gradient too weak" reading exactly — the lobes lose to + # the 20-50 px relief riding on them, and the normal map goes chunky. + want_rel[0] *= coarse_boost + w = want_rel / np.array( + [max(float(np.std(np.hypot(gx[i][mask], gy[i][mask]))), 1e-12) + for i in range(nb)] + ) + nx = ny = None + for it in range(iters): + nx = sum(w[i] * gx[i] for i in range(nb)) + ny = sum(w[i] * gy[i] for i in range(nb)) + if it == iters - 1: + break + got = 0.5 * (_ladder(nx, mask) + _ladder(ny, mask)) + w = w * np.clip(got[0] * want_rel / np.maximum(got, 1e-12), 0.25, 4.0) + return nx, ny + + +def assemble(rng, f, p, meta): + """Turn the marched fields into the four stored channels + the normal.""" + tau = up(f["tau"]) + surf = up(f["surf"]) + glow = up(f["glow"]) + body_v = up(f["body"].astype(np.float32), order=1) > 0.5 + + # --- coverage ---------------------------------------------------------- + # Normalise the peak optical depth rather than the peak alpha: alpha + # saturates, so scaling it cannot control how much of the cloud sits in the + # [0.98, 1] bin. The reference art puts under 1% of its tile there. + hi = float(np.percentile(tau, 99.85)) + tau = tau * (p["tau_peak"] / max(hi, 1e-6)) + + # Full-resolution crenellation of the SILHOUETTE. The volume is marched at + # half the sprite's resolution and then cubic-upsampled, so the boundary it + # produces is a smooth ramp: side by side with the reference at 1:1 the + # difference is not subtle, because a real cumulus edge is a row of little + # cauliflower puffs 5-15 px across, not a blur. + # + # Optical depth decays as exp(F/s) outside the surface, so multiplying it by + # exp(k * detail) TRANSLATES the boundary by k*detail*s — about +-7 px here — + # which buys silhouette detail at full resolution for one 2-D field. + # + # The gate protects the CORE and nothing else. Because tau decays + # exponentially outside the surface, multiplying it by exp(k*det) is a pure + # translation of the boundary — uniform in distance across the whole tail — + # so the tail wants the displacement at full strength; it is only the + # saturated interior that must be held out, and `1/(1+(tau/t0)^2)` does + # exactly that. Leaving the gate out is instructive: the same factor then + # applies at tau=8 in the core, a -3 sigma excursion takes that to tau=0.3, + # and the cloud comes out as lace — the popcorn-coverage failure, arrived at + # from a completely different direction. Confining it the other way, to a + # band around tau~1, leaves the outer isophotes undisplaced and the cloud + # silhouettes as a smooth oval with a scalloped ghost inside it. + # + # The displacement field is BAND-LIMITED after rectification, and that step + # is not cosmetic. sqrt(n^2 + soft^2) is what makes the field's peaks round + # like cauliflower, but rectifying a 50-px octave also manufactures harmonics + # at 25, 16, 12 px, and those harmonics displace the boundary too — into a + # mat of 5-px curls. A reference cumulus edge is scallops 20-60 px across + # with nothing finer; one blur is the difference between scallops and fur. + gate = gaussian_filter(1.0 / (1.0 + (tau / p["edge_gate"]) ** 2), 2.0) + det = gaussian_filter(billow2(rng, p["edge_lam"], 2, 0.40), p["edge_blur"]) + det = np.clip(det / max(float(det.std()), 1e-9), -2.0, 2.0) + # Asymmetric on purpose. A symmetric displacement is as likely to pull the + # boundary IN as push it out, and pulling in where coverage is still 0.4 + # opens a dark pocket just inside the rim: the silhouette comes out as a + # sponge. Cauliflower is the other shape — bulges that stick out, separated + # by shallow crevices — so the inward half is compressed to a third. + det = np.where(det > 0.0, det, det * 0.32) + tau *= np.exp(p["edge_detail"] * gate * (det - float(det.mean()))) + cov = 1.0 - np.exp(-tau) + + # Fine density texture on the face of the cloud. The volume is marched at + # half the sprite's resolution and, more importantly, `1 - exp(-tau)` has a + # derivative of only 0.05 once tau passes 3 — so a core that is physically + # lumpy still bakes out flat. This is the sub-voxel variation the transfer + # function swallowed, put back where the eye and `dens_cov_hp_std_thick` + # both look for it. + # + # It is deliberately WEAK, COARSE, and — the part that matters — confined to + # a MID-coverage window that closes again before full opacity. Pull up a + # reference core at 1:1 and it is flat 255 over hundreds of pixels; any grain + # that survives to cov=1 reads as grey mottle where the reference is clean, + # and it passes `dens_cov_hp_std_thick` while doing so. Coverage is the one + # channel where the eye notices texture the metric tolerates. + grain = spec2(rng, p["grain_lam"], 3, 0.55) - 0.5 + # Where the grain bites is class-dependent. On a cumulus it must stay off + # the 0.1-0.9 rim, or it inflates the edge gradient past the acceptance + # bound; on a torn veil almost nothing is above 0.5, so the same window + # would switch the grain off entirely and leave the sheet glassy. + window = np.clip((cov - p["grain_a"]) / p["grain_b"], 0.0, 1.0) * np.clip( + (p["grain_c"] - cov) / 0.22, 0.0, 1.0 + ) + cov *= 1.0 + p["grain"] * grain * window + cov = np.clip(cov, 0.0, 1.0) + # Kill the far field so the encoder has large constant regions to spend + # nothing on, and so the tile corners read a clean zero. + cov[cov < p["cov_floor"]] = 0.0 + + ext = cov >= 0.05 + if ext.sum() < 500: + raise RuntimeError("cloud vanished") + + # --- R: depth from the silhouette edge --------------------------------- + # A normalised interior distance, not a power of the optical depth. The + # shader erodes low-depth pixels and sweeps the deck reveal through this + # channel, so it has to stay non-zero out on the feathered rim and rise only + # about 2x into the core — a `tau ** 2.6` crushes the rim to zero and gives + # both nothing to read. + # + # But an interior distance ALONE is not what this channel is. On a cumulus + # it is dominated by HEIGHT, not by distance to the outline, and that is a + # visible difference rather than a statistical one. `clouds.frag` reads it + # twice — `edge = smoothstep(iEndEdge, iStartEdge, texDepth)` then + # `alpha = max(alpha*0.5, alpha - edge*edge*0.5)`, and + # `depthAlpha = clamp(texDepth / iSmooth, 0, 1)` once the deck is revealed — + # so a low-depth region is a region the shader thins out to half alpha. A + # distance transform puts that region in a ring around the whole silhouette + # and the cloud reads as a solid slab with a soft outline. A cumulus wants + # it along the BASE: a bright condensing crown standing on an evaporating + # underside that dissolves into the sky. That is what `depth_vert` buys, and + # the ramp below is the shape of it — flat over the upper two-thirds, + # decaying to about a fifth at the very bottom. + din = distance_transform_edt(cov >= 0.32).astype(np.float32) + dref = max(float(np.percentile(din[din > 0], 96.0)), 4.0) + u = np.clip(din / dref, 0.0, 1.0) ** 0.85 + # Blend in a smooth function of coverage. On a torn veil the cov>=0.32 mask + # barely exists, so the distance transform alone leaves the depth channel + # nearly constant — and a constant channel passes a range check while + # carrying no information for `calcDepthAlpha` to sweep. + cs = gaussian_filter(cov, 4.0) + u = 0.62 * u + 0.38 * np.clip(cs / max(float(np.percentile(cs[ext], 96)), 1e-6), + 0.0, 1.0) + u = gaussian_filter(u, 3.0) + # Two further terms, coarse and fine: the coarse one keeps depth from being a + # pure function of coverage (the reference set's depth/coverage correlation + # runs as low as 0.16), the fine one is the relief `dens_depth_hp_std_thick` + # measures inside the core. The coarse term is MIXED IN, not merely added. Depth built as a function of + # coverage plus a small perturbation correlates with coverage at 0.88 on every + # sprite; the reference set runs 0.16 to 0.89 with a dense median of 0.39, + # because on a real cloud "how deep am I" and "how opaque is this column" are + # related but not the same question. `depth_mix` trades one for the other per + # sprite, which is what puts the set's correlations across a range instead of + # stacking them all at the top of it. + ind = spec2(rng, p["depth_ind_lam"], 3, 0.5) + ind = (ind - float(ind[ext].mean())) / max(float(ind[ext].std()), 1e-6) + ind = np.clip(0.5 + 0.28 * ind, 0.0, 1.0) + mix = p["depth_mix"] + depth = ( + p["depth_lo"] + + p["depth_hi"] * ((1.0 - mix) * u + mix * ind) + + p["depth_noise"] * (spec2(rng, 150.0, 3, 0.55) - 0.5) + + p["depth_grain"] * (spec2(rng, p["depth_grain_lam"], 3, 0.5) - 0.5) + ) + # The vertical taper. `hgt` is 1 at the crown and 0 at the lowest pixel of + # the body, measured on the body's own bounding box so a low plate and a + # tall congestus each get the full ramp. Multiplicative, so every structure + # the terms above put into the channel survives — only its level is graded. + ys = np.arange(cov.shape[0], dtype=np.float32)[:, None] + rows = np.nonzero(ext.any(axis=1))[0] + y0, y1 = float(rows[0]), float(rows[-1]) + hgt = 1.0 - (ys - y0) / max(y1 - y0, 1.0) + t = np.clip(hgt / 0.38, 0.0, 1.0) + ramp = 0.18 + 0.82 * (t * t * (3.0 - 2.0 * t)) + depth *= 1.0 - p["depth_vert"] * (1.0 - ramp) + depth = np.clip(depth, 0.0, 1.0) * (cov > 0.0) + + # --- G: backness ------------------------------------------------------- + # "How far through to the far side" = the transmittance of the whole + # column: 1 on a rim you can see straight through, ~0 in a thick core. + # THAT SIGN IS THE POINT — the shader does + # `alpha *= smoothstep(1.0, 0.5, texBack)` and derives `backMulti` (the + # silver lining) from it, so a backness that peaks in the core fades the + # core and lights the middle of the cloud instead of its edge. + # A plain exp(-tau) transmittance is right in spirit but collapses to zero + # over most of the body, leaving `back_std_ext` far below the reference + # 0.075-0.151. A gentle power of the normalised column depth keeps the + # channel spread across its range while preserving the sign. + thin = 1.0 - np.clip(tau / max(p["tau_peak"], 1e-6), 0.0, 1.0) ** p["back_pow"] + thin = gaussian_filter(thin, 3.0) + # A minority of sprites lean the other way — the reference set's dense + # sprites run -0.78..+0.54 and its veils are strongly positive, since a + # veil IS its own far side. + field = thin if p["back_mix"] < 0.5 else 1.0 - thin + # Blend in an independent lobe-scale field. A pure function of column depth + # correlates with coverage at |0.95| whichever way it is signed, and the + # reference's dense sprites sit at -0.78..+0.54 — strongly signed but never + # a copy. This is the term that separates "backness follows thickness" from + # "backness IS thickness". + fi = spec2(rng, p["back_ind_lam"], 3, 0.5) + fi = np.clip(0.5 + 0.30 * (fi - float(fi[ext].mean())) + / max(float(fi[ext].std()), 1e-6), 0.0, 1.0) + field = (1.0 - p["back_ind"]) * field + p["back_ind"] * fi + back = p["back_lo"] + p["back_hi"] * field + back += p["back_noise"] * (spec2(rng, 120.0, 4, 0.55) - 0.5) + back = np.clip(back, 0.0, 1.0) * (cov > 0.0) + + # --- B: inner glow ----------------------------------------------------- + # Lightning lighting the cloud from within: pools of illumination, not a + # copy of coverage. `smoothstep(0.05, 1.0, texInner)` gates it, so the p99 + # has to reach ~0.3-0.6 or the flash never shows. + gl = glow / max(float(np.percentile(glow, 99.5)), 1e-6) + pocket = spec2(rng, 130.0, 4, 0.5) ** 2.6 + # The mask saturates well below full opacity on purpose: multiplying by + # coverage itself makes the glow a copy of the alpha channel (correlation + # 0.88 against the reference's 0.06-0.75) and no lightning reads as coming + # from inside anything. + lit = np.clip((gaussian_filter(gl, 6.0) - 0.008) / 0.055, 0.0, 1.0) + # A whisper of coverage dependence: with none at all the correlation can go + # NEGATIVE on a small torn sprite, and glow that avoids the cloud is as + # wrong as glow that copies it. + inner = (0.06 + 1.10 * pocket) * lit * (0.66 + 0.34 * np.clip(cov * 4.5, 0, 1)) + inner = gaussian_filter(inner, 2.5) + inner = inner / max(float(np.percentile(inner[ext], 99.0)), 1e-6) + # Centre the glow vertically. The pockets are a random field, so left alone + # their centroid wanders tens of pixels either way; the reference glow sits + # within +-6 % of the cloud's own centroid (and, in the cumulus, a shade + # ABOVE it — lightning lights the body, not the base). Solve for the single + # exponential tilt that puts it there rather than hoping. + yy = np.linspace(0.0, 1.0, TILE_H, dtype=np.float32)[:, None] + com = float((cov * yy).sum() / max(cov.sum(), 1e-9)) + ys = np.arange(TILE_H, dtype=np.float32)[:, None] + com_px = com * TILE_H + x0, y0, x1, y1 = bbox_of(cov >= 0.5) + target = com_px - p["inner_bias"] * (y1 - y0 + 1) + lo, hi = -0.06, 0.06 + for _ in range(22): + lam = 0.5 * (lo + hi) + w = inner * np.exp(-lam * (ys - com_px)) + gy = float((w * ys)[ext].sum() / max(float(w[ext].sum()), 1e-9)) + if gy > target: + lo = lam # tilt harder toward the top + else: + hi = lam + inner = inner * np.exp(-0.5 * (lo + hi) * (ys - com_px)) + inner *= p["inner_gain"] / max(float(np.percentile(inner[ext], 99.0)), 1e-6) + inner = np.clip(inner, 0.0, 1.0) * (cov > 0.0) + + # --- normal map -------------------------------------------------------- + # TWO SCALES, BUILT SEPARATELY. Coverage above came from the full-detail + # field so the silhouette stays cauliflower; the normal is built from a + # deliberately SMOOTHED height, with relief added back as its own term. + # + # The coarse height is a dome per lobe, mixed from two sources because + # neither alone is enough: + # `front` the marched first-scatter depth. It domes over every lobe, so + # it carries the crown's internal structure — but it is the + # expectation of almost nothing out in the tail, so it has to be + # trusted only inside the body and diffused outward from there. + # `thick` a blurred coverage. Its gradient is the OUTWARD normal of the + # whole mass: up at the crown, down under the base, out at the + # flanks. That is the big saturated colour field the reference + # has and a raw surface gradient does not, and it is also what + # draws the bright crenellated rim around the silhouette. + solid = gaussian_filter(cov, 2.0) >= 0.30 + solid &= body_v + if solid.sum() < 400: + solid = cov >= 0.15 + front = 1.0 - extend_outward(np.clip(surf, 0.0, 1.0), solid) + front = gaussian_filter(front, p["dome_sig"]) + # Two blurs, not one. The tighter one carries per-lobe doming; the wide one + # is the whole cloud's own dome, and it is what puts real amplitude above + # 300 px — the band the reference art leads with (its coarsest octave is + # 1.6x its next, where a single 12-px blur gives 1.2) and the band that makes + # a normal map read as one lit solid instead of a field of separate bumps. + thick = gaussian_filter(np.clip(cov, 0.0, 1.0) ** 0.65, p["dome_sig"] * 1.5) + thick = thick + 1.35 * gaussian_filter(np.clip(cov, 0.0, 1.0) ** 0.65, 40.0) + + core = gaussian_filter(cov, 2.0) > 0.25 + if core.sum() < 400: + core = ext + + def znorm(a): + return (a - float(a[core].mean())) / max(float(a[core].std()), 1e-9) + + dome = p["dome_mix"] * znorm(front) + (1.0 - p["dome_mix"]) * znorm(thick) + + # Fine relief: an independent cauliflower field, not a residual of the + # march. Modulated by a smooth body mask so the empty tile stays flat, and + # slightly stronger where the cloud faces the viewer, as real cauliflower is. + mrel = np.clip((gaussian_filter(cov, 6.0) - 0.05) / 0.35, 0.0, 1.0) + relief = billow2(rng, p["relief_lam"], 5, 0.45) * (0.30 + 0.70 * mrel) + height = dome + p["relief"] * relief + + nx, ny = shaped_normal(height, core, p["band_ratio"], p["fine_floor"], + p["coarse_boost"]) + + # `height` rises toward the viewer, so the outward normal is + # (-dh/dx_world, -dh/dy_world). Row index runs DOWN the image while world y + # runs up, so the y term flips sign a second time and comes out positive. + # Getting that one sign wrong is not subtle and not caught by any metric in + # the profile: it turns the cloud inside out, lighting the base and + # shadowing the crown, and on a contact sheet it shows as magenta on top of + # green where the reference is green on top of magenta. + nx = -nx + + # Saturate, the way a real surface normal saturates. A raw gradient has + # Gaussian-or-worse tails: the reference's |nx| p99 is 2.0 sigma, a plain + # band-weighted gradient measures 3.2 — a pale field with hot specks in it, + # which is precisely how the earlier normal maps read. Dividing by + # sqrt(1 + |n|^2) is the same map that turns a height gradient into a unit + # normal; it compresses the specks and fills the mid-range, giving the broad + # saturated colour fields the reference has. + sd = float(np.std(np.stack([nx[core], ny[core]]))) + nx /= max(sd, 1e-9) + ny /= max(sd, 1e-9) + c = p["sat"] + damp = 1.0 / np.sqrt(1.0 + (nx * nx + ny * ny) / (c * c)) + nx *= damp + ny *= damp + sd = float(np.std(np.stack([nx[ext], ny[ext]]))) + gain = p["nrm_xy"] / max(sd, 1e-9) + nx = np.clip(nx * gain, -0.98, 0.98) + ny = np.clip(ny * gain, -0.98, 0.98) + + # z: the reference stores an un-normalised normal, so z is a free scalar that sets + # how strongly the surface tilts. Derive it from local flatness — high where + # the surface faces the viewer, low on steep rims — then map it through a + # rank transform onto a Gaussian with the target mean and spread. Fitting + # mean and std directly instead leaves the shape of `flat`, which is skewed, + # and lands nrm_z_p99 at 0.25 against a reference 0.36. `mapN.z = + # max(mapN.z, 0.08)` in the shader flattens any pixel that lands negative, + # so the negative tail is kept small on the dense sprites. + slope = gaussian_filter(np.hypot(nx, ny), 4.0) + flat = 1.0 / np.sqrt(1.0 + (slope * 2.4) ** 2) + nz = np.zeros_like(flat) + vals = flat[ext] + order = np.argsort(vals, kind="stable") + ranks = np.empty(vals.size, dtype=np.float32) + ranks[order] = (np.arange(vals.size, dtype=np.float32) + 0.5) / vals.size + nz[ext] = p["nrm_z_mean"] + p["nrm_z_std"] * ndtri( + np.clip(ranks, 1e-4, 1 - 1e-4) + ).astype(np.float32) + nz = np.clip(nz, -0.42, 0.75) + + # Fade the normal to flat grey at the visible edge. Narrow on purpose: the + # reference keeps its normal saturated right out to the silhouette and drops + # to grey there, and that bright crenellated outline is a large part of what + # makes it read as a lit solid. The mask is keyed to a blurred coverage so it + # cannot inherit every detached speck in the halo and multiply that structure + # into nx/ny, which would land squarely in the sub-16-px band the sandpaper + # gate measures. + vis = np.clip((gaussian_filter(cov, 1.4) - 0.012) / 0.055, 0.0, 1.0) + vis = gaussian_filter(vis, 1.1) + nx *= vis + ny *= vis + nz *= vis + + return { + "cov": cov, + "depth": depth, + "back": back, + "inner": inner, + "normal": np.stack([nx, ny, nz], axis=-1), + } + + +# -------------------------------------------------------------------------- +# per-sprite parameters +# -------------------------------------------------------------------------- +def draw_params(rng, spec, meta): # noqa: C901 + """Everything that varies sprite to sprite. + + The profile's variety gate is not satisfied by re-seeding noise: it measures + the spread of aspect, lobe count, edge width, halo mass, normal roughness + and normal wavelength across the set. So each of those has its own drawn + parameter here, not a shared constant. + """ + veil = meta["veil"] + over = spec[7] + solid = bool(over.get("solid", 0)) + p = { + "warp": float(rng.uniform(0.09, 0.17)), + "warp_freq": float(rng.uniform(0.55, 0.95)), + "shear": float(rng.uniform(0.5, 1.3)) if veil else float(rng.uniform(-0.22, 0.22)), + "blend": float(rng.uniform(0.17, 0.24)), + "base_blend": float(rng.uniform(0.07, 0.13)), + "base_wobble": float(rng.uniform(0.13, 0.24)), + "erode_freq": float(rng.uniform(2.6, 4.0)), + "erode": float(rng.uniform(0.24, 0.34)), + "shell_w": float(rng.uniform(0.07, 0.12)), + "inner_freq": float(rng.uniform(0.8, 1.6)), + "inner_amp": float(rng.uniform(0.24, 0.44)), + "tear_freq": float(rng.uniform(3.4, 4.6)), + "tear_reach": float(rng.uniform(2.0, 2.8)), + "tear_cut": float(rng.uniform(0.34, 0.48)), + "tear_in": float(rng.uniform(0.0, 0.05)), + "tear_ax": float(rng.uniform(1.2, 1.8)), + "tear_crown": float(rng.uniform(0.18, 0.34)), + # How much longer the base's exponential tail is than the crown's. + # The reference art's crown edges are ~4 px and its bases 30+, so the + # ratio wants to be several-fold, not the 1.8x an earlier value gave. + # This is the broad soft apron a cumulus stands on, and it is what + # the depth channel's vertical taper then fades out — the two terms + # are the same feature seen from the silhouette and from the shading. + "base_soft": float(rng.uniform(1.05, 2.05)), + "sigma_e": 1.0, + "grain": float(rng.uniform(0.090, 0.125)), + "grain_lam": float(rng.uniform(34.0, 52.0)), + "grain_a": 0.42, + "grain_b": 0.30, + "grain_c": 1.13, + "cov_floor": 0.0095, + "edge_detail": float(rng.uniform(0.90, 1.15)), + "edge_lam": float(rng.uniform(46.0, 66.0)), + "edge_gate": float(rng.uniform(1.00, 1.55)), + "edge_blur": float(rng.uniform(2.6, 3.8)), + # How much of the depth channel is graded by height rather than by + # distance to the outline. A cumulus is nearly all height (its base + # evaporates, so the shader's erosion band belongs there); a shredded + # fractus or a stratocumulus deck has no single base to fade and keeps + # the distance reading. Overridden per family below. + "depth_vert": float(rng.uniform(0.80, 0.95)), + "depth_lo": float(rng.uniform(0.085, 0.135)), + "depth_mix": float(rng.uniform(0.12, 0.58)), + "depth_ind_lam": float(rng.uniform(90.0, 170.0)), + "depth_grain_lam": float(rng.uniform(54.0, 76.0)), + # Sized so the body's plateau lands near 0.30. `clouds.frag` erodes + # everything under `iEndEdge` = 0.23 and scales alpha by + # `texDepth / iSmooth` = /0.35, so a channel that plateaus at 0.40 is + # one the erosion never reaches and the reveal saturates — the cloud + # comes out uniformly opaque with a hard outline. The reference set + # plateaus at 0.31 and puts 41 % of its body inside the band. + "depth_hi": float(rng.uniform(0.33, 0.42)), + "depth_noise": float(rng.uniform(0.16, 0.26)), + "depth_grain": float(rng.uniform(0.10, 0.15)), + # Placed so the channel spans the reference's operating band. What the + # shader makes of backness is + # `backMulti = smoothstep(0.05, 1, mix(1 - iCloudDepth, 1, texBack))`, + # which drives the base colour gain from 1.17x to the 1.8 ceiling — so + # backness IS the silver lining, and its SPAN is the lining's contrast. + # A channel bunched around its middle lights the whole cloud evenly and + # the rim stops reading as a rim. Kept below 0.5 at p98 on the dense + # families, since past that `alpha *= smoothstep(1, 0.5, texBack)` + # starts eating the cloud itself — which is a thing veils do and + # cumulus do not. + "back_lo": float(rng.uniform(0.00, 0.05)), + "back_hi": float(rng.uniform(0.44, 0.64)), + "back_pow": float(rng.uniform(0.55, 0.95)), + "back_noise": float(rng.uniform(0.07, 0.13)), + "back_ind": float(rng.uniform(0.30, 0.56)), + "back_ind_lam": float(rng.uniform(90.0, 180.0)), + # <0.5 = backness is inverse to thickness (the reading the shader + # needs); >0.5 flips it. Drawn away from the middle so both modes keep + # a full spread — a half-and-half blend would flatten the channel. + "back_mix": 0.0 if rng.random() < 0.68 else 1.0, + "inner_gain": float(rng.uniform(0.40, 0.54)), + "inner_bias": float(rng.uniform(-0.010, 0.045)), + # normal-map shaping + "dome_sig": float(rng.uniform(6.0, 10.0)), + "dome_mix": float(rng.uniform(0.46, 0.66)), + "relief": float(rng.uniform(0.26, 0.38)), + "relief_lam": float(rng.uniform(46.0, 72.0)), + "band_ratio": float(rng.uniform(0.53, 0.60)), + "fine_floor": float(rng.uniform(0.030, 0.075)), + "coarse_boost": float(rng.uniform(1.24, 1.48)), + "sat": float(rng.uniform(1.35, 1.75)), + "nrm_xy": float(rng.uniform(0.18, 0.28)), + "nrm_z_mean": float(rng.uniform(0.10, 0.19)), + "nrm_z_std": float(rng.uniform(0.085, 0.155)), + } + if veil: + p.update( + tail=float(rng.uniform(0.075, 0.100)), + tear_lo=0.0, + tear_hi=float(rng.uniform(1.9, 2.6)), + tear_cut=float(rng.uniform(0.60, 0.70)), + tear_in=float(rng.uniform(0.72, 1.0)), + tear_reach=float(rng.uniform(1.2, 2.0)), + tear_freq=float(rng.uniform(4.0, 6.0)), + tear_crown=1.0, + base_soft=float(rng.uniform(0.10, 0.40)), + tear_ax=float(rng.uniform(3.0, 4.5)), + erode=float(rng.uniform(0.24, 0.38)), + base_wobble=float(rng.uniform(0.04, 0.10)), + back_lo=float(rng.uniform(0.10, 0.16)), + back_hi=float(rng.uniform(0.36, 0.50)), + depth_vert=float(rng.uniform(0.20, 0.34)), + depth_hi=float(rng.uniform(0.50, 0.62)), + inner_bias=float(rng.uniform(-0.03, 0.02)), + depth_grain=float(rng.uniform(0.15, 0.21)), + depth_noise=float(rng.uniform(0.14, 0.22)), + grain=float(rng.uniform(0.34, 0.48)), + grain_lam=float(rng.uniform(24.0, 34.0)), + grain_a=0.03, + grain_b=0.22, + grain_c=1.30, + cov_floor=0.0045, + edge_detail=float(rng.uniform(0.30, 0.46)), + edge_lam=float(rng.uniform(34.0, 52.0)), + edge_gate=float(rng.uniform(0.30, 0.55)), + edge_blur=float(rng.uniform(1.6, 2.6)), + inner_gain=float(rng.uniform(0.44, 0.58)), + tau_peak=float(rng.uniform(0.95, 1.25)), + back_mix=1.0, + blend=float(rng.uniform(0.10, 0.16)), + dome_sig=float(rng.uniform(4.0, 6.5)), + dome_mix=float(rng.uniform(0.20, 0.40)), + band_ratio=float(rng.uniform(0.66, 0.72)), + fine_floor=float(rng.uniform(0.09, 0.15)), + coarse_boost=float(rng.uniform(1.05, 1.25)), + sat=float(rng.uniform(1.5, 2.0)), + nrm_xy=float(rng.uniform(0.15, 0.21)), + nrm_z_mean=float(rng.uniform(0.004, 0.020)), + nrm_z_std=float(rng.uniform(0.014, 0.030)), + relief=float(rng.uniform(0.34, 0.50)), + relief_lam=float(rng.uniform(26.0, 40.0)), + ) + elif solid: + # A near-opaque cumulus: high optical depth and a narrow skirt. Two of + # the nine reference cumulus are like this, and without them the set has + # no spread in edge softness or mean coverage at all. + p.update( + tail=float(rng.uniform(0.020, 0.028)), + tau_peak=float(rng.uniform(5.5, 8.0)), + tear_lo=float(rng.uniform(0.14, 0.26)), + tear_hi=float(rng.uniform(0.95, 1.20)), + tear_reach=float(rng.uniform(1.6, 2.4)), + ) + else: + p.update( + tail=float(rng.uniform(0.028, 0.039)), + tau_peak=float(rng.uniform(3.4, 5.2)), + tear_lo=float(rng.uniform(0.12, 0.24)), + tear_hi=float(rng.uniform(1.00, 1.30)), + ) + # Family overrides last: this is where the set is tuned as a set. + for key, val in over.items(): + if key in ("solid", "nbody"): + continue + p[key] = float(rng.uniform(*val)) if isinstance(val, tuple) else float(val) + return p + + +# -------------------------------------------------------------------------- +# packing +# -------------------------------------------------------------------------- +def to_image(ch): + """Pack into the 512x576 two-part sprite (see the contract at the top).""" + cov = ch["cov"] + h, w = cov.shape + top = np.zeros((h, w, 4), dtype=np.uint8) + # The normal map is about half the file. Rounding it to even codes drops + # ~8 % of the whole set's bytes. Safe because the shader magnifies the + # sprite ~2.7x through its own bilinear tap (`sampleSprite`), which + # interpolates the 2/255 steps back into a continuous ramp, and because the + # normal is un-normalised — only the xy:z RATIO is used, so a common step + # cancels. + nq = np.clip(np.rint((ch["normal"] * 0.5 + 0.5) * 127.5), 0, 127).astype(np.uint8) + top[..., :3] = nq * 2 + top[..., 3] = 255 # premultiply must be a no-op on the normal + bot = np.zeros((h, w, 4), dtype=np.uint8) + bot[..., 0] = np.clip(np.rint(ch["depth"] * 255.0), 0, 255).astype(np.uint8) + bot[..., 1] = np.clip(np.rint(ch["back"] * 255.0), 0, 255).astype(np.uint8) + bot[..., 2] = np.clip(np.rint(ch["inner"] * 255.0), 0, 255).astype(np.uint8) + bot[..., 3] = np.clip(np.rint(cov * 255.0), 0, 255).astype(np.uint8) + return Image.fromarray(np.concatenate([top, bot], axis=0), "RGBA") + + +def save(img, path): + """Lossless WebP. The lower half's four channels are independent scalar + fields — a lossy codec corrupts depth, backness and coverage, and the shader + divides RGB by coverage, which amplifies any error it introduces.""" + img.save(path, "WEBP", lossless=True, quality=100, method=6, exact=True) + + +# -------------------------------------------------------------------------- +# sheets +# -------------------------------------------------------------------------- +def contact_sheet(paths, out, scale=0.42): + """All sprites across: coverage on top, normal map below.""" + pw, ph = int(TILE_W * scale), int(TILE_H * scale) + pad, lab = 6, 14 + n = len(paths) + W = pad + n * (pw + pad) + H = 22 + lab + ph + 4 + ph + pad + sheet = Image.new("RGB", (W, H), (20, 24, 32)) + d = ImageDraw.Draw(sheet) + d.text((pad, 5), "generated cloud sprites — coverage (top) / normal map (bottom)", + fill=(190, 200, 215)) + for i, path in enumerate(paths): + a = np.asarray(Image.open(path).convert("RGBA")).astype(np.float32) / 255.0 + nrm, bot = a[:TILE_H], a[TILE_H:] + cov = bot[..., 3] + slate = np.array([20.0, 24.0, 32.0]) + sil = slate[None, None, :] * (1 - cov[..., None]) + 255.0 * cov[..., None] + x = pad + i * (pw + pad) + sheet.paste(Image.fromarray(sil.astype(np.uint8), "RGB").resize((pw, ph), + Image.LANCZOS), (x, 22 + lab)) + sheet.paste(Image.fromarray((nrm[..., :3] * 255).astype(np.uint8), "RGB") + .resize((pw, ph), Image.LANCZOS), (x, 22 + lab + ph + 4)) + d.text((x, 22), "%s %.0fkB" % (os.path.basename(path), os.path.getsize(path) / 1024), + fill=(150, 165, 185)) + sheet.save(out) + return out + + +def compare_sheet(ref_path, mine_path, out): + """One reference sprite beside one of ours: coverage and normal, same scale. + + For visual comparison only — the reference is never read at generation time. + """ + scale = 0.72 + pw, ph = int(TILE_W * scale), int(TILE_H * scale) + pad = 10 + sheet = Image.new("RGB", (pad + 2 * (pw + pad), 40 + 2 * (ph + pad)), (20, 24, 32)) + d = ImageDraw.Draw(sheet) + d.text((pad, 6), "left: the reference reference (comparison only) right: generated original", + fill=(190, 200, 215)) + for col, path in enumerate((ref_path, mine_path)): + a = np.asarray(Image.open(path).convert("RGBA")).astype(np.float32) / 255.0 + nrm, bot = a[:TILE_H], a[TILE_H:] + cov = bot[..., 3] + slate = np.array([20.0, 24.0, 32.0]) + sil = slate[None, None, :] * (1 - cov[..., None]) + 255.0 * cov[..., None] + x = pad + col * (pw + pad) + sheet.paste(Image.fromarray(sil.astype(np.uint8), "RGB").resize((pw, ph), + Image.LANCZOS), (x, 24)) + sheet.paste(Image.fromarray((nrm[..., :3] * 255).astype(np.uint8), "RGB") + .resize((pw, ph), Image.LANCZOS), (x, 24 + ph + pad)) + d.text((x, 24 + ph + 1), os.path.basename(path), fill=(150, 165, 185)) + sheet.save(out) + return out + + +# -------------------------------------------------------------------------- +def render_one(seed, spec): + rng = np.random.default_rng(seed) + blobs, pend, meta = build_shape(rng, spec) + p = draw_params(rng, spec, meta) + f = march(rng, blobs, pend, meta, p) + return assemble(rng, f, p, meta) + + +# Which archetype fills each sprite slot. +# +# The slot index is not cosmetic. `sky_clouds.dart`'s layouts pick sprites by +# index — `fair` uses 0/2/4, `scattered` 0-4, `overcast` 0/3/8/9/10 and `rain` +# 3/8/9/10/11 — so slots 0..4 have to be well-formed standalone cumulus and +# 8..11 the wide decks and broken clusters a rain sky is built from. Slots 5..7 +# are referenced by no layout at all, which is where the torn veils park: they +# cost 50 kB each and nothing draws them, but dropping them would renumber +# everything above and silently rewrite every deck. +SLOT_ARCHETYPE = (0, 1, 2, 3, 8, 9, 10, 11, 4, 5, 6, 7) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", default="assets/weather/clouds") + ap.add_argument("--count", type=int, default=12) + ap.add_argument("--seed", type=int, default=7) + ap.add_argument("--start", type=int, default=0) + ap.add_argument("--sheet", default=None) + ap.add_argument("--compare", default=None, + help="REF_SPRITE:OUT_PNG — side-by-side with one reference") + args = ap.parse_args() + + os.makedirs(args.out, exist_ok=True) + paths = [] + for i in range(args.start, args.start + args.count): + spec = ARCHETYPES[SLOT_ARCHETYPE[i % len(SLOT_ARCHETYPE)]] + t0 = time.time() + ch = render_one(args.seed * 1000003 + SLOT_ARCHETYPE[i % 12] * 7919, spec) + path = os.path.join(args.out, "%02d.webp" % i) + save(to_image(ch), path) + paths.append(path) + print("%-22s %-11s %7d B %5.1fs" + % (os.path.basename(path), spec[0], os.path.getsize(path), time.time() - t0)) + + total = sum(os.path.getsize(p) for p in paths) + print("total %d B (%.3f MB)" % (total, total / 1e6)) + + if args.sheet: + print("wrote", contact_sheet(sorted(paths), args.sheet)) + if args.compare: + ref, outp = args.compare.split(":") + print("wrote", compare_sheet(ref, sorted(paths)[0], outp)) + + +if __name__ == "__main__": + main() diff --git a/tool/gen_particle_sprites.py b/tool/gen_particle_sprites.py new file mode 100644 index 000000000..369bd4873 --- /dev/null +++ b/tool/gen_particle_sprites.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Generate every particle texture the weather scene draws. + +All four are DPIP's own artwork, generated rather than extracted. Two of them +(`particle_blurred`, `drop_normal`) are not artwork at all once you look at +them — they are a blurred disc and an analytic hemisphere — so they are +reproduced from the maths directly and land within a couple of levels of the +textures they replace. The other two are drawn to the same *character* as the +originals without being the same picture. + +Outputs, all into `assets/weather/particles/`: + + rain_drop.webp 128x68 4 width variants of a motion-blurred streak + snow_flake.webp 40x40 a soft out-of-focus flake + particle_blurred.webp 64x64 the metaball kernel the card water splats + drop_normal.webp 128x128 hemisphere normal map for one water drop + +Run: `python3 tool/gen_particle_sprites.py` (needs numpy, scipy, pillow). +""" + +import argparse +import os + +import numpy as np +from PIL import Image +from scipy.ndimage import gaussian_filter + +WEBP = dict(format="WEBP", lossless=True, quality=100, method=6, exact=True) + + +# --------------------------------------------------------------------------- +# rain +# --------------------------------------------------------------------------- +# One atlas cell. `drawAtlas` can only scale uniformly, but the engine's rain +# quad is `startSize * 0.4 * (3 - 2*depth)` wide against `startSize * 3.0` tall +# — so width over length is `0.1333 * (3 - 2*depth)` and a *distant* drop is +# relatively the widest. That ratio has to live in the texture instead: four +# width variants across one row, widest first, indexed by depth. +CELL_W, CELL_H = 32, 68 + +# Ink width in texels = (0.2222 - 0.1481 * depth) * CELL_H, evaluated at depth +# 0, 1/3, 2/3, 1. The 0.2222 is that width-over-length ratio scaled by the +# fraction of the source quad its ink actually covered. +RAIN_INK = (15, 12, 8, 5) + +# Fraction of the streak's length taken by the head's falloff — the drop's own +# diameter smeared over one frame, which is what stops the sprite ending in a +# hard edge. +HEAD = 0.06 +# The tail's ramp. 1.0 is a linear wedge; a little convexity keeps the faint +# end from reading as a hairline. +TAIL_GAMMA = 1.3 + +FLAKE = 40 +KERNEL = 64 +NORMAL = 128 + + +def _cross_section(u): + """Lateral profile at |x| / halfInk, zero outside the ink. + + Half-maximum lands at u = 0.61, so ink extent / FWHM is 1.64 — the soft + shoulders of a blurred drop rather than a Gaussian's long tails. + """ + return np.where(u >= 1.0, 0.0, np.clip(1.0 - u * u, 0.0, None) ** 1.5) + + +def rain_cell(ink): + """One motion-blurred streak: faint tail at the top, bright head below.""" + # 4x supersampled across the texel — the narrowest variant is five texels + # wide and point sampling quantises its width away. + sub = (np.arange(CELL_W * 4) + 0.5) / 4.0 - 0.5 + cx = CELL_W / 2.0 - 0.5 + lateral = _cross_section(np.abs(sub - cx) / (ink / 2.0)) + lateral = lateral.reshape(CELL_W, 4).mean(axis=1) + + t = np.linspace(0.0, 1.0, CELL_H) + body = np.clip(t / (1.0 - HEAD), 0.0, 1.0) ** TAIL_GAMMA + # Cosine shoulder over the head, so the leading edge fades instead of + # clipping — a hard edge there reads as a falling matchstick. + k = np.clip((t - (1.0 - HEAD)) / HEAD, 0.0, 1.0) + head = 0.5 * (1.0 + np.cos(np.pi * k)) + along = np.where(t <= 1.0 - HEAD, body, head) + + return np.clip(along[:, None] * lateral[None, :], 0.0, 1.0) + + +def rain_atlas(): + alpha = np.concatenate([rain_cell(ink) for ink in RAIN_INK], axis=1) + rgba = np.zeros(alpha.shape + (4,), dtype=np.uint8) + # White; the per-particle colour tints it. The engine's own streak texture + # is white too — night rain dims because its alpha is tiny over a dark sky, + # not because the drops are coloured. + rgba[..., :3] = 250 + rgba[..., 3] = np.clip(alpha * 255.0, 0, 255).astype(np.uint8) + return Image.fromarray(rgba, "RGBA") + + +# --------------------------------------------------------------------------- +# snow +# --------------------------------------------------------------------------- +def snow_flake(seed=7): + """A soft, out-of-focus flake. + + Not a dendrite: at the size these are drawn a six-armed crystal turns into + grey mush, and the texture being replaced is itself an out-of-focus faceted + grain. This builds the same thing — an irregular hexagonal crystal, blurred + past the point of recognition — so the silhouette keeps a few flat facets + instead of collapsing to a circle. + """ + rng = np.random.default_rng(seed) + yy, xx = np.mgrid[0:FLAKE, 0:FLAKE].astype(np.float32) + c = (FLAKE - 1) / 2.0 + + # An irregular convex crystal: half-planes at jittered angles and distances, + # intersected. Ice grows on facets, so the silhouette wants straight edges + # and corners — a pile of discs only ever gives a lumpy oval. + theta = np.arctan2(yy - c, xx - c) + r = np.hypot(xx - c, yy - c) + sides = 6 + field = np.full((FLAKE, FLAKE), np.inf, dtype=np.float32) + for i in range(sides): + ang = 2.0 * np.pi * (i + rng.uniform(-0.18, 0.18)) / sides + reach = rng.uniform(0.26, 0.36) * FLAKE + # Distance inside the half-plane whose outward normal points at `ang`. + field = np.minimum(field, reach - r * np.cos(theta - ang)) + field = np.clip(field, 0.0, 1.0) + + # Blur, then push the interior back to solid. Blurring alone gives an even + # Gaussian smudge; what a flake at this size actually looks like is a solid + # irregular core with a soft rim, so the core has to be restored after the + # blur has done its work on the silhouette. + alpha = np.clip(gaussian_filter(field, FLAKE * 0.050) * 1.7, 0.0, 1.0) + # A round vignette guarantees the sprite reaches zero at its border, so + # neighbouring atlas cells can never bleed into it. + r = np.hypot(xx - c, yy - c) / c + alpha *= np.clip(1.0 - r ** 6, 0.0, 1.0) + alpha /= max(alpha.max(), 1e-6) + + rgba = np.zeros((FLAKE, FLAKE, 4), dtype=np.uint8) + rgba[..., :3] = 255 + rgba[..., 3] = np.clip(alpha * 255.0, 0, 255).astype(np.uint8) + return Image.fromarray(rgba, "RGBA") + + +# --------------------------------------------------------------------------- +# card-water particle +# --------------------------------------------------------------------------- +# The kernel the card-water pass splats per particle, then thresholds to get a +# metaball. Fitting the texture it replaces recovers exactly what its name +# says: a hard disc, blurred. Radius and sigma below are that fit (RMSE 0.023 +# over the whole 64x64), so this is the same function, not a copy of the file. +KERNEL_RADIUS = 26.23 +KERNEL_SIGMA = 3.30 + + +def particle_kernel(): + yy, xx = np.mgrid[0:KERNEL, 0:KERNEL].astype(np.float32) + c = (KERNEL - 1) / 2.0 + r = np.hypot(xx - c, yy - c) + disc = np.clip(KERNEL_RADIUS + 0.5 - r, 0.0, 1.0) # antialiased edge + alpha = np.clip(gaussian_filter(disc, KERNEL_SIGMA), 0.0, 1.0) + + rgba = np.zeros((KERNEL, KERNEL, 4), dtype=np.uint8) + rgba[..., :3] = 255 + rgba[..., 3] = np.clip(alpha * 255.0, 0, 255).astype(np.uint8) + return Image.fromarray(rgba, "RGBA") + + +def drop_normal(): + """The per-drop normal map: a plain hemisphere, `n = (x, y, sqrt(1-r^2))`. + + Verified against the texture it replaces rather than guessed — that one is + the analytic hemisphere to the level: at r = 0.9375 it stores (8, 128, 173) + and this returns (8, 128, 173). There is no artwork here to infringe. + """ + yy, xx = np.mgrid[0:NORMAL, 0:NORMAL].astype(np.float64) + c = (NORMAL - 1) / 2.0 + x = (xx - c) / (NORMAL / 2.0) + y = (yy - c) / (NORMAL / 2.0) + r2 = x * x + y * y + inside = r2 <= 1.0 + z = np.sqrt(np.clip(1.0 - r2, 0.0, None)) + + rgba = np.zeros((NORMAL, NORMAL, 4), dtype=np.uint8) + for i, comp in enumerate((x, y, z)): + rgba[..., i] = np.clip((comp * 0.5 + 0.5) * 255.0, 0, 255).astype(np.uint8) + rgba[..., 3] = np.where(inside, 255, 0).astype(np.uint8) + rgba[~inside, :3] = 0 + return Image.fromarray(rgba, "RGBA") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--out", default="assets/weather/particles") + args = ap.parse_args() + os.makedirs(args.out, exist_ok=True) + + for name, image in ( + ("rain_drop.webp", rain_atlas()), + ("snow_flake.webp", snow_flake()), + ("particle_blurred.webp", particle_kernel()), + ("drop_normal.webp", drop_normal()), + ): + path = os.path.join(args.out, name) + image.save(path, **WEBP) + print("wrote %-22s %-9s %6d bytes" % (name, "%dx%d" % image.size, + os.path.getsize(path))) + + +if __name__ == "__main__": + main() diff --git a/tool/gen_sky_textures.py b/tool/gen_sky_textures.py new file mode 100644 index 000000000..f34aa7bc0 --- /dev/null +++ b/tool/gen_sky_textures.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Generate the sky's four support textures. + +Two of these are pictures (a star field, a ray fan) and are generated freely — +the output is a different sky and a different fan, not a redraw of anything. +The other two are one-dimensional lookup curves that a lens-flare shader +indexes by radius; those are *functions*, so they are reproduced as functions, +fitted to the optics they describe rather than copied sample by sample. + +Outputs, all into `assets/weather/sky/`: + + starmap.webp 2048x1024 equirectangular night sky + sun_rays.webp 512x512 radial ray fan for the sun's flare + sun_profile.webp 540x2 flare falloff vs radius, per channel + annulus.webp 540x4 the dispersed halo ring, per channel + +Run: `python3 tool/gen_sky_textures.py` (needs numpy, scipy, pillow). +""" + +import argparse +import os + +import numpy as np +from PIL import Image +from scipy.ndimage import gaussian_filter, map_coordinates + +WEBP_LOSSLESS = dict(format="WEBP", lossless=True, quality=100, method=6, exact=True) + +STAR_W, STAR_H = 2048, 1024 +# How the field is dialled in. The targets are the sky this replaces: a mean +# level around 20/255 with only ~5 % of pixels above 40 — a dim, dense haze +# with sparse bright stars, not a scatter of hot pixels. +STAR_COUNT = 38_000 +# A steep magnitude law is what separates a sky from static: at gamma 4.5 the +# overwhelming majority of stars sit below the visible threshold and contribute +# only to the haze, and a few dozen stand out. A shallow law puts every star at +# the same middling grey and the field reads as sensor noise. +STAR_GAMMA = 4.5 +STAR_PEAK = 0.95 +MILKY_LEVEL = 0.055 +STAR_BLUR = 1.0 +STAR_FLOOR = 0.020 +RAYS = 512 +LUT = 540 + + +# --------------------------------------------------------------------------- +# star field +# --------------------------------------------------------------------------- +def starmap(seed=20260809): + """An equirectangular night sky. + + Stars are sampled uniformly **on the sphere**, not on the image: an + equirectangular map stretches enormously toward the poles, so a uniform + scatter in image space piles stars up at the top and bottom edges where the + projection is most obvious. + + Brightness follows a power law — a great many faint stars and a handful of + bright ones — because an evenly-lit field reads as noise. Colour comes from + a blackbody-ish blue/orange spread around white, and the brightest few get + a small cross-shaped bloom, which is what makes them read as *stars* rather + than as hot pixels. + """ + rng = np.random.default_rng(seed) + + # A faint galactic band: low-frequency noise confined near a great circle + # drawn across the map, so the sky is not uniformly empty. + lon = np.linspace(0.0, 2.0 * np.pi, STAR_W, endpoint=False)[None, :] + lat = np.linspace(np.pi / 2, -np.pi / 2, STAR_H)[:, None] + tilt, phase = 0.42, 1.1 + band = np.arcsin( + np.clip( + np.sin(lat) * np.cos(tilt) + - np.cos(lat) * np.sin(tilt) * np.sin(lon - phase), + -1.0, + 1.0, + ) + ) + coarse = gaussian_filter(rng.random((64, 128)).astype(np.float32), 3.0, mode="wrap") + clouds = map_coordinates( + coarse, + np.meshgrid( + np.linspace(0, 64, STAR_H, endpoint=False), + np.linspace(0, 128, STAR_W, endpoint=False), + indexing="ij", + ), + order=1, + mode="grid-wrap", + ) + clouds = (clouds - clouds.min()) / max(float(clouds.max() - clouds.min()), 1e-6) + milky = np.exp(-(band / 0.22) ** 2) * (0.35 + 0.65 * clouds) + + # Stars, sampled uniformly on the sphere and denser inside the band. + stars = np.zeros((STAR_H, STAR_W, 3), dtype=np.float32) + count = STAR_COUNT + z = rng.uniform(-1.0, 1.0, count) + theta = rng.uniform(0.0, 2.0 * np.pi, count) + ys = ((np.arcsin(z) - np.pi / 2) / -np.pi * (STAR_H - 1)).astype(np.int32) + xs = (theta / (2.0 * np.pi) * STAR_W).astype(np.int32) % STAR_W + keep = rng.random(count) < 0.45 + 0.55 * milky[ys, xs] / max(milky.max(), 1e-6) + xs, ys = xs[keep], ys[keep] + + mag = np.clip(rng.random(xs.size) ** STAR_GAMMA, 1e-4, 1.0) * STAR_PEAK + warmth = rng.normal(0.0, 0.16, xs.size) + tint = np.stack( + [ + np.clip(1.0 + warmth * 0.55, 0.6, 1.4), + np.ones_like(warmth), + np.clip(1.0 - warmth * 0.55, 0.6, 1.4), + ], + axis=1, + ).astype(np.float32) + np.add.at(stars, (ys, xs), mag[:, None] * tint) + + # Spread each star over ~3 px. This is not decoration: single-pixel spikes + # read as sensor noise, and — because they are the highest frequency an + # image can hold — they are also what makes a star field expensive to + # encode. Blurring first drops the file by a third at the same brightness. + for c in range(3): + stars[..., c] = gaussian_filter(stars[..., c], STAR_BLUR) + img = (milky * MILKY_LEVEL)[..., None] * np.array([0.86, 0.88, 1.0], np.float32) + img += stars * (1.0 + 2.2 * STAR_BLUR) + # A dim floor of unresolved stars, so the sky between the bright ones is + # not pure black. + img += STAR_FLOOR * np.array([0.80, 0.82, 0.95], np.float32) + + img = np.clip(img, 0.0, 1.0) ** (1 / 1.35) + return Image.fromarray((img * 255.0 + 0.5).astype(np.uint8), "RGB") + + +# --------------------------------------------------------------------------- +# ray fan +# --------------------------------------------------------------------------- +def sun_rays(seed=31): + """The sun's ray fan: brightness varies with angle only, not radius. + + Built by summing a few sine harmonics of the polar angle and sharpening the + result, which gives rays of uneven width and spacing — a fixed comb reads + as a machine part. The fan is warm because it stands in for sunlight + through haze; the shader multiplies it, so it is stored dim. + """ + rng = np.random.default_rng(seed) + yy, xx = np.mgrid[0:RAYS, 0:RAYS].astype(np.float32) + c = (RAYS - 1) / 2.0 + theta = np.arctan2(yy - c, xx - c) + + fan = np.zeros_like(theta) + for k, amp in ((7, 1.0), (11, 0.55), (17, 0.32), (23, 0.18)): + fan += amp * np.sin(k * theta + rng.uniform(0.0, 2.0 * np.pi)) + fan = fan / 2.05 * 0.5 + 0.5 + fan = np.clip(fan, 0.0, 1.0) ** 2.2 + + # The very centre has no defined angle, so fade the fan out there instead + # of letting the singularity show as a pinwheel of aliasing. + r = np.hypot(xx - c, yy - c) / c + fan *= np.clip(r / 0.05, 0.0, 1.0) + + warm = np.array([1.0, 0.83, 0.44], dtype=np.float32) # the original's hue + rgb = np.clip(fan[..., None] * warm * 0.215 + 0.012, 0.0, 1.0) + return Image.fromarray((rgb * 255.0 + 0.5).astype(np.uint8), "RGB") + + +# --------------------------------------------------------------------------- +# lens-flare lookup curves +# --------------------------------------------------------------------------- +def sun_profile(): + """Flare intensity against radius: a forward-scattering falloff. + + `(1 + x*A)^-B` per channel, with A rising and B falling toward the blue — + which is scattering behaving as scattering: short wavelengths spread wider + and decay more gently, so the flare's core is white and its skirt is blue. + The three (A, B) pairs are a least-squares fit of that form to the optics + the curve describes. + """ + x = (np.arange(LUT) + 0.5) / LUT + coeffs = ((2.97, 2.939), (6.09, 2.305), (26.31, 1.444)) + rgb = np.stack([(1.0 + x * a) ** -b for a, b in coeffs], axis=1) + row = np.clip(rgb * 255.0 + 0.5, 0, 255).astype(np.uint8) + return Image.fromarray(np.repeat(row[None, :, :], 2, axis=0), "RGB") + + +def annulus(): + """The dispersed halo ring — one bump per channel at its own radius. + + A halo is a single optical feature smeared by wavelength, so this is one + profile shifted three times: red innermost, blue outermost. The lobe is + asymmetric (it rises over ~25 samples and decays over ~65) because the ring + is a caustic — sharp on its inner edge, trailing outward. + """ + x = np.arange(LUT, dtype=np.float64) + centres = (348.0, 371.0, 413.0) + peaks = (119.0, 109.0, 108.0) + sigma_in, sigma_out = 25.0, 65.0 + + cols = [] + for mu, peak in zip(centres, peaks): + d = x - mu + sigma = np.where(d < 0, sigma_in, sigma_out) + cols.append(peak * np.exp(-0.5 * (d / sigma) ** 2)) + rgb = np.clip(np.stack(cols, axis=1), 0, 255) + # Below ~1/255 the shader sees nothing; snapping the tails to zero keeps + # the encoded row flat and small. + rgb[rgb < 1.0] = 0.0 + row = (rgb + 0.5).astype(np.uint8) + return Image.fromarray(np.repeat(row[None, :, :], 4, axis=0), "RGB") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--out", default="assets/weather/sky") + args = ap.parse_args() + os.makedirs(args.out, exist_ok=True) + + for name, image, opts in ( + # The star field is the one texture where lossless costs real bytes and + # buys nothing: it is consumed as a dim background, and near-lossless + # WebP holds every star while halving the file. + ("starmap.webp", starmap(), dict(format="WEBP", quality=86, method=6)), + ("sun_rays.webp", sun_rays(), WEBP_LOSSLESS), + ("sun_profile.webp", sun_profile(), WEBP_LOSSLESS), + ("annulus.webp", annulus(), WEBP_LOSSLESS), + ): + path = os.path.join(args.out, name) + image.save(path, **opts) + print("wrote %-18s %-11s %7d bytes" % (name, "%dx%d" % image.size, + os.path.getsize(path))) + + +if __name__ == "__main__": + main()