From e9a67b3033676fe585a043094999acb49bbc6bc4 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Sun, 9 Aug 2026 23:15:46 +0800 Subject: [PATCH 01/92] feat: assets --- api.md | 11 + assets/weather/clouds/00.webp | Bin 0 -> 90296 bytes assets/weather/clouds/01.webp | Bin 0 -> 91188 bytes assets/weather/clouds/02.webp | Bin 0 -> 90922 bytes assets/weather/clouds/03.webp | Bin 0 -> 77158 bytes assets/weather/clouds/04.webp | Bin 0 -> 97964 bytes assets/weather/clouds/05.webp | Bin 0 -> 70424 bytes assets/weather/clouds/06.webp | Bin 0 -> 62264 bytes assets/weather/clouds/07.webp | Bin 0 -> 52540 bytes assets/weather/clouds/08.webp | Bin 0 -> 98684 bytes assets/weather/clouds/09.webp | Bin 0 -> 97148 bytes assets/weather/clouds/10.webp | Bin 0 -> 76090 bytes assets/weather/clouds/11.webp | Bin 0 -> 73250 bytes assets/weather/particles/drop_normal.webp | Bin 0 -> 3630 bytes .../weather/particles/particle_blurred.webp | Bin 0 -> 1004 bytes assets/weather/particles/rain_drop.webp | Bin 0 -> 940 bytes assets/weather/particles/snow_flake.webp | Bin 0 -> 638 bytes assets/weather/sky/annulus.webp | Bin 0 -> 256 bytes assets/weather/sky/starmap.webp | Bin 0 -> 95530 bytes assets/weather/sky/sun_profile.webp | Bin 0 -> 282 bytes assets/weather/sky/sun_rays.webp | Bin 0 -> 34390 bytes dart_test.yaml | 4 + lib/app/theme/app_glass.dart | 83 +- lib/core/settings/experimental_settings.dart | 16 + lib/core/settings/preference_keys.dart | 6 + lib/core/settings/sky_time_mode.dart | 54 + lib/core/settings/weather_mode.dart | 12 + .../presentation/pages/events_page.dart | 50 +- .../home_active_events_controller.dart | 6 +- .../home/presentation/pages/home_page.dart | 5 +- .../widgets/home_active_events_section.dart | 48 +- .../presentation/widgets/home_content.dart | 69 +- .../widgets/home_forecast_section.dart | 33 +- .../widgets/home_map_backdrop.dart | 55 +- .../widgets/home_rain_trend_section.dart | 228 ++- .../home/presentation/widgets/home_sheet.dart | 9 +- .../widgets/weather_sky/card_water_field.dart | 818 +++++++++ .../weather_sky/precipitation_field.dart | 240 +++ .../widgets/weather_sky/rain_on_card.dart | 509 ++++++ .../widgets/weather_sky/rain_on_glass.dart | 252 +++ .../widgets/weather_sky/sky_clouds.dart | 248 +++ .../widgets/weather_sky/sky_keyframe.dart | 239 +++ .../weather_sky/sky_keyframe_data.dart | 1148 ++++++++++++ .../widgets/weather_sky/sky_lut_cache.dart | 221 +++ .../widgets/weather_sky/solar_time.dart | 130 ++ .../weather_sky/weather_sky_background.dart | 588 +++++++ .../weather_sky/weather_sky_painter.dart | 607 +++++++ .../widgets/weather_sky_background.dart | 244 --- .../presentation/layers/qpesums_layer.dart | 64 + .../map/presentation/layers/radar_layer.dart | 275 ++- .../presentation/layers/radar_scan_range.dart | 219 +++ .../presentation/layers/typhoon_layer.dart | 168 +- .../map/presentation/pages/map_page.dart | 3 + .../widgets/radar_overlay_menu.dart | 73 + .../widgets/typhoon_overlay_menu.dart | 132 +- .../presentation/pages/experimental_page.dart | 52 +- lib/features/weather/data/qpesums_api.dart | 51 + .../weather/data/qpesums_repository_impl.dart | 26 + .../weather/domain/qpesums_repository.dart | 11 + lib/features/weather/weather_providers.dart | 19 +- lib/l10n/app_en.arb | 122 +- lib/l10n/app_fil.arb | 106 +- lib/l10n/app_id.arb | 106 +- lib/l10n/app_ja.arb | 106 +- lib/l10n/app_ko.arb | 106 +- lib/l10n/app_th.arb | 106 +- lib/l10n/app_vi.arb | 106 +- lib/l10n/app_zh.arb | 106 +- lib/l10n/app_zh_Hans.arb | 106 +- lib/l10n/app_zh_Hant_HK.arb | 106 +- lib/l10n/app_zh_TW.arb | 106 +- lib/l10n/gen/app_localizations.dart | 162 ++ lib/l10n/gen/app_localizations_en.dart | 84 + lib/l10n/gen/app_localizations_fil.dart | 84 + lib/l10n/gen/app_localizations_id.dart | 84 + lib/l10n/gen/app_localizations_ja.dart | 81 + lib/l10n/gen/app_localizations_ko.dart | 81 + lib/l10n/gen/app_localizations_th.dart | 84 + lib/l10n/gen/app_localizations_vi.dart | 84 + lib/l10n/gen/app_localizations_zh.dart | 324 ++++ lib/shared/map/admin_outline.dart | 131 ++ lib/shared/map/map_scaffold.dart | 9 + lib/shared/map/map_timeline.dart | 7 +- lib/shared/map/raster_timeline_layer.dart | 75 +- lib/shared/widgets/map_color_legend.dart | 121 ++ lib/shared/widgets/map_menu_toggle_row.dart | 106 ++ pubspec.lock | 8 +- pubspec.yaml | 17 +- shaders/README.md | 106 ++ shaders/cloud/clouds.frag | 308 ++++ shaders/fog.frag | 81 - shaders/sky/sky_lut.frag | 229 +++ shaders/sky/sky_view.frag | 83 + shaders/sky/transmittance.frag | 116 ++ shaders/thunderstorm.frag | 95 - shaders/weather/card_water.frag | 133 ++ shaders/weather/galaxy.frag | 135 ++ shaders/weather/lightning.frag | 161 ++ shaders/weather/night.frag | 185 ++ shaders/weather/rain_on_glass.frag | 243 +++ shaders/weather/rainbow.frag | 145 ++ shaders/weather/sun_flare.frag | 233 +++ shaders/weather_sky.frag | 358 ---- test/features/events/events_page_test.dart | 109 ++ .../widgets/home_content_test.dart | 55 +- .../widgets/home_map_backdrop_test.dart | 30 + .../presentation/widgets/home_sheet_test.dart | 2 + .../weather_sky/card_water_field_test.dart | 389 ++++ .../weather_sky/card_water_pipeline_test.dart | 275 +++ .../weather_sky/precipitation_field_test.dart | 178 ++ .../home/weather_sky/rain_on_glass_test.dart | 63 + .../home/weather_sky/sky_keyframe_test.dart | 228 +++ test/features/map/qpesums_layer_test.dart | 116 ++ test/features/map/radar_layer_test.dart | 247 ++- .../features/map/radar_overlay_menu_test.dart | 139 ++ test/features/map/radar_scan_range_test.dart | 154 ++ .../features/map/raster_timeline_harness.dart | 48 +- test/features/map/satellite_layer_test.dart | 5 +- .../map/typhoon_overlay_menu_test.dart | 227 +++ test/features/weather/qpesums_api_test.dart | 29 + test/shaders/card_water_preview_test.dart | 254 +++ test/shaders/preview_render_test.dart | 623 +++++++ test/shaders/rain_on_glass_test.dart | 98 ++ test/shaders/weather_sky_shader_test.dart | 231 +++ tool/gen_asset_diff.py | 183 ++ tool/gen_cloud_sprites.py | 1559 +++++++++++++++++ tool/gen_particle_sprites.py | 212 +++ tool/gen_sky_keyframes.py | 237 +++ tool/gen_sky_textures.py | 230 +++ 129 files changed, 17168 insertions(+), 1114 deletions(-) create mode 100644 assets/weather/clouds/00.webp create mode 100644 assets/weather/clouds/01.webp create mode 100644 assets/weather/clouds/02.webp create mode 100644 assets/weather/clouds/03.webp create mode 100644 assets/weather/clouds/04.webp create mode 100644 assets/weather/clouds/05.webp create mode 100644 assets/weather/clouds/06.webp create mode 100644 assets/weather/clouds/07.webp create mode 100644 assets/weather/clouds/08.webp create mode 100644 assets/weather/clouds/09.webp create mode 100644 assets/weather/clouds/10.webp create mode 100644 assets/weather/clouds/11.webp create mode 100644 assets/weather/particles/drop_normal.webp create mode 100644 assets/weather/particles/particle_blurred.webp create mode 100644 assets/weather/particles/rain_drop.webp create mode 100644 assets/weather/particles/snow_flake.webp create mode 100644 assets/weather/sky/annulus.webp create mode 100644 assets/weather/sky/starmap.webp create mode 100644 assets/weather/sky/sun_profile.webp create mode 100644 assets/weather/sky/sun_rays.webp create mode 100644 dart_test.yaml create mode 100644 lib/core/settings/sky_time_mode.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/card_water_field.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/sky_keyframe.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/solar_time.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart create mode 100644 lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart delete mode 100644 lib/features/home/presentation/widgets/weather_sky_background.dart create mode 100644 lib/features/map/presentation/layers/qpesums_layer.dart create mode 100644 lib/features/map/presentation/layers/radar_scan_range.dart create mode 100644 lib/features/map/presentation/widgets/radar_overlay_menu.dart create mode 100644 lib/features/weather/data/qpesums_api.dart create mode 100644 lib/features/weather/data/qpesums_repository_impl.dart create mode 100644 lib/features/weather/domain/qpesums_repository.dart create mode 100644 lib/shared/map/admin_outline.dart create mode 100644 lib/shared/widgets/map_menu_toggle_row.dart create mode 100644 shaders/README.md create mode 100644 shaders/cloud/clouds.frag delete mode 100644 shaders/fog.frag create mode 100644 shaders/sky/sky_lut.frag create mode 100644 shaders/sky/sky_view.frag create mode 100644 shaders/sky/transmittance.frag delete mode 100644 shaders/thunderstorm.frag create mode 100644 shaders/weather/card_water.frag create mode 100644 shaders/weather/galaxy.frag create mode 100644 shaders/weather/lightning.frag create mode 100644 shaders/weather/night.frag create mode 100644 shaders/weather/rain_on_glass.frag create mode 100644 shaders/weather/rainbow.frag create mode 100644 shaders/weather/sun_flare.frag delete mode 100644 shaders/weather_sky.frag create mode 100644 test/features/events/events_page_test.dart create mode 100644 test/features/home/presentation/widgets/home_map_backdrop_test.dart create mode 100644 test/features/home/weather_sky/card_water_field_test.dart create mode 100644 test/features/home/weather_sky/card_water_pipeline_test.dart create mode 100644 test/features/home/weather_sky/precipitation_field_test.dart create mode 100644 test/features/home/weather_sky/rain_on_glass_test.dart create mode 100644 test/features/home/weather_sky/sky_keyframe_test.dart create mode 100644 test/features/map/qpesums_layer_test.dart create mode 100644 test/features/map/radar_overlay_menu_test.dart create mode 100644 test/features/map/radar_scan_range_test.dart create mode 100644 test/features/map/typhoon_overlay_menu_test.dart create mode 100644 test/features/weather/qpesums_api_test.dart create mode 100644 test/shaders/card_water_preview_test.dart create mode 100644 test/shaders/preview_render_test.dart create mode 100644 test/shaders/rain_on_glass_test.dart create mode 100644 test/shaders/weather_sky_shader_test.dart create mode 100644 tool/gen_asset_diff.py create mode 100644 tool/gen_cloud_sprites.py create mode 100644 tool/gen_particle_sprites.py create mode 100644 tool/gen_sky_keyframes.py create mode 100644 tool/gen_sky_textures.py diff --git a/api.md b/api.md index ff86eea42..288ef542a 100644 --- a/api.md +++ b/api.md @@ -72,6 +72,17 @@ Himawari Band-13 IR XYZ WebP。時間清單是差量編碼的 Unix 秒(`[baseS | `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` | +### 未來1小時降水預報 QPESUMS(v2)—— `core-tnn1` + +QPESUMS 定量降水預報 XYZ WebP。時間清單是差量編碼的 Unix **毫秒** +(`[baseMs, Δ, …]`);tile 在 **static** 主機。`{ms}` 就是解出清單後的 13 位數 +毫秒,直接使用(時間軸解析已同時支援秒與毫秒)。 + +| 方法 | 路徑 | 層級 | 主機 | +|---|---|---|---| +| `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` MapLibre **vector tiles**(gzip MVT)+ 點位詳情 JSON。目前僅 **AED**;未來其他 diff --git a/assets/weather/clouds/00.webp b/assets/weather/clouds/00.webp new file mode 100644 index 0000000000000000000000000000000000000000..1f2c7a9b4710eafffa736c9b67e90da3f4b09da8 GIT binary patch literal 90296 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 P)lW34d~W~0`u~m%gB3mi literal 0 HcmV?d00001 diff --git a/assets/weather/clouds/02.webp b/assets/weather/clouds/02.webp new file mode 100644 index 0000000000000000000000000000000000000000..8221dfe28990a86927529401955c9566c78eb3cf GIT binary patch literal 90922 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 Jg8zRxwgJKra5?}0 literal 0 HcmV?d00001 diff --git a/assets/weather/clouds/03.webp b/assets/weather/clouds/03.webp new file mode 100644 index 0000000000000000000000000000000000000000..c1c6485ba4696b05fd31356342c8f8b57eee860c GIT binary patch literal 77158 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 UE-x>y1K&&toBhAp|1S<40O+<>mjD0& literal 0 HcmV?d00001 diff --git a/assets/weather/clouds/04.webp b/assets/weather/clouds/04.webp new file mode 100644 index 0000000000000000000000000000000000000000..681ceddd53e8d66cd19773b51cc2caab72d58841 GIT binary patch literal 97964 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�Czo#=5T&1$5x-#u%Xtv#L1$53E=2 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%0A38%N8?`QDY!YOn?kF-7 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 literal 0 HcmV?d00001 diff --git a/assets/weather/clouds/05.webp b/assets/weather/clouds/05.webp new file mode 100644 index 0000000000000000000000000000000000000000..6c688bd12e40c6c5e9db6b38656841999a5c9ad5 GIT binary patch literal 70424 zcmV(&K;geqNk&Ex69E8MMM6+kP&iBk69E7(|G|$C30abD$8A&R@oA{M!~HK900|J8 zzasiS0sQe-?FJOYUYpnNgs1{O?5JK@jV6dxfj}&aLKUm5P)+*)9>=N*ycjeWt5nHF zndazXbfQ2dR|ONaXd(hk$Xt;*ML}&qb)+IhTnZ{eWk55oin7X{^fR+pnGxb@R8X)- zRfjfdP;1&%>>)bJEC6d+sUo0?3^N6*s+hqQEwn(n))h*)1s6a|WoaJ0sw%7iQfMFq z1;}YyodTpSKmp-k9Mh7YeH9STt40U4!T;<@Y}VD-=c9U_<~HhSyAeOh0NXuWysmuU zlJJqMYF*}%e7#<;RV^N~BELR02abD@uh&C>0FF@%!d*My#*N+c+BrDaO*}gr;(2y9 z2KED#CmkRj*WfAm`WOw|f)0yR#<%RlfblKgIWHoD%cXep_4#PToXN@saQvXP&(c!e zD8ZM;8cUqkU*z?eboUmhAN|qP77+<5tcnQ43C}HxcEUE2BqxouG08tNlDT_@GRY=w zbJD4Fs^iGEO=~^docZOS%nRTQ-?xqGzaqD78?oozS6l>M7cKPhqi^#f%^}dk0T_xL zsFSo&fex_ong>}H&*~pBWA}sv3n8?yJM_R6gwTnGyCQ>1yn$og>pC%lxB+GW{xMWY zG|kQd3U~qlg3T_536=0^>mLyZ0GK!vLpfr#C}6Wl$1cDg3IW8L{fwByj@<}O^-i%TFp1mOp+G~lV2#0k1@Q5>G(dViolOIiLb zm+>o>H!JCwZA0G1GaHF?u4w{}iY6ve=qCno2yi6G#zZan!|&>+ z$B}_m`~O#R!MZO~w- zbGE4l`Dyxxh5uBrs4l~B;STJj1xK*Q`A1jK*)B5I;BXv77cTwNURboD^_ZD9bPnyD z!zL{(YAV44x8NB1NB0W+JY^bb4n<|y^uTSZ$4Jx-2RY{}XllA#@VjBFg?hRWhlCE1ih59w%w-f=>Nxv zdUlTOb$s@I7~5&5Yde=wbq%hb?b?cyvTd7tjWHxilB7tITGPPt?*AXT$DwosY1=gS zulbt*J=y%GWP$;!LX1dL8Xn$Ki;=qOn{yU zv|!tIomHw=uf!j$8`Z1j7Q`SRAiIm6wRXlKUT7jArX)y z(*Mm76X56lzt@sv%UL_lyO~#@#~W*2?^xpzd=2uwoY>S_dVh)pwv|+#3?nEajRRyG zNzHVEV#EO&BOajBc&HlSHYr9uPBsu1=uJ8Ds-)(q#zg_s=guY&RLf23P0hy&AOjag zofJjQq)3W`KF4^R0MZI-y4xreu)`?el+h?)^vrgWnrSRzu2@uQ>=!__oGhRkm_^mZ z=$W8sqbL%GWt_SQ0!j5yGo1o9HJ7!3&H;kz;>^LNx}w_%=a6T_ayHP`b_zJx4C5ex z*hT=;#$i&93MVWA+lU56gq21PaK=dfFPlDQOeiID`?TL{BPjhRPaX*uh;n^yJyzgTRI5{NSfL*NmKSv2s_;&Q0DIr8JXb@hRZDia?_R>JT3~u zqc{jfXs^IZt#`jzNWjDh}SGCP{tX?`KaJ>2%( z_9>S!qtQN}AJAU26ncz{kWVP{;K?$CC{!Cgu26ygY{EQIqdd8xnKm53_?(`-^1?lO zV2o`(r7+6FnnHPYU@}SKGrYVsn;wTx`q*f@^(kjQ!KS{zcI?)a&+1k5moSvi>@Y=t zflul5qp(MRrqPIwC);QGP+rpr=`L^3W^V=tWZ$9(eziwmm z27~_YTSFi>fGp78MQdOun-GI64FvkVbW?Bgxl}@${ZFs#WC3OjNkZr*gU}NppGAcb zZPI8j;Bau@U``KvExY?bMxYd9c=Ui02xD)M1@2WX+bvqD4zw>JK3~n`@z_zq@mvMgF857 zc>?AD0*igRohyaq@#Is9I3%(wNN>0(A>_kvG@96LrUhMQrax z&6nO6h_S7T+~iK>$l3%U7;Srq$TRs^n@vNbEebB}>4ApbG))KBCw^CJ`WU!_#btnc z`jN1(mjmTc_xzGX2**5?#1VNELB6IYA2p-xH$7;v+pfLdDZ`Y#ciHUw$r;}Bw0iFX zf_iHOpa=KcEaO#NRqFd4!qIaxIG5p~B$DJ1Dnynb&jr1g2fbH~jC{Ndjh+XZd8YS9 zwa3#JDLug&<$jvFrVFIK%@XW`D+@1E&8gIPOjXxkyJ^>Pvo$XM?w7-zl_RO7q9R$z z67rJLi0x2z+cq-D$4K8mfKB*)nu0KFvI7Nh3Ycimd|<0Z2rTGAusx5QwI5rrk#zKc zy9IS2r@RezON_d8HIxSa9EF6_4p~@Bo^Cx6NEfwF zSs3H~&0$M8k{? zXamM`32lYioRd2+{BX(hfJbB4r}huI;zj@g%KnsP%6`NqlsAuH0bYuK=-Yf@>NFOE z+ye%=26S-hcbb|4abw7(RxHHs$VbFLQvx8M2|EM`;DofZA|yM37Kc((WdImPEeZ(W z;NcV^DV~GdBX^55nT-3cS?8=wi_(crZ^Jza( zQ|6X`y7ti6(=*u)@WMgvR!;#=TlJQ)+E3u;E2S84AvUGv*^S&Bz$q@UIR+aUpabcr zlb?jZ=RylpGBFO#5Njjm!(iK(6u>~~CfRJ(h2t+tX+HxyY~wOBlSV-C5Hn^dKmi{- zGGNm_W9W=NuWbwyjy#%ETQH%Y3ZYwg*|U$BE-2;({0ye~dIe+w!v(Mnt+9}Pk~j;h z?=_j?ir6;mi6#AU&Xd9eEe=ZTXGM@JlnTFQ;61}8(-Lm9Oecia2K0E9<$6pt+VpGOY*k4^qP~)7Z6AQMne;FfLLmVCjl1XiyEo-3Ool!MV1zc@Co#^Xt`NXl z({Riv{-FbTm@0II{poi1`Qd340Xf%V277L(kvo~vgd@kJ$4?l$&)>Io%hlA+Gv(4? z%06q*QQkr3FCu^qvSFkME14X&H>pwA9SbHVJ`CF!reFg!^vG3jJGd5l)|XyoR%x+O zdW-!C$zn~Qz_dNa!QfN~Vle;$3#WTe<}yngB4&_7`2LGJLiBS1G)4{z;OBY8ZSg6W z1caf3#v^Ea_})7PMh&|W z!p5}w?UX%xyR@{Z=QA_YvDObq5265@ao~^abzzF(l#rTW+~f|IN)KT^0tN%{AAe0Et$}(~Z2*nf98OY`9Ltxbv z3L==F$^v-0vPEv|=b_!B#@w9Cgk4{r3;4@}nj zFAH5=y1G9Ewu99s1oal-;Cc!xb8+LV0JnX1D4s&*wu^(VK!QSGsGn@S?V=Q)5?c;| z04%Z}dET(jh3L2z|0Y*5K>&lUBMlG`j}9iF9j(8{{RSti+kIQ(*x# zV}`+#uQ#L=L=;AOdV~;005=9B?>-ES2<*}L=>bG>@$_DbK-nmcr=mdvMZLW>n7|jf zg5I}mH3OrDTsEzfJO*P}lX;&f%KdKu5|#+ZPvL|O8`h?y5EchQ+_xKO zZSacNZ=qAPlqQd5X-AP)g}Ms)dIvgCKu(GI{1j&nZ<^o?0b;m13J1Vm0hSM+?lq`B z_M%5pzP<4&ua|C6GcqDJ-T4%Qd;=&fKi~sS1=LJ8s2x3FLQLLW+wP>yW>O%0{0^hM zx=buY{OY+?zH+x_x&od6qNy_VVqm)4vF#MzZbl1&-u^;y+F;=VGe8240#F%c@%*}N`SFxv- zt^txtL~w8=6y4EBya~F)v^42J?X}1ePNB?x0Yn)Ar~Cc$)Uh0gbv3dZhMfAjAfT<+ zS(~m8Z3K0FakJsan#c77nM6=aM`uM$%)6^RY-t8y0Q2E%7itG0bT21}A+f)FW-j}$ zjLdtMtHQ7(gm81L^1K1fiq9;v!ybTPrl5m+G~!^h=|oHhsGMa=gd>?Ajohy@0T|cD z{fAoMQV{7$AIw<8bqBizXLWaP%4H&OYgo62Rjh~KnCUOE(OgW7jr6?j>25NCz-EJiYzrs^!vNY+@`ZssU5+cKi5g@g@38W4 z6!S)j^&84e0Y&k^-Jm_Hz4U5hpH2gtm5^Z;ug+z6PqS)NUB84rQ)5b4sP)V0Xvm;4 zAW9Cy|3bAqQ!M(wEUV@SiP+pSP z!9)w9DrJe0`c>2<8uerYW`K$Yb5u_jaH|3UfwKhQT{9&YF6^n6ySV6utpWjUE!h)Z zJ6{PeT#6(l;WDXcre@zz2OMhfg8^5`&bpoSB6B-PxQ3!2#+%Ifsd z8)Fi3hRYYx^^T6 zbj*c;?lDOBwqb*>PT^gl=3Chh`ddZfY}+9j5ohc7)RUf3c^x{R-OSS%^MFy(H>s;GP4b-)}UFc*ve-=~;H zjb}!H?Ru+BWa&M)Z_tc%<({t3T>yXbG|^$NV&d70v0&m1TYYMp8TgxkKM~5n#DIM%nR6K9FAYccZ zkV$2PC{b|hH0-NbVoz`PF3Qt|_d38oaZ2dX1+kh1RIxJP#fR{TNVA|;xY=9R9pPNt z07P*DcnM4imiy!eS}5r?Y-eW74Av;^qGIpukj?JqIXrGKhRx;Ytm7C^e7~t@o$8Ke zO(t1g&|ddlGquMIbE|t0z+jul1L}gi%=SKo81*)R!jz{OX@9H$Ej?n6=AbHs6wAn< zCpbK{@dgUZ2%3ZNMjV!pjPnCaoE+=x(8nn4%HCr>*-Qn62Y%4riS(J1!^9bYWCAN_ zTgd5}Oq_yu_x2{~9((T{Xc!0t`{5x#g?e7csgd8SbFp371n}<>H2S@xnawiI-0EDs zL?#qdZSYSk55IK}iy37#-bAPx8e}sKM1lf0Jeq(y+}f^#l{Ko*mCi?7L(n_(jC}zZ@Tve zLYf!>0j^gU)b@YW0b>ki3Yc;ka);@?#(4K=yP47ER?p{Rx~l~J@;zlJljO%yGp%mF z@|s$p7ZgOnwNRH5F1sB>RN!*RKH1IAM*tZRbkDbUa>E?94ITQ$70dj2QrL?|6^tra zvE}%nZYTx>s1W5Kv;rah6B#}Z*t4Odw7@3akU)SyVFjQdUk?i9_PomY>QhWEgMHIw z8xKb{`u#HKZkUB}z&?cwuo^m~(D~}+jy1KrSGt~6A_PRLJAPW;fSJq@Vz3V0f@D=f zm@ROJ^XP`y+8o{Q4*(%-GO-xi23s1E*#YKnoOB3K-Z2oN8~Y!T3rkz?zbH@H)Y)@J?!oYHzC!mf&wP=;EKUqyEumHxn?JAKao>l&@tSY-R62))TSQt@}4f^au4`-lC!H-!p_~fN4nT>(?}TJm6)nmFJY( zQ$;HncTl2asPCbT-9~8fptm2Q)=vBK4U7n5wu}!~xB*qZzL&v*wqLCzLm1N!qS0(v z{-;g=OBycR!EKQQG=8&Yqc5Lr$c9`wIF+`$yFSYkpz|2$-X#I9*XtEZieR>gA-C5Y zobtKM`x_E6`YDg-x2oVbz$rAfk{|cb{5cG4 zqJt~VDZuWg-ch)jjHL$g-e)eoV8Is#?1qELG`gGb-dAJ0Sg@zciZmG1RWch-{KE9% z!sQ4>6)ZH`xPlH({_~rL+<>)Ggweo(lwq63X?l0D?XNrCW?#j}#!U`*m4j znRX$YrlGZM-i{D9G%}z7gd61>L@UE6A>^$YQo6hCuX;~67GeuvrJNOBGFf=POrcn; z@cjy}m`%1!At4wXmNkhNLc{uKFFd)tv4dEa_H&q^UdImsq&u($nCQGe=Q#7FT_>AR z_4YcyN>A`W8#HpyfK!6#emipAg)A-$9TXffx+v4{?im0Fu#17~SE!+WBDUkQ!`m1F%~S3O0|Xf6MHpaUoEzHPKT7LAzsIFRgosRm z8L-8c>!prcW7vu-MA*PUH5?8=bWe)5ZfhCV2UbsRz=ClqJRm4fShxYSOP~NlrPj2Y zL4_4?O~>sHmyu!Fy`yKNr(;lGjb{q1MXsT>&oWRT)Pqx8?in(UX`!0~7~HH4zM)J` zF$ReCd(Q==>9{7;ppYp-XCrSxfT(lKCRC;^RajG?!dx}9Bia8)nAo2rCt+2oDI|3tufffimYLnXW*hH@vbY@7?lA@eJ|5P(uC`w6-IAXX6JQUQ#=i$- zR*YW%3a42nz6U)kvga{89+>-L8unQ&;8O>TQ##j>540J;J8s&ipy7%F*LJ`P`gu5_vF7TIWVFyMOaxtNm=V?utlj7F24Dcly%R2(+x4M3Bz*rh+FdHZ z0Ohgk!&5Nwoh=(r0io&#UJWk8f5#PXn+5j03^oVX32@9Wr`Cp!4P|6^0xBg3UnM9I znsN#dUqQ6Am3x!@JG5M_)4!kFlIuPl+Yk~$ejpHT76*5@)W!p!N=C}Lu+4&F?`E6t z^OJCRvj;Q7%)u!ZtTMK0kzxfv@Cpd+6~<3Xsb1b@e~JcZWa6DZ%Y4Ok)Us^&zJc}V zVIa(Sz9%aMbhzOtpXp{`cRMYMNCEojNA=-+txSBKj`ar+nDn_1b6B{a>f7F$2iMpO z)~R)t@kjD48Mm6j2YHNPb1DLrac~OMqQT`2g$)9096;Ms+@j5Q+*EU1${ln0!@oU# zTYtEzJify*5s3)-LELyf7@JepCLZ8nQHP8z8!sqjxbFy6ZxtCB9uJs{7*5sGw+`~I z6(|cds=g)p$yjYZWaz^fFJ+Y_Gh5~4&7h9Ms*%QQX|J!K%LsPC(^`RHJtcPIIM7YnSxW}~P^B#u(hq>TG>E6!I@Y|i2S8abYgU$0gI-bw(#3a2k2~d>^i8@hzrX%`9lcX`L}W=Ki9E9*evwIaEj^A9~jPyOj@T~ zLt!mm!%i11K|~sWVO`-}O=suUtRHSfcR$}FXJ0lFR*pY@_V-%Xjn8oQ1Qreb@C6Q6 zhvW6NjY@=H-D(`f-Z*S1eznGDjCLG! zLFsqW|74g+nbegS2TdRtEbMO=gi-~b>3X4&J)Wy57n65x4HNYll( z2QuIWt}g0RHPDNuBoKfYs5j~N(jTI=Htp)0er4V2I2~0kWYJUSsu+Y|2O$vR#(Jan z1Fj6ce*ppM2q8yiW@4N#Up%@pf4lEyg8jk=d8-VW0;{9{+X1KC#)`Clej*@9n?Y~9 zp5s^FfxVg@`k+1*Kilmu%V+SSyQ?bi!j zSaXSX!r)2Llnfvw#6U%$0>YpQ^!w1XY27z#>o2NG^$cr4kC6o03u|3*FNSSn-loAY zh5;G{loUph?wRC81zUloy(m3$_dffqT_XW*0GD$;=r_1EcSmAa_s=&zVQHAxJb&j7 zp2|_Mo7FCY4!(P~C;1GGv(J5|nZrmsfwokiL=4w0%=&Y`zF7Gs*2V^n;Ep|sBn*Vj z^?ToEq&bFF<2=Go>68No;(_-Vj~o(8Z~$%qY(kP>oY17!rT_~HtEt1_4_u;cfq^`q z5a0`BgqTbO!XTtd1^pg2E=}Ley2)90S`P=yneYOWVjBn#oU(z?>P;fU3^q8afI>7y zW=6uyV;IBd+Bipk1vKqUau)RrHwIAWk9Qu$&f*H|hpUU6%oGtnu|B`C3T4KGZIz^n zuc$x~i2W4%M1p`PF?{JueU0>Z{vO_+Fvx#$*#nz^Z2jPkh!I!`qz(=w62?mnOOxZK z1s}(Q=6R;wK}a-6P#_FITHpb?!30P^^exH)WPZ;YFm2~;d|(Fj6c)IEhefF>WMF_K zrjWMCBlI|F>(+j)6PE)FFqrgWm^zzN;@~iV_EwKEGk|GwssO5{qDsO@0=6lE5TUOR z_*Q5%f>pw$s_1qdG~zhCg4x~)wGp-)nzdmH9l`>+!!*|8D;^QES4>%^Jpj1L%D;Qq zfB92e+@3%5!DSrQ<53sm;e;M8JQ_I8FRX+YPDZ3JGy}a2FW@%-!L~~CVWpc-#ye0T z$03yNQj%XD0yQOo$&6TT0W7BRq%16&@4dYR+dvtn5E-5ag%*LqGDvbYQDP#GXqqwS zZTpqMiNQd13^0662_8=velReYw}_ar@YP7%K>tZ#m`U7&%^2hTUn#66BeD*D#ixV_ z)Ma~YDD!+R>DJ#iYV*CJ#w_ZUCc-E4_~foyAqRxMc-p6LqiEs1nJ1&0lyo-K=*_o& z_~DyJ+vxr3vssL5+WO;rM*{QfzO_ReP;x5~3p0Rrs-YM(a%%ux1mH_I)ekY>AqW@+ zFc_{t1GE59P%y@Wf@C${&8pk>cHR_ZE-(xml}%y97(AE@pq`n?l1DUpsOIL8IA8?6 z%mQ}d(J_61EA;ht&7B!AU{p4&mQLArCCeBmFo@SIq0l3AY4Ys`dH@4twablmet`g`7-+q`hOxLj zc!E>C$0hXk;(4$!!=*uH*j8y%L+xc5nOB19$;y(9JU|;xTm84Pjv?XTTy)t?c+A?p z!bA4o&mFPeB(1RRD~66xGjgzy?x_{?@Z_Dr&oh`|vu}OyC-(cauvs0WjG0qGC>hMM z*82D7DFealTcHdqoB(WcM54g__iyPw@xsYy{(L>9A;DUnhK2I~|CLSJ>D@S6%wapF zAbak(i&A4)cCQt)W`i!)aGapZd7MZW@JZjp(FD8`!32Rp{wQ}lfVklKpi^Cd3n;)u z9ySLst~te44UHMxSnx1ykQte~KW?}MGDf3Ol#akaS!rUe?u#v; zD}8ZyXTgNFFs2HgKRFaYi=RL(Fs)@6(@R+Cn>+jZAn@{gd->s!ixH<+$Rl0UuV~>VQ z?VPJ_G$Pb^6jm9TI zcU#YXg9B!~wMRrz!g<0PhVA$5^xg?nTl<6kBwE1C&=dg|R(lOx%O)}$aHaT^No%HY z{Z*LiIjXzOpo6VGe^Dkz143cA<5}i^>{@h9gAw+^FrclP{2Y@d7I|jhKno5kEL=4; z!CWrHTU`L#cQ*m5Wd66XI#^C=M!b=_=(iGu-yS|?zzYl!g)M|RR=LNLBJVzdY;xzV zj>*3{1DnHQ32H&E0Q>rSaRqtWwHSgqfungFw=a)=^DluT>W7*YkP?oz-F%*IF4_f# z3K7+Iuj9NiF5mHrEJiv9= zDYXwL$w-UTH4g`;AikTmxx28_?QFr|c51x$+w>@n)yXCb1~8uN(a@)79tSc(ifF`vfm08t5L@jy z5{jq5m)YFvtc9*4)2Pk!GWKSRs&>=&skxJx48(4p`@;+7Z=N#X4pIu|Vao4T90AYG z)d#8bcx0W@1TEU+EZ2Z`hF*X6D%2rZ6q&NE{i$c;X#xxG?-sB&@4!-cOM!HdZLV;) z>mbDi?^PuzBQ98P-a6d-gpq|&tM)O}JJj76@X{Q#T)juC2?yI?TtE29O#n-@zs>Ih zZl1*DvOTHaH_r1t;!?u?xx8ht07j$niGx$VHs4AK9uo75+$+X=;sy=Bqr$1B;sI}) z*9=Xb=K*m!)ko*qO9_-rr(0PuYjy}hzQo@QhcQi&xd$t}v5M?JV2jo7CR7au( zphBhBJTq|!pS)ByBr%Sq&#NgCh*fRLYIvj}b}%$mEt-6Z#`lIY_7tkrqXp)qQESsi_KXup99ez8ONb$6yu z@~NVh#axBmm_@L~hQdz&K#XCLgb~cGRD?pn0=BDq z1IG2aqMsQqRN_5ZlvfyZFUoioFpyps`~4>UqDe&rj7QjR4RZ;FoDBxSA77{m#?yD) z5SbM?(xBp^KVZRFJH`-Z4(2IT zQ5&adgEqBm;8n~%vxUq1f}wLc-3EaMgBOqJ_0?P4z4l|naA8uF($}oiasn37f}6h? z6g>l-nn$FW3xI>CdjTDhw&U_8b-nuHS^G%Cb*QB{h3bFQS@`B|CMJi1g$EGQ9po4j zKiFUNJXCwoER!xb$FO=Ft3;H9h`!=rJ_9jm>Lz>bTOOQ9fEk6F*@d42vsneMS*s>7&g|r6#*D3@?1s0YhhSIW(;1mgU!a9=L2ln z0&K{+4V=8)$gMdfq&p(f8^?9f2w;Xy0xcxrUJiuC?l}I18TC6V7I}Ud8LNp~p8)lC zgY_VCfkm~ue9*`nbN*cF#pVrlW~Jd4lgi9+cv)s;Cnt<;IMD;(s6XX`76u?Zz!B(M z;sjzrNnTQYa-D^a5RY3i6{Xi#xXJ;05!^RSnl-3V2%B6NxJ#L0pnK6NA;jeC2oU8B z3p!Ay&QsB1E(ny|awTcP7+xGyyXKH7FP<%s^lXG1pwm%!YwQ zYR0feE3;_hP1$Si9S#uyc6JhDnHv;L4P34-(okqn!3;0_0yxwo#`yax%rxpJj$e2p zP%-O-GBc^l(vJukusaz2CHH21*nQonOAmExFRPEhx&0b3L#eA-xb?!ArFL_2V!Rg! z4vIJJoGy3^|7aT5#X}zK=19+DTuwC=wbUXMe|$GSjX4nkv^h|K$zPzJxdT>l(u8Ef zFDQ}(x@RKIo0xo=27-u*)=exFQO6(;KnT#^PvOAETseKvQ-~6=A5Cm8ELax(PN7(v z=|rni5lbB|PzbdXgn(|q`UbtDPwqDkKNCAV4{gg25>RR)l5)C%uxV z&2M$zw(K_G+^6QUk10OGd*A}=lY<*fSTYUioI7Z9o^IM; z18!h$5ixm-=nXu2BE(M~)eV20byYYLFvoUnLy7JQHqh~dcf1BM>#=4{Wfl+|VgT(d z@E$C}rNt6eCdKM!fWM^d9&8V=;nWrW zC?*Y0#(qJwkV)q;oQu?a5b9!;h-ygT#*M(`M;VHCRKPMjgMr8%2o#jGC;`_#10Y=; z210WK)FF+s>o+7t09>t0+F_gU9BO$v{I*x0$=5c7O|iwnjkd%4$%tAJF=lzEHN&~B zjaulB{?mWvRZU^2MA$p?-pt*gZDn6E7a|&h=TAIZu^Ye-UT%0gB+lQ1i7pT_B2`XR zf)2^Smz_InL8U((gxSPM=xLCe90BCOiKtMN0}}tbNm!=>PxeopV03thDKxejZzeCzl=FvA zgMMInRT4qw2p~so5BQkXY0-^5)p~x(RRDFVvb`9 zt9ish-d?>Q);Qq34;qQ@oBGi8@Tt%8bdL_e*qH@4H;2R1E)F$2SHJ39dG?hup7hGn~eFE*E(>}>YC73R_sAx4&K0{(1jwZpat zMf~wE4^FjUGYp1uPye_pba}qoKqf4!lX;9P0!y2sMP$L2LYgqZ=K`Fho_h!xeE?+3 zgNkH(i=JT=cmmAfa0(R|>MjiFZy1;HncOKLD2NTZ86kILFZlBHxN$aKLSyKMh1VW* zYJM$si{tRce}7IM-$B83$FCoH3oXAU^2;W~k))lp&)jWh+Sh?e=F89bXPLHWF#R#+a#`I#p_RHSQk6J{#q@^(pfkUZeW1|Hd4wwG1 z$myjMJ3{uJBq82ELhNjc#tVMJ@F@!;CcQTyLGTnAwUU9j+B z-4m658uap``*avl);dmjxk9VX7@ngpivqpwG1bob28Ln0y z5om9JHFw5#f7}M|p7&ll7&I;Wd9JSNgPDzI>h_hn4Q?g)fT8Vs;)LwlghKrH*+W z8<_xNsK#*2?y_At*W+*(>2i5*dCuY3ULqL?%C&bJ18@cSr zS1|XGOqfCDK>&S|!k#9~*x+M*JJyinOY^r<-d0hXicn?~=?Jd#%k2Har?14?wL(x@ z{4nrcTMuLh{FKw{%lA!zHbp?_weWnStp;6D@(~BUtpXFa<`R<*x7|>8C|bO%>z8xY z4x1e64csWslZC}2oa^A!qN@Ym0sf5Bliok?#v6*7K{yQjc|^&yWsC`iVhUgM@O|m3 zj7bQLh(YQiV$?sBs4;X80{x}#hNkXx@ddK|0GsQdp;2vrZDt3<8X--6yt`MsVQ^{W z3Bh#N_QCp08JubcdbPJa0NpF_aFB92pBaHi!%sb%mBC=SI9MHMz%Je|?i7QbpxHYB zN{h`t^2Qfo2mN1|B{DGM-;XkzYz3sKl~Z1#54&x%PO=}C!nX1`$_@8XHo={hV>E6tF;sM0}qR~zXpcg>}Sm@n>K z*vKN>z}u&?{^2o-tF`Anfv@eo;h?{8Y7<40VOf3CJ&K6>%Ap_k$c+Bfxan_&8y76mZ=x zuxo*4yDfupi%2YWLi*c%_i(Clj2=*qr1uR#&!Qw)_f+7nF|+<)@n;0oJq&+AZOz5O zwd91sn!qZrcW$Q5DYtc#zW%=EuiM~H{*tqJ;r*$JTH)1@;D`NZv!PXcc5TWwBBrDP zo&;HdVp-m;<@6KZ3{Go|C?GNbJZSrAWq*M|ap1W|x6NiSV0F=!=yrxVLOpq5mRV#D z`kvfJa2*P<>u4L(q(wmH)b)(ngy~*IJV@Mhqp4lB#Y6<-a7g*rEe#{ zwQ;2ju4QLDI8BBiaR>-9pX@YTS09fTfQd+>>Sid=Ip+I-UflE`rUN~qd7WM^o-V0t zmlJ`oc_wZFQ@B5-HLw|g;e~Uqui#7`0BMx^3(h@BmzBX%_lv!Y% z+`JygWp;lq_KKvZj)Oj}4ICbg5g>%8PaxQp&uGt%>lxOnw^#8Q93GF*Oz*QID`N%% zV895OZkI}HBMe%T<6OO2qxd>B#-_*IEpUu9Gxu1t%s&EuN&mr>J;2=9!CZp8yt?%s zL<~ICb*1N4^WIz87aTRFY*i4L zZ`=o=eGPgGBgo9yh4XGGAY!uXYe9bdKz9m4TeWC527$Ak!45~&r|+=EnzugY(Oo0jfQ2}h*&{Qn3}S@Gz*J9L zSH#3iURd{qf50xxz+$m)g7)&WcFH!!1Rz5Lg~&s+p*AfZV7kE$t7FOCsbL@#q$#dn zpiKITN@5!ja5A9$um!e<9fWwVn78i&Ky?eG$$$gR9I&$ek6&L9jl-)ZUmqFG z@PK;`u4d*`j*fw>AIH5<&utjY+X4uO4$K5~0jZWiBd&*Wj*h8h$o%7}&ek_~8JA3} zt{x|}*Ui{3We6bbqV(iA%nWjx!)mP?GpjN3FhIX`d=lD}l6+YtXupodc;20n|*upEE7 z|55qR06oniC<}T;-yH?Z9m@oyemJ}{Z^K%IpVvRxtFiUd9K~B65AP3J@_IdW0E;?3 zUBHMVK-tU-xj$&v*aOU|&quaa;QD1k*LD=K6Zm`MET^w{5$pO?7#Sb9|BjB(&a*Jl z4RrP&RR8S{J`n<_qplN}5;W5kSs+aCGLQ(ym=u9>_W5^x>0zKtL;HejZ<`xHL8(=R znMG7wWlS3mWCFukw`MSoh)Yc+d*urela;d;YcoTi9yVKedMDO-)i&%~I>!UlgXq~tENpYN@cE< zXbciZ*aWI6QD{MYn_NSARVytT2c6KzV}vpwtc~!IaO~;NC+-bM7s_XBa<8=({de6Fw$rX@mkJ@ zgfyh=WF-gg;9Mgg7U7lm9l3e?bpqCwR^!q1nc6qA3rw4G6KLkq9HfrL`S8Z(LCHAX zos}B7#Jtn*{g^ff^$Qpj4L((~xW(%lV1684w>4v^$)^2oQ{UR%{}bC&a|h(C*gCJO zu(ravNv?k-5 zRB!OcUTCp}=L5%>5cCqPh9d!JA4&Nh0ZPt76z&#=!PuTK*dkr$VnOepZ5`~6ynnrN z45!-kziHhX{lu3I<3yF+n1xM4je`s!TVp$!qcsRYF|d=o-ZmR(2rD_)5oUk{=PXDw zIelHOZ!>CR?|fpF(@+x@Tj9VmJ(Yn{0mBt1E8g|+B4n{8>i4<aLx41qPZ7j}*{XT}^R(x`#y zfRmlq5B&r|n_`l6-3OzbB4ZEX$iH=VG0LuQCsxI@H*C0;x-T}_9k|i>yfr#x1g+sW z{EEwH;+%g=-@s}68H5ucUG|+rZQjk>X9v19G7Eh>2>JJMVfgOWvqe;5#8GFbS~Su- zj`3Kp1+mnc8zTsW0<^~V(=#Uzcj-B|ks-CAkQh{85UTct``-Zi9`sk1|6~Sma4t3@ zl$ZhZ6cht#ZV8zmk4ahT8sX*f>mCLVE)Dq%#nBAIwrM|RymQ+8c{ltMv>)sVvg|k= zn-M1>vvDLc{qKlv5_AD5!0v|+`H+T@T^W*wkLNlv5iw`Iyw~CIbe_%Jb!$Uy$`@$? z{JZOzdATzTFTzV&6pdKmcg`59MYehH>Vr-rVG44UUvx~vtc?7$c)clRE2N;*7p)V<*;NJ zXIkIj8yi0wx6`!y+_x_nq-HzlBR4i-URrhr3#JDBy0P;K5a$JGn;ZrgK*q+-#8rt9 zAmig8Dgh7iUCij(0$#s#jY7Z9Q{pDQwBAYub+ zktr8oQK_E)Xj$*oZfp;;6lN&!0FSUM;Gm}o3JUFq<@lH}X_LWeQm`v0I^!UTP9m%v3Cp)qVT(D>b;ng$4*q!g(rQD^c%Z z@m0P>_mRBaeHVL5`~9%KwebnlR6mKHzxMVQG!7jy5!(3irljHG+Hlau!>R4g(xKph z&8cXCGnj=aw<6n~fVW&If0w8Y$prQ&gvFzVxs7}F3sVhHDgk_+e^ARnki#F+? z!3==w!KtwR$yQcgmasP(3Rw2=%`2F<7Gv+`eStpoUknAUMUC z?EqYdf~+ZDE21McVN(*INgPMw6@vhv3J?IYX7?83zN9@D{P;%CglfB)vZDRWmnrI+Wv_DS)hSC6`1DlPw zn_t=a$7|-r=5FBzOFSGCn@xA|K2dQvr!4D&)o1(IR1z_W0sxIT0FXo-`}Um9DMX4Z z|0=%Bj6h)qOfag6D=T}2VRC22hOi@3fmvV;~iCm0kU zGixnE>H|~eX1fMfV7g#-F?{xsTOe$5D!zN?X(%z569{4R<^qUIA^N6VaI=AskcP|f zH)0h;#^caAH!IOH^CyY3RDf&s5;-$2`vzo@$b~z;vp2+oUMo^@$v2Wkse@+-&5}V@ z^@Q06O%Q<|DGEFSRB8rU?bo4fGe&@04bZ{d@Nl^B z#B1bK3RT2lE8a0(8ZEw9VRc~fB$g@=X5!_)3jBiAEEn{3Xi<06<* z0K$!h0OTe>ytxQK0O9v|V*qmTH*6X51k0IC#IQ6aiTNq{-x4vPiw$)FBeO9s;^Qr` zdksj0)4bJ)NM8VVR5ms@f&tIwYzU&FBm$L4c1$xNzni5a&J_g(GdAUM5YxMS#1|R_Mk z1D`5Dpv)l{9V(IhU}QI z4Dr?@{cR54)~sM_1ibE^2pcvqxWWN+8=U;-XQ7!r2!ccwh!MzPD#33TC!oZjqM#8cTOmSD0zhKw%_NHA z7ncD2wk7wdjLTu9ICyyz6mV|i{}&v z;OKfHDVwHvkla3L}C`xD+6ngMbN6I056HfXgl5LDCU~CkhP% zoWB_dMFMWKY~GR@KK20Usl;`)AmW>Dp#Y|W(+xqOCIE!YZ;I;B#WF52!1Z9W<_ja< zz~DL@9@C6t%qgN86!FIVt=Kk0Ot^6RK64$-HWXSgHV~*?V8(;npS|*>|NZ|s#bM0oaTQaNaQN|J%`C@;#ufw*Ft9MS z(esgIc4!H^aF)CH88R6_elE`$umWXxy@fHTnt&w$z0jpRe(|*!GkmX<>hHgf=uoQ+cVuB1%hx{w{G-YPvFR=XTgR5mJOX7 zXuu%3^!7NceS2PyLJur0iGig1$HAHb1HndsL13a_E0}+Wu)|DK`mM{iE3;a-RD3=- zsIG4HF}nj5=r4 zX~rF6*ccFii)k7u6pH|(xwTfG=c1!D7|Kj+b6EunhMEBUoJHF^&)Bx9W-`q5*zw~6 z9E82p8YsY`3}?phTeCvT2GvdQ261CGjR+>*@~~MrtuUg1jXSfLAN80EbENc%4$44U zp#;5gK|_CQ?PeebG3iuiHSHrlYm5+fiRK_=g;GwL%^sU2+#5hs z5sQEs#yCLcMY&9r3awqPu}N_Ld{f_1I-acT6+PXUMnivt?JoP7RMqU97-K77NM{BQ z+5qDK*r)c@$SK93B?zsRV=y;o6+@_jW{H$0fFoe}&J;JrMPbZ6!_sDY3WIT%iy&Y? z%f)QkBPojjh;7)4&2)o{1g!yMEO0Oz3`m_4E=1{B-RAOL>M!{Dgekk(REWCC(cdqP z9-8*ee`he6>QvXl9Dx;V;6dqVabA}42tCU^l2QP378@ww`M}fa4Sx+oT@JY5vLK-Oxvi@qozbp!PG%)QZ!1BX`_q&_G2=clZnurKmE>Iw&CE*wK)Z` z7@PXmxKMBZ`jtVZ3&k$QWda2#AqKY1jR!p?bEDZEr-EWA20Nm=zQF7E@pOPfk3gGe z3KU!s#V#;y?{{}8-^z@20Z_m(S*CRFmAm?h78f*(Gmu95KCT@RYR|Zgrs#LTjSU7{*NgyW3A48<00|m=Kdi@a)g+hhO`NX|#ScjfTzRAcERV z4;MSTc?YOVH1hSp_!Ps=0fF8#^R&9CuPLW~@Z;*DKT5#FfZ-T01YZz?N^CgNYgbJK z0;1cD#63kRIZMojCJAvV+cp8Zd=@uA6oQ$Hn{A+RFc&@MQ*RmEvZw_ZQ5wf_vgpsZ zkJ+1o+TMk>ZTr26-4GOP%v0a~=Q5cY%=LKSQv+-RVb#Yhe@=gBiE_XxdiZnWU;a@Y zh!Ws{%U~QX4F${C8$wPY&V@r?nk7j%x~Qs*S#6udP71XbA;q?!g3ZAd%&ANh2zXEc z#}4#eZ##TzgbSStB@2siFaPWmUFK|wQYkC023)0n-<JKkW+6R~^bfr2TZ6@{~qDif~|dV5d?m#4M?8`?rxc&{IS%x*~o(1S+r zpuK7!ILChM_Ip4;S-pM@K62GMYuP|ym35f^!XRozY`G9P?6GG=0fY#2!Q#N20=Zl) zR>%nta@D*3mez!o{6xvgQLM>K0dW?qEoLPlHz1V0d&GibU}FGD2(xlS`^4C06s0We zmK*T;w6oHt1PG>~?Y1u&@7y}p+LCoI6Jg3KaYrL=cDecg?w>(^18MLV3=AZ0Uf#-) zD>tfTJxV4U%6;(Dtce1`rs>3Ck?;lX8SvB{QNR>}@CBL%d=rx=!q{V@z%o=NHE{|n zB|w0Sw(ZTA&TI9zo&&`I3xq7}2|e;><|=rf#f`<~#-N;r{JAi=cn87Zz})$ibWxlU z1Jpo^o8GbPL@5)q{Nw;R<{iBQi1UR2=CWM~(cp-POHGgoae)fzJOz4XVGcM|cy!<) z009CG+P3`wv(w$a?bi%ofFmInR~a9`sYf>gLvJ$W(uF9@e+!Ti%>^vrEZyJG;?;4? z)c~ekwry<6ZXsidd;}ka&{G$XbrHanB5p2NxeyhAA)o>Uu23I>QD5Z4jES z)^^)?vbD!MC?F9~3;8P*RK@3Vlcgy{W$X z>TXXq1g5-jxRU7 zrZGDqG+GVaG@-qI+mDxMw68s{fPMkm^YQ$9?D;_Z;vvLSf)HrirfEp~TJuDEvY*SQ zUn#!Q_H?|w)9<;jH@4Zdhan!eZF|0VLcd---;aKf`AXu|`xS!zNZ7{1-o!RL?QuW# z8#9})IeYyA{j#O#4~Ex_XM5*2ZakZpG@@S=53^I(1O1Y*-OEmYNc?8cX1h=xmLVF2 zp`VF|qo8cBoo9Q4!Iko4en+yISAt3>Y<1O4q4 z=yyRFSrWmYZ<^AMI*wUu=&rNW9smF5%eixHiZ%{oik8|GL@skVo0k>w)bj|G)ZH_^#0gQ-=d$cbb`oLvKh_RY9I<+xC&3%^<|Y(z(9t zyA0HZl;Oqv$J-Dwn4~V7k6}-1+n@D@^1=&x7#w7ecrp*#et(cwc-gsd)?Qws6#eRm&PGlSha7y3x0z_Tj zfXynx8P=vWF88qM|JS37*4n%PZM!iJPxo^&^%VV~75hO<5MjCL4rcV_X+jspTr9$% zdWO(+j$9q`xS6YQYU5=3nb6RiAgCu^o;jb=A6S8)Ob8+}`~q9jR5!Q--~g{cM@}tB zxguD}udsjYrqQ$)wrw+Nd7yW?ubtjx$Ec67c%;T|2orN#x#&VFgAGE%r-dd*CeF#pzZcv zUL+$>jrRDFysUj_XY(*)sThlcuwfokBnwc%$nl5`?ACD}E;iG%LvLO&doqL*?QwfD z7;3M5eCLr7#Do;cY$_zg!6>J|fQ^Z}sUMt8LaH^|7maO(qV2br#*^(sJr13be`dw- zpkK$fKgslKe1>Pr=brfNlLzh7K4|~i|112zqN;ZXp5F@pukim0|G$ZV=bggyOA2#l zarilIUef#%Nr=)=%9Wrl`sR$8=*hS4bO3+Kn)sFpwl zNW;Qy#W2CLVxd!1Q$V+xiM#JvkxYU_C`^Z?qC!p(*((CAr3ulkKhBi# zp1VOqjzw^D(n>-uaj6}y%2ji9?LY#HTv)#wx@Y;!gG!TRR3c0bgrS47VjV`H#kS~f z`kwcsShy1Kq%;Wy<2}PWlA;~1tOP8La|rCGJw)^t=@ zaS$1{l4c-iQPY%8CB5ypBdHo8u5k6{BnYUd=f8VgZc$a7AWee~bL_C-V1uROGu<&c z78US_9zI?(36~Q!L({`0aR_6YT205}>Emewrk{TLO1L}-g0W|g2TAIwMwCVxYt-RN zqeUatNt#fl&6^jN@P^VtkPIcF?P}Hg$Pp@12Di{mdfW2jDdFkrR#ghF#wZAaXC|@6 z9aHRNKsVL=kM-NigU^0}NF{iHNfoyenK<>3`?8A||Jk8*w_5%|*tCd<1GhcmV4 z;us!MOy>oVB2ycXJ-lIrfnEiy zN7PQliqOvkA*K}XwUR)9$3N!Eg#}J2Z(fjJ6^w%QT7Yr}R)<6efVm0j~G)UvT+_ZQV0qBXwSzGfRF{#RRaN}z;&Pxs@qT-orKbC5~aBm zFryqFTn*E0YE-r|QT7QfYN+VZj>+pG&|<*#v8_VjX%E2vN6W4na0;ZQ`bGLi0D|MD zwwet=4em27|uqJF+NMWCH{(zYH8Q%=vGams3$fLNPg@vIRU}y7PeXvAEf2P+1b^nNko` zCTR6>%K|RrQ>^dMa9oDpy5KE?mpdU2{fd|<4PmP#4~%{?GFXTqc~vLF1P^PF#IO*s zYYC2Q|0;D#!wTrUcTK_DeGnifS+9>^frc_GW|U-b3^W!|o$7{Q*(1mrAc)8#ivToG zo;2-{@#oxO@O~$Pvot1E>s`RNqy=0Rj5T^JQ8(2Cvcv%31y-yaf+T_UeZxRn!2Wg|GQ#&`wP8&rSEH0@Kb)u)E4}d`Vo0AY7M0sgB6DDh-`Nm;}e%C0|d3&(HZr?34j zRsGVP7t7oCQ-1Kq(HamS1t^;DyQMq!`$&EDP!^U=*y_f_<-{%aSXP+JF@3ZbFMBUoyn@vRd1<&G+m z?(~D8#ktR8)pYQ0(#OB*U-hr5C->&?S13G+d5!rH<7gpTI_Oh>?fbyZoAbm90lxdG zH%%s+C}RJgW|&{ z6_39~u7$XHAGlAt*}yn(=<)8&H{F-Iuds=tpWbNT;h);fNT)Ks_j{X(--Py%O@Zt) zmj2%x3+Bzgd4BPeFCrI7T}1t!>g#@{wz=cGjYOkhB55+?_xfZR(ts)k8%`Z;ceqsb z`3BfFkZRk8wEt_iWzdMMX~Iwo8ZvUvsiUEx-h_v=cOIf~krjxRrE*eszbGVOd8ntg zhpr2%05samg>nxptn&4yP3fZi=-w-(cmFt3MW0*yb3f|tu!R?slBYcP70&+cYmTfu z5;S#l!$A&&6D=;ZL;)kPRjdb#PqLl&0*eSoVzJ6E493d}l|u{HB0IvZZT7M0n_~>J z2~-2Jp8*(DH>MFyyMHTtf1`}J;kYE844FG8)$V$n|abaCJ>JCN&ZD+h-CB(Y9J5I|C zjaTB<2)U8Q!@5OS8dU~@paghqq2+B+(H@X0Sr z)%(46yZ655@h`jg>t2-)+(qZ#buD%(D-oZ4&YW8ZAu^6Mvman_0Q zk-gPV`tyFy{Lrv7ll`(@PzD}fw$SU>^Vo-|hD<@Lw6DW}D9F`=fz@5Gt&WBe8z)kr z3N5+BVsH%&O2b^&)=r_LXzw08idj+*mOD`w%yC^Hrq;2#Vwl!~#S$2e(8~x~6(Gke zk-Aue&7tWLyP=zerlCgX1&8-(xz|Jm-~RN~m#%&@0Pnx`W{-dIU&jm3nJ-kexbebJ zJ}wrG#HYPed(C`&^;DTnZ@u-_r{7yAN3yUfWU3ns^;Rkm{eJ7z_@%1$!=)b{_~D^X zUyVf_b3}O(SO={XH3z3b8oR?hF^wOt4L^q$*>bi###Y4hQjVl7d$@bW&%52lAc4mhb5O5RZ4{K zgVi*90UTW&;`E`h)7cnyUgQ0`UnY%kkALZZf6lAXk%s{VNBV!fZ9nFNApZ1s#$L5w zuzD`L0`)1i-J*i**ui4LVMDGvzgzjl$1UYy-))^0zw{mp?l#lD-zE!nHO`UX@!hDR z43R4l`4JM3R$9RfNp~(XD-w7c8I=T58UkE&;8U=m3kUf}`svo$T<7l~;_LCF zyY5h{`J(N4)KqLN-m0j?n4r&lEoX-7av2VHpFDI+ZEphujW%_zHOMpPJ);`p3P^Yg zSdui!yC#jP&wOslZne~;5%cZ{opP}(yoGFO^J=WIo2#6=L$X3^(fKUy9D|OerTY>9 zu+X!qJK~hv&sE0O|7p9kSGNJ4Y6JPC22gnE#L8>ukE~~h$ zq8aQ8T2DWbPn#Du%grm85^aM)&J9Ihhep_JxtF1>IaB*Bj$Q7hq^+P$?YbM5FYpW9 zKaY3Eu7YKPBI&z9^GeZil<~4MLym>}iGg1>fzo#8UqJ&oYR8naQ)j|Qw9#bXH~sWF z#9%~92S+q^C73lENBBP0nYM&76LW*>f?@0c9jZ~0M@S}hLP3@ZLpTB+DJVY)R6R6D z=mtlUc01_V-X{EhloX>q;SY51SaY^lr9@;Eo!S_Hx0bQ3e3=gX%)EN&)Ltbb#>rAc zR8SpMihu~6K?_!cn$n65Tq1g9*Z|<5Rr!4YU517(({X(a|H`6*(hv_qm^0OnGWf7&lGO))Q$lQ8QKy_C8#Iw(^>EQRcW9Ruq`%vUZ1AzQ6Y*ANSVgV;nWiHj-8fkA1R8XC@* zfN?E!nTrfOhm(j&!U6K>OLbCmsf4gfkT_RCE-jS-BfXrHHBf!JOc_lwUS2>@XhcP8 z>^#E)xBg4Mb*jx@=?6X7?G3*Vt5R^w+11q-gdFV`J7KPGd58qqZ!^KQ7p$|ky={(! zRwJq`j<7}26i{c%n=g(e_VM{dR~YXDyX5+%~QD5RB2VcGR?6O|oJ zx5~}Ypl21vAMX^=7wpKvUsojWC;J8y;2EsMDohn~Vl>LWGKpSuy8j z5ddnQoOp;Sl~;Wt6|({}(l9<#qiv?fLH38r*%`!wC0KDZMH{bJWd(!WoQoAjwPdFo z6(zw9ASl128n?g&zzkG|aUEyqxENvT#_TFEu-3pcoES)98`kAu536`#v9gOn9;Wo6 z&`bA=-7mZhEmrVfU@oumzhz4YPx1Nm)GbN}o;(PVTTE}(- zk3C3$VNXeSm_-HFEn`Zwmt)u~=gT0G>DD@3w7LXO^DcUavnN|ZtUJWVA(Yqfur#!S zgA%$$U|5WLjq_QuI*8bwHnDcltR+oh%8^)X9Bk*oi^<$Hr5|3DH8YNSZ`F=sI1*kv zU0}m1ITn}PBR77e>MgXe605ZDjAas_ujELXc?1)L^RyT3sg;Umu{;-okzDGi=mG!( zOl#8_xZa#Fv>Z-I1Ctp1)lXIKKYv^e9!BxP3m#JzJn2)48rFwW&{@*{89u)!2yVTV z$jK@u(y}WU9(2#JsRLLzurF4hlt!G4yV}?=ix}l*hu!b+o9Fc zQmFjWUkftT!zETAmY*Q+TqD3i83lXk%2AkXApmPryD(eQ(iF5)nY58Z?S5$$8e*vzN<^rg2^Xksh}2c6 z;qj91)et|jy|b<&q0D1UC*$=qY(`$_u^kv9Ufp^n7_#G+EYVFa^oNR`eRiT?^{KZ^ zTEz{e$(k8c7CYKt$+kIiPa%4TD?i1=DLFiRfHq>;T ztuX??F9%Dqaoa{&4{!=hU^i_$Zt1pTWTMDUi_s|=j13|nAS;bD%TJubPxp;_OQ02E z7|o$&sF7iUM}{hOwFGRys+hnA$SJA|y@=8kW&ks8^;}n8^a$DIK@2YhXI1IzaP6(k z)#&A6$c|rP9k2AVe3VmRM3xTXy1Rbk;oJfjhW%0`PtZNcpXI0?$mom?jLT{m!fntYiCZGa2y2Pnx0Mtar3fNxs*1KLP(=#eAHZ3{D{*{B(dZ+JoV3MObp=-Zj$8!py|hRnJASWMeo;d@tC+bD%gcHdyuoHN1r#om+FtX;|&NYFAU}&Uow&Rg1b^!!i*t>9&s%z`A-EZ%i z4}4f$IrQxOiLw$^Xxnx9jKj0~-S~{zQw>w|+IXy~$My`S2JEph&b@We)mS8JX@#~&+Q=+FNr zL-st*mPKh}15~ikt&YplM+&g??zyY*V&7?ZaN)UX*d4}MeXOwcrhjQ+lp0G$mM+^v z?PEt|xw!o!T4n?qDOSiKm(?|goH<*T=H+G;&P$g=1s5ea? zS#DZ=_PB~`#mpWqKk3=Y@P_vOC0_nz4Ta^ZHUeA6r5a`CV(p>RVPA5k;i|4&+#G7& zu?YZuzU$BzT35M>htnv=r~ZK3g&7zpbd{k%hdBg-sq7%sw&z6(kqX8%y>{~6(R-QB zaSi%0jO+x(P0CF?7T7~Z8~_;RM%+rYV472&a&=47(;VV-51D~v{F*+=gNMCgMK|5O zQ%xYz=m?Y=Ce7;E*l20rC>^s_KW-}Un|k_y+5y^%r*WGh!swOXpMjGzTDTU*Lt*1k z5ef|TZjAOtdcN=4<8v+C;;}O5lbBZQ9XoI%L}F&toxLA?|6q6ne2!~CG?)ZbkKv3L zJu2<~@RaT1XgcSr2B3Q_vjl_DkOs7%7%dtOIwl#1cJ7pb4LZ3RE44v<>k$`jX29&M zLuA?VVoz^xtoh?u72cbGhxo1d&YU01BeyVfKt`U5r;#1o@#@+PAW}(q}o=XqD2G5Y&q4Q5A8iNoUKXfz3+GL zKX^m@=>lN@r~s|b*mhHeqsuezs*{IT&bS_s>Av~xwTE~_;ehe@jN2Uk)&(U|n4j+x zY%sRHG2=%_zoC}MT7FX43~Fc4d*@!;&YgCifUltbi8bA!t~HcyY_N=TSCG+C0H8iQd?|8RKeq6D?cGU21961t`pc@_gqdW01 zM09Hi9FBXB6>C6X#A6%Npb^+1#w5pD0UL7Wkkx`UP&L6&Eq|n9uG`rhLA5WgxxV!dTc3q{8 zxcJM7|7)vcS!jEM?lhdr7^dCvw6Gk%k}AP@L&~bNhk=#ZHZ2H^PoS*t4iLI{1TKlR zu20i&8B3GX@v|@;E`;?Q8-n!0v+dRDUr9D}qhA2?(@O9lP#b zC^M)Oct(|b^Tu663-*q28l{!!oxfb$T@81?LjAYBLB;}MP}D<(WdkdzR!iXvY%7)^ z3wBQ26Ji@?c|XQ;))T^DrU(!GJDc)Wr?;b-Bt88t>{ye6I?O`

=zB9ady{7G4z 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

y8O0Hd^ZERcdJFlXnGzy;;Q*{f~pBo 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 literal 0 HcmV?d00001 diff --git a/assets/weather/clouds/06.webp b/assets/weather/clouds/06.webp new file mode 100644 index 0000000000000000000000000000000000000000..2dca574077b98cdb41780640462be40ea840b5b8 GIT binary patch literal 62264 zcmV(}K+wNZNk&F6^8f%>MM6+kP&iB@^8f%Z|G|$C2}y1wIg$j6j1jwT^Zy5Dmdake zmx%sP0RN6Apy-hcdz)^-8BjE{wGFh0hoB?s*kd4g@7SZ5kV}Yrbi24*5bYfRLAa7i zoVNhVQH8RqQY;W~UOC^C6!B_60Tgfs>z;1CVT}dgnig+@y1+pMJZ4ty3$zAIVw$yr zYCMn2?hAAaBw>)G0thIc1`v?s0?nZSijyh_Os#`$GrKN9(PWQ`fQq7~6iuvM%Cl&c z7@*j*(L@C$0WpJpo3&P1nWfpaU>sCJu~M+MINPO&vK+8mD*PX-P%q=j6CTzsA8)Jc zADhg%$?=>|zgqVOz!TbA02jVaDIh+^xgCE*)Lyz8^vlc*{evpBH$&Sq|Wt z@AsX4_9rEUW0)TVC2f+K9 zBQBAH3;?|4Z0QFr5z4YZiP?D|pI^ZU3fZ#6oSbr4BTAn!`qOa1E~*7`8%d6&%&JKL zjc!%DC*Bb;0s0pPAX_pEc*SNgfX`$SdFtks=HgNHA$qGv!q(H(_3q>bsG3MXx zL}QX@&KqFF zrpGM2E@RYQ3oFbXv#ePwH@aOkl!Jya(3(VpIRe#*{=3ITYprUnYazl#)mm7}dd!X6 zE}Cs}-EpPp0BuT1mC!)F^iy1~5Y4k_hkhY+e zGBY#defM-%I&9lUlIZkb@ZPHl;2QtGwAq%NCr;eB(f(W8apS}y`;=x~{B=OH6&Z~* zT`P)ic%xyt@M@Nl%7KL9?TcVi!NPydN(Y)IZkU>^__@$#bVTyv;tiAZkT(424<9S8 z14-M7E-1WRNW83QD>Pj{-iWqf+mLi&8Bji?CMy;!E;laTuw2kQykKjV3chsVw4$xp zP!5RqfMvkJ8$O0OFeFKkLTcy!|J*eS#6*%DNkKBK^551ME1H-9toi>tmn5flI*Rpv zC9U_~dlRwu-h1!83tPsz3>Ys2Dzf5o5#>Uy!cSTqLt3T!8e7We>^GNEMyMp zI1cTGmp0VK@IoQi3CJ7zA{i>dq6G*4WrMh33~aiz=?iyh!^ZOJ7!<*!q6Xdnt;1Jym^d71Q*y1A0UajYfaGKj!JQN3L>o^UJwLlny0OA+{++LN`Dx9F^F`_AhDvhF+ zSAF6xSk+Df1?r&s0qhGLYcSgYwMgSo)KJEX09GC+ z0m=bt)dN%*k9F_e@TkgpoVma;G6D9YsM6>*sJWsjIsu09P=#B-Py{s}ssb|6XzDO( zj8#DlnzAf9wr#7nl`&Q+a56$Cboa`-YwA!Q-W*hHZCkc2$8kO1pWR*10TB6#G={(; zl3+G}B3UMR^2!9X0>H5`!rGwJ#{r92i0|X7IS(Mo}34JZ=huLR-@LyMGL~d&AKdl!`*y zFQwrT6cWSVed(k&6t7ueWS1!l`xZuF2=}#LA42oBpeawLJ+ zaQ#>NaS#*WC)>79k{ti*L&%mtraj7u$n2K7swQOVB)NoyLa1amkJ}ZB!7w~tlEdS6 zA3_3HLy;9TE+6(ID%Hw7Cu zmH`IZjBm9DNZAk?($Lf}T#qfREq`k@uwk>=unF00YK#ovvoMu^2Q{CQzeNrrI%vAn zlr+gsHyVUpie<_a~)G`)Tj8J~jC85y`;=ASxcGYJwz3ka{Snw?(tF39(QG&XCICZ0vZNa$!OMY)$B8~rutxes39PO;K@q(fIJvM4QTI8 zh+*X}rmlf9-^R;Qz_<`E>=l>Ak^^+UTb=6v|EZtqG%60%fSVzW0T3lqW}fmzyW!~whxzSA_^%8Z9H2nVEJ zgF6+ysn@r*QRPuoQ9%6~#!S5E-}tNl`HVb4gz&;a1Ek2Pt5c9(VEd&g(TgEm7yb|g z20>tMNE;xIDTEkaS?h_Rj;HlBL$oUbr`QB|R{%{QLP)5wt7=oj^^Gk(6cj)iEaOau zHb2cV<{0IG0L464LjnZqsv;c`7MWxxvCTf_W`8_(~v-v zI+PSs0Q$6@s-iG^tRt@^U_J;NFwjNoCP1CRdhjK6slOTP<}6A8xjQg7H3BK$0SI9P z=Qrrra!C)1lQGe_3!mCZGeSF-gmyBeF05QxSzC!LcS$pGazS)%2LJrC7<|vWx+SS<> zVq@qp`QxskdR1*Ys?(_Iv~k4g>*ryt;nJQEFq99@^7sYBn(5vbdqcGq2nGMT(8uza zeD^5i5v?hxvWK6{nAiR?`@<$bQeyl0aClA{%#JQPiw;KpNN5${Gh-_Z^|$&$Kv9SO zvUkb{1H~Mk)dnLzL>pP=;VyI-htiV1(V7pq6bQDcVt_o-;|t2QTrQDK4PSd(bAMSM zbo@5&*E0oW^p{0;0g~Q0nC<}>h_L!rYLf!qs)gF>8eq-gF^ghsB8d55opWW>Xam9^ zQkVVKbV86|L<=m{aIu(Aql#&#!tCvhCC0DknqSY|H<$Xu^D1|ZbnEScAW$8<2(%ci zaD6!nQYCtSq7Zm(dYq#-ltBQ(hwDar>;W!d0T|>qyZN=neNj|k6Y^_gJgvF7qXuIl zhHE~Bd3_l3Yaaczzv`QpGrK5KZ_=cU_BV9}8Wnpkz$*P%RIexX4h4=`)R7yCgI&O^ z4>$ANnms>i_YT(q#XJDlqHOllk(6aW<)+qMNl)ZqEx%djhBh2Ca#0>=Q{&q=Ij$Ypqw)=AL!{GV~Lt5=7VeBWk?^H zhc)QIVg^>{U4?S7lpdi{6y(a1HvRVv#wi_X@f>8~Ss8s@WYjl5?Fnx^?+=fQw@q$% zXLf`bO9S_0E0*;rl<1A&fm=55?h=i@*Mh}_hMJMFXZc{;Ldm57rhs{03y2!9f(p=p zK*US!PP;~(R34umw0`uI)W?t3WV^|2X8z#xP5mPWP5Fcxy3O$Hv(|mA zO*!HZB12wkz^38d(3Q*R8S=G<@DYMLOK{dYO6EbKv_Db?g`8S*y8&$6v@8bhBBS(& z;-!-zIc$Q)2TEgYf}Pozm8~A7Dm2!4jq{HVbvn7y^65H1xKl?xAL`=Q|NG_Yb)(al zkU6DJqPTtZC%BdGKXz+K?~%qq0_`hoa^vBWe#>H0^lh_%Q(>CY080+i@nqT1ZU&bb zg}F`RWkO%K4-9K&?7`i)k{Wg;dYSb|hfb|wG(dJwxuZ%}nQ$R$pfA5%<50({xfbR1 z+Yd)w!cQ>wE2sJW)Up%nPsP*Ww@AH4SVzVPW-A_si%;|V$AjPVSS^MOsx_-Z3_eE2 z!-3Gy9z36d6tu;5*{tQf&;ko;ko9z2pB|7I+uA3hO-+qKxB2kWLzpI&9J_p`X+?%YR35uE4EVUsShtC2$6Dz4o3isRm9tIsV9 zFHfMaVZ19UJbhEfKtZwQ@GGRjxN^U5l~w7e$dmUq0W1&V0Zh%sQkMB3It;{iX!EoWxE<)JKJj*&b9IHAwZgSP>@{IqJE}bW z_pUahrcgE=rmh}YiI>NUb(RKWrmBYV)i|bZIEHPx(|(hc`(HSf zrwe_ot2WZp_&mg|+{;gSz@}K6ySf*n^di z|sm%{-ci1p?1{$kR&f#cQ7W3{>X^m$}}aKU+$i#fUS7*$1odDJ>3$1`(yx=yJz zxO%H9lB-R7&)fR3?iu69r(bveD}8(A@d&2!aWUTjXIuLf(BRv}=ZThcNVPc(SoKo* z@FHPKpN^#b46ysZa1Y1;)?K`EDS-VO^|Chcnv;L@qek7cU4B5-&*QO*Hdl&?7(Kas1@x50~a=YoGc7Ha#|~%U+e2 z>nQ^3X{a97ZN|tTh7$FIiD7m~((WTIxk>qM?+yl!UjS!;T616)*RT6>cVqqu_|isP zh>EWmDVI@PKzWoWqWY>$*FB*TIhdN~d0RvMgnj%&+xpOEvaSB(4f5pXPn8eztsbDw z(|$Xxe5v0rFZ5jXa%XF$r%KD~F&PBcR8;+dg0(@Pj;E;V#gm>)#U##&aA9YnhVOt+UQSMu5UbGpshk3CU{INTaQ5BF&3Al<2*fbvn9%K!UG@Kj1EeTBJ z<+b8^dPo(Ubo8F*hT!Y(Dd+9=5m{XPxzFa^RJ}ZC$){B>G11o5c zw7qBd{t==;#8vsYI4j32S4E2R^?OdMr68Ypvo7X1;J$7}=j%?@Se-DToI=$6`iRR{ zI7yvA_%zm$axooc)?(DW%$Hrvm&fv$9Otta?Kz3M;GDu^no+fBmvN((S#jrqu`#5S zstP$mSPN^dc9!;dPkVP`AOcr{+swRqx#d6B{Cb%MuxalNb^qdgSbat59NDxhhaS=J zk_$A-TYXlJD}FieKD7+M7G&|&TVx*u1TA6fsedAQetiT??-g*mTyRdBM=3WIg5@L`JVIYi(+(EjVX6( ztDhUF)jvbEfTPkQ2c_OT{ue!~kd+N^ifhIEFrsXmwD&PpwdlFFBBz3TD<$j~YjS{7 zbz&w;x1VdYbAmv3WY_t)!i`mw0bX_iZ0j>>6QK#As-_p;nMkvOQ5>CB)YJ8IhnvzF z*?-Lf7S%ycWtR5f6c7aM>nsh#xx@vzR3Ai^V3vvQ1K%Csj0%D;{jI#<9ysoGKZ4Z!_QXD38z&A zeR?e0n;u$1;M0eYX&Q&!-EzRgGGCwQsf67X3}BRks6K#T2NS{{59nxl`4!w#;uX1o zBe;55^v?RdO${n{zs}*RQ+?o4T#Ig3dC5H#Ftm{6m{CkPVdl`911N`c`4U`)%B+W{ z)SUgHy@+~DkTHb^Z&3*I%#S}dnmzI}pte7?YGQx|EO0~tQGEaboPf1n85{MWoa#V* zWk4HasxM#p3B}sCr8cP#FRP|B9F*XbJmw@AiXLFhTC#B`gIU1L9A=V&714dYsh`K) zlSk~kKE}m-&uCC&QjUcg^D-|xDcgKaS!sVZl=i0|pz!qq3-Bgb6hsdZFiQ(l7f*fw zL|+jw3}V5AtsIxjm*k^A)fJ9v-O^I7G0SHU-tfsqoH3yz?<-O1)%XL=6i)z;@vnBeOSsG7WKFZH@JI2u41JxEivYrPz6P?zhLbz zf$Fiw=+i7r6$_{domGZ4#+v|Ufpt6WX5MPq00sr(;F4r=h~;I#?|r!r8qS-2SyVK)`-AjgM;*4m;lA(b&| zWEA&&Os8urf$0lbq`(CZCH74%&Z3L?d~T%nPf>ig)k@3Vmp8JqZ>wfs-4A2VYBON& zPZZ2sdS3_bE=Vmvn+^=rn6=7?GJ-VThHeT*yIFzN!ok3MhA|f=fVVY$LF10agd82f z0>nF0WYl0qH`}VmD(T6HX>au)*whASELw2E{=$OGzRu^U7^M{RbBV3AoJE}|6eDz1 z_oJ-L!r&PwrYnpIRz{V}Eh#Ng)LagQq!eKPp;x}8aC(`c1iuH7HsHXJ*Zn=1N~; zuG2ChzpY|+mi$2bTMs}Bdd5v5Hfeg!iP8RrSPd8HtL%qQ^ei;&Bb)|+V-$OG>Qsj5 z?AvOi3x`_W1VDy4;9{g!)mgI`)B=&IiYb6y3Oh;nu1%eg!04~O!#gt)*35F94Ggjj z@j-s@F(%)AGe*fR;dNVT1BiD+%f8hz6^|(ukK1ClDT{?siSmty z&`Jx)Ssh#v1ZGX(+z(Z$Y3me7cT{#tB$Qo~gd@9Wg?4S&1e?wS{`E;t5m;<2oR?)f zu?#DBJj`ZFDYf6;Dw2;#e8>;HLYCM1?Uj)#1}X~lwYywPWor``bJ5^ZXbp#%)AF3Y zJv@$@SwjU}zo4}!#PA#);{HJStQYFglVitsUL<5&i3DzAQGBPV2&Se?tuCi%`SqZH zYuN1eRKeWT2FnEw1T!6wI8aIXjQ#cvi+lYiL<;x#=T8ICNk&m=AMXTBoYX^4V)arFs=BvyDud z0(iNm@SM_8cR40ZIQWFQcC+avrenjY>qG@YOjSw@6mIkcW%g{fV24lOVkV04MSnN5 zjaN*Ro{JI)X`f2(n;ihX!_}{!JCw2cu&p*A%v|1sOz6aG?%Ps0R>kQjFWlkNXSGQ; z|NSQ12iAE3B#oyc$tqEJ%qUv5T|5Hwf_I&%GR(rk^QCE1=&2BUkBu18EDs!_wll8j z^UNpsz>~+_ODzTPU*G_jK~9B&9tfQG(LXl*9RRZ8b#Qlmx@qZd$lenuucycgF5{f9 zzM4{4vuA&*vcdigS|Gw$v&>TO{Tb`z0|@l|^e|89i`PxU1FC~S_+_0}8X_oU!s>>w zBguTXv$6{eayR7&8&tN50Y2ihN(&AtaAE9mA23k^C^lWYVFfn$^_ZHy1e;(X#5Iph z;anH}?fgg)6ZEg~!0~1seAFuqoC5lkIbn0(l)%Evy4p&?qr^Pz?-}r5jKvZyBrrp% z<4qqMoL7|*nYD@6M#d)oHWoCvd@5q%-2V2mp{UrD9G6vu4-(kXJFzK)l+@}jZcEo7 z3c`7P;t{2!6KYTg1)Fv>s_{A|4upd{b!LKh7CZ_-z`Ro!#`_on1PdfIBd9?A8m8V) zArQw0^NuO+EF=lQ4P>$G29xQkSwrPK`Z&C_WbBS$`JSmN6vW^RDG=poD-L=Ds|DQ` z+bY`FSqEN_(u)p4HNnrQhV1TwzKt1O5TnIMp4VOz_ zl=rT-b=kmV$}lGDj7}+)v{pv-r){+v zNmBh&$0R8!?xllNVZK$gDZ79#-W|MvnqgE6^$hSJ_FzYk!0xKIqq^FQok471tNk_teDLdH8e1RxwhO=$}Q+n5pz zQR4(Q8vE0%W#PDPI+;PRN_OngJLM{(t4vZKRWB5<7MkT=W5No(YVq|E)9b3`O7glf$L$QS*t1Ax#9rc}ENB=IRDI_5cE`*WyB}`is7N8DHwuivkz8yi8v{hHzMr zf0n;#Saw^*zye(Anj>%Z zu*uML%K<;coRoq!{FRnC*{mC}K&e(#N#Racu(gHed}2;d_b z6IBQ=IoQ~6E||MX3UgCn6dDYpB|r$=p*@AZ=!-O}syYF&;4TN?nNt8J(4TF$v&0LG zX(QklHUt6GfN~5qa~0qQpNoNNQ-d*n_lfovMP=SqEv4m$@B;`zys{Qo!-mIAQlscR zZi37qRAqKK}4d4ij@Eh*4U+Fre%6 zV)kN0arQZU6c+TyvMwzg$%tOJzy{9R3sPb>xtZ`~4qbau#0Bh|R8Gm1U_-%P=Jjbd zpB-yyr_i4Wy_ibn=>GALb)>~ipeM_ns)(1I zfA6A)LzE>3FrL$=U057pSG*|X;8ewuUtyELUT%Y-oOMg6s9rE2dfcvx_+c^9r_IB3zZM@r3!QPmJo(h>G4zL*tR^^xn3i)@@K|)HR5VnD0S@NP z!h{PBJoS`6!zy5O;1(8~dInk(22aOe+*b%b7q01u?=}@k-cRKkoEE5oa2*3C7!kr= z1|sMV9FP{GXVA->m8!Bebh!IEwEFs9&*_JyT+hLuyf`uMW1FsI-2E28p8Ssc<*4$& zy27K1YA2~%1e`amI~tLs0GqS|Q(B5PF+nf_|3;IS;6N9TiwL;`0>!6~RE)a#6#Qap zHwGx!09&6i6gC3`$Mgwo_sKltas+Ol3j;2h0zCix*5*ppJA3`$V8peHfB{6Y%Vr7l zZfXA&idAhgXZ7`ARVlHLqqHs1>e;`p-bT}KUk8h#u5|^K=NC<{&PkPZb9>}>x>Uu2 zN}VUYMt{p%2jtRXvWdN+FTdBWOQ@(Jy_zY$f_dow?WqYrgH!yV014$C2UMHb;ncPW z!4Lx&N{#In16whocIM|-=GGE1^O!Dfx<7sPxo)uewfp>$d$YN+u6NU9&eNP`X7-E) zH7E^(?1a^ULe$z{U{PAR{3ZOVcSsPkuj|?rE!Zd3xYaA!UUldl5QrS=ac;Wmv*DY$ z>h>qY((K(V@Txk^Bd%Ir(flja?4FI1utMh!6ZYx8&CwU0z)*%+(dimZXj5D%}uOpH}Iy;^?JuZ@1pU$ zRT+$*o?<(bJRns{Of_kES zoUDv-|JpA$`O;xoe=Ut)ZkLi}IF?6Equk!k-1N&H4pz^x*tEvOaoj2bh~jQcrx8+R zCM<;s)kBzk=T4D1rUaV5J`pf%=ITf10x{xUWmbU9^c=qcTA>?Y=6Y%j5!K3z*cV&X zXO*b;W$&POdUX;;F@8FFHu=jrLDK;g_iHiPKo|QaXo%oid>}l{66u)dW38c*fotAJ zwQ@0Q`HTjam5!>1is9LS<%hq4k4}`k(4!k#xkwm8W<| zn3{>=Vs?X5DFmwb-ZuX2BRRemcs|ne#lytT9$+RP%Kd94fxEMB&t;Q$%!@ zAnx2&FQ>b6n`_o48q#y{v92z&Is<$^U)MZs>(s_anZwV!_@s>PnIbh|x!bo+@BOU~ zi{+|b_6)G{2~=9qLYbqc_605~<#OSUFb5$40@Jjw%H-N|v)lqA7Z;X|93)Yn(-%^@ zUH5X4muq)b^kiO7DV(*5`G|@;8k<OvpB*6Rd5Lx$XUR6=z*;Umo4VJv@~F_P+k4T%2Pkjtka1O2B@6HP>M6R zQ>WrscD7gcV~?ZNXO>dEhb2~lGAX~GP_sgnYo{_XW0nvFo`U<`#iTnXy1hDq0&ALf z*$9JlJTnLgIh(ATzVOGb8kEJuq2>5?v)Z$kJNc9SQEcK4*cN$^0P9od-TtHQuivyF z4ccTd-l_bwlQAQlm8QYPAfpJXn4+5)>f1x&G0`f72dLn4_qK33sG#&n^+l{~HFdw# zW*}jYyD;arxIBe04fFX%Dl2VDsO^) zcrs7Wu!

GhcR!Jr6K-U;>7$C%9IOg`r9xh8-*U)IE2i=ui5 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 literal 0 HcmV?d00001 diff --git a/assets/weather/clouds/07.webp b/assets/weather/clouds/07.webp new file mode 100644 index 0000000000000000000000000000000000000000..cd0773a9222e05a42d08ff4fb38ea6ecbd9ad678 GIT binary patch literal 52540 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 literal 0 HcmV?d00001 diff --git a/assets/weather/clouds/08.webp b/assets/weather/clouds/08.webp new file mode 100644 index 0000000000000000000000000000000000000000..07281d64097f8bb98221ed6b0e0bdc5b1b20f47d GIT binary patch literal 98684 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

@gz#Qk__=8yybx5#=E*u6PEp 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�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%0A1%3c$HJ64#Swf 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 z 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 literal 0 HcmV?d00001 diff --git a/assets/weather/clouds/10.webp b/assets/weather/clouds/10.webp new file mode 100644 index 0000000000000000000000000000000000000000..ea039043944d83b5f34f6083b7b52c7ff91fd885 GIT binary patch literal 76090 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 MiTr=(|L?;U08wvkegFUf literal 0 HcmV?d00001 diff --git a/assets/weather/particles/drop_normal.webp b/assets/weather/particles/drop_normal.webp new file mode 100644 index 0000000000000000000000000000000000000000..2c761d0191d912d11cd762d955ea3e5437c65886 GIT binary patch literal 3630 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 literal 0 HcmV?d00001 diff --git a/assets/weather/particles/snow_flake.webp b/assets/weather/particles/snow_flake.webp new file mode 100644 index 0000000000000000000000000000000000000000..077f24765c54159748dc2b9f100cac8edfcff602 GIT binary patch literal 638 zcmV-^0)hQfNk&F?0ssJ4MM6+kP&iC#0ssInC%_31KgOVKBT42@dUvn$NQYql=bu1; zGNfR%Z5x#hIXmMV@ZaF32m>8e<&J{F>C#R_s{)@AqJscM0l=Rk6`!*86c=ndtOq3L zqlVPputb1>g0_vE-tUp}2Ymk=BKki8k`zx#;4x>Y0*pClhx+J1tqdsUTkM`;+ctph zHb#OJPM~Dlw%ay3_x(sz&T0M=%jKMHiU9s?fP^4dXh*~Z@PAQAVEch2r4SKV521*Z zb51H?j*r|@uC=A4Rsv%mqTD|FuC`K3N=z-Xj!~pkTdTQA6btW;t`V4>BA-6*snY6SPd=FKFQ4Z%daJ_2H?s5ec};~!k8E~+n)@fM#u%$_ zOw&}$^5_-!{_*o#ljfPLD!sQ){(fU40R{xZI(^^Me0RP^)K;H*3z(e-KFROjzr%n{ zx4Mcj7y~9jTEi-VgMa54j<) z@!yV!mP@Z$+&85x`+V2OyXUP?N-3@85cjo&z0BuDIh0GOt)>tM3VSS<4}&PVv|5rl Y-s^HUDy8NS59+Z94?aR0z#qZCArgf<4FCWD literal 0 HcmV?d00001 diff --git a/assets/weather/sky/annulus.webp b/assets/weather/sky/annulus.webp new file mode 100644 index 0000000000000000000000000000000000000000..0bf363771d07675ed1e66064763b8abfcda26b47 GIT binary patch literal 256 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%0DtMKz18u(h^txPU?>+Bci?|=mu?0=4{G@sZXe{2 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 literal 0 HcmV?d00001 diff --git a/assets/weather/sky/sun_profile.webp b/assets/weather/sky/sun_profile.webp new file mode 100644 index 0000000000000000000000000000000000000000..07e7d12988aa9345c9eaddc2f8765ad2d2ec0f49 GIT binary patch literal 282 zcmV+#0p?p0d`&95@s+4mrk~Zdr22;*Cnk z$Q>DB(T(5@q69Js3qmo2zygFrF+c_cSU@NS1_1~I2*8~a0SV$%kQ^1_G?tu+vlN#E gga8tVaYdXYXW|9{q2y|eo6UXVu7nUkj0cNn2l~EzPyhe` literal 0 HcmV?d00001 diff --git a/assets/weather/sky/sun_rays.webp b/assets/weather/sky/sun_rays.webp new file mode 100644 index 0000000000000000000000000000000000000000..8257379bf5e5908477e0c237f4b54c6275779e94 GIT binary patch literal 34390 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 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. 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, }; /// Ink for content drawn **on** the weather sky (header, region badges). diff --git a/lib/core/settings/experimental_settings.dart b/lib/core/settings/experimental_settings.dart index de1841eee..a4dd317b4 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'; @@ -16,9 +17,16 @@ class ExperimentalSettings extends ChangeNotifier { key: PreferenceKeys.weatherMode, values: WeatherMode.values, fallback: WeatherMode.auto, + ), + _skyTimeMode = PersistedEnum( + prefs, + key: PreferenceKeys.skyTimeMode, + values: SkyTimeMode.values, + fallback: SkyTimeMode.auto, ); final PersistedEnum _weatherMode; + final PersistedEnum _skyTimeMode; /// The forced weather-animation mode, or [WeatherMode.auto] to follow real /// conditions. @@ -27,4 +35,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/preference_keys.dart b/lib/core/settings/preference_keys.dart index 69aa1afc9..2d649380c 100644 --- a/lib/core/settings/preference_keys.dart +++ b/lib/core/settings/preference_keys.dart @@ -64,6 +64,12 @@ abstract final class PreferenceKeys { 'experimental.weatherMode', ); + /// Experimental time-of-day override for the backdrop. See + /// `ExperimentalSettings`. + static const PrefKey skyTimeMode = PrefKey._( + 'experimental.skyTimeMode', + ); + /// 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/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/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/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 44ed884a5..8d04db313 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -87,7 +87,9 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - final weatherMode = context.watch().weatherMode; + final experimental = context.watch(); + final weatherMode = experimental.weatherMode; + final skyTimeMode = experimental.skyTimeMode; final extent = context.read(); return RefreshOnAppear( tabIndex: HomePage.tabIndex, @@ -145,6 +147,7 @@ class _HomePageState extends State { scrollController: scrollController, extent: extent, weatherMode: weatherMode, + skyTimeMode: skyTimeMode, ), ), ), 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..ed28dae78 100644 --- a/lib/features/home/presentation/widgets/home_active_events_section.dart +++ b/lib/features/home/presentation/widgets/home_active_events_section.dart @@ -4,8 +4,7 @@ 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'; @@ -16,11 +15,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 +34,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 = weatherSkyIsLight(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, @@ -74,12 +97,7 @@ class HomeActiveEventsSection extends StatelessWidget { ], ), 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 +133,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), diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 37d3f33bf..da48ce16d 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -9,6 +9,7 @@ import 'package:dpip/features/home/presentation/widgets/home_active_events_secti 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/sky_lut_cache.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -22,7 +23,8 @@ import 'package:provider/provider.dart'; /// /// 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). +/// skips weather entirely (name + events only); 所在地 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,7 +63,30 @@ class HomeContent extends StatelessWidget { Widget build(BuildContext context) { final store = context.watch(); final areaIndex = store.selectedIndex; - final showWeather = store.selected is! NationwideArea; + final area = store.selected; + // 所在地 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; + // One listener for the whole panel: the sky colour only changes when the + // LUT re-bakes (a weather or time-slot change), and every card below tints + // itself from the same value the way the reference samples one gradient stop for all + // of them. + return ValueListenableBuilder( + valueListenable: SkyLutCache.panelAmbient, + builder: (context, sky, _) => + _build(context, areaIndex, located, showWeather, sky), + ); + } + + Widget _build( + BuildContext context, + int areaIndex, + bool located, + bool showWeather, + Color? sky, + ) { return ListView( controller: scrollController, padding: EdgeInsets.fromLTRB( @@ -92,25 +117,55 @@ class HomeContent extends StatelessWidget { 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), ), const SizedBox(height: AppSpacing.lg), HomeForecastSection( key: ValueKey('forecast-$areaIndex'), reveal: reveal, + sky: sky, + weatherMode: weatherMode, ), const SizedBox(height: AppSpacing.lg), ], - HomeActiveEventsSection( - key: ValueKey('active-$areaIndex'), - reveal: reveal, - expanded: expanded, - ), + if (located) + HomeActiveEventsSection( + key: ValueKey('active-$areaIndex'), + reveal: reveal, + expanded: expanded, + sky: sky, + weatherMode: weatherMode, + ), ], ), ), ], ); } + + /// 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..9898dc01c 100644 --- a/lib/features/home/presentation/widgets/home_forecast_section.dart +++ b/lib/features/home/presentation/widgets/home_forecast_section.dart @@ -7,6 +7,7 @@ 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/features/weather/domain/weather_forecast.dart'; @@ -22,12 +23,27 @@ import 'package:provider/provider.dart'; /// new: sparkline + selectable hour chips + a detail band for feels-like / /// humidity / wind (fields the final legacy strip hid). class HomeForecastSection extends StatefulWidget { - const HomeForecastSection({super.key, this.reveal = 0}); + const HomeForecastSection({ + super.key, + this.reveal = 0, + this.sky, + this.weatherMode = WeatherMode.auto, + }); /// 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; + @override State createState() => _HomeForecastSectionState(); } @@ -42,9 +58,18 @@ class _HomeForecastSectionState extends State { final colors = theme.colorScheme; final controller = context.watch(); final reveal = widget.reveal; - final foreground = glassOnSurface(colors); - final secondary = glassOnSurfaceVariant(colors); - final cardColor = glassSurface(colors, reveal); + final skyIsLight = weatherSkyIsLight(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; diff --git a/lib/features/home/presentation/widgets/home_map_backdrop.dart b/lib/features/home/presentation/widgets/home_map_backdrop.dart index 51d5b52f2..7f83f4866 100644 --- a/lib/features/home/presentation/widgets/home_map_backdrop.dart +++ b/lib/features/home/presentation/widgets/home_map_backdrop.dart @@ -6,6 +6,7 @@ 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 +38,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}); @@ -164,7 +177,6 @@ class _HomeMapBackdropState extends State // 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; List? bounds; @@ -174,7 +186,6 @@ class _HomeMapBackdropState extends State bounds = boundaries.boundsFor(code); } - 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)); @@ -189,7 +200,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); // 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 @@ -210,6 +221,32 @@ class _HomeMapBackdropState extends State }); } + /// 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) async { + final code = _selectedCode; + 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 +267,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 +297,11 @@ 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. ); + await _restackOverlays(controller); _radarFrameOnMap = latest; _radarFrameEpoch = styleEpoch; }); @@ -290,6 +331,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..e06fe5433 100644 --- a/lib/features/home/presentation/widgets/home_rain_trend_section.dart +++ b/lib/features/home/presentation/widgets/home_rain_trend_section.dart @@ -4,6 +4,8 @@ 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/weather_mode.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:fl_chart/fl_chart.dart'; @@ -13,7 +15,14 @@ import 'package:flutter/material.dart'; /// the bottom axis shows a few clock ticks. Uses [RainHourTrend.placeholder] /// until the forecast API is wired. 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; @@ -21,120 +30,151 @@ class HomeRainTrendSection extends StatelessWidget { /// Live trend when the API is ready; null → [RainHourTrend.placeholder]. 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 skyIsLight = weatherSkyIsLight(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 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( - decoration: BoxDecoration( - color: cardColor, - borderRadius: AppRadius.medium, - ), - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.lg, - AppSpacing.lg, - AppSpacing.md, + 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, + child: DecoratedBox( + decoration: BoxDecoration( + color: cardColor, + borderRadius: AppRadius.medium, ), - 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, + 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, + ), ), ), - ), - ], - ), - 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), - ), - ), - ], - ), - ], - 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, + ], + ), + 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), ), ), - ); - }, + ], + ), + ], + 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, + ), + ), + ); + }, + ), ), ), ), ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/features/home/presentation/widgets/home_sheet.dart b/lib/features/home/presentation/widgets/home_sheet.dart index 9cd7a6b57..2319cc065 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,7 @@ class HomeSheet extends StatelessWidget { required this.scrollController, required this.extent, required this.weatherMode, + required this.skyTimeMode, }); /// The sheet's detents, sourced from [HomeSheetExtent] so the value notifier's @@ -67,6 +69,9 @@ 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; + // 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) { @@ -131,6 +136,7 @@ class HomeSheet extends StatelessWidget { opacity: weatherOpacity, child: WeatherSkyBackground( mode: weatherMode, + timeMode: skyTimeMode, active: HomeChrome.weatherActive(e), ), ), @@ -165,6 +171,7 @@ class _HomeContentLayer extends StatelessWidget { final ScrollController scrollController; final WeatherMode weatherMode; + final double regionBarInset; @override 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..130b04b95 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart @@ -0,0 +1,818 @@ +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. +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, + }) => _emit(width, screenHeight / _worldHeight, preset, topEdge); + + @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}) { + final band = 2 * _particleRadius * screenHeight / _worldHeight; + var n = 0; + for (var i = 0; i < _live; i++) { + if (_p.y[i] > topEdge - 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. + void update( + double dt, { + required double width, + required double screenHeight, + required double intensity, + required CardWaterPreset preset, + double topEdge = 0, + double gravityScale = 1.0, + }) { + 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); + } + + // 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); + } + + 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, + ) { + if (_live == 0) return; + final invDt = 1.0 / dt; + final diameter = 2 * _particleRadius * unit; + final slop = _b2LinearSlop * unit; + + // 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 < topEdge + // and the signed surface distance is topEdge - y. + final weight = Float32List(_live); + final bodyW = Float32List(_live); + for (var i = 0; i < _live; i++) { + final d = topEdge - _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] >= topEdge || _p.vy[i] <= 0) continue; + final travel = _p.vy[i] * dt; + final frac = (topEdge - _p.y[i]) / travel; + if (frac < 0 || frac > 1) continue; + final newX = _p.x[i] + frac * dt * _p.vx[i]; + final newY = topEdge - 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, + ) { + final originX = _random.nextDouble() * width; + final originY = topEdge - 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, + ); +} + +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..9ecec3a2e --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart @@ -0,0 +1,240 @@ +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); + + 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 = fallSpeed * size.height * speedScale * dt; + _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`. + final progress = (_p.age[i] / _p.life[i]).clamp(0.0, 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; + final a = (lifeAlpha * depthAlpha * 0.4 * opacity).clamp(0.0, 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] * size.height / cell.height; + + final o = n * 4; + if (tumble) { + final spin = _p.spin[i] * (_p.age[i] + _p.phase[i]); + _p.transforms[o] = math.cos(spin) * scale; + _p.transforms[o + 1] = math.sin(spin) * scale; + } else { + _p.transforms[o] = cosT * scale; + _p.transforms[o + 1] = sinT * scale; + } + // Anchor at the cell centre so rotation pivots on the drop. + final ax = cell.width * 0.5; + final ay = cell.height * 0.5; + _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; + + _p.colors[n] = tint.withValues(alpha: a).toARGB32(); + 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..89eba2d54 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -0,0 +1,509 @@ +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/services.dart' show rootBundle; +import 'package:flutter/scheduler.dart'; + +/// Puts rain on one card: droplets clinging to its face, and water caught and +/// pooling along its top edge. +/// +/// 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`). +/// +/// 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, + }); + + /// The card. Water is drawn over it, and 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; + + /// 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; + + @override + void initState() { + super.initState(); + _load(); + _syncRunning(); + } + + 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; + } + + @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(); + } + } + + 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 been scrolled up into the top fifth of the screen. + /// + /// The reference feeds the panel's on-screen y into a comparison against a + /// fixed reference height — the water stops once `y` drops below 30 % of it. + /// That height defaults to 1714 against a 2400 px screen, so the cut-off + /// lands at 21.4 % of the screen height. + /// + /// 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 _gateReferenceHeight = 1714 / 2400; + static const double _gateFraction = 0.3; + + /// Where the card's top edge currently sits, in logical pixels from the top + /// of the window. Null before the first layout. + double? _cardTopY; + 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; + // 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 = + _cardTopY! >= _gateFraction * _gateReferenceHeight * _screenHeight; + 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(); + + final intensity = widget.intensity.clamp(0.0, 1.0); + final grade = _gradeFor(intensity); + _primary.update( + dt, + width: _size.width, + screenHeight: _screenHeight, + intensity: intensity, + preset: grade.$1, + gravityScale: _gravityScale, + ); + _secondary.update( + dt, + width: _size.width, + screenHeight: _screenHeight, + intensity: intensity, + preset: grade.$2, + gravityScale: _gravityScale, + ); + _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]. + return Stack( + // Water sits above the card's top edge and must not be cut off. + clipBehavior: Clip.none, + children: [ + RainOnGlass( + intensity: widget.intensity, + opacity: widget.opacity, + active: widget.active, + child: widget.child, + ), + 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..034089a2d --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart @@ -0,0 +1,252 @@ +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, + ); + } +} + +/// The droplet field's uniforms for one rain grade. +/// +/// One ladder, walked by a single 0->1 grade, rather than a table of +/// hand-picked rows: every uniform here is monotone in how hard it is raining, +/// so the rows a table would hold are just this line sampled at three points. +/// +/// The ladder runs the way intuition does **not**: the lighter the rain, the +/// *smaller and denser* the beads. Light rain deposits a fine mist that +/// condenses everywhere and rarely gathers enough mass to run; heavy rain +/// arrives in drops that land big, merge fast, and slide almost at once. +/// [staticSize] and [runningSize] are **divisors** in the shader +/// (`uv * 40 / uStaticDropSize`), so a smaller value means a tighter lattice: +/// 0.55 gives a 14.9 device-px cell against 1.15's 31 px — four times the +/// beads at half the size. Pinning every rain mode to the heavy end, 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, + }); + + /// The ladder itself: [grade] 0 is a fine mist, 1 is a storm. + factory _GlassGrade.atGrade(double grade) { + double at(double light, double heavy) => light + (heavy - light) * grade; + return _GlassGrade( + staticSize: at(0.55, 1.15), + staticAmount: at(0.55, 1.0), + staticSpeed: at(0.9, 2.2), + runningSize: at(0.9, 1.5), + runningAmount: at(0.5, 1.0), + runningSpeed: at(0.5, 1.4), + ); + } + + 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 that mostly stay put. + static final _GlassGrade drizzle = _GlassGrade.atGrade(0.0); + + /// Moderate rain. + /// + /// Unreachable today: [WeatherMode] has only `rain` and `thunderstorm` for + /// wet weather, which sit at the two ends. Named anyway because the ladder is + /// continuous and this is where a heavy-rain mode would land. + static final _GlassGrade steady = _GlassGrade.atGrade(0.5); + + /// Heavy rain through storm — big, fast beads that run almost at once. + static final _GlassGrade downpour = _GlassGrade.atGrade(1.0); + + /// Maps our 0->1 intensity onto the ladder. + /// + /// Quantised to three rungs rather than interpolated continuously: the + /// intensity is scaled by the sheet's expansion, so a continuous mapping + /// would have the beads visibly resizing under the user's finger mid-drag. + 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..d90d4add5 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart @@ -0,0 +1,1148 @@ +// GENERATED by tool/gen_sky_keyframes.py — do not edit by hand. +// +// The scene keyframe rings, in the GL units the sky shaders consume. +// See the generator for how each number is arrived at; the short +// version is that a weather type declares how hazy, damp and dusty it +// is and everything else follows from that plus the sun's elevation. + +import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; + +/// sunny — 17 keyframes. +const List sunnyKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.0133, + cameraYaw: 1.8, + sunIntensity: 11.6, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.0146, + cameraYaw: 1.8, + sunIntensity: 11.8, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.0183, + cameraYaw: 1.8, + sunIntensity: 12.5, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.0246, + cameraYaw: 2, + sunIntensity: 13.4, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 4, + sunAngleY: 0.0333, + cameraYaw: 2.1, + sunIntensity: 14.5, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.1658, + cameraYaw: 4.5, + sunIntensity: 11.5, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 6, + sunAngleY: 0.3064, + cameraYaw: 6.4, + sunIntensity: 16.3, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 7, + sunAngleY: 0.4003, + cameraYaw: 7.2, + sunIntensity: 18.3, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 8, + sunAngleY: 0.4333, + cameraYaw: 7.4, + sunIntensity: 18.7, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 9, + sunAngleY: 0.4003, + cameraYaw: 7.2, + sunIntensity: 18.3, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 10, + sunAngleY: 0.3064, + cameraYaw: 6.4, + sunIntensity: 16.3, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 11, + sunAngleY: 0.1658, + cameraYaw: 4.5, + sunIntensity: 11.5, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.0333, + cameraYaw: 2.1, + sunIntensity: 14.5, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 13, + sunAngleY: 0.0246, + cameraYaw: 2, + sunIntensity: 13.4, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 14, + sunAngleY: 0.0183, + cameraYaw: 1.8, + sunIntensity: 12.5, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.0146, + cameraYaw: 1.8, + sunIntensity: 11.8, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 16, + sunAngleY: 0.0133, + cameraYaw: 1.8, + sunIntensity: 11.6, + ozoneThickness: 20000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8000, 6400), + humidMieScatter: (3.996e-06, 1.039e-05), + humidMieAbsorb: (4.400e-06, 7.040e-06), + humidMieHeight: (1488, 2158), + humidMieAsymmetry: (0, 0.45), + ), +]; + +/// cloudy — 17 keyframes. +const List cloudyKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.0133, + cameraYaw: 1.8, + sunIntensity: 10.6, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.0146, + cameraYaw: 1.8, + sunIntensity: 10.8, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.0183, + cameraYaw: 1.8, + sunIntensity: 11.5, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.0246, + cameraYaw: 2, + sunIntensity: 12.4, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 4, + sunAngleY: 0.0333, + cameraYaw: 2.1, + sunIntensity: 13.3, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 5, + sunAngleY: 0.1658, + cameraYaw: 4.5, + sunIntensity: 10.6, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 6, + sunAngleY: 0.3064, + cameraYaw: 6.4, + sunIntensity: 15, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 7, + sunAngleY: 0.4003, + cameraYaw: 7.2, + sunIntensity: 16.8, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 8, + sunAngleY: 0.4333, + cameraYaw: 7.4, + sunIntensity: 17.2, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 9, + sunAngleY: 0.4003, + cameraYaw: 7.2, + sunIntensity: 16.8, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 10, + sunAngleY: 0.3064, + cameraYaw: 6.4, + sunIntensity: 15, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 11, + sunAngleY: 0.1658, + cameraYaw: 4.5, + sunIntensity: 10.6, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 12, + sunAngleY: 0.0333, + cameraYaw: 2.1, + sunIntensity: 13.3, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 13, + sunAngleY: 0.0246, + cameraYaw: 2, + sunIntensity: 12.4, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 14, + sunAngleY: 0.0183, + cameraYaw: 1.8, + sunIntensity: 11.5, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 15, + sunAngleY: 0.0146, + cameraYaw: 1.8, + sunIntensity: 10.8, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), + SkyKeyframe( + time: 16, + sunAngleY: 0.0133, + cameraYaw: 1.8, + sunIntensity: 10.6, + ozoneThickness: 20500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (8400, 6720), + humidMieScatter: (6.553e-06, 1.704e-05), + humidMieAbsorb: (4.664e-06, 7.462e-06), + humidMieHeight: (1776, 2575), + humidMieAsymmetry: (0, 0.45), + ), +]; + +/// overcast — 4 keyframes. +const List overcastKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 3.8, + ozoneThickness: 28500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (11600, 11600), + humidMieScatter: (3.772e-05, 3.772e-05), + humidMieAbsorb: (1.003e-05, 1.003e-05), + humidMieHeight: (2280, 2280), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 9.9, + ozoneThickness: 28500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (11600, 11600), + humidMieScatter: (3.772e-05, 3.772e-05), + humidMieAbsorb: (1.003e-05, 1.003e-05), + humidMieHeight: (2280, 2280), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 9.9, + ozoneThickness: 28500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (11600, 11600), + humidMieScatter: (3.772e-05, 3.772e-05), + humidMieAbsorb: (1.003e-05, 1.003e-05), + humidMieHeight: (2280, 2280), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 3.8, + ozoneThickness: 28500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (11600, 11600), + humidMieScatter: (3.772e-05, 3.772e-05), + humidMieAbsorb: (1.003e-05, 1.003e-05), + humidMieHeight: (2280, 2280), + humidMieAsymmetry: (0, 0), + ), +]; + +/// foggy — 4 keyframes. +const List foggyKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.7, + ozoneThickness: 24500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (4400, 4400), + humidMieScatter: (2.214e-05, 2.214e-05), + humidMieAbsorb: (7.348e-06, 7.348e-06), + humidMieHeight: (2640, 2640), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 12.4, + ozoneThickness: 24500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (4400, 4400), + humidMieScatter: (2.214e-05, 2.214e-05), + humidMieAbsorb: (7.348e-06, 7.348e-06), + humidMieHeight: (2640, 2640), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 12.4, + ozoneThickness: 24500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (4400, 4400), + humidMieScatter: (2.214e-05, 2.214e-05), + humidMieAbsorb: (7.348e-06, 7.348e-06), + humidMieHeight: (2640, 2640), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.7, + ozoneThickness: 24500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (4400, 4400), + humidMieScatter: (2.214e-05, 2.214e-05), + humidMieAbsorb: (7.348e-06, 7.348e-06), + humidMieHeight: (2640, 2640), + humidMieAsymmetry: (0, 0), + ), +]; + +/// smoggy — 4 keyframes. +const List smoggyKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4, + ozoneThickness: 29000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (13600, 13600), + humidMieScatter: (4.412e-05, 4.412e-05), + humidMieAbsorb: (2.790e-05, 2.790e-05), + humidMieHeight: (1992, 1992), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 10.5, + ozoneThickness: 29000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (13600, 13600), + humidMieScatter: (4.412e-05, 4.412e-05), + humidMieAbsorb: (2.790e-05, 2.790e-05), + humidMieHeight: (1992, 1992), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 10.5, + ozoneThickness: 29000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (13600, 13600), + humidMieScatter: (4.412e-05, 4.412e-05), + humidMieAbsorb: (2.790e-05, 2.790e-05), + humidMieHeight: (1992, 1992), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4, + ozoneThickness: 29000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (13600, 13600), + humidMieScatter: (4.412e-05, 4.412e-05), + humidMieAbsorb: (2.790e-05, 2.790e-05), + humidMieHeight: (1992, 1992), + humidMieAsymmetry: (0, 0), + ), +]; + +/// rainyLight — 4 keyframes. +const List rainyLightKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.5, + ozoneThickness: 23000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (12800, 12800), + humidMieScatter: (1.678e-05, 1.678e-05), + humidMieAbsorb: (5.720e-06, 5.720e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 11.8, + ozoneThickness: 23000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (12800, 12800), + humidMieScatter: (1.678e-05, 1.678e-05), + humidMieAbsorb: (5.720e-06, 5.720e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 11.8, + ozoneThickness: 23000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (12800, 12800), + humidMieScatter: (1.678e-05, 1.678e-05), + humidMieAbsorb: (5.720e-06, 5.720e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.5, + ozoneThickness: 23000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (12800, 12800), + humidMieScatter: (1.678e-05, 1.678e-05), + humidMieAbsorb: (5.720e-06, 5.720e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), +]; + +/// rainyMedium — 4 keyframes. +const List rainyMediumKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.1, + ozoneThickness: 26000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (12000, 12000), + humidMieScatter: (2.829e-05, 2.829e-05), + humidMieAbsorb: (6.908e-06, 6.908e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 10.9, + ozoneThickness: 26000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (12000, 12000), + humidMieScatter: (2.829e-05, 2.829e-05), + humidMieAbsorb: (6.908e-06, 6.908e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 10.9, + ozoneThickness: 26000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (12000, 12000), + humidMieScatter: (2.829e-05, 2.829e-05), + humidMieAbsorb: (6.908e-06, 6.908e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.1, + ozoneThickness: 26000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (12000, 12000), + humidMieScatter: (2.829e-05, 2.829e-05), + humidMieAbsorb: (6.908e-06, 6.908e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), +]; + +/// rainyHeavy — 4 keyframes. +const List rainyHeavyKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 3.7, + ozoneThickness: 27500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (11600, 11600), + humidMieScatter: (3.540e-05, 3.540e-05), + humidMieAbsorb: (1.087e-05, 1.087e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 9.7, + ozoneThickness: 27500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (11600, 11600), + humidMieScatter: (3.540e-05, 3.540e-05), + humidMieAbsorb: (1.087e-05, 1.087e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 9.7, + ozoneThickness: 27500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (11600, 11600), + humidMieScatter: (3.540e-05, 3.540e-05), + humidMieAbsorb: (1.087e-05, 1.087e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 3.7, + ozoneThickness: 27500, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (11600, 11600), + humidMieScatter: (3.540e-05, 3.540e-05), + humidMieAbsorb: (1.087e-05, 1.087e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), +]; + +/// rainyExtreme — 4 keyframes. +const List rainyExtremeKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 2.9, + ozoneThickness: 23000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10400, 10400), + humidMieScatter: (1.550e-05, 1.550e-05), + humidMieAbsorb: (5.588e-06, 5.588e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 7.7, + ozoneThickness: 23000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10400, 10400), + humidMieScatter: (1.550e-05, 1.550e-05), + humidMieAbsorb: (5.588e-06, 5.588e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 7.7, + ozoneThickness: 23000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10400, 10400), + humidMieScatter: (1.550e-05, 1.550e-05), + humidMieAbsorb: (5.588e-06, 5.588e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 2.9, + ozoneThickness: 23000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (10400, 10400), + humidMieScatter: (1.550e-05, 1.550e-05), + humidMieAbsorb: (5.588e-06, 5.588e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), +]; + +/// snowyLight — 4 keyframes. +const List snowyLightKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.9, + ozoneThickness: 33000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (6000, 6000), + humidMieScatter: (5.514e-05, 5.514e-05), + humidMieAbsorb: (9.680e-06, 9.680e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 13, + ozoneThickness: 33000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (6000, 6000), + humidMieScatter: (5.514e-05, 5.514e-05), + humidMieAbsorb: (9.680e-06, 9.680e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 13, + ozoneThickness: 33000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (6000, 6000), + humidMieScatter: (5.514e-05, 5.514e-05), + humidMieAbsorb: (9.680e-06, 9.680e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.9, + ozoneThickness: 33000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (6000, 6000), + humidMieScatter: (5.514e-05, 5.514e-05), + humidMieAbsorb: (9.680e-06, 9.680e-06), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), +]; + +/// snowyMedium — 4 keyframes. +const List snowyMediumKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.6, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (6400, 6400), + humidMieScatter: (6.282e-05, 6.282e-05), + humidMieAbsorb: (1.047e-05, 1.047e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 12.1, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (6400, 6400), + humidMieScatter: (6.282e-05, 6.282e-05), + humidMieAbsorb: (1.047e-05, 1.047e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 12.1, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (6400, 6400), + humidMieScatter: (6.282e-05, 6.282e-05), + humidMieAbsorb: (1.047e-05, 1.047e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.6, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (6400, 6400), + humidMieScatter: (6.282e-05, 6.282e-05), + humidMieAbsorb: (1.047e-05, 1.047e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), +]; + +/// snowyHeavy — 4 keyframes. +const List snowyHeavyKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.2, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (5200, 5200), + humidMieScatter: (6.282e-05, 6.282e-05), + humidMieAbsorb: (1.047e-05, 1.047e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 11.2, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (5200, 5200), + humidMieScatter: (6.282e-05, 6.282e-05), + humidMieAbsorb: (1.047e-05, 1.047e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 11.2, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (5200, 5200), + humidMieScatter: (6.282e-05, 6.282e-05), + humidMieAbsorb: (1.047e-05, 1.047e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 4.2, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (5200, 5200), + humidMieScatter: (6.282e-05, 6.282e-05), + humidMieAbsorb: (1.047e-05, 1.047e-05), + humidMieHeight: (2064, 2064), + humidMieAsymmetry: (0, 0), + ), +]; + +/// sandyHeavy — 4 keyframes. +const List sandyHeavyKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.3667, + cameraYaw: 7, + sunIntensity: 9.8, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (24000, 24000), + humidMieScatter: (9.127e-05, 9.127e-05), + humidMieAbsorb: (6.072e-05, 6.072e-05), + humidMieHeight: (1344, 1344), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.3667, + cameraYaw: 7, + sunIntensity: 25.7, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (24000, 24000), + humidMieScatter: (9.127e-05, 9.127e-05), + humidMieAbsorb: (6.072e-05, 6.072e-05), + humidMieHeight: (1344, 1344), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.3667, + cameraYaw: 7, + sunIntensity: 25.7, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (24000, 24000), + humidMieScatter: (9.127e-05, 9.127e-05), + humidMieAbsorb: (6.072e-05, 6.072e-05), + humidMieHeight: (1344, 1344), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.3667, + cameraYaw: 7, + sunIntensity: 9.8, + ozoneThickness: 34000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (24000, 24000), + humidMieScatter: (9.127e-05, 9.127e-05), + humidMieAbsorb: (6.072e-05, 6.072e-05), + humidMieHeight: (1344, 1344), + humidMieAsymmetry: (0, 0), + ), +]; + +/// sandyLight — 4 keyframes. +const List sandyLightKeyframes = [ + SkyKeyframe( + time: 0, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 6.1, + ozoneThickness: 33000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (16800, 16800), + humidMieScatter: (5.730e-05, 5.730e-05), + humidMieAbsorb: (1.958e-05, 1.958e-05), + humidMieHeight: (2496, 2496), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 1, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 16.1, + ozoneThickness: 33000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (16800, 16800), + humidMieScatter: (5.730e-05, 5.730e-05), + humidMieAbsorb: (1.958e-05, 1.958e-05), + humidMieHeight: (2496, 2496), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 2, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 16.1, + ozoneThickness: 33000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (16800, 16800), + humidMieScatter: (5.730e-05, 5.730e-05), + humidMieAbsorb: (1.958e-05, 1.958e-05), + humidMieHeight: (2496, 2496), + humidMieAsymmetry: (0, 0), + ), + SkyKeyframe( + time: 3, + sunAngleY: 0.1889, + cameraYaw: 4.9, + sunIntensity: 6.1, + ozoneThickness: 33000, + postColor: (1, 0, 0, 0, 1), + humidRayleighHeight: (16800, 16800), + humidMieScatter: (5.730e-05, 5.730e-05), + humidMieAbsorb: (1.958e-05, 1.958e-05), + humidMieHeight: (2496, 2496), + 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..e79a6bf30 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart @@ -0,0 +1,221 @@ +import 'dart:math' as math; +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; + + /// 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; + + /// 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; + _publishPanelAmbient(_skyView!); + return true; + } + + /// Reads the ambient row back — async and once per bake, never per frame. + void _publishPanelAmbient(ui.Image skyView) { + final x = (_ambientSample.dx * (skyViewSize.width - 1)).round(); + final y = (_ambientSample.dy * (skyViewSize.height - 1)).round(); + skyView + .toByteData(format: ui.ImageByteFormat.rawRgba) + .then((data) { + if (data == null) return; + final o = (y * skyViewSize.width.toInt() + x) * 4; + final px = data.buffer.asUint8List(); + panelAmbient.value = Color.fromARGB(255, px[o], px[o + 1], px[o + 2]); + }) + .catchError((Object _) {}); + } + + 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) { + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..shader = shader, + ); + final picture = recorder.endRecording(); + // Synchronous and GPU-resident — no CPU round trip, which is what makes + // an in-frame bake affordable. + 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(); + _transmittance = null; + _skyView = null; + _baked = null; + } +} + +/// The two rays that bound the visible band of sky: the screen's top edge and +/// its bottom edge, as directions from the eye. +/// +/// The backdrop deliberately shows a **narrow slice of sky just above the +/// horizon**, not a dome. That is the framing a card backdrop wants: the +/// horizon band is the most saturated, busiest part of the sky and putting it +/// on screen fights the content in front of it, while a slice taken a little +/// higher gives a clean gradient that text sits on. [_spanDeg] and +/// [_baseElevationDeg] state that choice directly, in degrees. +/// +/// There is no perspective divide and no aspect correction to do: the sky has +/// no azimuthal variation at all — `sky_view.frag` resolves every pixel in a +/// row to the same colour — so only the vertical extent means anything, and +/// the screen's width cannot enter into it. +typedef Frustum = ({List top, List bottom}); + +/// Vertical extent of the visible band, degrees. +const double _spanDeg = 19.264; + +/// Elevation of the band's centre at `cameraYaw == 0`, degrees above the +/// horizon. +const double _baseElevationDeg = 9.386; + +/// How much of a keyframe's `cameraYaw` reaches the band's centre. Slightly +/// under 1: the keyframes tilt the whole scene, and the sky takes most but not +/// all of that tilt. +const double _yawGain = 0.962; + +/// Builds the frustum for a keyframe's `cameraYaw` (degrees). +/// +/// The rays are unit vectors in the x/y plane, `x` forward and `y` up, so +/// `sky_view.frag`'s `normalize(mix(top, bottom, uv.y))` walks the band +/// linearly from one edge to the other. +Frustum buildFrustum(double cameraYawDeg) { + final centre = (_baseElevationDeg + _yawGain * cameraYawDeg) * math.pi / 180; + final half = (_spanDeg / 2) * math.pi / 180; + return ( + top: [math.cos(centre + half), math.sin(centre + half), 0.0], + bottom: [math.cos(centre - half), math.sin(centre - half), 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..6c8b10228 --- /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; + + /// 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.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.skyViewAsset, + 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 = []; + ui.Image? _starMap; + 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'); + } + } + + ui.Image? starMap; + try { + final data = await rootBundle.load('assets/weather/sky/starmap.webp'); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + starMap = (await codec.getNextFrame()).image; + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'Failed to load starmap'); + } + + 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(); + } + starMap?.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); + _starMap = starMap; + // 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(); + _starMap?.dispose(); + _starMap = null; + _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, + skyView: cache.skyView, + cloudSprites: _sprites, + sunTextures: _sunTextures, + starMap: _starMap, + 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; + + // 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: look.rain, + snow: look.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..09c3c9885 --- /dev/null +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart @@ -0,0 +1,607 @@ +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 ui.Image? skyView; + final List cloudSprites; + + /// Sun disc ramp, static ray pattern and ring ramp, in that order. + final List sunTextures; + + /// Equirectangular Milky Way, or `null` if it failed to load. + final ui.Image? starMap; + 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.skyView, + required this.cloudSprites, + required this.sunTextures, + required this.starMap, + required this.frame, + required this.rainField, + required this.snowField, + required this.dt, + }); + + // Asset keys, matching `pubspec.yaml`. + static const String skyViewAsset = 'shaders/sky/sky_view.frag'; + static const String cloudsAsset = 'shaders/cloud/clouds.frag'; + static const String galaxyAsset = 'shaders/weather/galaxy.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); + _paintGalaxy(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) || + !identical(old.skyView, skyView); + + void _fill( + Canvas canvas, + Size size, + ui.FragmentShader shader, [ + BlendMode blend = BlendMode.srcOver, + ]) { + canvas.drawRect( + Offset.zero & size, + Paint() + ..shader = shader + ..blendMode = blend, + ); + } + + // --- sky ---------------------------------------------------------------- + void _paintSky(Canvas canvas, Size size) { + final shader = shaders[skyViewAsset]; + final lut = skyView; + if (shader == null || lut == null) return; + + final fr = buildFrustum(frame.sky.cameraYaw); + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(size.width); + set(size.height); + 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(frame.sky.sunAngleY); + set(frame.time); + set(SkyLutCache.skyViewSize.width); + set(SkyLutCache.skyViewSize.height); + shader.setImageSampler(0, lut); + + _fill(canvas, size, shader); + } + + // --- galaxy --------------------------------------------------------------- + void _paintGalaxy(Canvas canvas, Size size) { + final shader = shaders[galaxyAsset]; + final map = starMap; + final night = frame.nightAmount; + if (shader == null || map == null || night < 0.01) return; + + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(size.width); + set(size.height); + // The engine's own orientation. Note the first is + // a rotation angle, not an opacity, despite its name. + set(0.38); + set(0.64); + set(0.16); + set(0.27); // fov + set(frame.time); + set(0.9); + // Cloud hides the galaxy faster than it hides the brighter stars. + set(night * (1.0 - 0.95 * frame.cloudCoverage)); + shader.setImageSampler(0, map); + + // `SRC_ALPHA, ONE` in the engine. + _fill(canvas, size, shader, BlendMode.plus); + } + + // --- 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 real Milky Way is its own layer now; the procedural band is off. + set(0.0); + + _fill(canvas, size, shader); + } + + // --- clouds ------------------------------------------------------------- + void _paintClouds(Canvas canvas, Size size) { + final shader = shaders[cloudsAsset]; + final lut = skyView; + if (shader == null || lut == null || cloudSprites.isEmpty) return; + if (frame.cloudCoverage < 0.02) return; + + // Clouds probe the same frustum the sky is drawn through. + final fr = buildFrustum(frame.sky.cameraYaw); + 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]; + + 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(frame.sky.sunAngleY); + 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); + set(SkyLutCache.skyViewSize.width); + set(SkyLutCache.skyViewSize.height); + 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(sprite.width.toDouble()); + set(sprite.height.toDouble()); + 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(); + } + } + + // --- 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/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart new file mode 100644 index 000000000..d439174b1 --- /dev/null +++ b/lib/features/map/presentation/layers/qpesums_layer.dart @@ -0,0 +1,64 @@ +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/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 is anchored under the base style's county borders (the default +/// [rasterBelowLayerId]) — unlike radar there is no coverage outline here, so +/// the ordinary border-through-raster layering is exactly right. +class QpesumsMapLayer extends RasterTimelineLayer { + QpesumsMapLayer(QpesumsRepository super.repository); + + @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; + + @override + Widget buildLegend(BuildContext context) => MapLegendCard( + child: 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'), + ], + ), + ); +} diff --git a/lib/features/map/presentation/layers/radar_layer.dart b/lib/features/map/presentation/layers/radar_layer.dart index 9a7fd2bca..d27a5590a 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -1,17 +1,184 @@ +import 'dart:async'; + +import 'package:dpip/app/theme/app_spacing.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/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/map_style.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 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. +/// 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. That is what makes them switchable at all: while they came through +/// from underneath there was no way to get an uninterrupted raster. +/// +/// All three default **on**, because each answers a question the echo alone +/// cannot — where the composite stops observing, and which county or township +/// a cell is over. class RadarMapLayer extends RasterTimelineLayer { RadarMapLayer(RadarRepository super.repository); + /// Whether the composite's observed area is outlined. + /// + /// On by default: outside it, blank means *not observed*, not *no rain*, and + /// on a disaster-prevention map that distinction should not have to be + /// discovered in a menu. + /// + /// Lives on the layer, not in settings: it is a property of *this map view*, + /// the same way the typhoon overlays are, so it is reachable from the map's + /// own options chip and resets with the session. + final ValueNotifier showScanRange = ValueNotifier(true); + + /// Whether 縣市 borders are redrawn above the echo. + final ValueNotifier showCountyOutline = ValueNotifier(true); + + /// Whether 鄉鎮 borders are redrawn above the echo. 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); + + MapLibreMapController? _controller; + bool _rangeShown = false; + final Set _boundariesShown = {}; + + /// 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; + + /// 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()); + } + + /// 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()); + } + + /// Which boundary sets should currently be on the map. + Set get _wantedBoundaries => { + // Town first so a later insert of county lands above it: the coarse frame + // should win where the two run together along a coastline. + if (showTownOutline.value) AdminBoundary.town, + if (showCountyOutline.value) AdminBoundary.county, + }; + + @override + Future onAttached(MapLibreMapController controller) async { + _controller = controller; + await _syncRange(); + // Borders last, so they end up above the scan range as well as the raster — + // the range is a dashed hint, the borders are the reference frame. + await _syncBoundaries(); + } + + @override + Future onDetached(MapLibreMapController controller) async { + _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); + } + if (_rangeShown) { + _rangeShown = false; + await RadarScanRange.remove(controller); + } + } + + /// Adds or removes the coverage outline to match [showScanRange]. + Future _syncRange() async { + final controller = _controller; + if (controller == null) return; + + final wanted = showScanRange.value; + if (wanted == _rangeShown) return; + _rangeShown = wanted; + + try { + if (wanted) { + await RadarScanRange.add( + controller, + outlineColor: _rangeColor, + belowLayerId: outlineLayerId, + ); + } else { + await RadarScanRange.remove(controller); + } + } catch (error, stackTrace) { + // A style reload can race the toggle; the next sync recovers. + _rangeShown = !wanted; + Log.handle(error, stackTrace, 'Failed to sync the radar scan range'); + } + } + + /// Brings the drawn boundary sets in line with the toggles. + Future _syncBoundaries() async { + final controller = _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 radar admin outlines'); + } + } + + @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; + _boundariesShown.clear(); + super.onStyleReset(); + } + + @override + Widget buildTopTrailingChrome(BuildContext context) => + RadarOverlayMenu(layer: this); + @override String get id => 'radar'; @@ -27,25 +194,91 @@ 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: Listenable.merge([ + showScanRange, + showCountyOutline, + showTownOutline, + ]), + builder: (context, _) { + final l10n = AppLocalizations.of(context); + // The overlays are switchable, so the key has to follow them: a legend + // naming a line that is not on the map is worse than no legend. + final overlays = [ + if (showScanRange.value) + SymbolLegendItem( + swatch: const LineSwatch( + color: Color(0xFF78909C), + width: 1.5, + opacity: 0.7, + dash: [3, 2], + ), + label: l10n.radarScanRange, + ), + if (showCountyOutline.value) + SymbolLegendItem( + swatch: LineSwatch( + color: const Color(0xFFFFFFFF), + 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: const Color(0xFFFFFFFF), + width: AdminBoundary.town.lineWidth, + opacity: AdminBoundary.town.lineOpacity, + casingColor: const Color(0xFF000000), + casingWidth: AdminBoundary.town.casingWidth, + casingOpacity: AdminBoundary.town.casingOpacity, + ), + label: l10n.radarTownOutline, + ), + ]; + + 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'), + ], + ), + if (overlays.isNotEmpty) ...[ + 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/radar_scan_range.dart b/lib/features/map/presentation/layers/radar_scan_range.dart new file mode 100644 index 000000000..5b0ba24a7 --- /dev/null +++ b/lib/features/map/presentation/layers/radar_scan_range.dart @@ -0,0 +1,219 @@ +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; + + /// `kᵢ = sin²(Rᵢ / 2Rₑ)` per site, precomputed. + static final List _k = [ + for (final s in sites) + math.pow(math.sin(s.radiusKm / (2 * earthRadiusKm)), 2) as double, + ]; + + /// `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 = math.pow(math.sin((lat - site.lat) * _deg / 2), 2) as double; + final b = math.pow(math.sin((lon - site.lon) * _deg / 2), 2) as double; + 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] - (math.pow(math.sin((lat - site.lat) * _deg / 2), 2) as double); + 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. + static Future add( + MapLibreMapController controller, { + required String outlineColor, + String? belowLayerId, + }) async { + await controller.addSource( + sourceId, + GeojsonSourceProperties(data: geoJson()), + ); + await controller.addLineLayer( + sourceId, + outlineLayerId, + 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. + static Future remove(MapLibreMapController controller) async { + try { + await controller.removeLayer(outlineLayerId); + } 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/typhoon_layer.dart b/lib/features/map/presentation/layers/typhoon_layer.dart index 0bc78e382..e1a53c661 100644 --- a/lib/features/map/presentation/layers/typhoon_layer.dart +++ b/lib/features/map/presentation/layers/typhoon_layer.dart @@ -7,6 +7,8 @@ import 'dart:math' as math; import 'package:dpip/core/error/result.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'; @@ -91,6 +93,30 @@ 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; + 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 []; @@ -778,6 +804,84 @@ 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 underlay only). + 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 underlay only). + 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 radar underlay's chrome — the coverage outline and the + /// county borders — 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 isRadar = weatherOverlay.value == TyphoonWeatherOverlay.radar; + final wantRange = isRadar && showScanRange.value; + final wantBoundaries = { + if (isRadar && showTownOutline.value) AdminBoundary.town, + if (isRadar && showCountyOutline.value) AdminBoundary.county, + }; + + 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); + } + } + // Borders after the range so they insert above it: the dashed range is a + // hint, the borders are the reference frame. + 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, 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; + _boundariesShown.clear(); + Log.handle(error, stackTrace, 'Failed to sync the typhoon radar chrome'); + } + } + Future _syncWeatherOverlay(MapLibreMapController controller) async { await _removeWeatherRaster(controller); final kind = weatherOverlay.value; @@ -843,9 +947,23 @@ 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); + } + for (final boundary in _boundariesShown.toList()) { + _boundariesShown.remove(boundary); + await AdminOutline.remove(controller, boundary); + } try { await controller.removeLayer(_wxLyr); } catch (_) {} @@ -981,10 +1099,18 @@ 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; return MapLegendCard( child: SymbolLegend( items: [ @@ -1113,6 +1239,40 @@ 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 (isRadar && showCountyOutline.value) + SymbolLegendItem( + swatch: LineSwatch( + color: const Color(0xFFFFFFFF), + 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 && showTownOutline.value) + SymbolLegendItem( + swatch: LineSwatch( + color: const Color(0xFFFFFFFF), + width: AdminBoundary.town.lineWidth, + opacity: AdminBoundary.town.lineOpacity, + casingColor: const Color(0xFF000000), + casingWidth: AdminBoundary.town.casingWidth, + casingOpacity: AdminBoundary.town.casingOpacity, + ), + label: l10n.radarTownOutline, + ), ], ), ); @@ -1131,7 +1291,13 @@ class TyphoonMapLayer implements MapLayer { 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; + _boundariesShown.clear(); + } void _parsePoints(Map geo) { _points.clear(); diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index 752a293c7..cc5b04007 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -10,6 +10,7 @@ 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'; @@ -21,6 +22,7 @@ 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_repository.dart'; import 'package:dpip/shared/map/map_layer.dart'; @@ -47,6 +49,7 @@ class _MapPageState extends State { // Built once so each layer keeps its own MapLibre state across rebuilds. late final List _layers = [ RadarMapLayer(context.read()), + QpesumsMapLayer(context.read()), SatelliteMapLayer(context.read()), LightningMapLayer(context.read()), TyphoonMapLayer( 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..c344fffe2 --- /dev/null +++ b/lib/features/map/presentation/widgets/radar_overlay_menu.dart @@ -0,0 +1,73 @@ +/// Frosted dropdown beside the layer switcher — radar overlay toggles. +library; + +import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; +import 'package:flutter/material.dart'; + +/// Compact icon chip that opens radar's overlay options — the same chrome and +/// position as the typhoon menu, so "layer options" is one affordance across +/// the map rather than one per layer. +class RadarOverlayMenu extends StatelessWidget { + const RadarOverlayMenu({super.key, required this.layer}); + + final RadarMapLayer layer; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListenableBuilder( + listenable: Listenable.merge([ + layer.showScanRange, + layer.showCountyOutline, + layer.showTownOutline, + ]), + builder: (context, _) { + final showRange = layer.showScanRange.value; + final showCounty = layer.showCountyOutline.value; + final showTown = layer.showTownOutline.value; + return MenuAnchor( + alignmentOffset: const Offset(0, 4), + style: MapChipButton.menuStyle(context), + builder: (context, controller, _) => MapChipButton( + icon: Icons.tune, + tooltip: l10n.radarOverlayMenuTooltip, + // The dot marks "not the defaults". All three ship on, so it + // lights up when one has been switched *off*. + active: !showRange || !showCounty || !showTown, + onTap: () => + controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + 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), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart index c0c6d3c42..26b5d6874 100644 --- a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart @@ -8,6 +8,7 @@ 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/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/material.dart'; @@ -31,6 +32,9 @@ class TyphoonOverlayMenu extends StatelessWidget { layer.showWarningAreas, layer.stormBand, layer.weatherOverlay, + layer.showScanRange, + layer.showCountyOutline, + layer.showTownOutline, ]), builder: (context, _) { final band = layer.stormBand.value; @@ -38,11 +42,19 @@ 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 active = showProb || !showCallouts || showWarn || 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), @@ -105,16 +117,46 @@ class TyphoonOverlayMenu extends StatelessWidget { 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), + ), + ], _menuDivider(colors), SectionHeader(l10n.typhoonOverlaySectionExtra), - _ToggleRow( + MapMenuToggleRow( selected: showCallouts, icon: Icons.info_outline, title: l10n.typhoonOverlayForecastCallouts, tooltip: l10n.typhoonOverlayForecastCalloutsTooltip, onTap: () => layer.setShowForecastCallouts(!showCallouts), ), - _ToggleRow( + MapMenuToggleRow( selected: showProb, icon: Icons.bubble_chart_outlined, title: l10n.typhoonLegendProbability, @@ -122,7 +164,7 @@ class TyphoonOverlayMenu extends StatelessWidget { tooltip: l10n.typhoonOverlayProbabilityTooltip, onTap: () => layer.setShowProbability(!showProb), ), - _ToggleRow( + MapMenuToggleRow( selected: showWarn, icon: Icons.warning_amber_outlined, title: l10n.typhoonLegendWarningAreas, @@ -375,87 +417,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/settings/presentation/pages/experimental_page.dart b/lib/features/settings/presentation/pages/experimental_page.dart index cdcf506f6..f2a61b96f 100644 --- a/lib/features/settings/presentation/pages/experimental_page.dart +++ b/lib/features/settings/presentation/pages/experimental_page.dart @@ -1,4 +1,5 @@ import 'package:dpip/core/settings/experimental_settings.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 +8,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 +35,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 +54,11 @@ class ExperimentalPage extends StatelessWidget { IconData _iconFor(WeatherMode mode) => switch (mode) { WeatherMode.auto => Icons.auto_mode, WeatherMode.clear => Icons.wb_sunny_outlined, + WeatherMode.cloudy => Icons.filter_drama_outlined, + WeatherMode.overcast => Icons.cloud_outlined, WeatherMode.rain => Icons.water_drop_outlined, + WeatherMode.snow => Icons.ac_unit_outlined, + WeatherMode.sand => Icons.blur_on_outlined, WeatherMode.fog => Icons.foggy, WeatherMode.thunderstorm => Icons.thunderstorm_outlined, }; @@ -49,8 +66,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/weather/data/qpesums_api.dart b/lib/features/weather/data/qpesums_api.dart new file mode 100644 index 000000000..0d07b882a --- /dev/null +++ b/lib/features/weather/data/qpesums_api.dart @@ -0,0 +1,51 @@ +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_region.dart'; + +/// QPESUMS next-1-hour precipitation forecast (未來一小時降水預報) tile +/// endpoints — **API v2**, region topology. +/// +/// Everything is keyed by 13-digit Unix **milliseconds**; a frame id *is* the +/// millisecond. The frame list is delta-encoded (`[baseMs, Δ, Δ, …]`) and +/// served from `api.core-tnn1`; tiles are WebP on `static.core-tnn1` +/// ([ApiTier.coreStaticExclusive]); [tileUrl] feeds MapLibre. Same dual-host + +/// delta-list shape as the v2 radar/satellite tiles. +class QpesumsApi { + const QpesumsApi(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 forecast frame milliseconds, **newest first**. + /// + /// `https://api.core-tnn1.exptech.dev/api/v2/tiles/qpesums/list` + Future> getFrames() async => framesFromList( + await _client.get(_listTier, '/api/v2/tiles/qpesums/list') as List, + ); + + /// XYZ WebP raster tile URL template for a QPESUMS [frame] (a Unix + /// millisecond). + /// + /// `https://static.core-tnn1.exptech.dev/api/v2/tiles/qpesums//{z}/{x}/{y}.webp` + String tileUrl(String frame) => + '${_client.hostsFor(_tileTier).first}' + '/api/v2/tiles/qpesums/$frame/{z}/{x}/{y}.webp'; + + /// Restores the delta-encoded list `[baseMs, Δ, Δ, …]` to absolute + /// milliseconds 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 millis = (deltas.first as num).toInt(); + final values = [millis]; + for (var i = 1; i < deltas.length; i++) { + millis += (deltas[i] as num).toInt(); + values.add(millis); + } + return [for (final v in values.reversed) v.toString()]; + } +} diff --git a/lib/features/weather/data/qpesums_repository_impl.dart b/lib/features/weather/data/qpesums_repository_impl.dart new file mode 100644 index 000000000..711c718b9 --- /dev/null +++ b/lib/features/weather/data/qpesums_repository_impl.dart @@ -0,0 +1,26 @@ +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/qpesums_api.dart'; +import 'package:dpip/features/weather/domain/qpesums_repository.dart'; + +/// [QpesumsRepository] backed by [QpesumsApi], with tile warming from +/// [FrameTileRepository]. +final class QpesumsRepositoryImpl extends FrameTileRepository + implements QpesumsRepository { + QpesumsRepositoryImpl(this._api, super.warmer); + + final QpesumsApi _api; + + @override + int get maxZoom => 11; + + @override + String get tilePathPrefix => '/api/v2/tiles/qpesums/'; + + @override + Future>> frames() => guardResult(_api.getFrames); + + @override + String tileUrl(String frame) => _api.tileUrl(frame); +} 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/weather_providers.dart b/lib/features/weather/weather_providers.dart index 1babca4b0..9751438e5 100644 --- a/lib/features/weather/weather_providers.dart +++ b/lib/features/weather/weather_providers.dart @@ -5,6 +5,8 @@ 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_weather_api.dart'; import 'package:dpip/features/weather/data/meteor_weather_repository_impl.dart'; +import 'package:dpip/features/weather/data/qpesums_api.dart'; +import 'package:dpip/features/weather/data/qpesums_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'; @@ -12,17 +14,18 @@ import 'package:dpip/features/weather/data/satellite_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/satellite_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. /// -/// 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( @@ -31,6 +34,12 @@ List weatherProviders(SharedDeps deps) { deps.mapTileWarmer(), ), ), + Provider.value( + value: QpesumsRepositoryImpl( + QpesumsApi(deps.apiClient), + deps.mapTileWarmer(), + ), + ), Provider.value( value: SatelliteRepositoryImpl( SatelliteApi(deps.apiClient), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2088badf2..651e62c2b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -725,6 +725,10 @@ "@mapLayerSatellite": { "description": "Name of the Himawari infrared layer in the layer picker" }, + "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" @@ -755,6 +759,10 @@ "@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" + }, "notifySettingsMenu": "Notification settings", "@notifySettingsMenu": { "description": "More-menu entry that opens the notification-settings page" @@ -1601,15 +1609,21 @@ "@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" + } } }, "typhoonIntensityMild": "Mild typhoon", @@ -1834,5 +1848,105 @@ "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." + }, + "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." } -} \ No newline at end of file +} diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 393a9f3f1..7f3048973 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -154,11 +154,13 @@ "mapLayers": "Mga Layer", "mapLayerRadar": "Composite Radar Reflectivity", "mapLayerSatellite": "Himawari Infrared", + "mapLayerQpesums": "Pagtaya ng ulan sa susunod na 1 oras", "mapLayerLightning": "Kidlat", "lightningLegendCg": "Ulap–lupa · {minutes} min", "lightningLegendCc": "Ulap–ulap · {minutes} min", "mapTimelineNow": "Ngayon", "mapTimelineObserved": "Naobserbahan", + "mapTimelineForecast": "Pagtaya", "notifySettingsMenu": "Mga setting ng notipikasyon", "notifyTitle": "Mga Notipikasyon", "notifyUnavailable": "Hindi pa handa ang push notifications — subukan muli mamaya.", @@ -417,5 +419,105 @@ "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." + }, + "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." + } +} diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index e3a7de1ca..0d3d13aaf 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -154,11 +154,13 @@ "mapLayers": "Lapisan", "mapLayerRadar": "Radar Komposit", "mapLayerSatellite": "Himawari Inframerah", + "mapLayerQpesums": "Prakiraan hujan 1 jam ke depan", "mapLayerLightning": "Petir", "lightningLegendCg": "Awan–tanah · {minutes} mnt", "lightningLegendCc": "Awan–awan · {minutes} mnt", "mapTimelineNow": "Sekarang", "mapTimelineObserved": "Diamati", + "mapTimelineForecast": "Prakiraan", "notifySettingsMenu": "Pengaturan notifikasi", "notifyTitle": "Notifikasi", "notifyUnavailable": "Notifikasi push belum siap — coba lagi sebentar lagi.", @@ -417,5 +419,105 @@ "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." + }, + "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." + } +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index e73b42b37..ee52b9033 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -154,11 +154,13 @@ "mapLayers": "レイヤー", "mapLayerRadar": "レーダー合成エコー図", "mapLayerSatellite": "ひまわり 赤外線図", + "mapLayerQpesums": "1時間降水量予報", "mapLayerLightning": "雷", "lightningLegendCg": "対地 · {minutes} 分以内", "lightningLegendCc": "雲間 · {minutes} 分以内", "mapTimelineNow": "現在", "mapTimelineObserved": "観測", + "mapTimelineForecast": "予報", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "プッシュ通知はまだ準備できていません。しばらくしてから再度お試しください。", @@ -417,5 +419,105 @@ "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." + }, + "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." + } +} diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 2ba761fed..a9ec335fa 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -154,11 +154,13 @@ "mapLayers": "레이어", "mapLayerRadar": "레이더 합성 에코", "mapLayerSatellite": "히마와리 적외", + "mapLayerQpesums": "1시간 강수 예보", "mapLayerLightning": "번개", "lightningLegendCg": "대지로 · {minutes}분 이내", "lightningLegendCc": "구름 사이 · {minutes}분 이내", "mapTimelineNow": "현재", "mapTimelineObserved": "관측", + "mapTimelineForecast": "예보", "notifySettingsMenu": "알림 설정", "notifyTitle": "알림", "notifyUnavailable": "푸시 알림이 아직 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.", @@ -417,5 +419,105 @@ "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." + }, + "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." + } +} diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 497c349ca..5e4c93f89 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -154,11 +154,13 @@ "mapLayers": "ชั้นข้อมูล", "mapLayerRadar": "เรดาร์สะท้อนสังเคราะห์", "mapLayerSatellite": "ฮิมาวาริ อินฟราเรด", + "mapLayerQpesums": "พยากรณ์ฝน 1 ชั่วโมงข้างหน้า", "mapLayerLightning": "ฟ้าผ่า", "lightningLegendCg": "เมฆสู่พื้น · {minutes} นาที", "lightningLegendCc": "เมฆสู่เมฆ · {minutes} นาที", "mapTimelineNow": "ตอนนี้", "mapTimelineObserved": "เวลาตรวจวัด", + "mapTimelineForecast": "พยากรณ์", "notifySettingsMenu": "การตั้งค่าการแจ้งเตือน", "notifyTitle": "การแจ้งเตือน", "notifyUnavailable": "การแจ้งเตือนแบบพุชยังไม่พร้อม — โปรดลองอีกครั้งในภายหลัง", @@ -417,5 +419,105 @@ "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." + }, + "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." + } +} diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 4b70691a2..9b8c8ec6b 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -154,11 +154,13 @@ "mapLayers": "Lớp bản đồ", "mapLayerRadar": "Radar phản xạ tổng hợp", "mapLayerSatellite": "Himawari hồng ngoại", + "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ờ", "mapTimelineObserved": "Quan trắc", + "mapTimelineForecast": "Dự báo", "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.", @@ -417,5 +419,105 @@ "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." + }, + "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." + } +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bb957b328..cc2185eb8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -154,11 +154,13 @@ "mapLayers": "圖層", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", + "mapLayerQpesums": "未來1小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", "mapTimelineNow": "現在", "mapTimelineObserved": "觀測", + "mapTimelineForecast": "預報", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "推播尚未就緒,請稍後再試。", @@ -417,5 +419,105 @@ "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." + }, + "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." + } +} diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index faceeb3dc..27f7aeacf 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -154,11 +154,13 @@ "mapLayers": "图层", "mapLayerRadar": "雷达合成回波图", "mapLayerSatellite": "ひまわり 红外线图", + "mapLayerQpesums": "未来1小时降水预报", "mapLayerLightning": "闪电", "lightningLegendCg": "对地 · {minutes} 分钟内", "lightningLegendCc": "云间 · {minutes} 分钟内", "mapTimelineNow": "现在", "mapTimelineObserved": "观测", + "mapTimelineForecast": "预报", "notifySettingsMenu": "通知设置", "notifyTitle": "通知", "notifyUnavailable": "推送通知尚未就绪,请稍后再试。", @@ -417,5 +419,105 @@ "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." + }, + "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." + } +} diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index f59ada750..229df8544 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -154,11 +154,13 @@ "mapLayers": "圖層", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", + "mapLayerQpesums": "未來1小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", "mapTimelineNow": "現在", "mapTimelineObserved": "觀測", + "mapTimelineForecast": "預報", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "推送尚未就緒,請稍後再試。", @@ -417,5 +419,105 @@ "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." + }, + "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." + } +} diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 1754df009..99475d9ac 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -154,11 +154,13 @@ "mapLayers": "圖層", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", + "mapLayerQpesums": "未來1小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", "mapTimelineNow": "現在", "mapTimelineObserved": "觀測", + "mapTimelineForecast": "預報", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "推播尚未就緒,請稍後再試。", @@ -417,5 +419,105 @@ "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." + }, + "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." + } +} diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 06bddd95f..ce44aa4b9 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1041,6 +1041,12 @@ abstract class AppLocalizations { /// **'Himawari Infrared'** String get mapLayerSatellite; + /// 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: @@ -1071,6 +1077,12 @@ abstract class AppLocalizations { /// **'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; + /// More-menu entry that opens the notification-settings page /// /// In en, this message translates to: @@ -2624,6 +2636,156 @@ 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; + + /// 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; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index f9609731b..d400d2bef 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -515,6 +515,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapLayerSatellite => 'Himawari Infrared'; + @override + String get mapLayerQpesums => '1h Precipitation Forecast'; + @override String get mapLayerLightning => 'Lightning'; @@ -534,6 +537,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapTimelineObserved => 'Observed'; + @override + String get mapTimelineForecast => 'Forecast'; + @override String get notifySettingsMenu => 'Notification settings'; @@ -1370,4 +1376,82 @@ 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 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.'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 2532d6aa2..40a037cbc 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -520,6 +520,9 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapLayerSatellite => 'Himawari Infrared'; + @override + String get mapLayerQpesums => 'Pagtaya ng ulan sa susunod na 1 oras'; + @override String get mapLayerLightning => 'Kidlat'; @@ -539,6 +542,9 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapTimelineObserved => 'Naobserbahan'; + @override + String get mapTimelineForecast => 'Pagtaya'; + @override String get notifySettingsMenu => 'Mga setting ng notipikasyon'; @@ -1377,4 +1383,82 @@ 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 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.'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 95b7b6ba4..5be499929 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -516,6 +516,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapLayerSatellite => 'Himawari Inframerah'; + @override + String get mapLayerQpesums => 'Prakiraan hujan 1 jam ke depan'; + @override String get mapLayerLightning => 'Petir'; @@ -535,6 +538,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapTimelineObserved => 'Diamati'; + @override + String get mapTimelineForecast => 'Prakiraan'; + @override String get notifySettingsMenu => 'Pengaturan notifikasi'; @@ -1373,4 +1379,82 @@ 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 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.'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 85f0ffe35..f5281c6d0 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -513,6 +513,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapLayerSatellite => 'ひまわり 赤外線図'; + @override + String get mapLayerQpesums => '1時間降水量予報'; + @override String get mapLayerLightning => '雷'; @@ -532,6 +535,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapTimelineObserved => '観測'; + @override + String get mapTimelineForecast => '予報'; + @override String get notifySettingsMenu => '通知設定'; @@ -1353,4 +1359,79 @@ 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 radarCountyOutlineHint => 'エコーの上に描画'; + + @override + String get radarCountyOutlineSubtitle => 'レーダーエコーの下でも県市境界が見えるようにします。'; + + @override + String get radarTownOutline => '市町村境界'; + + @override + String get radarTownOutlineHint => 'より細かい区分'; + + @override + String get radarTownOutlineSubtitle => 'レーダーエコーの下でも市町村境界が見えるようにします。'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 2934f583d..08bd1bc38 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -513,6 +513,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapLayerSatellite => '히마와리 적외'; + @override + String get mapLayerQpesums => '1시간 강수 예보'; + @override String get mapLayerLightning => '번개'; @@ -532,6 +535,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapTimelineObserved => '관측'; + @override + String get mapTimelineForecast => '예보'; + @override String get notifySettingsMenu => '알림 설정'; @@ -1361,4 +1367,79 @@ 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 radarCountyOutlineHint => '에코 위에 표시'; + + @override + String get radarCountyOutlineSubtitle => '레이더 에코 아래에서도 경계가 보이도록 합니다.'; + + @override + String get radarTownOutline => '읍·면·동 경계'; + + @override + String get radarTownOutlineHint => '더 세밀한 구획'; + + @override + String get radarTownOutlineSubtitle => '레이더 에코 아래에서도 읍·면·동 경계가 보이도록 합니다.'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index e1f1b60e3..90603aefa 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -514,6 +514,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapLayerSatellite => 'ฮิมาวาริ อินฟราเรด'; + @override + String get mapLayerQpesums => 'พยากรณ์ฝน 1 ชั่วโมงข้างหน้า'; + @override String get mapLayerLightning => 'ฟ้าผ่า'; @@ -533,6 +536,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapTimelineObserved => 'เวลาตรวจวัด'; + @override + String get mapTimelineForecast => 'พยากรณ์'; + @override String get notifySettingsMenu => 'การตั้งค่าการแจ้งเตือน'; @@ -1369,4 +1375,82 @@ 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 radarCountyOutlineHint => 'วาดทับภาพเอคโค'; + + @override + String get radarCountyOutlineSubtitle => + 'ทำให้เส้นแบ่งเขตยังอ่านออกใต้ภาพเอคโคเรดาร์'; + + @override + String get radarTownOutline => 'เส้นแบ่งเขตอำเภอ'; + + @override + String get radarTownOutlineHint => 'เส้นแบ่งย่อยกว่า'; + + @override + String get radarTownOutlineSubtitle => + 'ทำให้เส้นแบ่งเขตอำเภอยังอ่านออกใต้ภาพเอคโคเรดาร์'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 34ca59586..8ac257554 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -515,6 +515,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapLayerSatellite => 'Himawari hồng ngoại'; + @override + String get mapLayerQpesums => 'Dự báo mưa 1 giờ tới'; + @override String get mapLayerLightning => 'Sét'; @@ -534,6 +537,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapTimelineObserved => 'Quan trắc'; + @override + String get mapTimelineForecast => 'Dự báo'; + @override String get notifySettingsMenu => 'Cài đặt thông báo'; @@ -1373,4 +1379,82 @@ 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 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.'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index ae734223b..54b9436a1 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -512,6 +512,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapLayerSatellite => 'ひまわり 紅外線圖'; + @override + String get mapLayerQpesums => '未來1小時降水預報'; + @override String get mapLayerLightning => '閃電'; @@ -531,6 +534,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapTimelineObserved => '觀測'; + @override + String get mapTimelineForecast => '預報'; + @override String get notifySettingsMenu => '通知設定'; @@ -1347,6 +1353,81 @@ class AppLocalizationsZh 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 radarCountyOutlineHint => '畫在回波之上'; + + @override + String get radarCountyOutlineSubtitle => '讓縣市界線在雷達回波下仍然清楚。'; + + @override + String get radarTownOutline => '鄉鎮界線'; + + @override + String get radarTownOutlineHint => '較細的分區'; + + @override + String get radarTownOutlineSubtitle => '讓鄉鎮界線在雷達回波下仍然清楚。'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -1857,6 +1938,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapLayerSatellite => 'ひまわり 红外线图'; + @override + String get mapLayerQpesums => '未来1小时降水预报'; + @override String get mapLayerLightning => '闪电'; @@ -1876,6 +1960,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapTimelineObserved => '观测'; + @override + String get mapTimelineForecast => '预报'; + @override String get notifySettingsMenu => '通知设置'; @@ -2692,6 +2779,81 @@ class AppLocalizationsZhHans 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 radarCountyOutlineHint => '画在回波之上'; + + @override + String get radarCountyOutlineSubtitle => '让县市界线在雷达回波下仍然清楚。'; + + @override + String get radarTownOutline => '乡镇界线'; + + @override + String get radarTownOutlineHint => '较细的分区'; + + @override + String get radarTownOutlineSubtitle => '让乡镇界线在雷达回波下仍然清楚。'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -3202,6 +3364,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapLayerSatellite => 'ひまわり 紅外線圖'; + @override + String get mapLayerQpesums => '未來1小時降水預報'; + @override String get mapLayerLightning => '閃電'; @@ -3221,6 +3386,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapTimelineObserved => '觀測'; + @override + String get mapTimelineForecast => '預報'; + @override String get notifySettingsMenu => '通知設定'; @@ -4037,6 +4205,81 @@ class AppLocalizationsZhHantHk 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 radarCountyOutlineHint => '畫在回波之上'; + + @override + String get radarCountyOutlineSubtitle => '讓縣市界線在雷達回波下仍然清楚。'; + + @override + String get radarTownOutline => '鄉鎮界線'; + + @override + String get radarTownOutlineHint => '較細的分區'; + + @override + String get radarTownOutlineSubtitle => '讓鄉鎮界線在雷達回波下仍然清楚。'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -4547,6 +4790,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapLayerSatellite => 'ひまわり 紅外線圖'; + @override + String get mapLayerQpesums => '未來1小時降水預報'; + @override String get mapLayerLightning => '閃電'; @@ -4566,6 +4812,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapTimelineObserved => '觀測'; + @override + String get mapTimelineForecast => '預報'; + @override String get notifySettingsMenu => '通知設定'; @@ -5382,4 +5631,79 @@ 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 radarCountyOutlineHint => '畫在回波之上'; + + @override + String get radarCountyOutlineSubtitle => '讓縣市界線在雷達回波下仍然清楚。'; + + @override + String get radarTownOutline => '鄉鎮界線'; + + @override + String get radarTownOutlineHint => '較細的分區'; + + @override + String get radarTownOutlineSubtitle => '讓鄉鎮界線在雷達回波下仍然清楚。'; } diff --git a/lib/shared/map/admin_outline.dart b/lib/shared/map/admin_outline.dart new file mode 100644 index 000000000..234bf484a --- /dev/null +++ b/lib/shared/map/admin_outline.dart @@ -0,0 +1,131 @@ +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// An administrative boundary set that can be redrawn over a weather raster. +enum AdminBoundary { + /// 縣市 — 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. + /// + /// [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? 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/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index b59d159af..2aa961289 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -16,6 +16,7 @@ import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_layer_switcher.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:dpip/shared/map/map_timeline.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'; @@ -667,6 +668,13 @@ 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 caption = active is RasterTimelineLayer + ? active.timelineCaption(context) + : AppLocalizations.of(context).mapTimelineObserved; return FrostedSurface( borderRadius: AppRadius.large, child: Padding( @@ -678,6 +686,7 @@ class _MapScaffoldState extends State { selectedIndex: _selectedIndex, onSelected: _onFrameSelected, onScrubbing: _onScrubbing, + caption: caption, ), ), ); diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index 6fed21c2e..5398f03a4 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -23,6 +23,7 @@ class MapTimeline extends StatefulWidget { required this.selectedIndex, required this.onSelected, this.onScrubbing, + this.caption, }); /// Frames in chronological order (oldest first); must be non-empty. @@ -37,6 +38,10 @@ 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; + @override State createState() => _MapTimelineState(); } @@ -182,7 +187,7 @@ 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, diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index e4d571b29..e33f5436d 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -6,6 +6,7 @@ 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'; @@ -85,6 +86,23 @@ 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; + Future onAttached(MapLibreMapController controller) async {} /// Undoes [onAttached]. Called from [clear]. @@ -157,27 +175,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. + /// + /// 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 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); + /// 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 @@ -256,24 +289,28 @@ abstract class RasterTimelineLayer implements MapLayer { await _evictOverflow(controller, keep: {..._ring, frameId}); } - /// 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 +367,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 +384,7 @@ abstract class RasterTimelineLayer implements MapLayer { _sourceId(id), _layerId(id), _mountPaint(value), - belowLayerId: outlineLayerId, + belowLayerId: rasterBelowLayerId, ); _resident.add(id); } diff --git a/lib/shared/widgets/map_color_legend.dart b/lib/shared/widgets/map_color_legend.dart index a294d7fa2..db6d9747d 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'; @@ -180,3 +182,122 @@ class SymbolLegend extends StatelessWidget { ); } } + +/// 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/pubspec.lock b/pubspec.lock index 6adbcb056..c9c20a049 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -648,11 +648,9 @@ packages: maplibre_gl: dependency: "direct main" description: - path: maplibre_gl - ref: "2c515a3feaa7fee565e1ae03971aade841bec54f" - resolved-ref: "2c515a3feaa7fee565e1ae03971aade841bec54f" - url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" - source: git + path: "/Users/yuyu1015/Documents/GitHub/flutter-maplibre-gl/maplibre_gl" + relative: false + source: path version: "0.26.2" maplibre_gl_platform_interface: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 4aad52785..d37aec46e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,12 +60,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..7dc794dba --- /dev/null +++ b/shaders/README.md @@ -0,0 +1,106 @@ +# 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 | +| screen pass | `sky/sky_view.frag` | full screen | every frame | + +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_view sky gradient from the LUT → sky/sky_view.frag +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..2b7819f34 --- /dev/null +++ b/shaders/cloud/clouds.frag @@ -0,0 +1,308 @@ +// 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 +// iSunElevParam (23) sky-LUT u coordinate for the current keyframe +// iOpacity (24) instance opacity +// iStartEdge (25) soft-edge start (cloud profile `uStartEdge`) +// iEndEdge (26) soft-edge end (cloud profile `uEndEdge`) +// iProgress (27) deck reveal 0..2 (the reference's `uProgress`) +// iSmooth (28) reveal softness (the reference's `uSmooth`) +// iCloudDepth (29) depth weighting 0..1 +// iSunIntensity (30) direct-sun strength 0..1 +// iFogDensity (31) haze blending the sprite toward the sky +// iInner (32) internal illumination 0..1 (lightning inside) +// iWhitePer (33) noon whitening (the reference's `whitePer`) +// iLutSize (34..35) sky LUT size in texels +// iTexSize (36..37) sprite size in texels +// sampler 0: iSprite the cloud sprite +// sampler 1: iSkyView the sky LUT (`sky/sky_lut.frag`) +#include + +precision highp 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 iSunElevParam; +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 vec3 iFrustumA; +uniform vec3 iFrustumC; +uniform vec2 iTexSize; +uniform sampler2D iSprite; +uniform sampler2D iSkyView; + +out vec4 fragColor; + +#define PI 3.14159265359 +#define ONE_OVER_PI 0.3183098861837907 + +/// Bilinear sky-LUT fetch. Flutter binds samplers as NEAREST with no way to +/// request linear, so without this the cloud lighting steps as the sun moves. +vec3 sampleLut(vec2 uv) { + vec2 texel = 1.0 / iLutSize; + vec2 st = uv * iLutSize - 0.5; + vec2 base = floor(st); + vec2 f = st - base; + vec2 uv0 = (base + 0.5) * texel; + + vec3 c00 = texture(iSkyView, uv0).rgb; + vec3 c10 = texture(iSkyView, uv0 + vec2(texel.x, 0.0)).rgb; + vec3 c01 = texture(iSkyView, uv0 + vec2(0.0, texel.y)).rgb; + vec3 c11 = texture(iSkyView, uv0 + texel).rgb; + return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y); +} + +/// 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) { + float elevation = clamp(picker, 0.0, 1.0) * (PI / 2.0); + float m = sqrt(elevation / (PI / 2.0)); + float v = clamp((m + 0.1015625) / 1.1015625, 0.0, 1.0); + return sampleLut(vec2(clamp(iSunElevParam, 0.0, 1.0), 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 ----------------------- + 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.x = mix(0.05, baseCfg.x, iCloudDepth); + baseCfg.y = mix(1.0, baseCfg.y, iCloudDepth); + vec3 baseColor = readColorConfig(baseCfg); + + // 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 fogMulti = 1.0 - pow(max(0.0, iFogDensity), 2.0); + + 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. + color = mix(color, skyAt(0.95), 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..2ccf2b5e0 --- /dev/null +++ b/shaders/sky/sky_view.frag @@ -0,0 +1,83 @@ +// Sky screen pass — stage 3. +// +// 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..79ba8cc74 --- /dev/null +++ b/shaders/weather/card_water.frag @@ -0,0 +1,133 @@ +// 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 highp 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. +const float kGloss = 10.0; + +/// `float alpha = 0.6;` — the fixed surface opacity past the threshold. +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); + + float specular = pow(max(dot(normal, halfDir), 0.0), kGloss); + // `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..f62c55ef8 --- /dev/null +++ b/shaders/weather/galaxy.frag @@ -0,0 +1,135 @@ +// 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. + float fb = clamp(mix(1.0, 0.0, 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..0e2dde1db --- /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, pow(angleOffset, 0.5) / (PI * 0.25)); + glow += smoothstep(len + 0.05, 1.1, sqrtd) * 0.5; + + // Tight core. + if (sqrtd > 0.895) { + glow += pow(clamp(br * smoothstep(0.895, 1.0, sqrtd), 0.0, 2.0), 2.0) * + 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..62f9d244c --- /dev/null +++ b/shaders/weather/rain_on_glass.frag @@ -0,0 +1,243 @@ +// Rain on the glass — droplets clinging to a card's face, refracting it. +// +// Applied as a `ui.ImageFilter.shader` over the card's own pixels, so what +// bends is the content itself: text visibly warps behind a bead rather than +// having a droplet drawn flat on top of it. +// +// The field is built from two populations, which is what water on a vertical +// pane actually does: +// +// • **Beads** condense in place on a jittered lattice, grow quickly, hold, +// and evaporate slowly. The asymmetry matters — a symmetric envelope makes +// the whole field pulse in step, which reads as a flicker rather than as +// weather. Each bead runs its own clock, seeded from its cell. +// • **Runners** are beads that grew heavy enough to break their pinning and +// slide. One track per column; a runner clings, releases, accelerates, and +// drags a thinning ribbon behind it that pinches off into a line of shed +// droplets. The ribbon and the shed beads are unioned, not added, because +// the tail is one body of water breaking up, not two overlapping ones. +// +// The two populations are combined with a **soft union** (`max` plus a +// fraction of `min`) rather than a sum: surface tension merges two touching +// drops into one larger drop, it does not stack their heights. +// +// Refraction is the field's own gradient. The coverage mask is sampled three +// times — here, one step in x, one step in y — and the pair `(c-cx, c-cy)` is +// the surface's slope, which offsets the texture lookup. There is no lighting +// term at all; the displacement IS the effect, and it is large: 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. +// +// The coordinate frame is a **fixed 1080x1080** ([uResolution]), not the +// filtered widget. Every bead size and the refraction displacement are +// computed against that frame in device pixels, so a bead is the same physical +// size on every card in the app. Deriving the frame from the filtered widget's +// own size — as an earlier version did — shrinks the beads by the card/screen +// ratio and they stop reading as water. 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 1080x1080 frame, in device pixels +// uTime (4) animation time (seconds) +// uStaticDropSize (5) bead lattice scale — a DIVISOR, larger = fewer, +// bigger beads +// uStaticDropAmount (6) bead coverage +// uStaticDropSpeed (7) how fast beads condense and evaporate +// uRunningDropSize (8) runner column width — also a divisor +// uRunningDropAmount(9) runner coverage +// uRunningDropSpeed (10) how fast runners slide +// uAlpha (11) effect strength, 0 passes the content through +// sampler 0: uTex the content being filtered — SET BY THE ENGINE +#include + +precision highp 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; + +/// Runner tracks across the fixed frame's width, at `uRunningDropSize` 1. +const float kColumns = 14.0; + +/// How far down the frame a runner travels, in frame heights. +const float kRunSpan = 1.25; + +/// Runner releases per second at `uRunningDropSpeed` 1. +/// +/// This and [kRunSpan] are the two halves of one speed, and setting only one +/// of them is how the first version came out four times too fast: a full-frame +/// traverse at one release per second crosses a card in a tenth of a second, +/// which reads as a streak rather than as a drop. At the rate below a runner +/// takes about half a second to cross a 120pt card in a downpour and a couple +/// of seconds in drizzle, which is what water on glass actually does. +const float kRunRate = 0.21; + +/// Bead condense-and-evaporate cycles per second at `uStaticDropSpeed` 1. +/// Condensation is slow — a bead that appears and is gone inside a second +/// reads as a flicker, not as water gathering. +const float kBeadRate = 0.15; + +/// Where a runner releases, in frame units. Nothing of its tail exists above +/// this line. +const float kReleaseY = 0.62; + +/// Three uncorrelated values from a lattice cell. +vec3 hash32(vec2 p) { + vec3 q = vec3(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)), + dot(p, vec2(419.2, 371.9))); + return fract(sin(q) * 43758.5453123); +} + +/// Condensed beads on a jittered lattice. +float beadLayer(vec2 uv, float t) { + vec2 g = uv * (40.0 / uStaticDropSize); + vec2 cell = floor(g); + vec2 f = fract(g) - 0.5; + vec3 h = hash32(cell); + + // Condense fast, hold, evaporate slowly. Offsetting each bead's clock by its + // own cell hash is what keeps the lattice from breathing as one sheet. + float life = fract(t * uStaticDropSpeed * kBeadRate + h.z); + float envelope = + smoothstep(0.0, 0.10, life) * (1.0 - smoothstep(0.42, 1.0, life)); + + // Jitter is bounded so a bead cannot reach its cell's edge — one cell is + // sampled, not a neighbourhood, and a bead crossing the boundary would clip. + vec2 centre = (h.xy - 0.5) * 0.46; + float radius = (0.11 + 0.17 * fract(h.z * 7.31)) * envelope; + float d = length(f - centre); + // Nearly flat-topped with a quick shoulder: a spherical cap held by surface + // tension, not a gaussian blob. + return smoothstep(radius, radius * 0.45, d); +} + +/// A runner and the tail it drags, for the column containing [uv]. +float runLayer(vec2 uv, float t) { + float cellW = uRunningDropSize / kColumns; + float col = floor(uv.x / cellW); + float xu = uv.x - (col + 0.5) * cellW; + vec3 h = hash32(vec2(col, 11.7)); + + // Successive drops down one track, staggered per column so no two columns + // release together. The index is wrapped: it feeds a hash, and an unbounded + // argument to `sin` loses precision long before the app is closed. + float k = t * uRunningDropSpeed * kRunRate * (0.6 + 0.8 * h.x) + h.y; + vec3 hd = hash32(vec2(col, mod(floor(k), 64.0))); + float u = fract(k); + + // Cling, release, accelerate, and slow again as the tail drains. A linear + // descent reads as a machine part sliding on a rail. + float travel = u * u * (1.6 - 0.6 * u); + float headY = kReleaseY - travel * kRunSpan; + + float dx = xu - (hd.x - 0.5) * cellW * 0.5; + float dy = uv.y - headY; // > 0 is above the head, i.e. the tail + + float rHead = cellW * (0.18 + 0.13 * hd.y); + // Taller than wide: a drop being dragged is stretched along its travel. + float head = smoothstep(rHead, rHead * 0.40, length(vec2(dx, dy * 0.82))); + + // The tail exists only behind the head and only below the release point — + // a runner leaves a wet track, it does not paint the column it came from. + float inTrack = step(0.0, dy) * step(uv.y, kReleaseY); + // And it is SHORT. Tapering by a fraction of the total travel keeps the + // trail full width however far the drop has gone, which silhouettes as a + // hard vertical bar — the one shape here that reads as a machine part. + float taper = exp(-dy / (rHead * 7.0)); + + // A rounded ridge, not a flat-topped ribbon: `smoothstep(r, 0, |dx|)` peaks + // at the centre line and reaches zero at the edge. + // Faded by `taper` in AMPLITUDE as well as width. Width alone is not + // enough: `smoothstep(r, 0, |dx|)` is exactly 1 on the centre line for any + // r > 0, so a ribbon that only narrows leaves a permanent one-pixel hairline + // running the full height of the track. + float ribbonR = rHead * 0.34 * taper; + float ribbon = smoothstep(ribbonR, 0.0, abs(dx)) * inTrack * taper; + + // The track pinches off into a line of beads rather than staying a filament, + // each smaller and more scattered the further back it is. + float pitch = rHead * 2.4; + float sIdx = floor(dy / pitch); + float sY = fract(dy / pitch) - 0.5; + vec3 hs = hash32(vec2(col * 7.0 + sIdx, floor(k))); + float shedR = 0.42 * taper * (0.6 + 0.5 * hs.x); + float sX = dx - (hs.y - 0.5) * rHead * 0.6; + float shed = + smoothstep(shedR, shedR * 0.30, length(vec2(sX / (rHead * 1.2), sY))) * + inTrack; + + return head + max(ribbon, shed); +} + +/// The droplet coverage mask, 0 (dry) to 1. +float coverage(vec2 uv, float t, float beadGain) { + float b = beadLayer(uv, t) * uStaticDropAmount * beadGain; + float r = runLayer(uv, t) * uRunningDropAmount; + // Soft union: two touching drops merge into one, they do not stack. + float merged = max(b, r) + 0.35 * min(b, r); + return smoothstep(0.22, 0.92, merged); +} + +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 + // runners then slide *up* the glass. Worse, the mirror is about `uSize.y/2` + // while the field is scaled by the fixed frame, so it also translates the + // lattice by `uSize.y - uResolution.y`. Keep the two coordinates separate. + vec2 texXY = xy; +#ifdef IMPELLER_TARGET_OPENGLES + texXY.y = uSize.y - xy.y; +#endif + + // The field's frame: centred, normalised by the fixed frame's height, and + // with y increasing upward so a runner's descent is a decrease. + vec2 uv = (xy - 0.5 * uResolution) / uResolution.y; + uv.y = -uv.y; + + // Fade the whole effect in, so it does not appear fully formed. + float alpha = smoothstep(0.0, 2.0, uTime); + // Wrapped well inside single-precision range; the hashes are seeded by cell, + // not by time, so nothing is lost at the seam. + float t = mod(uTime, 600.0); + + // Beads thin out toward the top of the frame, where a card's own edge sheds + // water fastest. A flat field over the whole card reads as frosted glass. + float beadGain = 0.75 + 0.35 * smoothstep(0.45, -0.35, uv.y); + + float c = coverage(uv, t, beadGain); + if (c > 0.001) { + // A true 2-D gradient, so a bead refracts radially the way a lens does. + vec2 e = vec2(0.0006, 0.0); + float cx = coverage(uv + e.xy, t, beadGain); + float cy = coverage(uv + e.yx, t, beadGain); + vec2 n = vec2(c - cx, c - cy); + + // `n` is in frame units, so the displacement is `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. + 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..b86ca9e46 --- /dev/null +++ b/shaders/weather/sun_flare.frag @@ -0,0 +1,233 @@ +// 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 highp 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 r = max(2.0 / (1.0 + 32.0 * pow(length(uvd + (dist - 0.05) * pos), 2.0)), 0.0) * 0.25; + float g = max(2.0 / (1.0 + 32.0 * pow(length(uvd + dist * pos), 2.0)), 0.0) * 0.23; + float b = max(2.0 / (1.0 + 32.0 * pow(length(uvd + (dist + 0.05) * pos), 2.0)), 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 fan = min(max(0.3, pow(clamp(perFromVal( + dot(normalize(sunPos + 1e-5), normalize(sunPos - uv + 1e-5)), + -1.0, 1.0), 0.0, 1.0), 3.0)), 0.8); + fan = max(pow(max(1.0 - dist, 0.0), 3.0), 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/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/widgets/home_content_test.dart b/test/features/home/presentation/widgets/home_content_test.dart index a44f36787..6d476a588 100644 --- a/test/features/home/presentation/widgets/home_content_test.dart +++ b/test/features/home/presentation/widgets/home_content_test.dart @@ -7,7 +7,10 @@ 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/weather/domain/meteor_weather_repository.dart'; import 'package:dpip/features/weather/domain/weather_forecast.dart'; @@ -45,7 +48,7 @@ class _FakeEventRepository implements EventRepository { /// Pumps [HomeContent] with everything it reads: a [RegionStore] to switch on /// and localizations for the body. -Widget _wrap(RegionStore store) { +Widget _wrap(RegionStore store, {bool expanded = false}) { const directory = TownDirectory({}); final events = _FakeEventRepository(); return MaterialApp( @@ -65,7 +68,12 @@ Widget _wrap(RegionStore store) { create: (_) => HomeActiveEventsController(events, store), ), ], - child: Scaffold(body: HomeContent(scrollController: ScrollController())), + child: Scaffold( + body: HomeContent( + scrollController: ScrollController(), + expanded: expanded, + ), + ), ), ); } @@ -180,4 +188,47 @@ 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('全國 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_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_test.dart b/test/features/home/presentation/widgets/home_sheet_test.dart index f8f941587..4540e4406 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'; @@ -72,6 +73,7 @@ Widget _wrap(RegionStore store, HomeSheetExtent extent) { scrollController: ScrollController(), extent: extent, weatherMode: WeatherMode.auto, + skyTimeMode: SkyTimeMode.auto, ), ), ), 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..1c5cded84 --- /dev/null +++ b/test/features/home/weather_sky/card_water_field_test.dart @@ -0,0 +1,389 @@ +import 'dart:math' as math; +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, +}) { + for (var t = 0.0; t < seconds; t += step) { + field.update( + step, + width: _width, + screenHeight: _screen, + intensity: intensity, + preset: preset, + ); + } +} + +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); + }); +} 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/qpesums_layer_test.dart b/test/features/map/qpesums_layer_test.dart new file mode 100644 index 000000000..21569f367 --- /dev/null +++ b/test/features/map/qpesums_layer_test.dart @@ -0,0 +1,116 @@ +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); + }); +} + +/// [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..16747e9b8 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); @@ -189,8 +215,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 +228,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..7baf25bfa --- /dev/null +++ b/test/features/map/radar_overlay_menu_test.dart @@ -0,0 +1,139 @@ +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/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) => 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), + ), + ), +); + +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 three 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.radarScanRange), findsOneWidget); + expect(find.text(l10n.radarCountyOutline), findsOneWidget); + expect(find.text(l10n.radarTownOutline), findsOneWidget); + // All three ship on, so every box starts ticked. + expect(find.byIcon(Icons.check_box), findsNWidgets(3)); + expect(find.byIcon(Icons.check_box_outline_blank), findsNothing); + }); + + 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('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..f568c9f89 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -59,8 +59,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 +106,35 @@ class RecordingMapController implements MapLibreMapController { double? maxzoom, dynamic filter, bool enableInteraction = true, - }) async => calls.add('addLineLayer:$layerId'); + }) async { + below[layerId] = belowLayerId; + 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]; + + @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 => diff --git a/test/features/map/satellite_layer_test.dart b/test/features/map/satellite_layer_test.dart index 24294eb7a..5e70acbfa 100644 --- a/test/features/map/satellite_layer_test.dart +++ b/test/features/map/satellite_layer_test.dart @@ -54,10 +54,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', ); }, ); 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..8216c5e7c --- /dev/null +++ b/test/features/map/typhoon_overlay_menu_test.dart @@ -0,0 +1,227 @@ +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/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'; +} + +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), + ), + ), +); + +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(); + // A coverage boundary over Himawari IR would bound an instrument that is + // not on screen, and IR does not bury the base style's borders. + 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, + ); + }); + + 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('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/weather/qpesums_api_test.dart b/test/features/weather/qpesums_api_test.dart new file mode 100644 index 000000000..ec1c00d24 --- /dev/null +++ b/test/features/weather/qpesums_api_test.dart @@ -0,0 +1,29 @@ +import 'package:dpip/features/weather/data/qpesums_api.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('QpesumsApi.framesFromList', () { + test('decodes delta-millis and returns them newest first', () { + // [baseMs, +600000, +600000] → 1786208400000, 1786209000000, + // 1786209600000 (10-min steps) + expect(QpesumsApi.framesFromList([1786208400000, 600000, 600000]), [ + '1786209600000', + '1786209000000', + '1786208400000', + ]); + }); + + test('an empty list yields no frames', () { + expect(QpesumsApi.framesFromList(const []), isEmpty); + }); + + test('a single-frame list is just the base millisecond', () { + expect(QpesumsApi.framesFromList([1786208400000]), ['1786208400000']); + }); + + test('frame ids are 13-digit millis, usable straight in a tile URL', () { + final frames = QpesumsApi.framesFromList([1786208400000, 600000]); + expect(frames.first.length, 13); + }); + }); +} 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..4550a1d3a --- /dev/null +++ b/test/shaders/preview_render_test.dart @@ -0,0 +1,623 @@ +@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.png'); + 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 = _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); +} + +ui.Image _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, +) { + 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, + ); + 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(sky.sunAngleY); + 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); + f(SkyConstants.skyLutWidth.toDouble()); + f(SkyConstants.skyLutHeight.toDouble()); + f(sprite.width.toDouble()); + f(sprite.height.toDouble()); + 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; +} + +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/weather_sky_shader_test.dart b/test/shaders/weather_sky_shader_test.dart new file mode 100644 index 000000000..ed6cf1183 --- /dev/null +++ b/test/shaders/weather_sky_shader_test.dart @@ -0,0 +1,231 @@ +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.9214, 0.3886, 0.0, // uFrustumA + 0.9980, 0.0628, 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 + 0.5, // iSunElevParam + 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 + 0.9214, 0.3886, 0.0, // iFrustumA + 0.9980, 0.0628, 0.0, // iFrustumC + 256, 288, // iTexSize + ], + 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/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_keyframes.py b/tool/gen_sky_keyframes.py new file mode 100644 index 000000000..0ef93c76a --- /dev/null +++ b/tool/gen_sky_keyframes.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Generate the sky's keyframe rings as a Dart source file. + +WHAT A KEYFRAME IS +------------------ +The sky is not simulated per frame. Two lookup tables are baked whenever the +weather or the time slot changes (`shaders/sky/transmittance.frag` then +`sky_lut.frag`), and the screen pass just reads them. A keyframe is the set of +inputs to that bake: where the sun is, how bright it is, and what the air is +made of. `sky_keyframe.dart` holds the contract; this file holds the content. + +Clear and cloudy skies carry a full 17-point ring around the day. Everything +else carries four points — night, day, day, night — because a thick deck +flattens the sky into something that barely changes with the sun, and four +points say that more honestly than seventeen near-identical ones would. + +HOW THE NUMBERS ARE ARRIVED AT +------------------------------ +Nothing here is hand-typed per keyframe. Each weather type declares its +*intent* — how much haze, how damp, how much dust, how much light gets through +— and the medium is derived from that: + + * The **dry baseline** is the standard Earth atmosphere from Hillaire, "A + Scalable and Production Ready Sky and Atmosphere Rendering Technique" + (EGSR 2020): Mie scattering 3.996e-6 /m, Mie absorption 4.4e-6 /m, aerosol + scale height 1200 m. The Rayleigh coefficients and the planet radii are the + same paper's and live in `SkyConstants`. + * **Haze and dust** multiply the aerosol terms. Haze scatters far more than + it absorbs, which is why fog is bright; dust does the reverse, which is why + a sand sky is dim and brown. + * **Dampness** lifts the aerosol layer (`mieHeight`) and, in this shader, + `rayleighHeight` — which is the knob that decides how much blue survives to + the top of the frame rather than being a physical scale height. It is named + for what it does here, not for the atmosphere. + * The **sun's elevation** is a sine over the daylight half of the ring. It is + the sky LUT's u coordinate directly (`sunAngleY = elevation / 180`), since + the LUT's u axis sweeps the sun from one horizon to the other. + +`sunIntensity` is deliberately NOT proportional to elevation: it carries a +dawn/dusk boost, because the interesting sky is the low-sun one and a straight +`sin(elevation)` makes it the dullest moment of the day. + +Run: `python3 tool/gen_sky_keyframes.py > ` then `dart format` +(no dependencies). The generator does not try to match dartfmt itself. +""" + +import math +import sys + +# --- the dry baseline ------------------------------------------------------- +MIE_SCATTER_DRY = 3.996e-6 +MIE_ABSORB_DRY = 4.4e-6 +MIE_HEIGHT_DRY = 1200.0 +# What `rayleighHeight` means here: how far up the blue survives. The clear-sky +# value is the real scale height; the rest of the range is art direction. +RAYLEIGH_HEIGHT_CLEAR = 8000.0 + +# --- the day ---------------------------------------------------------------- +RING = 17 # keyframes in a full-day ring +DAY_START, DAY_END = 4, 12 # indices the sun is above the horizon between +PEAK_ELEVATION = 78.0 # degrees at local noon +TWILIGHT_ELEVATION = 2.4 # degrees held through the night, so dusk has a floor + +# How far the night frames of a four-point ring are dimmed against their day +# frames. The sky is the same sky; only the light changes. +NIGHT_GAIN = 0.38 + +# Ozone half-thickness (m). Real enough at the clear end; thickening it drains +# the last of the blue, which is what a heavy overcast wants. +OZONE_CLEAR = 20000.0 + +# Weather types, in the order `weatherKeyframes` publishes them. +# +# haze aerosol load that scatters (cloud, fog, spray) +# dust aerosol load that absorbs (sand, smog) +# damp how far the scattering layer is lifted +# gain how much sunlight reaches the top of the atmosphere at all +# blue how much of the Rayleigh blue is kept +# pin for four-point rings: the elevation the sky is pinned to, degrees +WEATHERS = [ + # name frames haze dust damp gain blue pin + ("sunny", RING, 0.00, 0.00, 0.20, 1.00, 1.00, None), + ("cloudy", RING, 0.20, 0.00, 0.40, 0.92, 1.05, None), + ("overcast", 4, 2.60, 0.10, 0.75, 0.80, 1.45, 34.0), + ("foggy", 4, 1.40, 0.05, 1.00, 1.00, 0.55, 34.0), + ("smoggy", 4, 2.80, 0.90, 0.55, 0.85, 1.70, 34.0), + ("rainyLight", 4, 1.00, 0.00, 0.60, 0.95, 1.60, 34.0), + ("rainyMedium", 4, 1.90, 0.00, 0.60, 0.88, 1.50, 34.0), + ("rainyHeavy", 4, 2.40, 0.15, 0.60, 0.78, 1.45, 34.0), + ("rainyExtreme", 4, 0.90, 0.00, 0.60, 0.62, 1.30, 34.0), + ("snowyLight", 4, 4.00, 0.00, 0.60, 1.05, 0.75, 34.0), + ("snowyMedium", 4, 4.60, 0.00, 0.60, 0.98, 0.80, 34.0), + ("snowyHeavy", 4, 4.60, 0.00, 0.60, 0.90, 0.65, 34.0), + ("sandyHeavy", 4, 6.00, 2.20, 0.10, 1.45, 3.00, 66.0), + ("sandyLight", 4, 4.00, 0.45, 0.90, 1.30, 2.10, 34.0), +] + +# The tonemap `sky_lut.frag` applies inside the bake, as (a, b, c, d, e) in +# `(x*(a*x + b)) / (x*(c*x + d) + e)`. These give `x^2 / (x + 1)`: near-square +# in the shadows so the night sky stays dark, asymptotically linear so a bright +# horizon does not clip. +POST = (1, 0, 0, 0, 1) + + +def elevation(i, frames, pin): + """Sun elevation in degrees at ring index [i].""" + if pin is not None: + return pin + if i <= DAY_START or i >= DAY_END: + # Night. Ease down to the floor and back so dusk is not a step. + edge = min(abs(i - DAY_START), abs(i - DAY_END), 4) / 4.0 + return TWILIGHT_ELEVATION + (6.0 - TWILIGHT_ELEVATION) * (1.0 - edge) ** 2 + phase = (i - DAY_START) / (DAY_END - DAY_START) + return max(TWILIGHT_ELEVATION, PEAK_ELEVATION * math.sin(math.pi * phase)) + + +def sun_intensity(elev, gain): + """Sun radiance multiplier. + + Rises with elevation, but with a dawn/dusk term on top: the low-sun sky is + the one worth looking at, and a plain `sin(elevation)` makes it the dullest + part of the day. + """ + s = math.sin(math.radians(elev)) + low = math.exp(-((elev - 7.0) / 9.0) ** 2) # peaks just above the horizon + return round((4.0 + 15.0 * s + 9.0 * low) * gain, 1) + + +def medium(haze, dust, damp, blue): + """The five `(dry, wet)` ramps, from one weather's intent.""" + # Haze scatters far more than it absorbs — that is why fog is bright and + # smog is not. + scatter = MIE_SCATTER_DRY * (1.0 + 3.2 * haze + 1.2 * dust) + absorb = MIE_ABSORB_DRY * (1.0 + 0.3 * haze + 5.0 * dust) + height = MIE_HEIGHT_DRY * (1.0 + 1.2 * damp) + rayleigh = RAYLEIGH_HEIGHT_CLEAR * blue + + def ramp(dry, wet_factor): + # Humidity only has room to move on a sky that is not already saturated + # with water; a rainy ring's two ends are the same number. + wet = dry * wet_factor if haze < 0.5 else dry + return (_num(dry), _num(wet)) + + return dict( + humidRayleighHeight=ramp(rayleigh, 0.80), + humidMieScatter=ramp(scatter, 2.60), + humidMieAbsorb=ramp(absorb, 1.60), + humidMieHeight=ramp(height, 1.45), + # Aerosols scatter forward; the clear sky's remaining haze barely does. + humidMieAsymmetry=(_num(0.0), _num(0.45 if haze < 0.5 else 0.0)), + ) + + +def _num(v): + """Format a float the way Dart wants it, without trailing noise.""" + if v == 0: + return "0" + if abs(v) >= 1000: + return "%d" % round(v) + if abs(v) < 1e-3: + return "%.3e" % v + return ("%.4f" % v).rstrip("0").rstrip(".") + + +def keyframes(name, frames, haze, dust, damp, gain, blue, pin): + out = [] + med = medium(haze, dust, damp, blue) + # Ozone absorbs hardest in the GREEN (see `SkyConstants.ozoneAbsorb`), so + # this is a narrow knob, not a dimmer: run the tent much past the real + # layer's thickness and the sky loses its green channel and goes maroon. + ozone = min(34000.0, OZONE_CLEAR * (1.0 + 0.16 * haze)) + for i in range(frames): + if pin is None: + elev = elevation(i, frames, None) + night = 1.0 + else: + # A four-point ring is night, day, day, night — and its sun does + # NOT move. Under a deck thick enough to need this ring you cannot + # see where the sun is; the sky is lit diffusely and night is the + # same sky, dimmer. Letting the elevation fall to the horizon for + # the night frames instead makes every overcast midnight a sunset, + # which is a red sky under a rain cloud. + elev = pin + night = 1.0 if i % 3 else NIGHT_GAIN + # The band lifts a little as the sun climbs, so a bright sky shows more + # of its own gradient and a dark one sits closer to the horizon. + yaw = round(1.5 + 6.0 * math.sin(math.radians(elev)), 1) + out.append(dict( + time=i, + sunAngleY=round(elev / 180.0, 4), + cameraYaw=yaw, + sunIntensity=sun_intensity(elev, gain * night), + ozoneThickness=int(round(ozone / 500.0) * 500), + **med, + )) + return out + + +def emit(stream): + w = stream.write + w("// GENERATED by tool/gen_sky_keyframes.py — do not edit by hand.\n") + w("//\n") + w("// The scene keyframe rings, in the GL units the sky shaders consume.\n") + w("// See the generator for how each number is arrived at; the short\n") + w("// version is that a weather type declares how hazy, damp and dusty it\n") + w("// is and everything else follows from that plus the sun's elevation.\n") + w("\n") + w("import 'package:dpip/features/home/presentation/widgets/weather_sky/" + "sky_keyframe.dart';\n") + + for name, frames, haze, dust, damp, gain, blue, pin in WEATHERS: + rows = keyframes(name, frames, haze, dust, damp, gain, blue, pin) + w("\n/// %s — %d keyframes.\n" % (name, frames)) + w("const List %sKeyframes = [\n" % name) + for r in rows: + w(" SkyKeyframe(\n") + w(" time: %d,\n" % r["time"]) + w(" sunAngleY: %s,\n" % _num(r["sunAngleY"])) + w(" cameraYaw: %s,\n" % _num(r["cameraYaw"])) + w(" sunIntensity: %s,\n" % _num(r["sunIntensity"])) + w(" ozoneThickness: %d,\n" % r["ozoneThickness"]) + w(" postColor: (%s),\n" % ", ".join(str(v) for v in POST)) + for k in ("humidRayleighHeight", "humidMieScatter", "humidMieAbsorb", + "humidMieHeight", "humidMieAsymmetry"): + w(" %s: (%s, %s),\n" % (k, r[k][0], r[k][1])) + w(" ),\n") + w("];\n") + + w("\n/// All weather types, in weather-code order.\n") + w("const List> weatherKeyframes = [\n") + for name, *_ in WEATHERS: + w(" %sKeyframes,\n" % name) + w("];\n") + + +if __name__ == "__main__": + emit(sys.stdout) 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() From 39e7521777208dcd09dd64b4cf7cffaba8ef2397 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Sun, 9 Aug 2026 23:53:43 +0800 Subject: [PATCH 02/92] fix: background --- .../widgets/weather_sky/rain_on_glass.dart | 78 +- .../weather_sky/sky_keyframe_data.dart | 1560 +++++++++-------- .../widgets/weather_sky/sky_lut_cache.dart | 52 +- shaders/weather/rain_on_glass.frag | 313 ++-- test/shaders/weather_sky_shader_test.dart | 8 +- tool/gen_sky_keyframes.py | 237 --- 6 files changed, 982 insertions(+), 1266 deletions(-) delete mode 100644 tool/gen_sky_keyframes.py 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 index 034089a2d..73cf9e1db 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart @@ -180,22 +180,18 @@ class _RainOnGlassState extends State } } -/// The droplet field's uniforms for one rain grade. +/// One of the three droplet uniform sets for the card effect. /// -/// One ladder, walked by a single 0->1 grade, rather than a table of -/// hand-picked rows: every uniform here is monotone in how hard it is raining, -/// so the rows a table would hold are just this line sampled at three points. +/// 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. Light rain deposits a fine mist that -/// condenses everywhere and rarely gathers enough mass to run; heavy rain -/// arrives in drops that land big, merge fast, and slide almost at once. -/// [staticSize] and [runningSize] are **divisors** in the shader -/// (`uv * 40 / uStaticDropSize`), so a smaller value means a tighter lattice: -/// 0.55 gives a 14.9 device-px cell against 1.15's 31 px — four times the -/// beads at half the size. Pinning every rain mode to the heavy end, as an -/// earlier version did, is exactly the "too big, too sparse" that a light-rain -/// capture shows it should not be. +/// *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, @@ -206,19 +202,6 @@ class _GlassGrade { required this.runningSpeed, }); - /// The ladder itself: [grade] 0 is a fine mist, 1 is a storm. - factory _GlassGrade.atGrade(double grade) { - double at(double light, double heavy) => light + (heavy - light) * grade; - return _GlassGrade( - staticSize: at(0.55, 1.15), - staticAmount: at(0.55, 1.0), - staticSpeed: at(0.9, 2.2), - runningSize: at(0.9, 1.5), - runningAmount: at(0.5, 1.0), - runningSpeed: at(0.5, 1.4), - ); - } - final double staticSize; final double staticAmount; final double staticSpeed; @@ -226,24 +209,43 @@ class _GlassGrade { final double runningAmount; final double runningSpeed; - /// Light rain and sleet — fine, dense beads that mostly stay put. - static final _GlassGrade drizzle = _GlassGrade.atGrade(0.0); + /// 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 at the two ends. Named anyway because the ladder is - /// continuous and this is where a heavy-rain mode would land. - static final _GlassGrade steady = _GlassGrade.atGrade(0.5); + /// 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 beads that run almost at once. - static final _GlassGrade downpour = _GlassGrade.atGrade(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. - /// - /// Quantised to three rungs rather than interpolated continuously: the - /// intensity is scaled by the sheet's expansion, so a continuous mapping - /// would have the beads visibly resizing under the user's finger mid-drag. + /// 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; 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 index d90d4add5..5baa0e297 100644 --- a/lib/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart +++ b/lib/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart @@ -1,9 +1,11 @@ -// GENERATED by tool/gen_sky_keyframes.py — do not edit by hand. +// GENERATED — do not edit by hand. // -// The scene keyframe rings, in the GL units the sky shaders consume. -// See the generator for how each number is arrived at; the short -// version is that a weather type declares how hazy, damp and dusty it -// is and everything else follows from that plus the sun's elevation. +// 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'; @@ -11,224 +13,224 @@ import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe const List sunnyKeyframes = [ SkyKeyframe( time: 0, - sunAngleY: 0.0133, - cameraYaw: 1.8, - sunIntensity: 11.6, - ozoneThickness: 20000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + 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.0146, - cameraYaw: 1.8, - sunIntensity: 11.8, - ozoneThickness: 20000, + sunAngleY: 0.02, + cameraYaw: 4, + sunIntensity: 11, + ozoneThickness: 10000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (4000, 2000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 2, - sunAngleY: 0.0183, - cameraYaw: 1.8, - sunIntensity: 12.5, - ozoneThickness: 20000, + sunAngleY: 0.03, + cameraYaw: 3, + sunIntensity: 13, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (7000, 5000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 7000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 3, - sunAngleY: 0.0246, + sunAngleY: 0.05, cameraYaw: 2, - sunIntensity: 13.4, - ozoneThickness: 20000, + sunIntensity: 11, + ozoneThickness: 40000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + 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.0333, - cameraYaw: 2.1, - sunIntensity: 14.5, - ozoneThickness: 20000, + sunAngleY: 0.06, + cameraYaw: 0, + sunIntensity: 12, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + 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.1658, - cameraYaw: 4.5, - sunIntensity: 11.5, - ozoneThickness: 20000, + sunAngleY: 0.1, + cameraYaw: 1, + sunIntensity: 13, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + 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.3064, - cameraYaw: 6.4, - sunIntensity: 16.3, + sunAngleY: 0.2, + cameraYaw: 1, + sunIntensity: 13, ozoneThickness: 20000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 7, - sunAngleY: 0.4003, - cameraYaw: 7.2, - sunIntensity: 18.3, + sunAngleY: 0.4, + cameraYaw: 1, + sunIntensity: 17, ozoneThickness: 20000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 9000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 8, - sunAngleY: 0.4333, - cameraYaw: 7.4, - sunIntensity: 18.7, + sunAngleY: 0.5, + cameraYaw: 2, + sunIntensity: 19, ozoneThickness: 20000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 9000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 9, - sunAngleY: 0.4003, - cameraYaw: 7.2, - sunIntensity: 18.3, + sunAngleY: 0.33, + cameraYaw: 2, + sunIntensity: 15, ozoneThickness: 20000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 10, - sunAngleY: 0.3064, - cameraYaw: 6.4, - sunIntensity: 16.3, + sunAngleY: 0.13, + cameraYaw: 1, + sunIntensity: 14, ozoneThickness: 20000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (10000, 8000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 11, - sunAngleY: 0.1658, - cameraYaw: 4.5, - sunIntensity: 11.5, - ozoneThickness: 20000, + sunAngleY: 0.05, + cameraYaw: 1, + sunIntensity: 18, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (9000, 7000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 12, - sunAngleY: 0.0333, - cameraYaw: 2.1, - sunIntensity: 14.5, - ozoneThickness: 20000, + sunAngleY: 0.03, + cameraYaw: 1, + sunIntensity: 23, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (9000, 7000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 2000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 13, - sunAngleY: 0.0246, - cameraYaw: 2, - sunIntensity: 13.4, - ozoneThickness: 20000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + 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.0183, - cameraYaw: 1.8, - sunIntensity: 12.5, + sunAngleY: 0.015, + cameraYaw: 0, + sunIntensity: 15, ozoneThickness: 20000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + 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.0146, - cameraYaw: 1.8, - sunIntensity: 11.8, - ozoneThickness: 20000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + 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.0133, - cameraYaw: 1.8, - sunIntensity: 11.6, - ozoneThickness: 20000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8000, 6400), - humidMieScatter: (3.996e-06, 1.039e-05), - humidMieAbsorb: (4.400e-06, 7.040e-06), - humidMieHeight: (1488, 2158), - humidMieAsymmetry: (0, 0.45), + 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), ), ]; @@ -236,279 +238,279 @@ const List sunnyKeyframes = [ const List cloudyKeyframes = [ SkyKeyframe( time: 0, - sunAngleY: 0.0133, - cameraYaw: 1.8, - sunIntensity: 10.6, - ozoneThickness: 20500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + 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.0146, - cameraYaw: 1.8, - sunIntensity: 10.8, - ozoneThickness: 20500, + sunAngleY: 0.02, + cameraYaw: 4, + sunIntensity: 11, + ozoneThickness: 10000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (4000, 2000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 2, - sunAngleY: 0.0183, - cameraYaw: 1.8, - sunIntensity: 11.5, - ozoneThickness: 20500, + sunAngleY: 0.03, + cameraYaw: 3, + sunIntensity: 13, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (7000, 5000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 7000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 3, - sunAngleY: 0.0246, + sunAngleY: 0.05, cameraYaw: 2, - sunIntensity: 12.4, - ozoneThickness: 20500, + sunIntensity: 11, + ozoneThickness: 40000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + 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.0333, - cameraYaw: 2.1, - sunIntensity: 13.3, - ozoneThickness: 20500, + sunAngleY: 0.06, + cameraYaw: 0, + sunIntensity: 12, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + 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.1658, - cameraYaw: 4.5, - sunIntensity: 10.6, - ozoneThickness: 20500, + sunAngleY: 0.1, + cameraYaw: 1, + sunIntensity: 13, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + 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.3064, - cameraYaw: 6.4, - sunIntensity: 15, - ozoneThickness: 20500, + sunAngleY: 0.2, + cameraYaw: 1, + sunIntensity: 13, + ozoneThickness: 20000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 3000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 7, - sunAngleY: 0.4003, - cameraYaw: 7.2, - sunIntensity: 16.8, - ozoneThickness: 20500, + sunAngleY: 0.4, + cameraYaw: 1, + sunIntensity: 17, + ozoneThickness: 20000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 9000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 8, - sunAngleY: 0.4333, - cameraYaw: 7.4, - sunIntensity: 17.2, - ozoneThickness: 20500, + sunAngleY: 0.5, + cameraYaw: 2, + sunIntensity: 19, + ozoneThickness: 20000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (0, 2e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 9000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 9, - sunAngleY: 0.4003, - cameraYaw: 7.2, - sunIntensity: 16.8, - ozoneThickness: 20500, + sunAngleY: 0.33, + cameraYaw: 2, + sunIntensity: 15, + ozoneThickness: 20000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (8000, 6000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 10, - sunAngleY: 0.3064, - cameraYaw: 6.4, - sunIntensity: 15, - ozoneThickness: 20500, + sunAngleY: 0.13, + cameraYaw: 1, + sunIntensity: 14, + ozoneThickness: 20000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (10000, 8000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 11, - sunAngleY: 0.1658, - cameraYaw: 4.5, - sunIntensity: 10.6, - ozoneThickness: 20500, + sunAngleY: 0.05, + cameraYaw: 1, + sunIntensity: 18, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (9000, 7000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 4000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 12, - sunAngleY: 0.0333, - cameraYaw: 2.1, - sunIntensity: 13.3, - ozoneThickness: 20500, + sunAngleY: 0.03, + cameraYaw: 1, + sunIntensity: 23, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + humidRayleighHeight: (9000, 7000), + humidMieScatter: (3e-06, 7e-06), + humidMieAbsorb: (0, 2e-06), + humidMieHeight: (0, 2000), + humidMieAsymmetry: (0, 0.1), ), SkyKeyframe( time: 13, - sunAngleY: 0.0246, - cameraYaw: 2, - sunIntensity: 12.4, - ozoneThickness: 20500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + 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.0183, - cameraYaw: 1.8, - sunIntensity: 11.5, - ozoneThickness: 20500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + 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.0146, - cameraYaw: 1.8, - sunIntensity: 10.8, - ozoneThickness: 20500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + 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.0133, - cameraYaw: 1.8, - sunIntensity: 10.6, - ozoneThickness: 20500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (8400, 6720), - humidMieScatter: (6.553e-06, 1.704e-05), - humidMieAbsorb: (4.664e-06, 7.462e-06), - humidMieHeight: (1776, 2575), - humidMieAsymmetry: (0, 0.45), + 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: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 3.8, - ozoneThickness: 28500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (11600, 11600), - humidMieScatter: (3.772e-05, 3.772e-05), - humidMieAbsorb: (1.003e-05, 1.003e-05), - humidMieHeight: (2280, 2280), + 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: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 9.9, - ozoneThickness: 28500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (11600, 11600), - humidMieScatter: (3.772e-05, 3.772e-05), - humidMieAbsorb: (1.003e-05, 1.003e-05), - humidMieHeight: (2280, 2280), + 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: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 9.9, - ozoneThickness: 28500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (11600, 11600), - humidMieScatter: (3.772e-05, 3.772e-05), - humidMieAbsorb: (1.003e-05, 1.003e-05), - humidMieHeight: (2280, 2280), + 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: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 3.8, - ozoneThickness: 28500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (11600, 11600), - humidMieScatter: (3.772e-05, 3.772e-05), - humidMieAbsorb: (1.003e-05, 1.003e-05), - humidMieHeight: (2280, 2280), + 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), ), ]; @@ -516,55 +518,55 @@ const List overcastKeyframes = [ /// foggy — 4 keyframes. const List foggyKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.7, - ozoneThickness: 24500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (4400, 4400), - humidMieScatter: (2.214e-05, 2.214e-05), - humidMieAbsorb: (7.348e-06, 7.348e-06), - humidMieHeight: (2640, 2640), + 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: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 12.4, - ozoneThickness: 24500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (4400, 4400), - humidMieScatter: (2.214e-05, 2.214e-05), - humidMieAbsorb: (7.348e-06, 7.348e-06), - humidMieHeight: (2640, 2640), + 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: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 12.4, - ozoneThickness: 24500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (4400, 4400), - humidMieScatter: (2.214e-05, 2.214e-05), - humidMieAbsorb: (7.348e-06, 7.348e-06), - humidMieHeight: (2640, 2640), + 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: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.7, - ozoneThickness: 24500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (4400, 4400), - humidMieScatter: (2.214e-05, 2.214e-05), - humidMieAbsorb: (7.348e-06, 7.348e-06), - humidMieHeight: (2640, 2640), + 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), ), ]; @@ -572,55 +574,55 @@ const List foggyKeyframes = [ /// smoggy — 4 keyframes. const List smoggyKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, + time: 4, + sunAngleY: 0.2, + cameraYaw: 0, sunIntensity: 4, - ozoneThickness: 29000, + ozoneThickness: 8000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (13600, 13600), - humidMieScatter: (4.412e-05, 4.412e-05), - humidMieAbsorb: (2.790e-05, 2.790e-05), - humidMieHeight: (1992, 1992), + humidRayleighHeight: (10000, 10000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (1e-05, 1e-05), + humidMieHeight: (10000, 10000), humidMieAsymmetry: (0, 0), ), SkyKeyframe( - time: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 10.5, - ozoneThickness: 29000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (13600, 13600), - humidMieScatter: (4.412e-05, 4.412e-05), - humidMieAbsorb: (2.790e-05, 2.790e-05), - humidMieHeight: (1992, 1992), + 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: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 10.5, - ozoneThickness: 29000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (13600, 13600), - humidMieScatter: (4.412e-05, 4.412e-05), - humidMieAbsorb: (2.790e-05, 2.790e-05), - humidMieHeight: (1992, 1992), + 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: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, + time: 15, + sunAngleY: 0.2, + cameraYaw: 0, sunIntensity: 4, - ozoneThickness: 29000, + ozoneThickness: 8000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (13600, 13600), - humidMieScatter: (4.412e-05, 4.412e-05), - humidMieAbsorb: (2.790e-05, 2.790e-05), - humidMieHeight: (1992, 1992), + humidRayleighHeight: (10000, 10000), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (1e-05, 1e-05), + humidMieHeight: (10000, 10000), humidMieAsymmetry: (0, 0), ), ]; @@ -628,55 +630,55 @@ const List smoggyKeyframes = [ /// rainyLight — 4 keyframes. const List rainyLightKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.5, - ozoneThickness: 23000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (12800, 12800), - humidMieScatter: (1.678e-05, 1.678e-05), - humidMieAbsorb: (5.720e-06, 5.720e-06), - humidMieHeight: (2064, 2064), + 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: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 11.8, - ozoneThickness: 23000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (12800, 12800), - humidMieScatter: (1.678e-05, 1.678e-05), - humidMieAbsorb: (5.720e-06, 5.720e-06), - humidMieHeight: (2064, 2064), + 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: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 11.8, - ozoneThickness: 23000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (12800, 12800), - humidMieScatter: (1.678e-05, 1.678e-05), - humidMieAbsorb: (5.720e-06, 5.720e-06), - humidMieHeight: (2064, 2064), + 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: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.5, - ozoneThickness: 23000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (12800, 12800), - humidMieScatter: (1.678e-05, 1.678e-05), - humidMieAbsorb: (5.720e-06, 5.720e-06), - humidMieHeight: (2064, 2064), + 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), ), ]; @@ -684,55 +686,55 @@ const List rainyLightKeyframes = [ /// rainyMedium — 4 keyframes. const List rainyMediumKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.1, - ozoneThickness: 26000, + time: 4, + sunAngleY: 0.2, + cameraYaw: 5, + sunIntensity: 4, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (12000, 12000), - humidMieScatter: (2.829e-05, 2.829e-05), - humidMieAbsorb: (6.908e-06, 6.908e-06), - humidMieHeight: (2064, 2064), + humidRayleighHeight: (8000, 8000), + humidMieScatter: (3e-05, 3e-05), + humidMieAbsorb: (3.8e-05, 3.8e-05), + humidMieHeight: (2000, 2000), humidMieAsymmetry: (0, 0), ), SkyKeyframe( - time: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 10.9, - ozoneThickness: 26000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (12000, 12000), - humidMieScatter: (2.829e-05, 2.829e-05), - humidMieAbsorb: (6.908e-06, 6.908e-06), - humidMieHeight: (2064, 2064), + 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: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 10.9, - ozoneThickness: 26000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (12000, 12000), - humidMieScatter: (2.829e-05, 2.829e-05), - humidMieAbsorb: (6.908e-06, 6.908e-06), - humidMieHeight: (2064, 2064), + 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: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.1, - ozoneThickness: 26000, + time: 15, + sunAngleY: 0.2, + cameraYaw: 5, + sunIntensity: 4, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (12000, 12000), - humidMieScatter: (2.829e-05, 2.829e-05), - humidMieAbsorb: (6.908e-06, 6.908e-06), - humidMieHeight: (2064, 2064), + humidRayleighHeight: (8000, 8000), + humidMieScatter: (3e-05, 3e-05), + humidMieAbsorb: (3.8e-05, 3.8e-05), + humidMieHeight: (2000, 2000), humidMieAsymmetry: (0, 0), ), ]; @@ -740,55 +742,55 @@ const List rainyMediumKeyframes = [ /// rainyHeavy — 4 keyframes. const List rainyHeavyKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 3.7, - ozoneThickness: 27500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (11600, 11600), - humidMieScatter: (3.540e-05, 3.540e-05), - humidMieAbsorb: (1.087e-05, 1.087e-05), - humidMieHeight: (2064, 2064), + 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: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 9.7, - ozoneThickness: 27500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (11600, 11600), - humidMieScatter: (3.540e-05, 3.540e-05), - humidMieAbsorb: (1.087e-05, 1.087e-05), - humidMieHeight: (2064, 2064), + 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: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 9.7, - ozoneThickness: 27500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (11600, 11600), - humidMieScatter: (3.540e-05, 3.540e-05), - humidMieAbsorb: (1.087e-05, 1.087e-05), - humidMieHeight: (2064, 2064), + 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: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 3.7, - ozoneThickness: 27500, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (11600, 11600), - humidMieScatter: (3.540e-05, 3.540e-05), - humidMieAbsorb: (1.087e-05, 1.087e-05), - humidMieHeight: (2064, 2064), + 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), ), ]; @@ -796,55 +798,55 @@ const List rainyHeavyKeyframes = [ /// rainyExtreme — 4 keyframes. const List rainyExtremeKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 2.9, - ozoneThickness: 23000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (10400, 10400), - humidMieScatter: (1.550e-05, 1.550e-05), - humidMieAbsorb: (5.588e-06, 5.588e-06), - humidMieHeight: (2064, 2064), + 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: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 7.7, - ozoneThickness: 23000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (10400, 10400), - humidMieScatter: (1.550e-05, 1.550e-05), - humidMieAbsorb: (5.588e-06, 5.588e-06), - humidMieHeight: (2064, 2064), + 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: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 7.7, - ozoneThickness: 23000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (10400, 10400), - humidMieScatter: (1.550e-05, 1.550e-05), - humidMieAbsorb: (5.588e-06, 5.588e-06), - humidMieHeight: (2064, 2064), + 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: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 2.9, - ozoneThickness: 23000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (10400, 10400), - humidMieScatter: (1.550e-05, 1.550e-05), - humidMieAbsorb: (5.588e-06, 5.588e-06), - humidMieHeight: (2064, 2064), + 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), ), ]; @@ -852,55 +854,55 @@ const List rainyExtremeKeyframes = [ /// snowyLight — 4 keyframes. const List snowyLightKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.9, - ozoneThickness: 33000, + time: 4, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 4, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), humidRayleighHeight: (6000, 6000), - humidMieScatter: (5.514e-05, 5.514e-05), - humidMieAbsorb: (9.680e-06, 9.680e-06), - humidMieHeight: (2064, 2064), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), humidMieAsymmetry: (0, 0), ), SkyKeyframe( - time: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, + time: 5, + sunAngleY: 0.2, + cameraYaw: 6, sunIntensity: 13, - ozoneThickness: 33000, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (6000, 6000), - humidMieScatter: (5.514e-05, 5.514e-05), - humidMieAbsorb: (9.680e-06, 9.680e-06), - humidMieHeight: (2064, 2064), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), humidMieAsymmetry: (0, 0), ), SkyKeyframe( - time: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, + time: 12, + sunAngleY: 0.2, + cameraYaw: 6, sunIntensity: 13, - ozoneThickness: 33000, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (6000, 6000), - humidMieScatter: (5.514e-05, 5.514e-05), - humidMieAbsorb: (9.680e-06, 9.680e-06), - humidMieHeight: (2064, 2064), + humidRayleighHeight: (7000, 7000), + humidMieScatter: (6e-05, 6e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), humidMieAsymmetry: (0, 0), ), SkyKeyframe( - time: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.9, - ozoneThickness: 33000, + time: 15, + sunAngleY: 0.2, + cameraYaw: 6, + sunIntensity: 4, + ozoneThickness: 30000, postColor: (1, 0, 0, 0, 1), humidRayleighHeight: (6000, 6000), - humidMieScatter: (5.514e-05, 5.514e-05), - humidMieAbsorb: (9.680e-06, 9.680e-06), - humidMieHeight: (2064, 2064), + humidMieScatter: (4e-05, 4e-05), + humidMieAbsorb: (0, 0), + humidMieHeight: (2000, 2000), humidMieAsymmetry: (0, 0), ), ]; @@ -908,55 +910,55 @@ const List snowyLightKeyframes = [ /// snowyMedium — 4 keyframes. const List snowyMediumKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.6, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (6400, 6400), - humidMieScatter: (6.282e-05, 6.282e-05), - humidMieAbsorb: (1.047e-05, 1.047e-05), - humidMieHeight: (2064, 2064), + 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: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 12.1, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (6400, 6400), - humidMieScatter: (6.282e-05, 6.282e-05), - humidMieAbsorb: (1.047e-05, 1.047e-05), - humidMieHeight: (2064, 2064), + 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: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 12.1, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (6400, 6400), - humidMieScatter: (6.282e-05, 6.282e-05), - humidMieAbsorb: (1.047e-05, 1.047e-05), - humidMieHeight: (2064, 2064), + 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: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.6, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (6400, 6400), - humidMieScatter: (6.282e-05, 6.282e-05), - humidMieAbsorb: (1.047e-05, 1.047e-05), - humidMieHeight: (2064, 2064), + 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), ), ]; @@ -964,55 +966,55 @@ const List snowyMediumKeyframes = [ /// snowyHeavy — 4 keyframes. const List snowyHeavyKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.2, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (5200, 5200), - humidMieScatter: (6.282e-05, 6.282e-05), - humidMieAbsorb: (1.047e-05, 1.047e-05), - humidMieHeight: (2064, 2064), + 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: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 11.2, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (5200, 5200), - humidMieScatter: (6.282e-05, 6.282e-05), - humidMieAbsorb: (1.047e-05, 1.047e-05), - humidMieHeight: (2064, 2064), + 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: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 11.2, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (5200, 5200), - humidMieScatter: (6.282e-05, 6.282e-05), - humidMieAbsorb: (1.047e-05, 1.047e-05), - humidMieHeight: (2064, 2064), + 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: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 4.2, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (5200, 5200), - humidMieScatter: (6.282e-05, 6.282e-05), - humidMieAbsorb: (1.047e-05, 1.047e-05), - humidMieHeight: (2064, 2064), + 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), ), ]; @@ -1020,55 +1022,55 @@ const List snowyHeavyKeyframes = [ /// sandyHeavy — 4 keyframes. const List sandyHeavyKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.3667, - cameraYaw: 7, - sunIntensity: 9.8, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (24000, 24000), - humidMieScatter: (9.127e-05, 9.127e-05), - humidMieAbsorb: (6.072e-05, 6.072e-05), - humidMieHeight: (1344, 1344), + 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: 1, - sunAngleY: 0.3667, - cameraYaw: 7, - sunIntensity: 25.7, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (24000, 24000), - humidMieScatter: (9.127e-05, 9.127e-05), - humidMieAbsorb: (6.072e-05, 6.072e-05), - humidMieHeight: (1344, 1344), + 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: 2, - sunAngleY: 0.3667, - cameraYaw: 7, - sunIntensity: 25.7, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (24000, 24000), - humidMieScatter: (9.127e-05, 9.127e-05), - humidMieAbsorb: (6.072e-05, 6.072e-05), - humidMieHeight: (1344, 1344), + 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: 3, - sunAngleY: 0.3667, - cameraYaw: 7, - sunIntensity: 9.8, - ozoneThickness: 34000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (24000, 24000), - humidMieScatter: (9.127e-05, 9.127e-05), - humidMieAbsorb: (6.072e-05, 6.072e-05), - humidMieHeight: (1344, 1344), + 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), ), ]; @@ -1076,55 +1078,55 @@ const List sandyHeavyKeyframes = [ /// sandyLight — 4 keyframes. const List sandyLightKeyframes = [ SkyKeyframe( - time: 0, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 6.1, - ozoneThickness: 33000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (16800, 16800), - humidMieScatter: (5.730e-05, 5.730e-05), - humidMieAbsorb: (1.958e-05, 1.958e-05), - humidMieHeight: (2496, 2496), + 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: 1, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 16.1, - ozoneThickness: 33000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (16800, 16800), - humidMieScatter: (5.730e-05, 5.730e-05), - humidMieAbsorb: (1.958e-05, 1.958e-05), - humidMieHeight: (2496, 2496), + 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: 2, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 16.1, - ozoneThickness: 33000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (16800, 16800), - humidMieScatter: (5.730e-05, 5.730e-05), - humidMieAbsorb: (1.958e-05, 1.958e-05), - humidMieHeight: (2496, 2496), + 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: 3, - sunAngleY: 0.1889, - cameraYaw: 4.9, - sunIntensity: 6.1, - ozoneThickness: 33000, - postColor: (1, 0, 0, 0, 1), - humidRayleighHeight: (16800, 16800), - humidMieScatter: (5.730e-05, 5.730e-05), - humidMieAbsorb: (1.958e-05, 1.958e-05), - humidMieHeight: (2496, 2496), + 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), ), ]; 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 index e79a6bf30..5bcbe3ef6 100644 --- a/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart +++ b/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart @@ -178,44 +178,32 @@ class SkyLutCache { } } -/// The two rays that bound the visible band of sky: the screen's top edge and -/// its bottom edge, as directions from the eye. +/// The view frustum's top and bottom rays, as the engine builds them. /// -/// The backdrop deliberately shows a **narrow slice of sky just above the -/// horizon**, not a dome. That is the framing a card backdrop wants: the -/// horizon band is the most saturated, busiest part of the sky and putting it -/// on screen fights the content in front of it, while a slice taken a little -/// higher gives a clean gradient that text sits on. [_spanDeg] and -/// [_baseElevationDeg] state that choice directly, in degrees. +/// Two properties here are load-bearing for the look and are kept deliberately: /// -/// There is no perspective divide and no aspect correction to do: the sky has -/// no azimuthal variation at all — `sky_view.frag` resolves every pixel in a -/// row to the same colour — so only the vertical extent means anything, and -/// the screen's width cannot enter into it. +/// • 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}); -/// Vertical extent of the visible band, degrees. -const double _spanDeg = 19.264; - -/// Elevation of the band's centre at `cameraYaw == 0`, degrees above the -/// horizon. -const double _baseElevationDeg = 9.386; - -/// How much of a keyframe's `cameraYaw` reaches the band's centre. Slightly -/// under 1: the keyframes tilt the whole scene, and the sky takes most but not -/// all of that tilt. -const double _yawGain = 0.962; - /// Builds the frustum for a keyframe's `cameraYaw` (degrees). -/// -/// The rays are unit vectors in the x/y plane, `x` forward and `y` up, so -/// `sky_view.frag`'s `normalize(mix(top, bottom, uv.y))` walks the band -/// linearly from one edge to the other. Frustum buildFrustum(double cameraYawDeg) { - final centre = (_baseElevationDeg + _yawGain * cameraYawDeg) * math.pi / 180; - final half = (_spanDeg / 2) * math.pi / 180; + 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: [math.cos(centre + half), math.sin(centre + half), 0.0], - bottom: [math.cos(centre - half), math.sin(centre - half), 0.0], + 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/shaders/weather/rain_on_glass.frag b/shaders/weather/rain_on_glass.frag index 62f9d244c..0be987f25 100644 --- a/shaders/weather/rain_on_glass.frag +++ b/shaders/weather/rain_on_glass.frag @@ -1,52 +1,43 @@ // Rain on the glass — droplets clinging to a card's face, refracting it. // -// Applied as a `ui.ImageFilter.shader` over the card's own pixels, so what -// bends is the content itself: text visibly warps behind a bead rather than -// having a droplet drawn flat on top of 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. // -// The field is built from two populations, which is what water on a vertical -// pane actually does: +// 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` // -// • **Beads** condense in place on a jittered lattice, grow quickly, hold, -// and evaporate slowly. The asymmetry matters — a symmetric envelope makes -// the whole field pulse in step, which reads as a flicker rather than as -// weather. Each bead runs its own clock, seeded from its cell. -// • **Runners** are beads that grew heavy enough to break their pinning and -// slide. One track per column; a runner clings, releases, accelerates, and -// drags a thinning ribbon behind it that pinches off into a line of shed -// droplets. The ribbon and the shed beads are unioned, not added, because -// the tail is one body of water breaking up, not two overlapping ones. +// 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 two populations are combined with a **soft union** (`max` plus a -// fraction of `min`) rather than a sum: surface tension merges two touching -// drops into one larger drop, it does not stack their heights. -// -// Refraction is the field's own gradient. The coverage mask is sampled three -// times — here, one step in x, one step in y — and the pair `(c-cx, c-cy)` is -// the surface's slope, which offsets the texture lookup. There is no lighting -// term at all; the displacement IS the effect, and it is large: 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. -// -// The coordinate frame is a **fixed 1080x1080** ([uResolution]), not the -// filtered widget. Every bead size and the refraction displacement are -// computed against that frame in device pixels, so a bead is the same physical -// size on every card in the app. Deriving the frame from the filtered widget's +// 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 and they stop reading as water. The engine-fed input size ([uSize]) is -// used only to normalise the texture lookups. +// 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 1080x1080 frame, in device pixels +// uResolution (2..3) the fixed 1080×1080 frame, in device pixels // uTime (4) animation time (seconds) -// uStaticDropSize (5) bead lattice scale — a DIVISOR, larger = fewer, -// bigger beads -// uStaticDropAmount (6) bead coverage -// uStaticDropSpeed (7) how fast beads condense and evaporate -// uRunningDropSize (8) runner column width — also a divisor -// uRunningDropAmount(9) runner coverage -// uRunningDropSpeed (10) how fast runners slide +// 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 @@ -67,126 +58,96 @@ uniform sampler2D uTex; out vec4 fragColor; -/// Runner tracks across the fixed frame's width, at `uRunningDropSize` 1. -const float kColumns = 14.0; - -/// How far down the frame a runner travels, in frame heights. -const float kRunSpan = 1.25; - -/// Runner releases per second at `uRunningDropSpeed` 1. -/// -/// This and [kRunSpan] are the two halves of one speed, and setting only one -/// of them is how the first version came out four times too fast: a full-frame -/// traverse at one release per second crosses a card in a tenth of a second, -/// which reads as a streak rather than as a drop. At the rate below a runner -/// takes about half a second to cross a 120pt card in a downpour and a couple -/// of seconds in drizzle, which is what water on glass actually does. -const float kRunRate = 0.21; - -/// Bead condense-and-evaporate cycles per second at `uStaticDropSpeed` 1. -/// Condensation is slow — a bead that appears and is gone inside a second -/// reads as a flicker, not as water gathering. -const float kBeadRate = 0.15; - -/// Where a runner releases, in frame units. Nothing of its tail exists above -/// this line. -const float kReleaseY = 0.62; - -/// Three uncorrelated values from a lattice cell. -vec3 hash32(vec2 p) { - vec3 q = vec3(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)), - dot(p, vec2(419.2, 371.9))); - return fract(sin(q) * 43758.5453123); +/// 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); } -/// Condensed beads on a jittered lattice. -float beadLayer(vec2 uv, float t) { - vec2 g = uv * (40.0 / uStaticDropSize); - vec2 cell = floor(g); - vec2 f = fract(g) - 0.5; - vec3 h = hash32(cell); - - // Condense fast, hold, evaporate slowly. Offsetting each bead's clock by its - // own cell hash is what keeps the lattice from breathing as one sheet. - float life = fract(t * uStaticDropSpeed * kBeadRate + h.z); - float envelope = - smoothstep(0.0, 0.10, life) * (1.0 - smoothstep(0.42, 1.0, life)); - - // Jitter is bounded so a bead cannot reach its cell's edge — one cell is - // sampled, not a neighbourhood, and a bead crossing the boundary would clip. - vec2 centre = (h.xy - 0.5) * 0.46; - float radius = (0.11 + 0.17 * fract(h.z * 7.31)) * envelope; - float d = length(f - centre); - // Nearly flat-topped with a quick shoulder: a spherical cap held by surface - // tension, not a gaussian blob. - return smoothstep(radius, radius * 0.45, d); +/// 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); } -/// A runner and the tail it drags, for the column containing [uv]. -float runLayer(vec2 uv, float t) { - float cellW = uRunningDropSize / kColumns; - float col = floor(uv.x / cellW); - float xu = uv.x - (col + 0.5) * cellW; - vec3 h = hash32(vec2(col, 11.7)); - - // Successive drops down one track, staggered per column so no two columns - // release together. The index is wrapped: it feeds a hash, and an unbounded - // argument to `sin` loses precision long before the app is closed. - float k = t * uRunningDropSpeed * kRunRate * (0.6 + 0.8 * h.x) + h.y; - vec3 hd = hash32(vec2(col, mod(floor(k), 64.0))); - float u = fract(k); - - // Cling, release, accelerate, and slow again as the tail drains. A linear - // descent reads as a machine part sliding on a rail. - float travel = u * u * (1.6 - 0.6 * u); - float headY = kReleaseY - travel * kRunSpan; - - float dx = xu - (hd.x - 0.5) * cellW * 0.5; - float dy = uv.y - headY; // > 0 is above the head, i.e. the tail - - float rHead = cellW * (0.18 + 0.13 * hd.y); - // Taller than wide: a drop being dragged is stretched along its travel. - float head = smoothstep(rHead, rHead * 0.40, length(vec2(dx, dy * 0.82))); - - // The tail exists only behind the head and only below the release point — - // a runner leaves a wet track, it does not paint the column it came from. - float inTrack = step(0.0, dy) * step(uv.y, kReleaseY); - // And it is SHORT. Tapering by a fraction of the total travel keeps the - // trail full width however far the drop has gone, which silhouettes as a - // hard vertical bar — the one shape here that reads as a machine part. - float taper = exp(-dy / (rHead * 7.0)); - - // A rounded ridge, not a flat-topped ribbon: `smoothstep(r, 0, |dx|)` peaks - // at the centre line and reaches zero at the edge. - // Faded by `taper` in AMPLITUDE as well as width. Width alone is not - // enough: `smoothstep(r, 0, |dx|)` is exactly 1 on the centre line for any - // r > 0, so a ribbon that only narrows leaves a permanent one-pixel hairline - // running the full height of the track. - float ribbonR = rHead * 0.34 * taper; - float ribbon = smoothstep(ribbonR, 0.0, abs(dx)) * inTrack * taper; - - // The track pinches off into a line of beads rather than staying a filament, - // each smaller and more scattered the further back it is. - float pitch = rHead * 2.4; - float sIdx = floor(dy / pitch); - float sY = fract(dy / pitch) - 0.5; - vec3 hs = hash32(vec2(col * 7.0 + sIdx, floor(k))); - float shedR = 0.42 * taper * (0.6 + 0.5 * hs.x); - float sX = dx - (hs.y - 0.5) * rHead * 0.6; - float shed = - smoothstep(shedR, shedR * 0.30, length(vec2(sX / (rHead * 1.2), sY))) * - inTrack; - - return head + max(ribbon, shed); +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; } -/// The droplet coverage mask, 0 (dry) to 1. -float coverage(vec2 uv, float t, float beadGain) { - float b = beadLayer(uv, t) * uStaticDropAmount * beadGain; - float r = runLayer(uv, t) * uRunningDropAmount; - // Soft union: two touching drops merge into one, they do not stack. - float merged = max(b, r) + 0.35 * min(b, r); - return smoothstep(0.22, 0.92, merged); +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() { @@ -194,40 +155,40 @@ void main() { // 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 - // runners then slide *up* the glass. Worse, the mirror is about `uSize.y/2` - // while the field is scaled by the fixed frame, so it also translates the - // lattice by `uSize.y - uResolution.y`. Keep the two coordinates separate. + // 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 - // The field's frame: centred, normalised by the fixed frame's height, and - // with y increasing upward so a runner's descent is a decrease. + // All in the fixed 1080-frame, exactly as shipped. + vec2 UV = xy / uResolution; vec2 uv = (xy - 0.5 * uResolution) / uResolution.y; - uv.y = -uv.y; + uv.y = 1.0 - uv.y; - // Fade the whole effect in, so it does not appear fully formed. float alpha = smoothstep(0.0, 2.0, uTime); - // Wrapped well inside single-precision range; the hashes are seeded by cell, - // not by time, so nothing is lost at the seam. - float t = mod(uTime, 600.0); + // 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; - // Beads thin out toward the top of the frame, where a card's own edge sheds - // water fastest. A flat field over the whole card reads as frosted glass. - float beadGain = 0.75 + 0.35 * smoothstep(0.45, -0.35, uv.y); + vec4 dropN = GenDropLayerN(uv, t); + vec3 staticN = GenStaticLayerN(uv); + float c = Drops(uv, dropN.xyz, staticN, t, staticDrops, rainAmount, dropN.w); - float c = coverage(uv, t, beadGain); if (c > 0.001) { - // A true 2-D gradient, so a bead refracts radially the way a lens does. - vec2 e = vec2(0.0006, 0.0); - float cx = coverage(uv + e.xy, t, beadGain); - float cy = coverage(uv + e.yx, t, beadGain); - vec2 n = vec2(c - cx, c - cy); - - // `n` is in frame units, so the displacement is `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. + 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); diff --git a/test/shaders/weather_sky_shader_test.dart b/test/shaders/weather_sky_shader_test.dart index ed6cf1183..538f69991 100644 --- a/test/shaders/weather_sky_shader_test.dart +++ b/test/shaders/weather_sky_shader_test.dart @@ -55,8 +55,8 @@ void main() { asset: 'shaders/sky/sky_view.frag', floats: [ 200, 300, // uResolution - 0.9214, 0.3886, 0.0, // uFrustumA - 0.9980, 0.0628, 0.0, // uFrustumC + 0.0985, 0.0416, 0.0, // uFrustumA + 0.1010, 0.0064, 0.0, // uFrustumC 0.5, // uSunAngleY 7.0, // uTime 16, 16, // uLutSize @@ -82,8 +82,8 @@ void main() { 0.0, 0.0, // fog, inner 0.1, // iWhitePer 16, 16, // iLutSize - 0.9214, 0.3886, 0.0, // iFrustumA - 0.9980, 0.0628, 0.0, // iFrustumC + 0.0985, 0.0416, 0.0, // iFrustumA + 0.1010, 0.0064, 0.0, // iFrustumC 256, 288, // iTexSize ], samplers: 2, diff --git a/tool/gen_sky_keyframes.py b/tool/gen_sky_keyframes.py deleted file mode 100644 index 0ef93c76a..000000000 --- a/tool/gen_sky_keyframes.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the sky's keyframe rings as a Dart source file. - -WHAT A KEYFRAME IS ------------------- -The sky is not simulated per frame. Two lookup tables are baked whenever the -weather or the time slot changes (`shaders/sky/transmittance.frag` then -`sky_lut.frag`), and the screen pass just reads them. A keyframe is the set of -inputs to that bake: where the sun is, how bright it is, and what the air is -made of. `sky_keyframe.dart` holds the contract; this file holds the content. - -Clear and cloudy skies carry a full 17-point ring around the day. Everything -else carries four points — night, day, day, night — because a thick deck -flattens the sky into something that barely changes with the sun, and four -points say that more honestly than seventeen near-identical ones would. - -HOW THE NUMBERS ARE ARRIVED AT ------------------------------- -Nothing here is hand-typed per keyframe. Each weather type declares its -*intent* — how much haze, how damp, how much dust, how much light gets through -— and the medium is derived from that: - - * The **dry baseline** is the standard Earth atmosphere from Hillaire, "A - Scalable and Production Ready Sky and Atmosphere Rendering Technique" - (EGSR 2020): Mie scattering 3.996e-6 /m, Mie absorption 4.4e-6 /m, aerosol - scale height 1200 m. The Rayleigh coefficients and the planet radii are the - same paper's and live in `SkyConstants`. - * **Haze and dust** multiply the aerosol terms. Haze scatters far more than - it absorbs, which is why fog is bright; dust does the reverse, which is why - a sand sky is dim and brown. - * **Dampness** lifts the aerosol layer (`mieHeight`) and, in this shader, - `rayleighHeight` — which is the knob that decides how much blue survives to - the top of the frame rather than being a physical scale height. It is named - for what it does here, not for the atmosphere. - * The **sun's elevation** is a sine over the daylight half of the ring. It is - the sky LUT's u coordinate directly (`sunAngleY = elevation / 180`), since - the LUT's u axis sweeps the sun from one horizon to the other. - -`sunIntensity` is deliberately NOT proportional to elevation: it carries a -dawn/dusk boost, because the interesting sky is the low-sun one and a straight -`sin(elevation)` makes it the dullest moment of the day. - -Run: `python3 tool/gen_sky_keyframes.py > ` then `dart format` -(no dependencies). The generator does not try to match dartfmt itself. -""" - -import math -import sys - -# --- the dry baseline ------------------------------------------------------- -MIE_SCATTER_DRY = 3.996e-6 -MIE_ABSORB_DRY = 4.4e-6 -MIE_HEIGHT_DRY = 1200.0 -# What `rayleighHeight` means here: how far up the blue survives. The clear-sky -# value is the real scale height; the rest of the range is art direction. -RAYLEIGH_HEIGHT_CLEAR = 8000.0 - -# --- the day ---------------------------------------------------------------- -RING = 17 # keyframes in a full-day ring -DAY_START, DAY_END = 4, 12 # indices the sun is above the horizon between -PEAK_ELEVATION = 78.0 # degrees at local noon -TWILIGHT_ELEVATION = 2.4 # degrees held through the night, so dusk has a floor - -# How far the night frames of a four-point ring are dimmed against their day -# frames. The sky is the same sky; only the light changes. -NIGHT_GAIN = 0.38 - -# Ozone half-thickness (m). Real enough at the clear end; thickening it drains -# the last of the blue, which is what a heavy overcast wants. -OZONE_CLEAR = 20000.0 - -# Weather types, in the order `weatherKeyframes` publishes them. -# -# haze aerosol load that scatters (cloud, fog, spray) -# dust aerosol load that absorbs (sand, smog) -# damp how far the scattering layer is lifted -# gain how much sunlight reaches the top of the atmosphere at all -# blue how much of the Rayleigh blue is kept -# pin for four-point rings: the elevation the sky is pinned to, degrees -WEATHERS = [ - # name frames haze dust damp gain blue pin - ("sunny", RING, 0.00, 0.00, 0.20, 1.00, 1.00, None), - ("cloudy", RING, 0.20, 0.00, 0.40, 0.92, 1.05, None), - ("overcast", 4, 2.60, 0.10, 0.75, 0.80, 1.45, 34.0), - ("foggy", 4, 1.40, 0.05, 1.00, 1.00, 0.55, 34.0), - ("smoggy", 4, 2.80, 0.90, 0.55, 0.85, 1.70, 34.0), - ("rainyLight", 4, 1.00, 0.00, 0.60, 0.95, 1.60, 34.0), - ("rainyMedium", 4, 1.90, 0.00, 0.60, 0.88, 1.50, 34.0), - ("rainyHeavy", 4, 2.40, 0.15, 0.60, 0.78, 1.45, 34.0), - ("rainyExtreme", 4, 0.90, 0.00, 0.60, 0.62, 1.30, 34.0), - ("snowyLight", 4, 4.00, 0.00, 0.60, 1.05, 0.75, 34.0), - ("snowyMedium", 4, 4.60, 0.00, 0.60, 0.98, 0.80, 34.0), - ("snowyHeavy", 4, 4.60, 0.00, 0.60, 0.90, 0.65, 34.0), - ("sandyHeavy", 4, 6.00, 2.20, 0.10, 1.45, 3.00, 66.0), - ("sandyLight", 4, 4.00, 0.45, 0.90, 1.30, 2.10, 34.0), -] - -# The tonemap `sky_lut.frag` applies inside the bake, as (a, b, c, d, e) in -# `(x*(a*x + b)) / (x*(c*x + d) + e)`. These give `x^2 / (x + 1)`: near-square -# in the shadows so the night sky stays dark, asymptotically linear so a bright -# horizon does not clip. -POST = (1, 0, 0, 0, 1) - - -def elevation(i, frames, pin): - """Sun elevation in degrees at ring index [i].""" - if pin is not None: - return pin - if i <= DAY_START or i >= DAY_END: - # Night. Ease down to the floor and back so dusk is not a step. - edge = min(abs(i - DAY_START), abs(i - DAY_END), 4) / 4.0 - return TWILIGHT_ELEVATION + (6.0 - TWILIGHT_ELEVATION) * (1.0 - edge) ** 2 - phase = (i - DAY_START) / (DAY_END - DAY_START) - return max(TWILIGHT_ELEVATION, PEAK_ELEVATION * math.sin(math.pi * phase)) - - -def sun_intensity(elev, gain): - """Sun radiance multiplier. - - Rises with elevation, but with a dawn/dusk term on top: the low-sun sky is - the one worth looking at, and a plain `sin(elevation)` makes it the dullest - part of the day. - """ - s = math.sin(math.radians(elev)) - low = math.exp(-((elev - 7.0) / 9.0) ** 2) # peaks just above the horizon - return round((4.0 + 15.0 * s + 9.0 * low) * gain, 1) - - -def medium(haze, dust, damp, blue): - """The five `(dry, wet)` ramps, from one weather's intent.""" - # Haze scatters far more than it absorbs — that is why fog is bright and - # smog is not. - scatter = MIE_SCATTER_DRY * (1.0 + 3.2 * haze + 1.2 * dust) - absorb = MIE_ABSORB_DRY * (1.0 + 0.3 * haze + 5.0 * dust) - height = MIE_HEIGHT_DRY * (1.0 + 1.2 * damp) - rayleigh = RAYLEIGH_HEIGHT_CLEAR * blue - - def ramp(dry, wet_factor): - # Humidity only has room to move on a sky that is not already saturated - # with water; a rainy ring's two ends are the same number. - wet = dry * wet_factor if haze < 0.5 else dry - return (_num(dry), _num(wet)) - - return dict( - humidRayleighHeight=ramp(rayleigh, 0.80), - humidMieScatter=ramp(scatter, 2.60), - humidMieAbsorb=ramp(absorb, 1.60), - humidMieHeight=ramp(height, 1.45), - # Aerosols scatter forward; the clear sky's remaining haze barely does. - humidMieAsymmetry=(_num(0.0), _num(0.45 if haze < 0.5 else 0.0)), - ) - - -def _num(v): - """Format a float the way Dart wants it, without trailing noise.""" - if v == 0: - return "0" - if abs(v) >= 1000: - return "%d" % round(v) - if abs(v) < 1e-3: - return "%.3e" % v - return ("%.4f" % v).rstrip("0").rstrip(".") - - -def keyframes(name, frames, haze, dust, damp, gain, blue, pin): - out = [] - med = medium(haze, dust, damp, blue) - # Ozone absorbs hardest in the GREEN (see `SkyConstants.ozoneAbsorb`), so - # this is a narrow knob, not a dimmer: run the tent much past the real - # layer's thickness and the sky loses its green channel and goes maroon. - ozone = min(34000.0, OZONE_CLEAR * (1.0 + 0.16 * haze)) - for i in range(frames): - if pin is None: - elev = elevation(i, frames, None) - night = 1.0 - else: - # A four-point ring is night, day, day, night — and its sun does - # NOT move. Under a deck thick enough to need this ring you cannot - # see where the sun is; the sky is lit diffusely and night is the - # same sky, dimmer. Letting the elevation fall to the horizon for - # the night frames instead makes every overcast midnight a sunset, - # which is a red sky under a rain cloud. - elev = pin - night = 1.0 if i % 3 else NIGHT_GAIN - # The band lifts a little as the sun climbs, so a bright sky shows more - # of its own gradient and a dark one sits closer to the horizon. - yaw = round(1.5 + 6.0 * math.sin(math.radians(elev)), 1) - out.append(dict( - time=i, - sunAngleY=round(elev / 180.0, 4), - cameraYaw=yaw, - sunIntensity=sun_intensity(elev, gain * night), - ozoneThickness=int(round(ozone / 500.0) * 500), - **med, - )) - return out - - -def emit(stream): - w = stream.write - w("// GENERATED by tool/gen_sky_keyframes.py — do not edit by hand.\n") - w("//\n") - w("// The scene keyframe rings, in the GL units the sky shaders consume.\n") - w("// See the generator for how each number is arrived at; the short\n") - w("// version is that a weather type declares how hazy, damp and dusty it\n") - w("// is and everything else follows from that plus the sun's elevation.\n") - w("\n") - w("import 'package:dpip/features/home/presentation/widgets/weather_sky/" - "sky_keyframe.dart';\n") - - for name, frames, haze, dust, damp, gain, blue, pin in WEATHERS: - rows = keyframes(name, frames, haze, dust, damp, gain, blue, pin) - w("\n/// %s — %d keyframes.\n" % (name, frames)) - w("const List %sKeyframes = [\n" % name) - for r in rows: - w(" SkyKeyframe(\n") - w(" time: %d,\n" % r["time"]) - w(" sunAngleY: %s,\n" % _num(r["sunAngleY"])) - w(" cameraYaw: %s,\n" % _num(r["cameraYaw"])) - w(" sunIntensity: %s,\n" % _num(r["sunIntensity"])) - w(" ozoneThickness: %d,\n" % r["ozoneThickness"]) - w(" postColor: (%s),\n" % ", ".join(str(v) for v in POST)) - for k in ("humidRayleighHeight", "humidMieScatter", "humidMieAbsorb", - "humidMieHeight", "humidMieAsymmetry"): - w(" %s: (%s, %s),\n" % (k, r[k][0], r[k][1])) - w(" ),\n") - w("];\n") - - w("\n/// All weather types, in weather-code order.\n") - w("const List> weatherKeyframes = [\n") - for name, *_ in WEATHERS: - w(" %sKeyframes,\n" % name) - w("];\n") - - -if __name__ == "__main__": - emit(sys.stdout) From 01c64f709203ca882fe1a68081f1fb0744dcbcda Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 00:52:36 +0800 Subject: [PATCH 03/92] fix: lang --- lib/l10n/app_zh.arb | 2 +- lib/l10n/app_zh_Hans.arb | 2 +- lib/l10n/app_zh_TW.arb | 2 +- lib/l10n/gen/app_localizations_zh.dart | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cc2185eb8..7e0c31ba0 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": "資料", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 27f7aeacf..1ce7350fc 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": "资料", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 99475d9ac..f5ae5efc0 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": "資料", diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 54b9436a1..0adc4af08 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 => '事件'; @@ -1438,7 +1438,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get languageName => '简体中文'; @override - String get navHome => '首页'; + String get navHome => '主页'; @override String get navEvents => '事件'; @@ -4290,7 +4290,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get languageName => '繁體中文(臺灣)'; @override - String get navHome => '主頁'; + String get navHome => '首頁'; @override String get navEvents => '事件'; From 1b56b42523417c1b82e4836794817946a7516047 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 02:49:45 +0800 Subject: [PATCH 04/92] fix: background --- ...kotlin-compiler-3747455802650665087.salive | 0 lib/app/theme/app_glass.dart | 25 ++ .../home/presentation/pages/home_page.dart | 33 ++- .../widgets/home_active_events_section.dart | 2 +- .../presentation/widgets/home_content.dart | 278 ++++++++++++++---- .../widgets/home_forecast_section.dart | 2 +- .../widgets/home_rain_trend_section.dart | 5 +- .../home/presentation/widgets/home_sheet.dart | 74 ++++- .../widgets/home_sheet_header.dart | 13 +- .../widgets/weather_sky/card_water_field.dart | 178 ++++++++++- .../widgets/weather_sky/rain_on_card.dart | 179 +++++++++-- .../widgets/home_hero_preview_test.dart | 181 ++++++++++++ .../home_sheet_header_silhouette_test.dart | 175 +++++++++++ .../weather_sky/card_water_field_test.dart | 208 +++++++++++++ 14 files changed, 1236 insertions(+), 117 deletions(-) create mode 100644 android/.kotlin/sessions/kotlin-compiler-3747455802650665087.salive create mode 100644 test/features/home/presentation/widgets/home_hero_preview_test.dart create mode 100644 test/features/home/presentation/widgets/home_sheet_header_silhouette_test.dart diff --git a/android/.kotlin/sessions/kotlin-compiler-3747455802650665087.salive b/android/.kotlin/sessions/kotlin-compiler-3747455802650665087.salive new file mode 100644 index 000000000..e69de29bb diff --git a/lib/app/theme/app_glass.dart b/lib/app/theme/app_glass.dart index ab2e2db61..74f9a7b22 100644 --- a/lib/app/theme/app_glass.dart +++ b/lib/app/theme/app_glass.dart @@ -85,6 +85,13 @@ Color glassOnSurfaceVariant( /// 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 || @@ -97,6 +104,24 @@ bool weatherSkyIsLight(WeatherMode mode) => switch (mode) { 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/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 8d04db313..b4da41d61 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -9,6 +9,7 @@ import 'package:dpip/features/home/presentation/home_weather_controller.dart'; import 'package:dpip/features/home/presentation/home_reset_signal.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'; @@ -162,18 +163,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), - ), - ), + ), ), ], ), 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 ed28dae78..7b4755c9e 100644 --- a/lib/features/home/presentation/widgets/home_active_events_section.dart +++ b/lib/features/home/presentation/widgets/home_active_events_section.dart @@ -52,7 +52,7 @@ class HomeActiveEventsSection extends StatelessWidget { // 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 = weatherSkyIsLight(weatherMode); + final skyIsLight = skyIsLightFrom(sky, weatherMode); final foreground = glassOnSurface( colors, reveal: reveal, diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index da48ce16d..88a485c82 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -9,6 +9,7 @@ import 'package:dpip/features/home/presentation/widgets/home_active_events_secti 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/home/presentation/widgets/weather_sky/sky_lut_cache.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -21,10 +22,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); 所在地 with no GPS fix shows the -/// header's "can't locate you" notice **alone** — no cards at all. +/// 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, @@ -87,70 +95,212 @@ class HomeContent extends StatelessWidget { bool showWeather, Color? sky, ) { - 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, - 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), - ), - const SizedBox(height: AppSpacing.lg), - 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, - ), - ], + // 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. + // + // MediaQuery.paddingOf(context).bottom is *not* the device's real bottom + // safe area here: MainShell's Scaffold(extendBody: true) overrides it for + // this whole subtree to the bottom-nav-bar's reserved height instead (114 + // logical px, measured on a real device — the bug that previously made + // this leave a stray gap under the trend card). MediaQueryData.fromView + // reads straight off the platform view, bypassing that override, and + // gives back the one inset that's real in every state the hero block is + // ever in: the home indicator. + final bottomSafeArea = MediaQueryData.fromView( + View.of(context), + ).padding.bottom; + 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, + child: Column( + key: ValueKey(areaIndex), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (heroHeight != null) + SizedBox( + height: heroHeight, + // Only the trailing gap depends on scroll offset, so + // that's the only thing this rebuilds per tick — the + // header/trend-card subtree below is built once and + // handed through as ListenableBuilder's child, same + // split `HomeSheet`'s own `_ScrollBlurredWeather` uses. + child: ListenableBuilder( + listenable: scrollController, + 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. + intensity: _cardRain(weatherMode), + opacity: reveal, + glass: false, + silhouette: true, + // Pinned at the top of the block, not rising + // into the scroll-position cut-off the way the + // trend card does — see [RainOnCard.gated]. + 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()), + 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), + ), + ], + ), + // Deflates the tight SizedBox height passed to the + // Column above, 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. bottomSafeArea is the + // load-bearing part (always reserved, never ramped + // away); _heroBottomGap is the small extra on top + // that collapses once the sheet actually scrolls. + builder: (context, child) => Padding( + padding: EdgeInsets.only( + bottom: + bottomSafeArea + + _heroBottomGap(scrollController), + ), + child: child, + ), + ), + ) + 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). + if (heroHeight != null) ...[ + 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. Small: this is a + /// nicety on top of a reserve that's already doing the load-bearing work, + /// not a second one of the same size. + static const double _restBottomGap = AppSpacing.xs; + + /// Scroll distance over which [_restBottomGap] collapses to 0. Short on + /// purpose, same reasoning as `HomeSheet._ScrollBlurredWeather`'s own ramp: + /// this is a resting-state nicety, not something to keep paying for once + /// the sheet is actually moving — it should be gone within the first small + /// swipe rather than riding up the screen as a dead gap. + static const double _bottomGapRampExtent = 32; + + /// Current value of [_restBottomGap] for [controller]'s live scroll offset. + static double _heroBottomGap(ScrollController controller) { + final offset = controller.hasClients ? controller.offset : 0.0; + final t = (offset / _bottomGapRampExtent).clamp(0.0, 1.0); + return _restBottomGap * (1 - t); + } + /// 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. diff --git a/lib/features/home/presentation/widgets/home_forecast_section.dart b/lib/features/home/presentation/widgets/home_forecast_section.dart index 9898dc01c..4e8ecded7 100644 --- a/lib/features/home/presentation/widgets/home_forecast_section.dart +++ b/lib/features/home/presentation/widgets/home_forecast_section.dart @@ -58,7 +58,7 @@ class _HomeForecastSectionState extends State { final colors = theme.colorScheme; final controller = context.watch(); final reveal = widget.reveal; - final skyIsLight = weatherSkyIsLight(widget.weatherMode); + final skyIsLight = skyIsLightFrom(widget.sky, widget.weatherMode); final foreground = glassOnSurface( colors, reveal: reveal, 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 e06fe5433..f071ca241 100644 --- a/lib/features/home/presentation/widgets/home_rain_trend_section.dart +++ b/lib/features/home/presentation/widgets/home_rain_trend_section.dart @@ -49,7 +49,7 @@ class HomeRainTrendSection extends StatelessWidget { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - final skyIsLight = weatherSkyIsLight(weatherMode); + final skyIsLight = skyIsLightFrom(sky, weatherMode); final foreground = glassOnSurface( colors, reveal: reveal, @@ -72,6 +72,9 @@ class HomeRainTrendSection extends StatelessWidget { // 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, diff --git a/lib/features/home/presentation/widgets/home_sheet.dart b/lib/features/home/presentation/widgets/home_sheet.dart index 2319cc065..0ac5513b3 100644 --- a/lib/features/home/presentation/widgets/home_sheet.dart +++ b/lib/features/home/presentation/widgets/home_sheet.dart @@ -134,10 +134,13 @@ class HomeSheet extends StatelessWidget { IgnorePointer( child: Opacity( opacity: weatherOpacity, - child: WeatherSkyBackground( - mode: weatherMode, - timeMode: skyTimeMode, - active: HomeChrome.weatherActive(e), + child: _ScrollBlurredWeather( + scrollController: scrollController, + child: WeatherSkyBackground( + mode: weatherMode, + timeMode: skyTimeMode, + active: HomeChrome.weatherActive(e), + ), ), ), ), @@ -156,6 +159,69 @@ class HomeSheet extends StatelessWidget { ((e - _flushFrom) / (maxExtent - _flushFrom)).clamp(0.0, 1.0); } +/// Blurs [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. +/// +/// 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; + + @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); + // Skip the filter layer entirely at rest, rather than compositing an + // always-on blur of sigma 0 — the sheet spends most of its time here. + if (t <= 0) return child!; + final sigma = _maxSigma * t; + return ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: child, + ); + }, + ); + } +} + /// 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 diff --git a/lib/features/home/presentation/widgets/home_sheet_header.dart b/lib/features/home/presentation/widgets/home_sheet_header.dart index 69a141be5..8d67d33c9 100644 --- a/lib/features/home/presentation/widgets/home_sheet_header.dart +++ b/lib/features/home/presentation/widgets/home_sheet_header.dart @@ -28,6 +28,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 +37,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); 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 index 130b04b95..0ec9f6e1f 100644 --- a/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart +++ b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart @@ -34,6 +34,13 @@ import 'package:flutter/material.dart'; /// `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. /// @@ -81,7 +88,8 @@ class CardWaterField { required double screenHeight, CardWaterPreset preset = CardWaterPreset.moderate, double topEdge = 0, - }) => _emit(width, screenHeight / _worldHeight, preset, topEdge); + CardWaterSurface? surface, + }) => _emit(width, screenHeight / _worldHeight, preset, topEdge, surface); @visibleForTesting List get debugPositions => [ @@ -99,11 +107,16 @@ class CardWaterField { /// 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}) { + 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++) { - if (_p.y[i] > topEdge - band) n++; + final edge = surface?.heightAt(_p.x[i]) ?? topEdge; + if (_p.y[i] > edge - band) n++; } return n; } @@ -154,7 +167,8 @@ class CardWaterField { /// /// [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. + /// 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, @@ -163,6 +177,7 @@ class CardWaterField { 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. @@ -183,7 +198,7 @@ class CardWaterField { _stepIndex++; // the emitter: one group every `interval` ticks, nothing in between. if (_stepIndex % preset.interval == 0 && intensity > 0.001) { - _emit(width, unit, preset, topEdge); + _emit(width, unit, preset, topEdge, surface); } // Five particle iterations per world step, each on `_worldStep/5`. @@ -192,7 +207,7 @@ class CardWaterField { // (viscous, powder, tensile, rigid) removed. const sub = _worldStep / _particleIterations; for (var it = 0; it < _particleIterations; it++) { - _solveIteration(sub, unit, preset, topEdge, gravityScale); + _solveIteration(sub, unit, preset, topEdge, gravityScale, surface); } for (var i = 0; i < _live; i++) { @@ -234,12 +249,24 @@ class CardWaterField { 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++) { @@ -257,12 +284,12 @@ class CardWaterField { _p.vy[i] += gravity * dt; } - // Contacts. Screen y grows downward, so "above the edge" is y < topEdge - // and the signed surface distance is topEdge - y. + // 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 = topEdge - _p.y[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]; @@ -348,12 +375,12 @@ class CardWaterField { // 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] >= topEdge || _p.vy[i] <= 0) continue; + if (_p.y[i] >= edge[i] || _p.vy[i] <= 0) continue; final travel = _p.vy[i] * dt; - final frac = (topEdge - _p.y[i]) / travel; + 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 = topEdge - slop; + final newY = edge[i] - slop; final oldVx = _p.vx[i]; final oldVy = _p.vy[i]; _p.vx[i] = invDt * (newX - _p.x[i]); @@ -473,9 +500,24 @@ class CardWaterField { double unit, CardWaterPreset preset, double topEdge, + CardWaterSurface? surface, ) { - final originX = _random.nextDouble() * width; - final originY = topEdge - spawnHeight * unit; + 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; @@ -656,6 +698,114 @@ class CardWaterPreset { ); } +/// 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), 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 index 89eba2d54..14a0654e4 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -7,16 +7,18 @@ import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_lut_cach 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: droplets clinging to its face, and water caught and -/// pooling along its top edge. +/// 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`). +/// [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: /// @@ -38,16 +40,50 @@ class RainOnCard extends StatefulWidget { 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 it is refracted through the - /// droplets on its face. + /// 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 @@ -91,11 +127,67 @@ class _RainOnCardState extends State 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 { @@ -153,6 +245,14 @@ class _RainOnCardState extends State (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(); + } } void _syncRunning() { @@ -173,22 +273,44 @@ class _RainOnCardState extends State } /// The position gate: the panel water switches off entirely once the card - /// has been scrolled up into the top fifth of the screen. + /// 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. /// - /// The reference feeds the panel's on-screen y into a comparison against a - /// fixed reference height — the water stops once `y` drops below 30 % of it. - /// That height defaults to 1714 against a 2400 px screen, so the cut-off - /// lands at 21.4 % of the screen height. + /// 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 _gateReferenceHeight = 1714 / 2400; - static const double _gateFraction = 0.3; + 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 @@ -218,6 +340,7 @@ class _RainOnCardState extends State ? _scrollGravityScale : 1.0; final open = + !widget.gated || _cardTopY! >= _gateFraction * _gateReferenceHeight * _screenHeight; if (open == _gateOpen) return; setState(() => _gateOpen = open); @@ -235,7 +358,12 @@ class _RainOnCardState extends State _syncPositionGate(); - final intensity = widget.intensity.clamp(0.0, 1.0); + // 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, @@ -244,6 +372,7 @@ class _RainOnCardState extends State intensity: intensity, preset: grade.$1, gravityScale: _gravityScale, + surface: surface, ); _secondary.update( dt, @@ -252,6 +381,7 @@ class _RainOnCardState extends State intensity: intensity, preset: grade.$2, gravityScale: _gravityScale, + surface: surface, ); _frame.value++; @@ -330,16 +460,27 @@ class _RainOnCardState extends State 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: [ - RainOnGlass( - intensity: widget.intensity, - opacity: widget.opacity, - active: widget.active, - child: widget.child, - ), + // 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, + active: widget.active, + child: content, + ) + : content, Positioned.fill( child: IgnorePointer( child: RepaintBoundary( 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..883e3258e --- /dev/null +++ b/test/features/home/presentation/widgets/home_hero_preview_test.dart @@ -0,0 +1,181 @@ +@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/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(); +} + +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(), + 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_sheet_header_silhouette_test.dart b/test/features/home/presentation/widgets/home_sheet_header_silhouette_test.dart new file mode 100644 index 000000000..d44f1cf3e --- /dev/null +++ b/test/features/home/presentation/widgets/home_sheet_header_silhouette_test.dart @@ -0,0 +1,175 @@ +/// 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/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(); +} + +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(), + 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/weather_sky/card_water_field_test.dart b/test/features/home/weather_sky/card_water_field_test.dart index 1c5cded84..01714cb11 100644 --- a/test/features/home/weather_sky/card_water_field_test.dart +++ b/test/features/home/weather_sky/card_water_field_test.dart @@ -1,4 +1,5 @@ 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'; @@ -22,6 +23,7 @@ void _run( 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( @@ -30,6 +32,7 @@ void _run( screenHeight: _screen, intensity: intensity, preset: preset, + surface: surface, ); } } @@ -386,4 +389,209 @@ void main() { ); 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); + }); + }); } From 01ec52afad8878784651463f2d46cbfd3f199e56 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 03:10:51 +0800 Subject: [PATCH 05/92] fix: android crash --- .../kotlin-compiler-3747455802650665087.salive | 0 android/app/build.gradle.kts | 2 +- .../src/main/kotlin/com/exptech/dpip/MainActivity.kt | 12 ++++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) delete mode 100644 android/.kotlin/sessions/kotlin-compiler-3747455802650665087.salive diff --git a/android/.kotlin/sessions/kotlin-compiler-3747455802650665087.salive b/android/.kotlin/sessions/kotlin-compiler-3747455802650665087.salive deleted file mode 100644 index e69de29bb..000000000 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 From 869b51107f3d9eca36a5995708e6f77e14a1859f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 03:10:59 +0800 Subject: [PATCH 06/92] fix: background --- .../notifications/notification_service.dart | 8 ++- .../presentation/widgets/home_content.dart | 55 +++++++++++-------- .../widgets/weather_sky/rain_on_card.dart | 7 ++- 3 files changed, 45 insertions(+), 25 deletions(-) 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/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 88a485c82..3f1734954 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -172,10 +172,10 @@ class HomeContent extends StatelessWidget { opacity: reveal, glass: false, silhouette: true, - // Pinned at the top of the block, not rising - // into the scroll-position cut-off the way the - // trend card does — see [RainOnCard.gated]. - gated: false, + // Default gated: true — the header rises with + // the rest of the hero block once the sheet + // scrolls, so it closes the same short distance + // into the gesture the trend card now does. child: HomeSheetHeader( reveal: reveal, expanded: expanded, @@ -207,15 +207,16 @@ class HomeContent extends StatelessWidget { // Column above, 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. bottomSafeArea is the - // load-bearing part (always reserved, never ramped - // away); _heroBottomGap is the small extra on top - // that collapses once the sheet actually scrolls. + // 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. builder: (context, child) => Padding( padding: EdgeInsets.only( - bottom: - bottomSafeArea + - _heroBottomGap(scrollController), + bottom: _heroBottomGap( + scrollController, + bottomSafeArea, + ), ), child: child, ), @@ -282,23 +283,33 @@ class HomeContent extends StatelessWidget { /// 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. Small: this is a - /// nicety on top of a reserve that's already doing the load-bearing work, - /// not a second one of the same size. - static const double _restBottomGap = AppSpacing.xs; + /// doesn't read as glued to the very edge of the screen. + static const double _restBottomGap = AppSpacing.xl; - /// Scroll distance over which [_restBottomGap] collapses to 0. Short on + /// 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: - /// this is a resting-state nicety, not something to keep paying for once - /// the sheet is actually moving — it should be gone within the first small - /// swipe rather than riding up the screen as a dead gap. + /// 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 value of [_restBottomGap] for [controller]'s live scroll offset. - static double _heroBottomGap(ScrollController controller) { + /// Current size of the hero block's trailing reserve — [_restBottomGap] + /// plus [bottomSafeArea] — for [controller]'s live scroll offset. + static double _heroBottomGap(ScrollController controller, double bottomSafeArea) { + final rest = _restBottomGap + bottomSafeArea; final offset = controller.hasClients ? controller.offset : 0.0; final t = (offset / _bottomGapRampExtent).clamp(0.0, 1.0); - return _restBottomGap * (1 - t); + return rest * (1 - t); } /// How wet the rain-trend card gets for a given backdrop. 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 index 14a0654e4..17783ae6c 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -332,6 +332,10 @@ class _RainOnCardState extends State 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 = @@ -340,8 +344,7 @@ class _RainOnCardState extends State ? _scrollGravityScale : 1.0; final open = - !widget.gated || - _cardTopY! >= _gateFraction * _gateReferenceHeight * _screenHeight; + !widget.gated || _restTopY! - _cardTopY! < _gateCloseDistance; if (open == _gateOpen) return; setState(() => _gateOpen = open); } From 47dea660437e4bb55460c43b724faf6c507a09d9 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 03:28:06 +0800 Subject: [PATCH 07/92] fix: pin maplibre_gl to gzip fix --- pubspec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index d37aec46e..14a81bb90 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: d3deabfdbe8e99fa3f8c04247e4d404c8c22ecf9 package_info_plus: ^10.2.0 path_provider: ^2.1.6 provider: ^6.1.5+1 From ee5ec558407548297498794e94df545b4cef7180 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 16:28:41 +0800 Subject: [PATCH 08/92] fix: developer mode --- lib/core/settings/experimental_settings.dart | 20 ++++++++- lib/core/settings/preference_keys.dart | 7 +++ .../more/presentation/pages/more_page.dart | 14 +++--- .../presentation/pages/developer_page.dart | 43 ++++++++++++++++++- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/lib/core/settings/experimental_settings.dart b/lib/core/settings/experimental_settings.dart index a4dd317b4..2dab82ca6 100644 --- a/lib/core/settings/experimental_settings.dart +++ b/lib/core/settings/experimental_settings.dart @@ -9,10 +9,15 @@ 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, @@ -25,9 +30,22 @@ class ExperimentalSettings extends ChangeNotifier { 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. WeatherMode get weatherMode => _weatherMode.value; diff --git a/lib/core/settings/preference_keys.dart b/lib/core/settings/preference_keys.dart index 2d649380c..9628d4500 100644 --- a/lib/core/settings/preference_keys.dart +++ b/lib/core/settings/preference_keys.dart @@ -70,6 +70,13 @@ abstract final class PreferenceKeys { '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/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/settings/presentation/pages/developer_page.dart b/lib/features/settings/presentation/pages/developer_page.dart index e8e0f6751..e29860dd1 100644 --- a/lib/features/settings/presentation/pages/developer_page.dart +++ b/lib/features/settings/presentation/pages/developer_page.dart @@ -17,6 +17,7 @@ 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/shared/map/map_tile_cache.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:dpip/shared/widgets/section_header.dart'; @@ -63,6 +64,11 @@ class _DeveloperPageState extends State { List<({String title, List<_Field> fields})>? _sections; 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(); @@ -278,6 +284,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; @@ -321,6 +357,7 @@ class _DeveloperPageState extends State { for (final field in section.fields) _DiagRow( field: field, + onTap: field.label == 'Version' ? _onVersionTap : null, onCopy: _individuallyCopyableLabels.contains(field.label) ? _copy : null, @@ -359,17 +396,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 : '—', From 91bf45c5bee8f00d8830fe5eb8af2f877d6cbc7f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 17:06:18 +0800 Subject: [PATCH 09/92] fix: home page weather card --- .../home/presentation/home_chrome.dart | 16 +++++- .../home/presentation/pages/home_page.dart | 38 +++++++++++++ .../presentation/widgets/home_content.dart | 55 ++++++++++++++++--- .../home/presentation/widgets/home_sheet.dart | 34 ++++++++++-- 4 files changed, 125 insertions(+), 18 deletions(-) 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/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index b4da41d61..e46a6be78 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -1,3 +1,5 @@ +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'; @@ -43,6 +45,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(); @@ -128,6 +140,32 @@ 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); + if (t <= 0) return const SizedBox.shrink(); + final sigma = t * _mapBlurPeak; + final dim = t * _mapDimPeak; + return ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: sigma, + sigmaY: sigma, + ), + 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( diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 3f1734954..6a6faff8a 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -77,24 +77,58 @@ class HomeContent extends StatelessWidget { // 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; - // One listener for the whole panel: the sky colour only changes when the - // LUT re-bakes (a weather or time-slot change), and every card below tints - // itself from the same value the way the reference samples one gradient stop for all - // of them. - return ValueListenableBuilder( - valueListenable: SkyLutCache.panelAmbient, - builder: (context, sky, _) => - _build(context, areaIndex, located, showWeather, sky), + // Scroll and the sky bake change at very different rates and both feed the + // cards — the sky re-bakes rarely, the scroll focus dial moves on every + // tick — so merge the two listenables rather than nesting two builders + // (the inner ValueListenableBuilder would skip rebuilding on scroll, since + // the sky colour did not change, and the focus dial would stall). + return ListenableBuilder( + listenable: Listenable.merge([ + scrollController, + SkyLutCache.panelAmbient, + ]), + builder: (context, _) { + final sky = SkyLutCache.panelAmbient.value; + final offset = scrollController.hasClients + ? scrollController.offset + : 0.0; + return _build( + context, + areaIndex, + located, + showWeather, + sky, + _focus(offset), + ); + }, ); } + /// 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, + double focus, ) { + // 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); // 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 @@ -305,7 +339,10 @@ class HomeContent extends StatelessWidget { /// Current size of the hero block's trailing reserve — [_restBottomGap] /// plus [bottomSafeArea] — for [controller]'s live scroll offset. - static double _heroBottomGap(ScrollController controller, double bottomSafeArea) { + static double _heroBottomGap( + ScrollController controller, + double bottomSafeArea, + ) { final rest = _restBottomGap + bottomSafeArea; final offset = controller.hasClients ? controller.offset : 0.0; final t = (offset / _bottomGapRampExtent).clamp(0.0, 1.0); diff --git a/lib/features/home/presentation/widgets/home_sheet.dart b/lib/features/home/presentation/widgets/home_sheet.dart index 0ac5513b3..c190ec9d0 100644 --- a/lib/features/home/presentation/widgets/home_sheet.dart +++ b/lib/features/home/presentation/widgets/home_sheet.dart @@ -159,12 +159,19 @@ class HomeSheet extends StatelessWidget { ((e - _flushFrom) / (maxExtent - _flushFrom)).clamp(0.0, 1.0); } -/// Blurs [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 +/// 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 @@ -199,6 +206,11 @@ class _ScrollBlurredWeather extends StatelessWidget { /// 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( @@ -213,9 +225,19 @@ class _ScrollBlurredWeather extends StatelessWidget { // always-on blur of sigma 0 — the sheet spends most of its time here. if (t <= 0) return child!; final sigma = _maxSigma * t; - return ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), - child: child, + final dim = _maxDim * t; + return Stack( + fit: StackFit.expand, + children: [ + ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + 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. + ColoredBox(color: Colors.black.withValues(alpha: dim)), + ], ); }, ); From 3246c080121470745529077c7facf6575dcc0cb6 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 17:36:23 +0800 Subject: [PATCH 10/92] fix: home page weather icon --- lib/core/weather/weather_condition.dart | 101 ++++++++++++++++++ .../home/presentation/pages/home_page.dart | 14 ++- .../widgets/forecast_weather_visual.dart | 42 -------- .../widgets/home_forecast_section.dart | 4 +- .../widgets/home_sheet_header.dart | 16 ++- test/core/weather/weather_condition_test.dart | 52 +++++++++ .../widgets/forecast_weather_visual_test.dart | 32 ------ 7 files changed, 180 insertions(+), 81 deletions(-) create mode 100644 lib/core/weather/weather_condition.dart delete mode 100644 lib/features/home/presentation/widgets/forecast_weather_visual.dart create mode 100644 test/core/weather/weather_condition_test.dart delete mode 100644 test/features/home/presentation/widgets/forecast_weather_visual_test.dart diff --git a/lib/core/weather/weather_condition.dart b/lib/core/weather/weather_condition.dart new file mode 100644 index 000000000..1224bf901 --- /dev/null +++ b/lib/core/weather/weather_condition.dart @@ -0,0 +1,101 @@ +/// 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: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, // 有雷 +}; + +/// 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); +} + +/// Icon + accent for a forecast point's [weather] text and [weatherCode]. +/// +/// Codes are authoritative ([weatherModeFor]); the text only comes into play +/// when the code is missing (`0`), where the old substring matching (雷/雪/雨/ +/// 晴/雲/陰) is the only signal left. +(IconData, Color?) weatherVisual( + String weather, + int weatherCode, + ColorScheme 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 => (Icons.water_drop_outlined, 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), + }; +} + +/// 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 (Icons.water_drop_outlined, colors.primary); + } + if (weather.contains('晴')) { + return (Icons.wb_sunny_outlined, colors.tertiary); + } + return (Icons.cloud_outlined, colors.onSurfaceVariant); +} diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index e46a6be78..ed2ae1dda 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -4,6 +4,8 @@ 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'; @@ -101,8 +103,18 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { final experimental = context.watch(); - final weatherMode = experimental.weatherMode; final skyTimeMode = experimental.skyTimeMode; + // A forced experimental mode wins outright; otherwise the backdrop follows + // the station's live CWB code. 全國 / 所在地 without GPS / still loading + // have no reading yet, so they resolve to the `auto` default look. + final realtimeCode = context + .watch() + .weather + ?.data + .weatherCode; + final weatherMode = experimental.weatherMode == WeatherMode.auto + ? weatherModeFor(realtimeCode ?? 0) + : experimental.weatherMode; final extent = context.read(); return RefreshOnAppear( tabIndex: HomePage.tabIndex, 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_forecast_section.dart b/lib/features/home/presentation/widgets/home_forecast_section.dart index 4e8ecded7..93c4d8cfc 100644 --- a/lib/features/home/presentation/widgets/home_forecast_section.dart +++ b/lib/features/home/presentation/widgets/home_forecast_section.dart @@ -9,7 +9,7 @@ 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:flutter/material.dart'; @@ -192,7 +192,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, diff --git a/lib/features/home/presentation/widgets/home_sheet_header.dart b/lib/features/home/presentation/widgets/home_sheet_header.dart index 8d67d33c9..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'; @@ -76,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. @@ -149,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( @@ -204,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/test/core/weather/weather_condition_test.dart b/test/core/weather/weather_condition_test.dart new file mode 100644 index 000000000..0fe6700e5 --- /dev/null +++ b/test/core/weather/weather_condition_test.dart @@ -0,0 +1,52 @@ +import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/core/weather/weather_condition.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('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, Icons.water_drop_outlined); + expect(weatherVisual('陰', 208, colors).$1, Icons.ac_unit_outlined); + expect(weatherVisual('晴', 105, colors).$1, Icons.blur_on_outlined); + }); + + 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('a missing code falls back to substring matching', () { + expect(weatherVisual('午後雷陣雨', 0, colors).$1, Icons.thunderstorm_outlined); + expect(weatherVisual('短暫雨', 0, colors).$1, Icons.water_drop_outlined); + 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/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); - }); -} From 42589dcc9db8b3e100b7306a4b30461faf2ebe93 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 17:47:24 +0800 Subject: [PATCH 11/92] fix: home page weather background --- lib/core/weather/weather_condition.dart | 30 +++++++++++++++++++ .../home/presentation/pages/home_page.dart | 19 +++++++----- .../home/presentation/widgets/home_sheet.dart | 17 +++++++++++ .../weather_sky/weather_sky_background.dart | 20 +++++++++++-- test/core/weather/weather_condition_test.dart | 22 ++++++++++++++ 5 files changed, 98 insertions(+), 10 deletions(-) diff --git a/lib/core/weather/weather_condition.dart b/lib/core/weather/weather_condition.dart index 1224bf901..d73e957c5 100644 --- a/lib/core/weather/weather_condition.dart +++ b/lib/core/weather/weather_condition.dart @@ -57,6 +57,36 @@ WeatherMode weatherModeFor(int code) { 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 ([weatherModeFor]); the text only comes into play diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index ed2ae1dda..51cb9978f 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -104,17 +104,17 @@ class _HomePageState extends State { Widget build(BuildContext context) { final experimental = context.watch(); final skyTimeMode = experimental.skyTimeMode; - // A forced experimental mode wins outright; otherwise the backdrop follows - // the station's live CWB code. 全國 / 所在地 without GPS / still loading - // have no reading yet, so they resolve to the `auto` default look. - final realtimeCode = context - .watch() - .weather - ?.data - .weatherCode; + // 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 realtimeCode = data?.weatherCode; final weatherMode = experimental.weatherMode == WeatherMode.auto ? weatherModeFor(realtimeCode ?? 0) : experimental.weatherMode; + final rainIntensity = weatherRainIntensity(realtimeCode ?? 0); + final snowIntensity = weatherSnowIntensity(realtimeCode ?? 0); + final humidity = data?.humidity; final extent = context.read(); return RefreshOnAppear( tabIndex: HomePage.tabIndex, @@ -199,6 +199,9 @@ class _HomePageState extends State { extent: extent, weatherMode: weatherMode, skyTimeMode: skyTimeMode, + rainIntensity: rainIntensity, + snowIntensity: snowIntensity, + humidity: humidity == null ? null : humidity / 100, ), ), ), diff --git a/lib/features/home/presentation/widgets/home_sheet.dart b/lib/features/home/presentation/widgets/home_sheet.dart index c190ec9d0..17917654d 100644 --- a/lib/features/home/presentation/widgets/home_sheet.dart +++ b/lib/features/home/presentation/widgets/home_sheet.dart @@ -40,6 +40,9 @@ class HomeSheet extends StatelessWidget { 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 @@ -72,6 +75,17 @@ class HomeSheet extends StatelessWidget { /// 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) { @@ -138,6 +152,9 @@ class HomeSheet extends StatelessWidget { scrollController: scrollController, child: WeatherSkyBackground( mode: weatherMode, + rainIntensity: rainIntensity, + snowIntensity: snowIntensity, + humidity: humidity ?? 0.65, timeMode: skyTimeMode, active: HomeChrome.weatherActive(e), ), 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 index 6c8b10228..4da9e373b 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart @@ -39,6 +39,14 @@ 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; @@ -58,6 +66,8 @@ class WeatherSkyBackground extends StatefulWidget { const WeatherSkyBackground({ super.key, this.mode = WeatherMode.auto, + this.rainIntensity, + this.snowIntensity, this.active = true, this.latitude = kTaiwanLatitude, this.longitude = kTaiwanLongitude, @@ -371,6 +381,12 @@ class _WeatherSkyBackgroundState extends State 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. @@ -400,8 +416,8 @@ class _WeatherSkyBackgroundState extends State sky: sky, cloudLayout: look.layout, cloudCoverage: look.coverage, - rain: look.rain, - snow: look.snow, + rain: rain, + snow: snow, fog: look.fog, rainbow: look.rainbow, wind: look.wind, diff --git a/test/core/weather/weather_condition_test.dart b/test/core/weather/weather_condition_test.dart index 0fe6700e5..47040a69f 100644 --- a/test/core/weather/weather_condition_test.dart +++ b/test/core/weather/weather_condition_test.dart @@ -26,6 +26,28 @@ void main() { }); }); + 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. From 1e1eeeced2e0171e93f26090e5386885858a3955 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 22:22:57 +0800 Subject: [PATCH 12/92] feat: home sheet next-hour rain trend from rainforecast API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the home sheet's 近 1 小時降雨趨勢 card to GET /api/weather/rainforecast/{code} (exptech.dingbot.tw), decoding the start/60×mm envelope into RainHourTrend with loading/error states. --- api.md | 1 + lib/features/home/home_providers.dart | 2 + .../presentation/home_weather_controller.dart | 44 +++- .../widgets/home_rain_trend_section.dart | 208 +++++++++++------- .../weather/data/rain_hour_trend_api.dart | 26 +++ .../data/rain_hour_trend_repository_impl.dart | 21 ++ .../weather/domain/rain_hour_trend.dart | 37 +++- .../domain/rain_hour_trend_repository.dart | 15 ++ lib/features/weather/weather_providers.dart | 8 +- .../widgets/home_content_test.dart | 17 +- .../widgets/home_hero_preview_test.dart | 19 ++ .../home_sheet_header_silhouette_test.dart | 9 + .../presentation/widgets/home_sheet_test.dart | 16 +- .../weather/domain/rain_hour_trend_test.dart | 88 ++++++++ 14 files changed, 418 insertions(+), 93 deletions(-) create mode 100644 lib/features/weather/data/rain_hour_trend_api.dart create mode 100644 lib/features/weather/data/rain_hour_trend_repository_impl.dart create mode 100644 lib/features/weather/domain/rain_hour_trend_repository.dart create mode 100644 test/features/weather/domain/rain_hour_trend_test.dart diff --git a/api.md b/api.md index 288ef542a..b0a1b4060 100644 --- a/api.md +++ b/api.md @@ -183,6 +183,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]}]}`) | ## curl 可用性(2026-08-02,HTTP 狀態碼) 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_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/widgets/home_rain_trend_section.dart b/lib/features/home/presentation/widgets/home_rain_trend_section.dart index f071ca241..9606c5de2 100644 --- a/lib/features/home/presentation/widgets/home_rain_trend_section.dart +++ b/lib/features/home/presentation/widgets/home_rain_trend_section.dart @@ -5,15 +5,22 @@ 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/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:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; +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); a spinner shows while the first fetch is in flight and +/// an error + retry row on failure — never a fabricated chart. Falls back to +/// [RainHourTrend.placeholder] only when the controller has no data to report +/// at all (no township, pre-wiring state). class HomeRainTrendSection extends StatelessWidget { const HomeRainTrendSection({ super.key, @@ -27,7 +34,7 @@ class HomeRainTrendSection extends StatelessWidget { /// 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 @@ -61,11 +68,46 @@ class HomeRainTrendSection extends StatelessWidget { skyIsLight: skyIsLight, ); final cardColor = glassSurface(colors, reveal, sky: sky); - 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; + + final controller = context.watch(); + final data = trend ?? controller.hourTrend; + + final Widget body; + if (data != null) { + body = _Chart(data: data, barColor: colors.primary, secondary: secondary); + } else if (controller.loading) { + body = SizedBox( + height: 120, + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2, color: secondary), + ), + ), + ); + } else if (controller.hourTrendFailure != null) { + body = Row( + children: [ + Expanded( + child: Text( + l10n.homeForecastEmpty, + style: theme.textTheme.bodyMedium?.copyWith(color: secondary), + ), + ), + TextButton( + onPressed: controller.refresh, + child: Text(l10n.commonRetry), + ), + ], + ); + } else { + body = _Chart( + data: RainHourTrend.placeholder(), + barColor: colors.primary, + secondary: secondary, + ); + } return RainOnCard( intensity: rain, @@ -106,77 +148,91 @@ class HomeRainTrendSection extends StatelessWidget { ], ), 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), - ), - ), - ], - ), - ], - 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, - ), - ), - ); - }, - ), - ), + body, + ], + ), + ), + ), + ); + } +} + +/// The trend's bar chart: one rod per minute, no mm / numeric Y labels — height +/// alone carries intensity; the bottom axis shows a few clock ticks. +class _Chart extends StatelessWidget { + const _Chart({ + required this.data, + required this.barColor, + required this.secondary, + }); + + final RainHourTrend data; + final Color barColor; + final Color secondary; + + @override + Widget build(BuildContext context) { + 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; + + return 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), ), ), - ), + ], ), - ], + ], + 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.of( + context, + ).textTheme.labelSmall?.copyWith(color: secondary), + ), + ); + }, + ), + ), ), ), ), 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/domain/rain_hour_trend.dart b/lib/features/weather/domain/rain_hour_trend.dart index 6d865c7b4..c0185ead0 100644 --- a/lib/features/weather/domain/rain_hour_trend.dart +++ b/lib/features/weather/domain/rain_hour_trend.dart @@ -1,8 +1,11 @@ /// 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; /// Sixty minute-buckets of forecast rainfall starting at [startSecond]. @@ -10,12 +13,36 @@ class RainHourTrend { const RainHourTrend({required this.startSecond, required this.mm}) : assert(mm.length == 60, 'expected 60 one-minute samples'); + /// 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 — or a series with a non-numeric `start`, non-list `rain`, + /// or wrong sample count — 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; + final raw = series.first; + if (raw is! Map) continue; + final start = raw['start']; + final rain = raw['rain']; + if (start is! num || rain is! List) continue; + 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()], + ); + } + throw const FormatException('No rainforecast series in response'); + } + /// 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]. 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/weather_providers.dart b/lib/features/weather/weather_providers.dart index 9751438e5..6fd624ca2 100644 --- a/lib/features/weather/weather_providers.dart +++ b/lib/features/weather/weather_providers.dart @@ -9,6 +9,8 @@ import 'package:dpip/features/weather/data/qpesums_api.dart'; import 'package:dpip/features/weather/data/qpesums_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/rain_hour_trend_api.dart'; +import 'package:dpip/features/weather/data/rain_hour_trend_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/domain/meteor_lightning_repository.dart'; @@ -16,12 +18,13 @@ 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_repository.dart'; import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; /// Weather providers: radar / satellite / QPESUMS overlays, meteor weather / -/// rain / lightning. +/// rain / lightning, and the next-hour rain trend. /// /// 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 @@ -55,5 +58,8 @@ List weatherProviders(SharedDeps deps) { Provider.value( value: MeteorLightningRepositoryImpl(MeteorLightningApi(deps.apiClient)), ), + Provider.value( + value: RainHourTrendRepositoryImpl(RainHourTrendApi(deps.apiClient)), + ), ]; } diff --git a/test/features/home/presentation/widgets/home_content_test.dart b/test/features/home/presentation/widgets/home_content_test.dart index 6d476a588..501915acb 100644 --- a/test/features/home/presentation/widgets/home_content_test.dart +++ b/test/features/home/presentation/widgets/home_content_test.dart @@ -13,6 +13,8 @@ import 'package:dpip/features/home/presentation/widgets/home_forecast_section.da 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/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'; @@ -36,6 +38,13 @@ class _FakeWeatherRepository implements MeteorWeatherRepository { dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); } +/// Stub next-hour rain trend. +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 => @@ -61,8 +70,12 @@ Widget _wrap(RegionStore store, {bool expanded = false}) { 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), diff --git a/test/features/home/presentation/widgets/home_hero_preview_test.dart b/test/features/home/presentation/widgets/home_hero_preview_test.dart index 883e3258e..5d281575a 100644 --- a/test/features/home/presentation/widgets/home_hero_preview_test.dart +++ b/test/features/home/presentation/widgets/home_hero_preview_test.dart @@ -15,6 +15,8 @@ import 'package:dpip/features/home/presentation/home_active_events_controller.da 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'; @@ -66,6 +68,22 @@ class _FakeWeatherRepository implements MeteorWeatherRepository { 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 => @@ -127,6 +145,7 @@ void main() { ChangeNotifierProvider( create: (_) => HomeWeatherController( _FakeWeatherRepository(), + _FakeHourTrendRepository(), store, directory, ), 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 index d44f1cf3e..c56994590 100644 --- a/test/features/home/presentation/widgets/home_sheet_header_silhouette_test.dart +++ b/test/features/home/presentation/widgets/home_sheet_header_silhouette_test.dart @@ -31,6 +31,8 @@ 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'; @@ -74,6 +76,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))); +} + void main() { testWidgets( 'a located township header rasterises to a non-empty silhouette', @@ -108,6 +116,7 @@ void main() { ChangeNotifierProvider( create: (_) => HomeWeatherController( _FakeWeatherRepository(), + _FakeHourTrendRepository(), store, directory, ), diff --git a/test/features/home/presentation/widgets/home_sheet_test.dart b/test/features/home/presentation/widgets/home_sheet_test.dart index 4540e4406..f7fa1caa9 100644 --- a/test/features/home/presentation/widgets/home_sheet_test.dart +++ b/test/features/home/presentation/widgets/home_sheet_test.dart @@ -13,6 +13,8 @@ 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/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'; @@ -34,6 +36,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 => @@ -61,8 +69,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), 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..c93f0a893 --- /dev/null +++ b/test/features/weather/domain/rain_hour_trend_test.dart @@ -0,0 +1,88 @@ +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; +} + +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 aligns an empty response to FormatException', () { + expect(() => RainHourTrend.decode(const {}), throwsFormatException); + }); + + 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, + ); + }); +} From fd8db7d752ea9245599896030a1bf71138d96fe0 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:07:23 +0800 Subject: [PATCH 13/92] fix: home background blur/dim flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the blur/dim layer's tree shape constant (ImageFiltered.enabled + an always-present transparent scrim) so the sky's element is never re-parented when the sheet starts climbing or content scrolls — which used to reset WeatherSkyBackground's shader cache and flash the flat fallback colour. --- .../home/presentation/pages/home_page.dart | 8 +++++++- .../home/presentation/widgets/home_sheet.dart | 17 +++++++++++++---- .../widgets/weather_sky/rain_on_card.dart | 3 +-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 51cb9978f..fc3c3b353 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -162,14 +162,20 @@ class _HomePageState extends State { valueListenable: extent, builder: (context, extentValue, _) { final t = HomeChrome.mapDim(extentValue); - if (t <= 0) return const SizedBox.shrink(); 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), ), diff --git a/lib/features/home/presentation/widgets/home_sheet.dart b/lib/features/home/presentation/widgets/home_sheet.dart index 17917654d..e73454c18 100644 --- a/lib/features/home/presentation/widgets/home_sheet.dart +++ b/lib/features/home/presentation/widgets/home_sheet.dart @@ -238,21 +238,30 @@ class _ScrollBlurredWeather extends StatelessWidget { ? scrollController.offset : 0.0; final t = (offset / _rampExtent).clamp(0.0, 1.0); - // Skip the filter layer entirely at rest, rather than compositing an - // always-on blur of sigma 0 — the sheet spends most of its time here. - if (t <= 0) return child!; final sigma = _maxSigma * t; final dim = _maxDim * t; + // 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: t > 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. + // cards are never dimmed with it. Transparent at rest. ColoredBox(color: Colors.black.withValues(alpha: dim)), ], ); 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 index 17783ae6c..9d57dfeee 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -343,8 +343,7 @@ class _RainOnCardState extends State previous - _cardTopY! > _scrollThresholdRatio * _screenHeight ? _scrollGravityScale : 1.0; - final open = - !widget.gated || _restTopY! - _cardTopY! < _gateCloseDistance; + final open = !widget.gated || _restTopY! - _cardTopY! < _gateCloseDistance; if (open == _gateOpen) return; setState(() => _gateOpen = open); } From 8776ea372b4a01501b7689b927b997c7b02e4cf2 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:08:02 +0800 Subject: [PATCH 14/92] feat: weather phenomenon icons finer than the backdrop modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CWB's ones digit refines the glyph: showers, fog banks, hail and lightning each get their own icon (with 大雷雨 filled, the heaviest rung), while the accent colour keeps following the resolved mode so the family tint stays consistent. --- lib/core/weather/weather_condition.dart | 52 +++++++++++++++++-- test/core/weather/weather_condition_test.dart | 26 +++++++++- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/core/weather/weather_condition.dart b/lib/core/weather/weather_condition.dart index d73e957c5..7259048ec 100644 --- a/lib/core/weather/weather_condition.dart +++ b/lib/core/weather/weather_condition.dart @@ -40,6 +40,33 @@ const Map _phenomenonMode = { 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: Icons.water_drop_outlined, // 有雨 + 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: Icons.grain_outlined, // 有陣雨 — a shower, distinct from 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]. @@ -89,14 +116,22 @@ double? weatherSnowIntensity(int code) { /// Icon + accent for a forecast point's [weather] text and [weatherCode]. /// -/// Codes are authoritative ([weatherModeFor]); the text only comes into play -/// when the code is missing (`0`), where the old substring matching (雷/雪/雨/ -/// 晴/雲/陰) is the only signal left. +/// 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. @@ -112,6 +147,17 @@ double? weatherSnowIntensity(int code) { }; } +/// 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) { diff --git a/test/core/weather/weather_condition_test.dart b/test/core/weather/weather_condition_test.dart index 47040a69f..3e03c4ec1 100644 --- a/test/core/weather/weather_condition_test.dart +++ b/test/core/weather/weather_condition_test.dart @@ -53,8 +53,21 @@ void main() { // 200 有雷雨 — the code, not the 多雲 label, picks the storm. expect(weatherVisual('多雲', 214, colors).$1, Icons.thunderstorm_outlined); expect(weatherVisual('晴', 106, colors).$1, Icons.water_drop_outlined); - expect(weatherVisual('陰', 208, colors).$1, Icons.ac_unit_outlined); - expect(weatherVisual('晴', 105, colors).$1, Icons.blur_on_outlined); + 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, Icons.grain_outlined); // 有陣雨 + 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', () { @@ -63,6 +76,15 @@ void main() { 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, Icons.water_drop_outlined); From 46a92450114413b0831d73878d9c04cab0cfaf65 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:08:10 +0800 Subject: [PATCH 15/92] feat: beautify data page Featured earthquake card on primaryContainer plus a two-column ranking grid with accent-coloured icons and badges. --- .../data/presentation/pages/data_page.dart | 196 +++++++++++++----- 1 file changed, 144 insertions(+), 52 deletions(-) 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, + ), + ], + ), + ], + ), + ), + ), ); } } From b777bf116aaf1f52aa2aa9644abc4dee0111cb8d Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:08:47 +0800 Subject: [PATCH 16/92] feat: home rain trend summary + minute axis RainHourTrend gains a structured summary (grade, sustained, stop time) that drives a one-line reading under the card title; the chart's axis now labels minutes-from-now ticks instead of Taipei wall clock times. --- .../widgets/home_rain_trend_section.dart | 207 ++++++++++++------ .../weather/domain/rain_hour_trend.dart | 77 +++++++ lib/l10n/app_en.arb | 41 +++- lib/l10n/app_fil.arb | 6 + lib/l10n/app_id.arb | 6 + lib/l10n/app_ja.arb | 6 + lib/l10n/app_ko.arb | 6 + lib/l10n/app_th.arb | 6 + lib/l10n/app_vi.arb | 6 + lib/l10n/app_zh.arb | 8 +- lib/l10n/app_zh_Hans.arb | 8 +- lib/l10n/app_zh_Hant_HK.arb | 8 +- lib/l10n/app_zh_TW.arb | 8 +- lib/l10n/gen/app_localizations.dart | 38 +++- lib/l10n/gen/app_localizations_en.dart | 28 ++- lib/l10n/gen/app_localizations_fil.dart | 26 +++ lib/l10n/gen/app_localizations_id.dart | 26 +++ lib/l10n/gen/app_localizations_ja.dart | 24 ++ lib/l10n/gen/app_localizations_ko.dart | 24 ++ lib/l10n/gen/app_localizations_th.dart | 25 +++ lib/l10n/gen/app_localizations_vi.dart | 24 ++ lib/l10n/gen/app_localizations_zh.dart | 104 ++++++++- .../weather/domain/rain_hour_trend_test.dart | 62 ++++++ 23 files changed, 693 insertions(+), 81 deletions(-) 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 9606c5de2..bac3ae3c2 100644 --- a/lib/features/home/presentation/widgets/home_rain_trend_section.dart +++ b/lib/features/home/presentation/widgets/home_rain_trend_section.dart @@ -1,6 +1,8 @@ -/// 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'; @@ -109,6 +111,25 @@ class HomeRainTrendSection extends StatelessWidget { ); } + // 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 @@ -147,6 +168,16 @@ class HomeRainTrendSection extends StatelessWidget { ), ], ), + 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, ], @@ -158,7 +189,7 @@ class HomeRainTrendSection extends StatelessWidget { } /// The trend's bar chart: one rod per minute, no mm / numeric Y labels — height -/// alone carries intensity; the bottom axis shows a few clock ticks. +/// alone carries intensity; the bottom axis labels minutes from now. class _Chart extends StatelessWidget { const _Chart({ required this.data, @@ -166,71 +197,118 @@ class _Chart extends StatelessWidget { 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]; + final RainHourTrend data; final Color barColor; final Color secondary; @override Widget build(BuildContext context) { - 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; - - return 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), + final l10n = AppLocalizations.of(context); + return Padding( + // Room for the edge X labels (現在 / 50分) — the chart clips its own + // titles at the first/last bar otherwise. + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: SizedBox( + height: 120, + child: 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: math.min(data.mm[i], _maxMm), + width: 2.5, + color: barColor, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(1), + ), ), - ), - ], - ), - ], - 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.of( - context, - ).textTheme.labelSmall?.copyWith(color: secondary), + ], + ), + ], + // 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); + // The titles band starts flush under the X axis, and fl_chart + // stretches each title widget to the full band height — so + // start-alignment pins the tick's top to the axis line (the + // tick hangs down from it) and rides the label up beside it. + return 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: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: secondary), + ), + ], + ); + }, + ), ), ), ), @@ -238,15 +316,4 @@ class _Chart extends StatelessWidget { ), ); } - - /// 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'; - } } diff --git a/lib/features/weather/domain/rain_hour_trend.dart b/lib/features/weather/domain/rain_hour_trend.dart index c0185ead0..608648ae7 100644 --- a/lib/features/weather/domain/rain_hour_trend.dart +++ b/lib/features/weather/domain/rain_hour_trend.dart @@ -8,11 +8,88 @@ /// [placeholder]. library; +import 'dart:math' as math; + +/// 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 — or a series with a non-numeric `start`, non-list `rain`, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 651e62c2b..8f09fb15c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -709,10 +709,49 @@ "@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" + } + } + }, + "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" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 7f3048973..fb0992506 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -151,6 +151,12 @@ "homeActiveEventsTitle": "Mga aktibong event", "homeActiveEventsEmpty": "Walang aktibong event", "homeRainTrendTitle": "Ulan sa susunod na oras", + "homeRainTrendMinute": "{minute} min", + "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", "mapLayerRadar": "Composite Radar Reflectivity", "mapLayerSatellite": "Himawari Infrared", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 0d3d13aaf..5be81b4c2 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -151,6 +151,12 @@ "homeActiveEventsTitle": "Peristiwa aktif", "homeActiveEventsEmpty": "Tidak ada peristiwa aktif", "homeRainTrendTitle": "Hujan 1 jam ke depan", + "homeRainTrendMinute": "{minute} mnt", + "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", "mapLayerRadar": "Radar Komposit", "mapLayerSatellite": "Himawari Inframerah", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ee52b9033..eb58adf69 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -151,6 +151,12 @@ "homeActiveEventsTitle": "発生中の事象", "homeActiveEventsEmpty": "発生中の事象はありません", "homeRainTrendTitle": "今後1時間の雨", + "homeRainTrendMinute": "{minute}分", + "homeRainTrendScattered": "にわか雨の可能性があります", + "homeRainTrendLightSustained": "今後1時間は小雨が続きます", + "homeRainTrendLightStopping": "{minutes}分後に小雨が止む見込みです", + "homeRainTrendHeavySustained": "今後1時間は大雨が続きます", + "homeRainTrendHeavyStopping": "{minutes}分後に大雨が止む見込みです", "mapLayers": "レイヤー", "mapLayerRadar": "レーダー合成エコー図", "mapLayerSatellite": "ひまわり 赤外線図", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index a9ec335fa..c55a1f7b0 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -151,6 +151,12 @@ "homeActiveEventsTitle": "발효 중 이벤트", "homeActiveEventsEmpty": "발효 중인 이벤트가 없습니다", "homeRainTrendTitle": "향후 1시간 강수", + "homeRainTrendMinute": "{minute}분", + "homeRainTrendScattered": "약한 비가 올 수 있어요", + "homeRainTrendLightSustained": "앞으로 1시간 동안 약한 비가 이어질 거예요", + "homeRainTrendLightStopping": "{minutes}분 후에 비가 그칠 것으로 예상돼요", + "homeRainTrendHeavySustained": "앞으로 1시간 동안 강한 비가 이어질 거예요", + "homeRainTrendHeavyStopping": "{minutes}분 후에 강한 비가 그칠 것으로 예상돼요", "mapLayers": "레이어", "mapLayerRadar": "레이더 합성 에코", "mapLayerSatellite": "히마와리 적외", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 5e4c93f89..8b2f9c5ef 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -151,6 +151,12 @@ "homeActiveEventsTitle": "เหตุการณ์ที่ยังมีผล", "homeActiveEventsEmpty": "ไม่มีเหตุการณ์ที่ยังมีผล", "homeRainTrendTitle": "ฝนชั่วโมงถัดไป", + "homeRainTrendMinute": "{minute} นาที", + "homeRainTrendScattered": "อาจมีฝนตกประปราย", + "homeRainTrendLightSustained": "ฝนตกเล็กน้อยต่อเนื่องตลอดชั่วโมงหน้า", + "homeRainTrendLightStopping": "คาดว่าฝนจะหยุดในอีก {minutes} นาที", + "homeRainTrendHeavySustained": "ฝนตกหนักต่อเนื่องตลอดชั่วโมงหน้า", + "homeRainTrendHeavyStopping": "คาดว่าฝนตกหนักจะหยุดในอีก {minutes} นาที", "mapLayers": "ชั้นข้อมูล", "mapLayerRadar": "เรดาร์สะท้อนสังเคราะห์", "mapLayerSatellite": "ฮิมาวาริ อินฟราเรด", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 9b8c8ec6b..c027d296b 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -151,6 +151,12 @@ "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", + "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 đồ", "mapLayerRadar": "Radar phản xạ tổng hợp", "mapLayerSatellite": "Himawari hồng ngoại", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7e0c31ba0..9d214284c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -150,7 +150,13 @@ "homeForecastEmpty": "目前沒有預報資料", "homeActiveEventsTitle": "生效中事件", "homeActiveEventsEmpty": "目前沒有生效中的事件", - "homeRainTrendTitle": "近 1 小時降雨趨勢", + "homeRainTrendTitle": "近 1 小時降水趨勢", + "homeRainTrendMinute": "{minute}分", + "homeRainTrendScattered": "可能會有零星降雨", + "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", + "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", + "homeRainTrendHeavySustained": "未來 1 小時會有持續大雨", + "homeRainTrendHeavyStopping": "預計 {minutes} 分鐘後停止下大雨", "mapLayers": "圖層", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 1ce7350fc..0a2b3d323 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -150,7 +150,13 @@ "homeForecastEmpty": "目前没有预报数据", "homeActiveEventsTitle": "生效中事件", "homeActiveEventsEmpty": "目前没有生效中的事件", - "homeRainTrendTitle": "近 1 小时降雨趋势", + "homeRainTrendTitle": "近 1 小时降水趋势", + "homeRainTrendMinute": "{minute}分", + "homeRainTrendScattered": "可能会有零星降雨", + "homeRainTrendLightSustained": "未来 1 小时会有持续小雨", + "homeRainTrendLightStopping": "预计 {minutes} 分钟后停止下小雨", + "homeRainTrendHeavySustained": "未来 1 小时会有持续大雨", + "homeRainTrendHeavyStopping": "预计 {minutes} 分钟后停止下大雨", "mapLayers": "图层", "mapLayerRadar": "雷达合成回波图", "mapLayerSatellite": "ひまわり 红外线图", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 229df8544..5220eecaf 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -150,7 +150,13 @@ "homeForecastEmpty": "目前沒有預報資料", "homeActiveEventsTitle": "生效中事件", "homeActiveEventsEmpty": "目前沒有生效中的事件", - "homeRainTrendTitle": "近 1 小時降雨趨勢", + "homeRainTrendTitle": "近 1 小時降水趨勢", + "homeRainTrendMinute": "{minute}分", + "homeRainTrendScattered": "可能會有零星降雨", + "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", + "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", + "homeRainTrendHeavySustained": "未來 1 小時會有持續大雨", + "homeRainTrendHeavyStopping": "預計 {minutes} 分鐘後停止下大雨", "mapLayers": "圖層", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index f5ae5efc0..8e2b171bf 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -150,7 +150,13 @@ "homeForecastEmpty": "目前沒有預報資料", "homeActiveEventsTitle": "生效中事件", "homeActiveEventsEmpty": "目前沒有生效中的事件", - "homeRainTrendTitle": "近 1 小時降雨趨勢", + "homeRainTrendTitle": "近 1 小時降水趨勢", + "homeRainTrendMinute": "{minute}分", + "homeRainTrendScattered": "可能會有零星降雨", + "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", + "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", + "homeRainTrendHeavySustained": "未來 1 小時會有持續大雨", + "homeRainTrendHeavyStopping": "預計 {minutes} 分鐘後停止下大雨", "mapLayers": "圖層", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index ce44aa4b9..a4569bb3e 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1020,9 +1020,45 @@ 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); + + /// 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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index d400d2bef..223c2e1ac 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -504,7 +504,33 @@ 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 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'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 40a037cbc..32f7dd343 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -511,6 +511,32 @@ class AppLocalizationsFil extends AppLocalizations { @override String get homeRainTrendTitle => 'Ulan sa susunod na oras'; + @override + String homeRainTrendMinute(int minute) { + return '$minute min'; + } + + @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'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 5be499929..939edfda9 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -507,6 +507,32 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeRainTrendTitle => 'Hujan 1 jam ke depan'; + @override + String homeRainTrendMinute(int minute) { + return '$minute mnt'; + } + + @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'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index f5281c6d0..2637fff58 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -504,6 +504,30 @@ class AppLocalizationsJa extends AppLocalizations { @override String get homeRainTrendTitle => '今後1時間の雨'; + @override + String homeRainTrendMinute(int minute) { + return '$minute分'; + } + + @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 => 'レイヤー'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 08bd1bc38..f89e96502 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -504,6 +504,30 @@ class AppLocalizationsKo extends AppLocalizations { @override String get homeRainTrendTitle => '향후 1시간 강수'; + @override + String homeRainTrendMinute(int minute) { + return '$minute분'; + } + + @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 => '레이어'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 90603aefa..97aaca358 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -505,6 +505,31 @@ class AppLocalizationsTh extends AppLocalizations { @override String get homeRainTrendTitle => 'ฝนชั่วโมงถัดไป'; + @override + String homeRainTrendMinute(int minute) { + return '$minute นาที'; + } + + @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 => 'ชั้นข้อมูล'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 8ac257554..269799864 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -506,6 +506,30 @@ 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 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 đồ'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 0adc4af08..c659a9d3e 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -501,7 +501,31 @@ 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 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 => '圖層'; @@ -1927,7 +1951,31 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get homeActiveEventsEmpty => '目前没有生效中的事件'; @override - String get homeRainTrendTitle => '近 1 小时降雨趋势'; + String get homeRainTrendTitle => '近 1 小时降水趋势'; + + @override + String homeRainTrendMinute(int minute) { + return '$minute分'; + } + + @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 => '图层'; @@ -3353,7 +3401,31 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get homeActiveEventsEmpty => '目前沒有生效中的事件'; @override - String get homeRainTrendTitle => '近 1 小時降雨趨勢'; + String get homeRainTrendTitle => '近 1 小時降水趨勢'; + + @override + String homeRainTrendMinute(int minute) { + return '$minute分'; + } + + @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 => '圖層'; @@ -4779,7 +4851,31 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get homeActiveEventsEmpty => '目前沒有生效中的事件'; @override - String get homeRainTrendTitle => '近 1 小時降雨趨勢'; + String get homeRainTrendTitle => '近 1 小時降水趨勢'; + + @override + String homeRainTrendMinute(int minute) { + return '$minute分'; + } + + @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 => '圖層'; diff --git a/test/features/weather/domain/rain_hour_trend_test.dart b/test/features/weather/domain/rain_hour_trend_test.dart index c93f0a893..31bd8fea8 100644 --- a/test/features/weather/domain/rain_hour_trend_test.dart +++ b/test/features/weather/domain/rain_hour_trend_test.dart @@ -11,6 +11,13 @@ List _rain60(List leading) { 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({ @@ -85,4 +92,59 @@ void main() { 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); + }); + }); } From 3bce5ad9ab42209e9acd14e265f0e2850ef125ba Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:08:54 +0800 Subject: [PATCH 17/92] feat: wind map arrows with baked-in outline, speed-coloured Pre-render one PNG per speed bucket with a black outline baked in (MapLibre's icon-halo only works on true SDF images), pick the image via a step expression, and expose readingIcon so sheets can reuse the arrow. --- .../map/presentation/layers/rain_layer.dart | 3 + .../layers/weather_station_layer.dart | 3 + .../map/presentation/layers/wind_layer.dart | 190 +++++++++++++----- lib/features/map/presentation/wind_speed.dart | 81 ++++++++ .../map/presentation/wind_speed_test.dart | 29 +++ 5 files changed, 253 insertions(+), 53 deletions(-) create mode 100644 lib/features/map/presentation/wind_speed.dart create mode 100644 test/features/map/presentation/wind_speed_test.dart diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index a8a125e27..9a9c0e686 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -347,6 +347,9 @@ class RainMapLayer implements MapLayer, StationSheetSource { return value == null ? null : '${value.toStringAsFixed(_decimals)} $unit'; } + @override + Widget? readingIcon(String id) => null; + @override Future> trend(String id, String range) async { final result = await _repository.trend(id, range: range); diff --git a/lib/features/map/presentation/layers/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart index cdad6da00..95effb7a1 100644 --- a/lib/features/map/presentation/layers/weather_station_layer.dart +++ b/lib/features/map/presentation/layers/weather_station_layer.dart @@ -276,6 +276,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; diff --git a/lib/features/map/presentation/layers/wind_layer.dart b/lib/features/map/presentation/layers/wind_layer.dart index 3f85971af..63bb71dab 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:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; 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'; @@ -19,13 +21,18 @@ import 'package:maplibre_gl/maplibre_gl.dart'; 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 +91,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 +159,43 @@ 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; + const outlineDirs = <(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), + ]; + final outline = TextPainter( textDirection: TextDirection.ltr, text: TextSpan( text: String.fromCharCode(icon.codePoint), @@ -180,22 +203,67 @@ 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, + outlineDirs: outlineDirs, + ), + ]; + } + + Future _renderOne( + TextPainter outline, + Color fill, { + required String glyph, + required String? fontFamily, + required String? fontPackage, + required Offset center, + required int size, + required double halo, + required List<(double, double)> outlineDirs, + }) 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 outlineDirs) { + 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 +272,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°'; + } + + /// 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: (from + 180) * math.pi / 180, + child: WindArrowIcon( + size: 26, + outline: 1.5, + color: windSpeedColor(speed), + ), + ); } - /// 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]; + /// 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 +328,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 +339,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/wind_speed.dart b/lib/features/map/presentation/wind_speed.dart new file mode 100644 index 000000000..08d30a900 --- /dev/null +++ b/lib/features/map/presentation/wind_speed.dart @@ -0,0 +1,81 @@ +/// 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; + +/// 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; + + /// Offset copies that make up the outline — 8 neighbours (cardinal + + /// diagonal) at [outline] px. + static const List<(double, double)> _outlineDirs = [ + (1, 0), + (-1, 0), + (0, 1), + (0, -1), + (0.7071, 0.7071), + (0.7071, -0.7071), + (-0.7071, 0.7071), + (-0.7071, -0.7071), + ]; + + @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 _outlineDirs) + 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/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); + }); + }); +} From 30e38f8375b4ed198a117814eef45fdd84977db1 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:13:40 +0800 Subject: [PATCH 18/92] feat: wind chart segments coloured by speed bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The speed curve is drawn in per-bucket runs (the same white→pink ramp as the map arrows) instead of one flat line, and the X-axis ticks use speed-coloured WindArrowIcon glyphs; the station hero gains the same rotating arrow icon. --- .../presentation/widgets/station_sheet.dart | 251 ++++++++++++------ 1 file changed, 166 insertions(+), 85 deletions(-) diff --git a/lib/features/map/presentation/widgets/station_sheet.dart b/lib/features/map/presentation/widgets/station_sheet.dart index 6fec6b76f..c70363dab 100644 --- a/lib/features/map/presentation/widgets/station_sheet.dart +++ b/lib/features/map/presentation/widgets/station_sheet.dart @@ -8,6 +8,7 @@ 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/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'; @@ -55,6 +56,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. @@ -447,6 +452,7 @@ class _SheetBodyState extends State<_SheetBody> { 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 +541,7 @@ class _StationHero extends StatefulWidget { required this.onClose, this.subtitle, this.reading, + this.readingIcon, this.valueColor, }); @@ -542,6 +549,7 @@ class _StationHero extends StatefulWidget { final String name; final String? subtitle; final String? reading; + final Widget? readingIcon; final Color? valueColor; final VoidCallback onClose; @@ -671,11 +679,22 @@ 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), + 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), + ), + ), + ], ), ], ], @@ -913,20 +932,10 @@ 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; - } - final sample = sampleAt[spotIndex]; - if (sample >= directions.length) return null; - return directions[sample]; - } - - /// Nearest sample's meteorological "from" at chart-x [x] (for axis ticks). - int? directionNear(double x) { - final directions = series.directions; - if (directions == null || spots.isEmpty) 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. + int nearestSample(double x) { var best = 0; var bestDist = (spots.first.x - x).abs(); for (var i = 1; i < spots.length; i++) { @@ -936,9 +945,25 @@ class _TrendChart extends StatelessWidget { best = i; } } - return directionForSpot(best); + return sampleAt[best]; } + /// Nearest sample's meteorological "from" at chart-x [x] (for axis ticks). + int? nearestDirection(double x) { + final directions = series.directions; + 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 +1035,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: (from + 180) * math.pi / 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 +1323,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 +1379,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 +1416,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 +1424,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 +1446,22 @@ 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) { + final seen = {}; + return [ + for (final spot in touched) + if (seen.add(spot.x)) + _tooltipItem( + spot: spot, + unit: unit, + time: timeLabel(spot.x), + direction: nearestDirection(spot.x), + colors: colors, + textTheme: theme.textTheme, + accent: speedColorAt(spot.x), + ), + ]; + }, ), ), ), @@ -1426,6 +1472,41 @@ 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; +} + /// Chart touch tooltip: value emphasized, meta (direction / time) muted. LineTooltipItem _tooltipItem({ required FlSpot spot, From 6f73fb38139c6c7794e011e9315c593eb8b759e0 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:14:57 +0800 Subject: [PATCH 19/92] fix: wind chart tooltip crash on segmented bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fl_chart requires one tooltip item per touched spot; a touch at a segment boundary hits two bars at the same x, and deduping them shrank the list — drawTouchTooltip threw "tooltipItems and touchedSpots size should be same". windTooltipItems returns every item; the identical duplicates overlap and read as one card. --- .../presentation/widgets/station_sheet.dart | 57 ++++++++++++----- .../station_sheet_wind_touch_test.dart | 64 +++++++++++++++++++ 2 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 test/features/map/presentation/widgets/station_sheet_wind_touch_test.dart diff --git a/lib/features/map/presentation/widgets/station_sheet.dart b/lib/features/map/presentation/widgets/station_sheet.dart index c70363dab..85b8b56bb 100644 --- a/lib/features/map/presentation/widgets/station_sheet.dart +++ b/lib/features/map/presentation/widgets/station_sheet.dart @@ -1446,22 +1446,15 @@ class _TrendChart extends StatelessWidget { color: lineColor.withValues(alpha: 0.55), width: 1, ), - getTooltipItems: (touched) { - final seen = {}; - return [ - for (final spot in touched) - if (seen.add(spot.x)) - _tooltipItem( - spot: spot, - unit: unit, - time: timeLabel(spot.x), - direction: nearestDirection(spot.x), - colors: colors, - textTheme: theme.textTheme, - accent: speedColorAt(spot.x), - ), - ]; - }, + getTooltipItems: (touched) => windTooltipItems( + touched: touched, + unit: unit, + timeLabel: timeLabel, + directionAt: nearestDirection, + accentAt: speedColorAt, + colors: colors, + textTheme: theme.textTheme, + ), ), ), ), @@ -1507,6 +1500,38 @@ List<({List spots, Color color})> _windSegments( 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, 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); + }); +} From 9f97889ed03bd38957546b77ee82111468950bef Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:15:43 +0800 Subject: [PATCH 20/92] feat: 24M tile cache with fill-style warm injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the native memory cache to 24M and warm outward from the selected frame (0 ±4, then ±12 and beyond while space remains): warm() gains a fillUntil target so injection stops at the mirror's cap instead of churning LRU, and the timeline re-centres its warm band when scrubbing past it. --- .../weather/data/frame_tile_repository.dart | 29 ++++-- lib/shared/map/map_tile_cache.dart | 41 ++++++--- lib/shared/map/map_tile_warmer.dart | 12 ++- lib/shared/map/raster_frame_source.dart | 6 ++ lib/shared/map/raster_timeline_layer.dart | 91 ++++++++++++++++--- test/features/map/radar_layer_test.dart | 54 +++++++++-- .../features/map/raster_timeline_harness.dart | 1 + 7 files changed, 191 insertions(+), 43 deletions(-) diff --git a/lib/features/weather/data/frame_tile_repository.dart b/lib/features/weather/data/frame_tile_repository.dart index 99062d7b1..fb1a2df20 100644 --- a/lib/features/weather/data/frame_tile_repository.dart +++ b/lib/features/weather/data/frame_tile_repository.dart @@ -30,6 +30,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 +43,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 +55,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 diff --git a/lib/shared/map/map_tile_cache.dart b/lib/shared/map/map_tile_cache.dart index 3791d9e10..f824af4a2 100644 --- a/lib/shared/map/map_tile_cache.dart +++ b/lib/shared/map/map_tile_cache.dart @@ -45,19 +45,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 +131,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 +158,20 @@ 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( + final usage = await injectMapLibreTiles( end < tiles.length ? tiles.sublist(i, end) : tiles.sublist(i), ); + 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/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 e33f5436d..3820b6c7d 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -69,14 +69,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. @@ -166,6 +174,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'; @@ -287,6 +302,17 @@ 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, rarely touches `visibility`. @@ -408,32 +434,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, @@ -509,6 +571,7 @@ abstract class RasterTimelineLayer implements MapLayer { _orderedIds = const []; _indexById = const {}; _shownFrameId = null; + _warmCentre = null; _attached = false; } } diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index 16747e9b8..27a326982 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -181,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!; @@ -191,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', ); }); diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index f568c9f89..c1d6d14f4 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 From 1fab8d75fc5dfc39aca99617a92ef17bdbcdd1e6 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:16:33 +0800 Subject: [PATCH 21/92] feat: QPESUMS forecast overlay with scan/county/town chrome Extract the radar layer's scan-range + county + township overlay into a ScanRangeOverlayChrome mixin and reuse it for the QPESUMS layer, so the next-1-hour forecast layer draws the same boundaries as the radar. --- .../presentation/layers/qpesums_layer.dart | 89 +++++-- .../map/presentation/layers/radar_layer.dart | 216 +-------------- .../presentation/layers/radar_scan_range.dart | 18 +- .../layers/scan_range_overlay_chrome.dart | 250 ++++++++++++++++++ .../widgets/radar_overlay_menu.dart | 67 +---- .../widgets/scan_range_overlay_menu.dart | 78 ++++++ lib/l10n/app_en.arb | 4 + lib/l10n/app_fil.arb | 4 + lib/l10n/app_id.arb | 4 + lib/l10n/app_ja.arb | 4 + lib/l10n/app_ko.arb | 4 + lib/l10n/app_th.arb | 4 + lib/l10n/app_vi.arb | 4 + lib/l10n/app_zh.arb | 4 + lib/l10n/app_zh_Hans.arb | 4 + lib/l10n/app_zh_Hant_HK.arb | 4 + lib/l10n/app_zh_TW.arb | 4 + lib/l10n/gen/app_localizations.dart | 6 + lib/l10n/gen/app_localizations_en.dart | 3 + lib/l10n/gen/app_localizations_fil.dart | 4 + lib/l10n/gen/app_localizations_id.dart | 3 + lib/l10n/gen/app_localizations_ja.dart | 3 + lib/l10n/gen/app_localizations_ko.dart | 3 + lib/l10n/gen/app_localizations_th.dart | 3 + lib/l10n/gen/app_localizations_vi.dart | 3 + lib/l10n/gen/app_localizations_zh.dart | 12 + test/features/map/qpesums_layer_test.dart | 94 +++++++ 27 files changed, 606 insertions(+), 290 deletions(-) create mode 100644 lib/features/map/presentation/layers/scan_range_overlay_chrome.dart create mode 100644 lib/features/map/presentation/widgets/scan_range_overlay_menu.dart diff --git a/lib/features/map/presentation/layers/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart index d439174b1..1db389ddd 100644 --- a/lib/features/map/presentation/layers/qpesums_layer.dart +++ b/lib/features/map/presentation/layers/qpesums_layer.dart @@ -1,3 +1,5 @@ +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'; @@ -11,12 +13,29 @@ import 'package:flutter/material.dart'; /// 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 is anchored under the base style's county borders (the default -/// [rasterBelowLayerId]) — unlike radar there is no coverage outline here, so -/// the ordinary border-through-raster layering is exactly right. -class QpesumsMapLayer extends RasterTimelineLayer { +/// 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 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'; @@ -38,27 +57,45 @@ class QpesumsMapLayer extends RasterTimelineLayer { AppLocalizations.of(context).mapTimelineForecast; @override - Widget buildLegend(BuildContext context) => MapLegendCard( - child: 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'), - ], - ), + Widget buildTopTrailingChrome(BuildContext context) => ScanRangeOverlayMenu( + layer: this, + tooltip: AppLocalizations.of(context).qpesumsOverlayMenuTooltip, + ); + + @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 d27a5590a..ba4d4f5a4 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -1,17 +1,11 @@ -import 'dart:async'; - -import 'package:dpip/app/theme/app_spacing.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/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/map_style.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 radar echo (雷達回波) raster overlay. /// @@ -21,38 +15,21 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// /// The echo is mounted **above** the base style's borders (see /// [rasterBelowLayerId]) and the borders are put back on top as switchable -/// layers. That is what makes them switchable at all: while they came through -/// from underneath there was no way to get an uninterrupted raster. -/// -/// All three default **on**, because each answers a question the echo alone -/// cannot — where the composite stops observing, and which county or township -/// a cell is over. -class RadarMapLayer extends RasterTimelineLayer { +/// 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 ScanRangeOverlayChrome { RadarMapLayer(RadarRepository super.repository); - /// Whether the composite's observed area is outlined. - /// - /// On by default: outside it, blank means *not observed*, not *no rain*, and - /// on a disaster-prevention map that distinction should not have to be - /// discovered in a menu. - /// - /// Lives on the layer, not in settings: it is a property of *this map view*, - /// the same way the typhoon overlays are, so it is reachable from the map's - /// own options chip and resets with the session. - final ValueNotifier showScanRange = ValueNotifier(true); - - /// Whether 縣市 borders are redrawn above the echo. - final ValueNotifier showCountyOutline = ValueNotifier(true); + /// The radar composite's own ids — the default geometry and layer naming. + @override + String get scanRangeSourceId => RadarScanRange.sourceId; - /// Whether 鄉鎮 borders are redrawn above the echo. 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); + @override + String get scanRangeLayerId => RadarScanRange.outlineLayerId; - MapLibreMapController? _controller; - bool _rangeShown = false; - final Set _boundariesShown = {}; + @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. @@ -64,117 +41,6 @@ class RadarMapLayer extends RasterTimelineLayer { @override String? get rasterBelowLayerId => null; - /// 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()); - } - - /// 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()); - } - - /// Which boundary sets should currently be on the map. - Set get _wantedBoundaries => { - // Town first so a later insert of county lands above it: the coarse frame - // should win where the two run together along a coastline. - if (showTownOutline.value) AdminBoundary.town, - if (showCountyOutline.value) AdminBoundary.county, - }; - - @override - Future onAttached(MapLibreMapController controller) async { - _controller = controller; - await _syncRange(); - // Borders last, so they end up above the scan range as well as the raster — - // the range is a dashed hint, the borders are the reference frame. - await _syncBoundaries(); - } - - @override - Future onDetached(MapLibreMapController controller) async { - _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); - } - if (_rangeShown) { - _rangeShown = false; - await RadarScanRange.remove(controller); - } - } - - /// Adds or removes the coverage outline to match [showScanRange]. - Future _syncRange() async { - final controller = _controller; - if (controller == null) return; - - final wanted = showScanRange.value; - if (wanted == _rangeShown) return; - _rangeShown = wanted; - - try { - if (wanted) { - await RadarScanRange.add( - controller, - outlineColor: _rangeColor, - belowLayerId: outlineLayerId, - ); - } else { - await RadarScanRange.remove(controller); - } - } catch (error, stackTrace) { - // A style reload can race the toggle; the next sync recovers. - _rangeShown = !wanted; - Log.handle(error, stackTrace, 'Failed to sync the radar scan range'); - } - } - - /// Brings the drawn boundary sets in line with the toggles. - Future _syncBoundaries() async { - final controller = _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 radar admin outlines'); - } - } - - @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; - _boundariesShown.clear(); - super.onStyleReset(); - } - @override Widget buildTopTrailingChrome(BuildContext context) => RadarOverlayMenu(layer: this); @@ -195,52 +61,8 @@ class RadarMapLayer extends RasterTimelineLayer { @override Widget buildLegend(BuildContext context) => ListenableBuilder( - listenable: Listenable.merge([ - showScanRange, - showCountyOutline, - showTownOutline, - ]), + listenable: chromeListenable, builder: (context, _) { - final l10n = AppLocalizations.of(context); - // The overlays are switchable, so the key has to follow them: a legend - // naming a line that is not on the map is worse than no legend. - final overlays = [ - if (showScanRange.value) - SymbolLegendItem( - swatch: const LineSwatch( - color: Color(0xFF78909C), - width: 1.5, - opacity: 0.7, - dash: [3, 2], - ), - label: l10n.radarScanRange, - ), - if (showCountyOutline.value) - SymbolLegendItem( - swatch: LineSwatch( - color: const Color(0xFFFFFFFF), - 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: const Color(0xFFFFFFFF), - width: AdminBoundary.town.lineWidth, - opacity: AdminBoundary.town.lineOpacity, - casingColor: const Color(0xFF000000), - casingWidth: AdminBoundary.town.casingWidth, - casingOpacity: AdminBoundary.town.casingOpacity, - ), - label: l10n.radarTownOutline, - ), - ]; - return MapLegendCard( child: Column( mainAxisSize: MainAxisSize.min, @@ -265,17 +87,7 @@ class RadarMapLayer extends RasterTimelineLayer { (65, '#9600FF'), ], ), - if (overlays.isNotEmpty) ...[ - 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), - ], + chromeLegendSection(context), ], ), ); diff --git a/lib/features/map/presentation/layers/radar_scan_range.dart b/lib/features/map/presentation/layers/radar_scan_range.dart index 5b0ba24a7..ac816aa38 100644 --- a/lib/features/map/presentation/layers/radar_scan_range.dart +++ b/lib/features/map/presentation/layers/radar_scan_range.dart @@ -179,10 +179,16 @@ abstract final class RadarScanRange { /// /// 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, @@ -190,7 +196,7 @@ abstract final class RadarScanRange { ); await controller.addLineLayer( sourceId, - outlineLayerId, + layerId, LineLayerProperties( lineColor: outlineColor, lineWidth: 1.5, @@ -204,9 +210,15 @@ abstract final class RadarScanRange { } /// Removes the layer and source, tolerating a partially-added state. - static Future remove(MapLibreMapController controller) async { + /// + /// 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(outlineLayerId); + await controller.removeLayer(layerId); } catch (_) { // Not present — nothing to undo. } 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..af99e6c05 --- /dev/null +++ b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart @@ -0,0 +1,250 @@ +/// 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/radar_scan_range.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/admin_outline.dart'; +import 'package:dpip/shared/map/map_style.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 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 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 RasterTimelineLayer { + /// Whether the observed area is outlined. On by default — see the class doc. + final ValueNotifier showScanRange = ValueNotifier(true); + + /// 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); + + MapLibreMapController? _controller; + bool _rangeShown = false; + final Set _boundariesShown = {}; + + /// 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, showCountyOutline, showTownOutline]); + + /// Which boundary sets should currently be on the map. + Set get _wantedBoundaries => { + // Town first so a later insert of county lands above it: the coarse frame + // should win where the two run together along a coastline. + if (showTownOutline.value) AdminBoundary.town, + if (showCountyOutline.value) AdminBoundary.county, + }; + + /// 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()); + } + + /// 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); + _controller = controller; + await _syncRange(); + // Borders last, so they end up above the scan range as well as the raster — + // the range is a dashed hint, the borders are the reference frame. + await _syncBoundaries(); + } + + @override + Future onDetached(MapLibreMapController controller) async { + super.onDetached(controller); + _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); + } + 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; + _boundariesShown.clear(); + super.onStyleReset(); + } + + /// Adds or removes the coverage outline to match [showScanRange]. + Future _syncRange() async { + final controller = _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'); + } + } + + /// Brings the drawn boundary sets in line with the toggles. + Future _syncBoundaries() async { + final controller = _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 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, + ), + if (showCountyOutline.value) + SymbolLegendItem( + swatch: LineSwatch( + color: const Color(0xFFFFFFFF), + 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: const Color(0xFFFFFFFF), + 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 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/widgets/radar_overlay_menu.dart b/lib/features/map/presentation/widgets/radar_overlay_menu.dart index c344fffe2..5f30c1a8e 100644 --- a/lib/features/map/presentation/widgets/radar_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/radar_overlay_menu.dart @@ -2,72 +2,21 @@ 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:dpip/shared/widgets/map_chip_button.dart'; -import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; import 'package:flutter/material.dart'; -/// Compact icon chip that opens radar's overlay options — the same chrome and -/// position as the typhoon menu, so "layer options" is one affordance across -/// the map rather than one per layer. +/// 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}); final RadarMapLayer layer; @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - return ListenableBuilder( - listenable: Listenable.merge([ - layer.showScanRange, - layer.showCountyOutline, - layer.showTownOutline, - ]), - builder: (context, _) { - final showRange = layer.showScanRange.value; - final showCounty = layer.showCountyOutline.value; - final showTown = layer.showTownOutline.value; - return MenuAnchor( - alignmentOffset: const Offset(0, 4), - style: MapChipButton.menuStyle(context), - builder: (context, controller, _) => MapChipButton( - icon: Icons.tune, - tooltip: l10n.radarOverlayMenuTooltip, - // The dot marks "not the defaults". All three ship on, so it - // lights up when one has been switched *off*. - active: !showRange || !showCounty || !showTown, - onTap: () => - controller.isOpen ? controller.close() : controller.open(), - ), - menuChildren: [ - 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), - ), - ], - ); - }, - ); - } + Widget build(BuildContext context) => ScanRangeOverlayMenu( + layer: layer, + tooltip: AppLocalizations.of(context).radarOverlayMenuTooltip, + ); } 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..866914306 --- /dev/null +++ b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart @@ -0,0 +1,78 @@ +/// Frosted dropdown beside the layer switcher — a raster's reference-chrome +/// toggles (scan range, county borders, township borders). +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/widgets/map_chip_button.dart'; +import 'package:dpip/shared/widgets/map_menu_toggle_row.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. +class ScanRangeOverlayMenu extends StatelessWidget { + const ScanRangeOverlayMenu({ + super.key, + required this.layer, + required this.tooltip, + }); + + final ScanRangeOverlayChrome layer; + + /// Tooltip for the options chip (layer-specific, so each raster says what it + /// is configuring). + final String tooltip; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ListenableBuilder( + listenable: layer.chromeListenable, + builder: (context, _) { + final showRange = layer.showScanRange.value; + final showCounty = layer.showCountyOutline.value; + final showTown = layer.showTownOutline.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". All three ship on, so it + // lights up when one has been switched *off*. + active: !showRange || !showCounty || !showTown, + onTap: () => + controller.isOpen ? controller.close() : controller.open(), + ), + menuChildren: [ + 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), + ), + ], + ); + }, + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8f09fb15c..7abd66fe1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1987,5 +1987,9 @@ "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." } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index fb0992506..84c478cb4 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -525,5 +525,9 @@ "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." } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 5be81b4c2..185141dd0 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -525,5 +525,9 @@ "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." } } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index eb58adf69..5d7aa5072 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -525,5 +525,9 @@ "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." } } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c55a1f7b0..9fbfafe1b 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -525,5 +525,9 @@ "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." } } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 8b2f9c5ef..0b280e2bf 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -525,5 +525,9 @@ "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." } } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index c027d296b..11eaf5150 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -525,5 +525,9 @@ "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." } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9d214284c..76e2d8344 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -525,5 +525,9 @@ "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." } } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 0a2b3d323..23d61a2a4 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -525,5 +525,9 @@ "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." } } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 5220eecaf..e5d3aceb7 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -525,5 +525,9 @@ "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." } } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 8e2b171bf..25a0aec58 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -525,5 +525,9 @@ "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." } } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index a4569bb3e..a76d4f11b 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2822,6 +2822,12 @@ abstract class AppLocalizations { /// 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; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 223c2e1ac..4f93e399c 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1480,4 +1480,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get radarTownOutlineSubtitle => 'Keeps township borders legible under the radar echo.'; + + @override + String get qpesumsOverlayMenuTooltip => 'QPESUMS overlay options'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 32f7dd343..3b39e921b 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1487,4 +1487,8 @@ class AppLocalizationsFil extends AppLocalizations { @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'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 939edfda9..7b94862df 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1483,4 +1483,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get radarTownOutlineSubtitle => 'Menjaga batas kecamatan tetap terbaca di bawah gema radar.'; + + @override + String get qpesumsOverlayMenuTooltip => 'Opsi lapisan prakiraan curah hujan'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 2637fff58..73738f201 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1458,4 +1458,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get radarTownOutlineSubtitle => 'レーダーエコーの下でも市町村境界が見えるようにします。'; + + @override + String get qpesumsOverlayMenuTooltip => '定量降水予報レイヤー設定'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index f89e96502..ed276ca8c 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1466,4 +1466,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get radarTownOutlineSubtitle => '레이더 에코 아래에서도 읍·면·동 경계가 보이도록 합니다.'; + + @override + String get qpesumsOverlayMenuTooltip => '정량 강수 예보 레이어 옵션'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 97aaca358..1a39faeec 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1478,4 +1478,7 @@ class AppLocalizationsTh extends AppLocalizations { @override String get radarTownOutlineSubtitle => 'ทำให้เส้นแบ่งเขตอำเภอยังอ่านออกใต้ภาพเอคโคเรดาร์'; + + @override + String get qpesumsOverlayMenuTooltip => 'ตัวเลือกชั้นพยากรณ์น้ำฝน'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 269799864..6e01faa63 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1481,4 +1481,7 @@ class AppLocalizationsVi extends AppLocalizations { @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'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index c659a9d3e..ca3370299 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1452,6 +1452,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get radarTownOutlineSubtitle => '讓鄉鎮界線在雷達回波下仍然清楚。'; + + @override + String get qpesumsOverlayMenuTooltip => '定量降水預報圖層選項'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -2902,6 +2905,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get radarTownOutlineSubtitle => '让乡镇界线在雷达回波下仍然清楚。'; + + @override + String get qpesumsOverlayMenuTooltip => '定量降水预报图层选项'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -4352,6 +4358,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get radarTownOutlineSubtitle => '讓鄉鎮界線在雷達回波下仍然清楚。'; + + @override + String get qpesumsOverlayMenuTooltip => '定量降水預報圖層選項'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -5802,4 +5811,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get radarTownOutlineSubtitle => '讓鄉鎮界線在雷達回波下仍然清楚。'; + + @override + String get qpesumsOverlayMenuTooltip => '定量降水預報圖層選項'; } diff --git a/test/features/map/qpesums_layer_test.dart b/test/features/map/qpesums_layer_test.dart index 21569f367..eee8bcb8e 100644 --- a/test/features/map/qpesums_layer_test.dart +++ b/test/features/map/qpesums_layer_test.dart @@ -108,6 +108,100 @@ void main() { 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. From b9493ad7395a1685ee7a046f6593f77a44ff11b2 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:17:40 +0800 Subject: [PATCH 22/92] fix: self-heal map platform view on iOS hot restart After a hot restart the engine can still own the previous isolate's view id, so UiKitView creation fails with recreating_view and onMapCreated never fires. Remount the map with a fresh key when it isn't ready within 4s (bounded retries), and treat the benign platform exception as neither a log entry nor a fatal crash. --- lib/core/logging/log.dart | 10 +++ lib/shared/map/base_map.dart | 123 ++++++++++++++++++++++++++++++----- 2 files changed, 115 insertions(+), 18 deletions(-) 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/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, ); } } From 163c289d22a7250400284c4fec75e269b2d93392 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:17:47 +0800 Subject: [PATCH 23/92] fix: compass above all overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the native MapLibre compass (rendered inside the platform view, so any Flutter overlay — typhoon forecast tooltips, an expanded sheet — paints over it) with a Flutter MapCompass at the top of the map stack, driven by the camera bearing and tapping to reset north. --- lib/l10n/app_en.arb | 4 ++ lib/l10n/app_fil.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_th.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hans.arb | 1 + lib/l10n/app_zh_Hant_HK.arb | 1 + lib/l10n/app_zh_TW.arb | 1 + lib/l10n/gen/app_localizations.dart | 6 ++ lib/l10n/gen/app_localizations_en.dart | 3 + lib/l10n/gen/app_localizations_fil.dart | 3 + lib/l10n/gen/app_localizations_id.dart | 3 + lib/l10n/gen/app_localizations_ja.dart | 3 + lib/l10n/gen/app_localizations_ko.dart | 3 + lib/l10n/gen/app_localizations_th.dart | 3 + lib/l10n/gen/app_localizations_vi.dart | 3 + lib/l10n/gen/app_localizations_zh.dart | 12 ++++ lib/shared/map/map_compass.dart | 73 +++++++++++++++++++++++++ lib/shared/map/map_scaffold.dart | 53 ++++++++++++++++++ test/shared/map/map_compass_test.dart | 71 ++++++++++++++++++++++++ 23 files changed, 250 insertions(+) create mode 100644 lib/shared/map/map_compass.dart create mode 100644 test/shared/map/map_compass_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7abd66fe1..5122c007b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1705,6 +1705,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": { diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 84c478cb4..14b6c8349 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -361,6 +361,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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 185141dd0..d9d8443bb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -361,6 +361,7 @@ "mapLegendExpand": "Legenda", "mapLegendCollapse": "Sembunyikan legenda", "mapMyLocation": "Lokasi saya", + "mapResetNorth": "Kembali ke utara", "typhoonLegendCircle15": "Lingkar angin kencang", "typhoonLegendCircle25": "Lingkar badai", "typhoonLegendProbability": "Probabilitas serangan", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 5d7aa5072..086c84967 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -361,6 +361,7 @@ "mapLegendExpand": "凡例", "mapLegendCollapse": "凡例を閉じる", "mapMyLocation": "現在地", + "mapResetNorth": "北を上にする", "typhoonLegendCircle15": "強風域(30kt)", "typhoonLegendCircle25": "暴風域(50kt)", "typhoonLegendProbability": "接近確率", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9fbfafe1b..203510370 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -361,6 +361,7 @@ "mapLegendExpand": "범례", "mapLegendCollapse": "범례 숨기기", "mapMyLocation": "내 위치", + "mapResetNorth": "북쪽으로 되돌리기", "typhoonLegendCircle15": "강풍권 (7급)", "typhoonLegendCircle25": "폭풍권 (10급)", "typhoonLegendProbability": "내습 확률", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 0b280e2bf..52340c89c 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -361,6 +361,7 @@ "mapLegendExpand": "คำอธิบาย", "mapLegendCollapse": "ซ่อนคำอธิบาย", "mapMyLocation": "ตำแหน่งของฉัน", + "mapResetNorth": "กลับไปทางเหนือ", "typhoonLegendCircle15": "วงพายุ (แรง)", "typhoonLegendCircle25": "วงพายุ (รุนแรง)", "typhoonLegendProbability": "โอกาสกระทบ", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 11eaf5150..b2c364546 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -361,6 +361,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ộ", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 76e2d8344..d0b96bf21 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -387,6 +387,7 @@ "mapLegendExpand": "圖例", "mapLegendCollapse": "收合圖例", "mapMyLocation": "我的位置", + "mapResetNorth": "回到北方", "typhoonLegendCircle15": "七級風暴風圈", "typhoonLegendCircleAvg": "平均圓", "typhoonLegendCircle25": "十級風暴風圈", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 23d61a2a4..29210a30d 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -361,6 +361,7 @@ "mapLegendExpand": "图例", "mapLegendCollapse": "收起图例", "mapMyLocation": "我的位置", + "mapResetNorth": "回到正北", "typhoonLegendCircle15": "七级风暴风圈", "typhoonLegendCircle25": "十级风暴风圈", "typhoonLegendProbability": "侵袭概率", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index e5d3aceb7..bf0b1134a 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -361,6 +361,7 @@ "mapLegendExpand": "圖例", "mapLegendCollapse": "收合圖例", "mapMyLocation": "我的位置", + "mapResetNorth": "回到北方", "typhoonLegendCircle15": "七級風暴風圈", "typhoonLegendCircle25": "十級風暴風圈", "typhoonLegendProbability": "侵襲機率", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 25a0aec58..22032cea8 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -387,6 +387,7 @@ "mapLegendExpand": "圖例", "mapLegendCollapse": "收合圖例", "mapMyLocation": "我的位置", + "mapResetNorth": "回到北方", "typhoonLegendCircle15": "七級風暴風圈", "typhoonLegendCircleAvg": "平均圓", "typhoonLegendCircle25": "十級風暴風圈", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index a76d4f11b..3695c3881 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2439,6 +2439,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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 4f93e399c..91a40b51a 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1272,6 +1272,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapMyLocation => 'My location'; + @override + String get mapResetNorth => 'Reset north'; + @override String get typhoonLegendCircle15 => 'Gale circle (L7)'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 3b39e921b..a2db8eea4 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1279,6 +1279,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)'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 7b94862df..c7a2ca6c4 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1275,6 +1275,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'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 73738f201..79491e309 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1261,6 +1261,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapMyLocation => '現在地'; + @override + String get mapResetNorth => '北を上にする'; + @override String get typhoonLegendCircle15 => '強風域(30kt)'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index ed276ca8c..756b37ab5 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1261,6 +1261,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapMyLocation => '내 위치'; + @override + String get mapResetNorth => '북쪽으로 되돌리기'; + @override String get typhoonLegendCircle15 => '강풍권 (7급)'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 1a39faeec..a1976f54f 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1270,6 +1270,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapMyLocation => 'ตำแหน่งของฉัน'; + @override + String get mapResetNorth => 'กลับไปทางเหนือ'; + @override String get typhoonLegendCircle15 => 'วงพายุ (แรง)'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 6e01faa63..a46fd9dd7 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1273,6 +1273,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'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index ca3370299..614c811db 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1255,6 +1255,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapMyLocation => '我的位置'; + @override + String get mapResetNorth => '回到北方'; + @override String get typhoonLegendCircle15 => '七級風暴風圈'; @@ -2708,6 +2711,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapMyLocation => '我的位置'; + @override + String get mapResetNorth => '回到正北'; + @override String get typhoonLegendCircle15 => '七级风暴风圈'; @@ -4161,6 +4167,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapMyLocation => '我的位置'; + @override + String get mapResetNorth => '回到北方'; + @override String get typhoonLegendCircle15 => '七級風暴風圈'; @@ -5614,6 +5623,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapMyLocation => '我的位置'; + @override + String get mapResetNorth => '回到北方'; + @override String get typhoonLegendCircle15 => '七級風暴風圈'; diff --git a/lib/shared/map/map_compass.dart b/lib/shared/map/map_compass.dart new file mode 100644 index 000000000..5ce24fc1e --- /dev/null +++ b/lib/shared/map/map_compass.dart @@ -0,0 +1,73 @@ +/// 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 'dart:math' as math; + +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: -value * math.pi / 180, + child: Icon( + Icons.navigation, + size: 22, + color: colors.primary, + ), + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 2aa961289..54d84e49e 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -14,6 +14,7 @@ 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/raster_timeline_layer.dart'; @@ -112,6 +113,10 @@ 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); + /// 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; @@ -137,6 +142,7 @@ class _MapScaffoldState extends State { @override void dispose() { + _bearing.dispose(); _basemapWarmer?.cancel(); _handoff?.removeListener(_onHandoff); _stationHandoff?.removeListener(_onStationHandoff); @@ -288,6 +294,32 @@ 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; + unawaited( + controller.moveCamera( + CameraUpdate.newCameraPosition( + CameraPosition( + target: position.target, + zoom: position.zoom, + bearing: 0, + tilt: 0, + ), + ), + ), + ); + } + Future _warmBasemap(MapLibreMapController controller) async { final warmer = _basemapWarmer; if (warmer == null) return; @@ -535,6 +567,7 @@ class _MapScaffoldState extends State { onMapCreated: _onMapCreated, onStyleLoaded: _onStyleLoaded, onMapClick: (_, latLng) => _onMapClick(latLng), + onCameraMove: _onCameraMove, onCameraIdle: () { _active.onMapGestureEnd(); final controller = _controller; @@ -545,6 +578,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, ), ), ), @@ -622,6 +659,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 + // where the native compass sat: under the layer switcher chip. + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: EdgeInsets.only( + top: layerChipBand, + right: AppSpacing.lg, + ), + child: MapCompass(bearing: _bearing, onPressed: _resetNorth), + ), + ), + ), ], ); } 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); + }); +} From d740ca83eb0f0e5e2e6732124be4197a074f7194 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:52:23 +0800 Subject: [PATCH 24/92] fix: space the QPESUMS layer label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未來1小時 → 未來 1 小時 for the four Chinese variants. --- lib/l10n/app_zh.arb | 2 +- lib/l10n/app_zh_Hans.arb | 2 +- lib/l10n/app_zh_Hant_HK.arb | 2 +- lib/l10n/app_zh_TW.arb | 2 +- lib/l10n/gen/app_localizations_zh.dart | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d0b96bf21..64c6ad647 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -160,7 +160,7 @@ "mapLayers": "圖層", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", - "mapLayerQpesums": "未來1小時降水預報", + "mapLayerQpesums": "未來 1 小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 29210a30d..e782cae49 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -160,7 +160,7 @@ "mapLayers": "图层", "mapLayerRadar": "雷达合成回波图", "mapLayerSatellite": "ひまわり 红外线图", - "mapLayerQpesums": "未来1小时降水预报", + "mapLayerQpesums": "未来 1 小时降水预报", "mapLayerLightning": "闪电", "lightningLegendCg": "对地 · {minutes} 分钟内", "lightningLegendCc": "云间 · {minutes} 分钟内", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index bf0b1134a..7cda8cff1 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -160,7 +160,7 @@ "mapLayers": "圖層", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", - "mapLayerQpesums": "未來1小時降水預報", + "mapLayerQpesums": "未來 1 小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 22032cea8..e64c18525 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -160,7 +160,7 @@ "mapLayers": "圖層", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", - "mapLayerQpesums": "未來1小時降水預報", + "mapLayerQpesums": "未來 1 小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 614c811db..009d75240 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -537,7 +537,7 @@ class AppLocalizationsZh extends AppLocalizations { String get mapLayerSatellite => 'ひまわり 紅外線圖'; @override - String get mapLayerQpesums => '未來1小時降水預報'; + String get mapLayerQpesums => '未來 1 小時降水預報'; @override String get mapLayerLightning => '閃電'; @@ -1993,7 +1993,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get mapLayerSatellite => 'ひまわり 红外线图'; @override - String get mapLayerQpesums => '未来1小时降水预报'; + String get mapLayerQpesums => '未来 1 小时降水预报'; @override String get mapLayerLightning => '闪电'; @@ -3449,7 +3449,7 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get mapLayerSatellite => 'ひまわり 紅外線圖'; @override - String get mapLayerQpesums => '未來1小時降水預報'; + String get mapLayerQpesums => '未來 1 小時降水預報'; @override String get mapLayerLightning => '閃電'; @@ -4905,7 +4905,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get mapLayerSatellite => 'ひまわり 紅外線圖'; @override - String get mapLayerQpesums => '未來1小時降水預報'; + String get mapLayerQpesums => '未來 1 小時降水預報'; @override String get mapLayerLightning => '閃電'; From 7e3c2287586c3474a836d1792c1338fe2772abb7 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 10 Aug 2026 23:53:34 +0800 Subject: [PATCH 25/92] refactor: extract notification tap routing into its own file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy handled tap → navigation inside notify.dart; the rewrite had it as a private method on the app service host. Move it to notification_routes.dart (which already owns the channel → route table) as routeNotificationTap — the app host just registers it on NotificationTaps — with an injectable navigate for tests. --- lib/app/app.dart | 14 ++------ lib/app/router/notification_routes.dart | 28 +++++++++++++++ test/app/router/notification_routes_test.dart | 36 +++++++++++++++++++ 3 files changed, 67 insertions(+), 11 deletions(-) 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..0c805fae1 100644 --- a/lib/app/router/notification_routes.dart +++ b/lib/app/router/notification_routes.dart @@ -1,7 +1,35 @@ +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/test/app/router/notification_routes_test.dart b/test/app/router/notification_routes_test.dart index adfcf1eef..005dffb25 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,39 @@ 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, + ]); + }); } From 2d1a290231920631b179853dcf2a9e07d9004706 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 00:23:32 +0800 Subject: [PATCH 26/92] feat: open DPM points in an external map app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DPM detail sheet gains an "open in maps" action that hands the tapped point to Google Maps / Apple Maps, or copies its coordinates. A shared MapLaunchTarget + mapsAppUrl keep the URI schemes in one place, and the platform's home map app is marked 預設 in the picker. --- .../map/presentation/widgets/dpm_sheet.dart | 23 +++ lib/l10n/app_en.arb | 28 ++++ lib/l10n/app_fil.arb | 22 +++ lib/l10n/app_id.arb | 22 +++ lib/l10n/app_ja.arb | 22 +++ lib/l10n/app_ko.arb | 22 +++ lib/l10n/app_th.arb | 22 +++ lib/l10n/app_vi.arb | 22 +++ lib/l10n/app_zh.arb | 22 +++ lib/l10n/app_zh_Hans.arb | 22 +++ lib/l10n/app_zh_Hant_HK.arb | 22 +++ lib/l10n/app_zh_TW.arb | 22 +++ lib/l10n/gen/app_localizations.dart | 36 +++++ lib/l10n/gen/app_localizations_en.dart | 20 +++ lib/l10n/gen/app_localizations_fil.dart | 20 +++ lib/l10n/gen/app_localizations_id.dart | 20 +++ lib/l10n/gen/app_localizations_ja.dart | 20 +++ lib/l10n/gen/app_localizations_ko.dart | 20 +++ lib/l10n/gen/app_localizations_th.dart | 20 +++ lib/l10n/gen/app_localizations_vi.dart | 20 +++ lib/l10n/gen/app_localizations_zh.dart | 80 ++++++++++ lib/shared/map/maps_launcher.dart | 143 ++++++++++++++++++ test/shared/map/maps_launcher_test.dart | 47 ++++++ 23 files changed, 717 insertions(+) create mode 100644 lib/shared/map/maps_launcher.dart create mode 100644 test/shared/map/maps_launcher_test.dart diff --git a/lib/features/map/presentation/widgets/dpm_sheet.dart b/lib/features/map/presentation/widgets/dpm_sheet.dart index 853e88eaa..c31cd132f 100644 --- a/lib/features/map/presentation/widgets/dpm_sheet.dart +++ b/lib/features/map/presentation/widgets/dpm_sheet.dart @@ -11,6 +11,7 @@ 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/map/maps_launcher.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -498,6 +499,9 @@ class _AedBody extends StatelessWidget { name: name, place: place, onClose: onClose, + mapsTarget: d == null + ? null + : MapLaunchTarget(lat: d.lat, lng: d.lng, label: d.name), body: d == null ? [_Loading()] : [ @@ -567,6 +571,13 @@ class _RestroomBody extends StatelessWidget { name: name, place: place, onClose: onClose, + mapsTarget: d == null + ? null + : MapLaunchTarget( + lat: d.latitude, + lng: d.longitude, + label: d.name, + ), body: d == null ? [_Loading()] : [ @@ -625,6 +636,9 @@ class _ShelterBody extends StatelessWidget { name: name, place: place, onClose: onClose, + mapsTarget: d == null + ? null + : MapLaunchTarget(lat: d.lat, lng: d.lng, label: d.name), body: d == null ? [_Loading()] : [ @@ -663,6 +677,7 @@ class _Header extends StatelessWidget { required this.name, required this.place, required this.onClose, + this.mapsTarget, required this.body, }); @@ -670,12 +685,14 @@ class _Header extends StatelessWidget { 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( @@ -693,6 +710,12 @@ class _Header extends StatelessWidget { ), ), ), + if (mapsTarget != null) + IconButton( + tooltip: l10n.dpmOpenInMaps, + onPressed: () => showMapAppPicker(context, mapsTarget!), + icon: const Icon(Icons.directions_outlined), + ), IconButton( tooltip: MaterialLocalizations.of(context).closeButtonTooltip, onPressed: onClose, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5122c007b..b56edbd3b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1465,6 +1465,34 @@ "@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" + }, + "dpmSheetEmpty": "Tap a marker on the map for details", "@dpmSheetEmpty": { "description": "Hint in the disaster-map detail sheet when nothing is selected" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 14b6c8349..b00614f6c 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -314,6 +314,28 @@ "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": { + }, + "dpmSheetEmpty": "I-tap ang marker sa mapa para sa detalye", "dpmAddress": "Address", "restroomTypeLabel": "Uri", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d9d8443bb..d4689a15c 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -314,6 +314,28 @@ "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": { + }, + "dpmSheetEmpty": "Ketuk penanda di peta untuk detail", "dpmAddress": "Alamat", "restroomTypeLabel": "Jenis", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 086c84967..67deadf05 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -314,6 +314,28 @@ "mapLayerShelter": "避難所", "disasterMapOverlayRestroomTooltip": "トイレを表示", "disasterMapOverlayShelterTooltip": "避難所を表示", + "dpmOpenInMaps": "地図アプリで開く", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app}(デフォルト)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "座標をコピー", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "座標をコピーしました", + "@mapAppCoordinatesCopied": { + }, + "dpmSheetEmpty": "地図上のマーカーをタップして詳細を表示", "dpmAddress": "住所", "restroomTypeLabel": "種別", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 203510370..5d712c44c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -314,6 +314,28 @@ "mapLayerShelter": "대피소", "disasterMapOverlayRestroomTooltip": "공중화장실 표시", "disasterMapOverlayShelterTooltip": "대피소 표시", + "dpmOpenInMaps": "지도에서 열기", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app} (기본)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "좌표 복사", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "좌표가 복사되었습니다", + "@mapAppCoordinatesCopied": { + }, + "dpmSheetEmpty": "지도에서 마커를 눌러 상세 보기", "dpmAddress": "주소", "restroomTypeLabel": "유형", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 52340c89c..38b684793 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -314,6 +314,28 @@ "mapLayerShelter": "ศูนย์อพยพ", "disasterMapOverlayRestroomTooltip": "แสดงห้องน้ำสาธารณะ", "disasterMapOverlayShelterTooltip": "แสดงศูนย์อพยพ", + "dpmOpenInMaps": "เปิดในแผนที่", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app} (ค่าเริ่มต้น)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "คัดลอกพิกัด", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "คัดลอกพิกัดแล้ว", + "@mapAppCoordinatesCopied": { + }, + "dpmSheetEmpty": "แตะเครื่องหมายบนแผนที่เพื่อดูรายละเอียด", "dpmAddress": "ที่อยู่", "restroomTypeLabel": "ประเภท", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index b2c364546..a8584077c 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -314,6 +314,28 @@ "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": { + }, + "dpmSheetEmpty": "Chạm vào điểm đánh dấu trên bản đồ để xem chi tiết", "dpmAddress": "Địa chỉ", "restroomTypeLabel": "Loại", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 64c6ad647..9d88ede48 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -334,6 +334,28 @@ "mapLayerShelter": "避難收容場所", "disasterMapOverlayRestroomTooltip": "顯示公廁", "disasterMapOverlayShelterTooltip": "顯示避難收容場所", + "dpmOpenInMaps": "開啟地圖", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app}(預設)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "複製座標", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "已複製座標", + "@mapAppCoordinatesCopied": { + }, + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index e782cae49..35cf8f073 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -314,6 +314,28 @@ "mapLayerShelter": "避难收容场所", "disasterMapOverlayRestroomTooltip": "显示公厕", "disasterMapOverlayShelterTooltip": "显示避难收容场所", + "dpmOpenInMaps": "打开地图", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app}(默认)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "复制坐标", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "已复制坐标", + "@mapAppCoordinatesCopied": { + }, + "dpmSheetEmpty": "点击地图上的标记查看详情", "dpmAddress": "地址", "restroomTypeLabel": "厕所类型", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 7cda8cff1..24ba84320 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -314,6 +314,28 @@ "mapLayerShelter": "避難收容場所", "disasterMapOverlayRestroomTooltip": "顯示公廁", "disasterMapOverlayShelterTooltip": "顯示避難收容場所", + "dpmOpenInMaps": "開啟地圖", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app}(預設)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "複製座標", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "已複製座標", + "@mapAppCoordinatesCopied": { + }, + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index e64c18525..7cebf63b0 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -334,6 +334,28 @@ "mapLayerShelter": "避難收容場所", "disasterMapOverlayRestroomTooltip": "顯示公廁", "disasterMapOverlayShelterTooltip": "顯示避難收容場所", + "dpmOpenInMaps": "開啟地圖", + "@dpmOpenInMaps": { + }, + "mapAppGoogleMaps": "Google Maps", + "@mapAppGoogleMaps": { + }, + "mapAppAppleMaps": "Apple Maps", + "@mapAppAppleMaps": { + }, + "mapAppDefault": "{app}(預設)", + "@mapAppDefault": { + "placeholders": { + "app": {"type": "String"} + } + }, + "mapAppCopyCoordinates": "複製座標", + "@mapAppCopyCoordinates": { + }, + "mapAppCoordinatesCopied": "已複製座標", + "@mapAppCoordinatesCopied": { + }, + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 3695c3881..36c4ec990 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2121,6 +2121,42 @@ 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; + /// Hint in the disaster-map detail sheet when nothing is selected /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 91a40b51a..fbbf4dbe7 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1103,6 +1103,26 @@ 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 get dpmSheetEmpty => 'Tap a marker on the map for details'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index a2db8eea4..92dbd0709 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1110,6 +1110,26 @@ 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 get dpmSheetEmpty => 'I-tap ang marker sa mapa para sa detalye'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index c7a2ca6c4..05bbae0c5 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1106,6 +1106,26 @@ 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 get dpmSheetEmpty => 'Ketuk penanda di peta untuk detail'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 79491e309..7c26c1dcb 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1092,6 +1092,26 @@ 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 get dpmSheetEmpty => '地図上のマーカーをタップして詳細を表示'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 756b37ab5..630bad34e 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1092,6 +1092,26 @@ 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 get dpmSheetEmpty => '지도에서 마커를 눌러 상세 보기'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index a1976f54f..37d4f1ed0 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1101,6 +1101,26 @@ 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 get dpmSheetEmpty => 'แตะเครื่องหมายบนแผนที่เพื่อดูรายละเอียด'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index a46fd9dd7..9fa6b246f 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1103,6 +1103,26 @@ 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 get dpmSheetEmpty => 'Chạm vào điểm đánh dấu trên bản đồ để xem chi tiết'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 009d75240..0d9847053 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1086,6 +1086,26 @@ 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 get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -2542,6 +2562,26 @@ 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 get dpmSheetEmpty => '点击地图上的标记查看详情'; @@ -3998,6 +4038,26 @@ 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 get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -5454,6 +5514,26 @@ 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 get dpmSheetEmpty => '點選地圖上的標記查看詳情'; diff --git a/lib/shared/map/maps_launcher.dart b/lib/shared/map/maps_launcher.dart new file mode 100644 index 000000000..6e7f49042 --- /dev/null +++ b/lib/shared/map/maps_launcher.dart @@ -0,0 +1,143 @@ +/// 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, +); + +/// 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)), + ); + }, + ), + ], + ), + ), + ); +} + +String _enc(String s) => Uri.encodeComponent(s); diff --git a/test/shared/map/maps_launcher_test.dart b/test/shared/map/maps_launcher_test.dart new file mode 100644 index 000000000..ee1a84077 --- /dev/null +++ b/test/shared/map/maps_launcher_test.dart @@ -0,0 +1,47 @@ +import 'package:dpip/shared/map/maps_launcher.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +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); + } + }); + }); +} From 5187dca9ef8168f5486b35683ddf95cba4fbbcd2 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 00:30:52 +0800 Subject: [PATCH 27/92] style: reformat the notification route navigator typedef --- lib/app/router/notification_routes.dart | 15 ++++++++------- test/app/router/notification_routes_test.dart | 6 +----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/app/router/notification_routes.dart b/lib/app/router/notification_routes.dart index 0c805fae1..dfa92db68 100644 --- a/lib/app/router/notification_routes.dart +++ b/lib/app/router/notification_routes.dart @@ -5,13 +5,14 @@ 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, -}); +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 diff --git a/test/app/router/notification_routes_test.dart b/test/app/router/notification_routes_test.dart index 005dffb25..0e6f7442c 100644 --- a/test/app/router/notification_routes_test.dart +++ b/test/app/router/notification_routes_test.dart @@ -100,10 +100,6 @@ void main() { navigate: record, ); - expect(named, [ - AppRoutes.eew, - AppRoutes.earthquake, - AppRoutes.home, - ]); + expect(named, [AppRoutes.eew, AppRoutes.earthquake, AppRoutes.home]); }); } From e51250d1eb772c965d4763c6e19e2f5b1b84998a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 00:30:59 +0800 Subject: [PATCH 28/92] feat: show rain-trend update time and a no-data pane The section header now carries the data's server `start` stamp as Taipei wall clock, and when no trend data is available the chart band becomes a grey pane (spinner during the first fetch, error + retry on failure) instead of falling back to a fabricated placeholder chart. The chart itself is anchored to the corrected clock: minutes outside the data window render as full-height grey rods, so the covered span reads honestly even when the fetch is stale. --- .../widgets/home_rain_trend_section.dart | 344 +++++++++++------- lib/l10n/app_en.arb | 9 + lib/l10n/app_fil.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_th.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hans.arb | 1 + lib/l10n/app_zh_Hant_HK.arb | 1 + lib/l10n/app_zh_TW.arb | 1 + lib/l10n/gen/app_localizations.dart | 6 + lib/l10n/gen/app_localizations_en.dart | 5 + lib/l10n/gen/app_localizations_fil.dart | 5 + lib/l10n/gen/app_localizations_id.dart | 5 + lib/l10n/gen/app_localizations_ja.dart | 5 + lib/l10n/gen/app_localizations_ko.dart | 5 + lib/l10n/gen/app_localizations_th.dart | 5 + lib/l10n/gen/app_localizations_vi.dart | 5 + lib/l10n/gen/app_localizations_zh.dart | 20 + 21 files changed, 298 insertions(+), 126 deletions(-) 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 bac3ae3c2..d755adeff 100644 --- a/lib/features/home/presentation/widgets/home_rain_trend_section.dart +++ b/lib/features/home/presentation/widgets/home_rain_trend_section.dart @@ -6,6 +6,8 @@ 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'; @@ -13,16 +15,19 @@ import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; import 'package:dpip/l10n/gen/app_localizations.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. /// /// Reads the live trend from [HomeWeatherController] (which follows the -/// selected township); a spinner shows while the first fetch is in flight and -/// an error + retry row on failure — never a fabricated chart. Falls back to -/// [RainHourTrend.placeholder] only when the controller has no data to report -/// at all (no township, pre-wiring state). +/// 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, @@ -76,39 +81,19 @@ class HomeRainTrendSection extends StatelessWidget { final Widget body; if (data != null) { - body = _Chart(data: data, barColor: colors.primary, secondary: secondary); - } else if (controller.loading) { - body = SizedBox( - height: 120, - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2, color: secondary), - ), - ), - ); - } else if (controller.hourTrendFailure != null) { - body = Row( - children: [ - Expanded( - child: Text( - l10n.homeForecastEmpty, - style: theme.textTheme.bodyMedium?.copyWith(color: secondary), - ), - ), - TextButton( - onPressed: controller.refresh, - child: Text(l10n.commonRetry), - ), - ], - ); - } else { body = _Chart( - data: RainHourTrend.placeholder(), + 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 @@ -166,6 +151,14 @@ class HomeRainTrendSection extends StatelessWidget { ), ), ), + if (data != null) + Text( + l10n.homeRainTrendUpdated(_updatedClock(data)), + style: theme.textTheme.labelSmall?.copyWith( + color: secondary, + fontWeight: FontWeight.w500, + ), + ), ], ), if (subtitle != null) ...[ @@ -186,6 +179,69 @@ class HomeRainTrendSection extends StatelessWidget { ), ); } + + /// 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 = data.startUtc.add(const Duration(hours: 8)); + 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 = SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2, 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 = const SizedBox.shrink(); + } + return Container( + height: 120, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.55), + borderRadius: AppRadius.medium, + ), + 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 @@ -193,6 +249,7 @@ class HomeRainTrendSection extends StatelessWidget { class _Chart extends StatelessWidget { const _Chart({ required this.data, + required this.now, required this.barColor, required this.secondary, }); @@ -206,114 +263,149 @@ class _Chart extends StatelessWidget { /// X-axis ticks, minutes from [RainHourTrend.startSecond]. static const List _ticks = [0, 10, 20, 30, 40, 50]; + /// 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); - return Padding( - // Room for the edge X labels (現在 / 50分) — the chart clips its own - // titles at the first/last bar otherwise. - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - child: SizedBox( - height: 120, - child: 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: math.min(data.mm[i], _maxMm), - width: 2.5, - color: barColor, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(1), - ), - ), - ], + // 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 data window carry no forecast — render their rods as + // full-height grey bars, so the missing span reads as a grey band drawn by + // the chart itself (no overlay to misalign with the plot area). + bool hasDataAt(int i) => i >= headEnd && i < tailStart; + + final chart = 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) : _maxMm, + width: 2.5, + color: hasDataAt(i) + ? barColor + : secondary.withValues(alpha: 0.28), + 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, + ], + // 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); - // The titles band starts flush under the X axis, and fl_chart - // stretches each title widget to the full band height — so - // start-alignment pins the tick's top to the axis line (the - // tick hangs down from it) and rides the label up beside it. - return 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: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: secondary), - ), - ], - ); - }, - ), - ), + ), + ), + 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), + ], + ), + ); + }, ), ), ), ), ); + + return SizedBox(height: 120, child: chart); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b56edbd3b..cb18bbed1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -722,6 +722,15 @@ } } }, + "homeRainTrendUpdated": "Updated {time}", + "@homeRainTrendUpdated": { + "description": "Data-update time beside the home rain trend title, Taipei wall clock HH:mm", + "placeholders": { + "time": { + "type": "String" + } + } + }, "homeRainTrendScattered": "Light showers possible", "@homeRainTrendScattered": { "description": "Home rain trend subtitle: peak intensity below the light-rain threshold" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index b00614f6c..6f586d4ab 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -152,6 +152,7 @@ "homeActiveEventsEmpty": "Walang aktibong event", "homeRainTrendTitle": "Ulan sa susunod na oras", "homeRainTrendMinute": "{minute} min", + "homeRainTrendUpdated": "Na-update {time}", "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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d4689a15c..dfecbbdac 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -152,6 +152,7 @@ "homeActiveEventsEmpty": "Tidak ada peristiwa aktif", "homeRainTrendTitle": "Hujan 1 jam ke depan", "homeRainTrendMinute": "{minute} mnt", + "homeRainTrendUpdated": "Diperbarui {time}", "homeRainTrendScattered": "Kemungkinan hujan ringan", "homeRainTrendLightSustained": "Hujan ringan berlanjut selama 1 jam ke depan", "homeRainTrendLightStopping": "Hujan ringan diperkirakan berhenti dalam {minutes} menit", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 67deadf05..117c71762 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -152,6 +152,7 @@ "homeActiveEventsEmpty": "発生中の事象はありません", "homeRainTrendTitle": "今後1時間の雨", "homeRainTrendMinute": "{minute}分", + "homeRainTrendUpdated": "更新 {time}", "homeRainTrendScattered": "にわか雨の可能性があります", "homeRainTrendLightSustained": "今後1時間は小雨が続きます", "homeRainTrendLightStopping": "{minutes}分後に小雨が止む見込みです", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5d712c44c..dd3c8ad49 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -152,6 +152,7 @@ "homeActiveEventsEmpty": "발효 중인 이벤트가 없습니다", "homeRainTrendTitle": "향후 1시간 강수", "homeRainTrendMinute": "{minute}분", + "homeRainTrendUpdated": "업데이트 {time}", "homeRainTrendScattered": "약한 비가 올 수 있어요", "homeRainTrendLightSustained": "앞으로 1시간 동안 약한 비가 이어질 거예요", "homeRainTrendLightStopping": "{minutes}분 후에 비가 그칠 것으로 예상돼요", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 38b684793..910eb487d 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -152,6 +152,7 @@ "homeActiveEventsEmpty": "ไม่มีเหตุการณ์ที่ยังมีผล", "homeRainTrendTitle": "ฝนชั่วโมงถัดไป", "homeRainTrendMinute": "{minute} นาที", + "homeRainTrendUpdated": "อัปเดต {time}", "homeRainTrendScattered": "อาจมีฝนตกประปราย", "homeRainTrendLightSustained": "ฝนตกเล็กน้อยต่อเนื่องตลอดชั่วโมงหน้า", "homeRainTrendLightStopping": "คาดว่าฝนจะหยุดในอีก {minutes} นาที", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index a8584077c..07146c76e 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -152,6 +152,7 @@ "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}", "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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9d88ede48..2161c1d79 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -152,6 +152,7 @@ "homeActiveEventsEmpty": "目前沒有生效中的事件", "homeRainTrendTitle": "近 1 小時降水趨勢", "homeRainTrendMinute": "{minute}分", + "homeRainTrendUpdated": "更新 {time}", "homeRainTrendScattered": "可能會有零星降雨", "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 35cf8f073..570cd5340 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -152,6 +152,7 @@ "homeActiveEventsEmpty": "目前没有生效中的事件", "homeRainTrendTitle": "近 1 小时降水趋势", "homeRainTrendMinute": "{minute}分", + "homeRainTrendUpdated": "更新 {time}", "homeRainTrendScattered": "可能会有零星降雨", "homeRainTrendLightSustained": "未来 1 小时会有持续小雨", "homeRainTrendLightStopping": "预计 {minutes} 分钟后停止下小雨", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 24ba84320..1bb9c3a34 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -152,6 +152,7 @@ "homeActiveEventsEmpty": "目前沒有生效中的事件", "homeRainTrendTitle": "近 1 小時降水趨勢", "homeRainTrendMinute": "{minute}分", + "homeRainTrendUpdated": "更新 {time}", "homeRainTrendScattered": "可能會有零星降雨", "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7cebf63b0..d9fea49ef 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -152,6 +152,7 @@ "homeActiveEventsEmpty": "目前沒有生效中的事件", "homeRainTrendTitle": "近 1 小時降水趨勢", "homeRainTrendMinute": "{minute}分", + "homeRainTrendUpdated": "更新 {time}", "homeRainTrendScattered": "可能會有零星降雨", "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 36c4ec990..df8f9dbf1 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1029,6 +1029,12 @@ abstract class AppLocalizations { /// **'{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); + /// Home rain trend subtitle: peak intensity below the light-rain threshold /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index fbbf4dbe7..690d37cf7 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -511,6 +511,11 @@ class AppLocalizationsEn extends AppLocalizations { return '$minute min'; } + @override + String homeRainTrendUpdated(String time) { + return 'Updated $time'; + } + @override String get homeRainTrendScattered => 'Light showers possible'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 92dbd0709..0ef816686 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -516,6 +516,11 @@ class AppLocalizationsFil extends AppLocalizations { return '$minute min'; } + @override + String homeRainTrendUpdated(String time) { + return 'Na-update $time'; + } + @override String get homeRainTrendScattered => 'Posibleng mahinang ulan'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 05bbae0c5..476ec46a7 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -512,6 +512,11 @@ class AppLocalizationsId extends AppLocalizations { return '$minute mnt'; } + @override + String homeRainTrendUpdated(String time) { + return 'Diperbarui $time'; + } + @override String get homeRainTrendScattered => 'Kemungkinan hujan ringan'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 7c26c1dcb..17dfa0ac4 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -509,6 +509,11 @@ class AppLocalizationsJa extends AppLocalizations { return '$minute分'; } + @override + String homeRainTrendUpdated(String time) { + return '更新 $time'; + } + @override String get homeRainTrendScattered => 'にわか雨の可能性があります'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 630bad34e..e16d5dd20 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -509,6 +509,11 @@ class AppLocalizationsKo extends AppLocalizations { return '$minute분'; } + @override + String homeRainTrendUpdated(String time) { + return '업데이트 $time'; + } + @override String get homeRainTrendScattered => '약한 비가 올 수 있어요'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 37d4f1ed0..b31484e9d 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -510,6 +510,11 @@ class AppLocalizationsTh extends AppLocalizations { return '$minute นาที'; } + @override + String homeRainTrendUpdated(String time) { + return 'อัปเดต $time'; + } + @override String get homeRainTrendScattered => 'อาจมีฝนตกประปราย'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 9fa6b246f..255c765cd 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -511,6 +511,11 @@ class AppLocalizationsVi extends AppLocalizations { return '$minute phút'; } + @override + String homeRainTrendUpdated(String time) { + return 'Cập nhật $time'; + } + @override String get homeRainTrendScattered => 'Có thể có mưa rào nhẹ'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 0d9847053..cc6264f3f 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -508,6 +508,11 @@ class AppLocalizationsZh extends AppLocalizations { return '$minute分'; } + @override + String homeRainTrendUpdated(String time) { + return '更新 $time'; + } + @override String get homeRainTrendScattered => '可能會有零星降雨'; @@ -1984,6 +1989,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { return '$minute分'; } + @override + String homeRainTrendUpdated(String time) { + return '更新 $time'; + } + @override String get homeRainTrendScattered => '可能会有零星降雨'; @@ -3460,6 +3470,11 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { return '$minute分'; } + @override + String homeRainTrendUpdated(String time) { + return '更新 $time'; + } + @override String get homeRainTrendScattered => '可能會有零星降雨'; @@ -4936,6 +4951,11 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '$minute分'; } + @override + String homeRainTrendUpdated(String time) { + return '更新 $time'; + } + @override String get homeRainTrendScattered => '可能會有零星降雨'; From cd541028d93ea598ac3493cb820a361d1eac82f7 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 00:31:19 +0800 Subject: [PATCH 29/92] feat: outline lightning markers for visibility over pale tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strike dot/cross now bake a black offset ring like the wind arrows do — the icon-colour tint (multiply) leaves the black outline black, so yellow and blue strikes stay readable on the frosted card and pale map tiles. Icons are also ~60% larger to match the wind arrows' on-screen weight, and the legend marks mirror the outline. --- .../presentation/layers/lightning_layer.dart | 82 +++++++++++-------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/lib/features/map/presentation/layers/lightning_layer.dart b/lib/features/map/presentation/layers/lightning_layer.dart index c8bc37841..c510a6b8e 100644 --- a/lib/features/map/presentation/layers/lightning_layer.dart +++ b/lib/features/map/presentation/layers/lightning_layer.dart @@ -294,14 +294,17 @@ class LightningMapLayer implements MapLayer { 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, @@ -336,12 +339,22 @@ class LightningMapLayer implements MapLayer { Future _renderDot() async { const size = 64.0; + const halo = 3.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 image keeps its + // white fill for the `icon-color` tint, and the tint (multiply) leaves the + // black ring black so strikes stay readable over pale tiles. + 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 = const Color(0xFFFFFFFF), + ); final image = await recorder.endRecording().toImage( size.toInt(), size.toInt(), @@ -353,34 +366,24 @@ class LightningMapLayer implements MapLayer { Future _renderCross() async { const size = 64.0; const thickness = 10.0; + const halo = 3.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), + // Vertical + horizontal bars, black behind white (see [_renderDot]). + RRect bar(double w, double h) => RRect.fromRectAndRadius( + Rect.fromCenter( + center: const Offset(size / 2, size / 2), + width: w, + height: h, ), - paint, - ); - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromCenter( - center: const Offset(size / 2, size / 2), - width: size * 0.7, - height: thickness, - ), - const Radius.circular(2), - ), - paint, + const Radius.circular(2), ); + final black = Paint()..color = const Color(0xFF000000); + final white = Paint()..color = const Color(0xFFFFFFFF); + 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), white); + canvas.drawRRect(bar(size * 0.7, thickness), white); final image = await recorder.endRecording().toImage( size.toInt(), size.toInt(), @@ -411,7 +414,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 +438,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 From e9126d4bfba7ffe37cd54fedac1d1aaac0c9ea3a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 00:34:50 +0800 Subject: [PATCH 30/92] refactor: bake lightning colours in so the outline survives tinting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-SDF PNG shown through MapLibre's `iconColor` gets its RGB *replaced*, which erases the black outline — so instead of one image per shape plus a colour match, pre-render one PNG per shape × age bucket (colour + outline baked) and pick it straight from the feature's `icon` property. No tint, so the ring stays black. --- .../presentation/layers/lightning_layer.dart | 69 ++++++++++--------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/lib/features/map/presentation/layers/lightning_layer.dart b/lib/features/map/presentation/layers/lightning_layer.dart index c510a6b8e..3b36e27e7 100644 --- a/lib/features/map/presentation/layers/lightning_layer.dart +++ b/lib/features/map/presentation/layers/lightning_layer.dart @@ -28,8 +28,7 @@ class LightningMapLayer implements MapLayer { 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', @@ -253,8 +252,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 +272,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,24 +286,10 @@ 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, @@ -316,13 +315,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}; @@ -337,14 +341,14 @@ class LightningMapLayer implements MapLayer { return 60; } - Future _renderDot() async { + Future _renderDot(Color fill) async { const size = 64.0; const halo = 3.0; final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); - // Black offset-outline baked in, like the wind arrows: the image keeps its - // white fill for the `icon-color` tint, and the tint (multiply) leaves the - // black ring black so strikes stay readable over pale tiles. + // 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, @@ -353,7 +357,7 @@ class LightningMapLayer implements MapLayer { canvas.drawCircle( const Offset(size / 2, size / 2), size * 0.28, - Paint()..color = const Color(0xFFFFFFFF), + Paint()..color = fill, ); final image = await recorder.endRecording().toImage( size.toInt(), @@ -363,13 +367,13 @@ class LightningMapLayer implements MapLayer { return data!.buffer.asUint8List(); } - Future _renderCross() async { + Future _renderCross(Color fill) async { const size = 64.0; const thickness = 10.0; const halo = 3.0; final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); - // Vertical + horizontal bars, black behind white (see [_renderDot]). + // 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), @@ -379,11 +383,10 @@ class LightningMapLayer implements MapLayer { const Radius.circular(2), ); final black = Paint()..color = const Color(0xFF000000); - final white = Paint()..color = const Color(0xFFFFFFFF); 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), white); - canvas.drawRRect(bar(size * 0.7, thickness), white); + 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(), From 42b18ea1f8e59a5a8e523fa2b3d65d63c76df457 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 00:35:41 +0800 Subject: [PATCH 31/92] style: thicken the lightning outline to match the wind arrows 4.0 on the 64 px mark is the same proportional ring as 5.5 on the wind arrows' 96 px canvas, so both glyphs read the same after the layer scales them to equal on-screen size. --- lib/features/map/presentation/layers/lightning_layer.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/features/map/presentation/layers/lightning_layer.dart b/lib/features/map/presentation/layers/lightning_layer.dart index 3b36e27e7..2e7f46624 100644 --- a/lib/features/map/presentation/layers/lightning_layer.dart +++ b/lib/features/map/presentation/layers/lightning_layer.dart @@ -343,7 +343,10 @@ class LightningMapLayer implements MapLayer { Future _renderDot(Color fill) async { const size = 64.0; - const halo = 3.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); // Black offset-outline baked in (like the wind arrows): the PNG carries the @@ -370,7 +373,8 @@ class LightningMapLayer implements MapLayer { Future _renderCross(Color fill) async { const size = 64.0; const thickness = 10.0; - const halo = 3.0; + // Same proportional outline as the wind arrows — see [_renderDot]. + const halo = 4.0; final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); // Vertical + horizontal bars, black behind the colour (see [_renderDot]). From 087140d575f0118fc06cff3df1baf8269d2cfbbd Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 02:25:02 +0800 Subject: [PATCH 32/92] feat: expose the township-name toggle on every map layer The base map draws township names past townLabelMinZoom; the setting to turn them off used to live on the raster chrome only, so layers without a scan-range menu (wind, RTS, lightning) could not change it. Lift the state into MapScaffold, hand it to every layer's settings menu via buildTopTrailingChrome, and fall back to a standalone label menu for layers that ship no chrome. Menus are also sectioned (reference layers vs map settings) like the typhoon one, with a scroll view so long menus never fall off the bottom of the screen. --- .../layers/disaster_map_layer.dart | 13 +- .../presentation/layers/lightning_layer.dart | 9 +- .../presentation/layers/qpesums_layer.dart | 9 +- .../map/presentation/layers/radar_layer.dart | 12 +- .../map/presentation/layers/rain_layer.dart | 38 ++- .../map/presentation/layers/rts_layer.dart | 8 +- .../presentation/layers/typhoon_layer.dart | 12 +- .../layers/weather_station_layer.dart | 7 +- .../widgets/disaster_map_overlay_menu.dart | 47 +++- .../widgets/radar_overlay_menu.dart | 12 +- .../widgets/scan_range_overlay_menu.dart | 80 +++--- .../widgets/typhoon_overlay_menu.dart | 230 +++++++++--------- lib/l10n/app_en.arb | 22 ++ lib/l10n/app_fil.arb | 7 + lib/l10n/app_id.arb | 7 + lib/l10n/app_ja.arb | 7 + lib/l10n/app_ko.arb | 7 + lib/l10n/app_th.arb | 7 + lib/l10n/app_vi.arb | 7 + lib/l10n/app_zh.arb | 7 + lib/l10n/app_zh_Hans.arb | 7 + lib/l10n/app_zh_Hant_HK.arb | 7 + lib/l10n/app_zh_TW.arb | 7 + lib/l10n/gen/app_localizations.dart | 30 +++ lib/l10n/gen/app_localizations_en.dart | 15 ++ lib/l10n/gen/app_localizations_fil.dart | 16 ++ lib/l10n/gen/app_localizations_id.dart | 15 ++ lib/l10n/gen/app_localizations_ja.dart | 15 ++ lib/l10n/gen/app_localizations_ko.dart | 15 ++ lib/l10n/gen/app_localizations_th.dart | 15 ++ lib/l10n/gen/app_localizations_vi.dart | 15 ++ lib/l10n/gen/app_localizations_zh.dart | 60 +++++ lib/shared/map/map_layer.dart | 13 +- lib/shared/map/map_scaffold.dart | 47 +++- lib/shared/map/map_style.dart | 49 +++- lib/shared/map/map_town_labels.dart | 88 +++++++ lib/shared/map/raster_timeline_layer.dart | 8 +- lib/shared/widgets/map_chip_button.dart | 51 +++- .../features/map/radar_overlay_menu_test.dart | 50 +++- .../features/map/raster_timeline_harness.dart | 10 + .../map/typhoon_overlay_menu_test.dart | 6 +- test/shared/map/map_style_test.dart | 69 ++++++ test/shared/map/map_town_labels_test.dart | 83 +++++++ 43 files changed, 1046 insertions(+), 193 deletions(-) create mode 100644 lib/shared/map/map_town_labels.dart create mode 100644 test/shared/map/map_style_test.dart create mode 100644 test/shared/map/map_town_labels_test.dart diff --git a/lib/features/map/presentation/layers/disaster_map_layer.dart b/lib/features/map/presentation/layers/disaster_map_layer.dart index 244a5abdd..45a75aeb3 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'; @@ -623,8 +623,15 @@ class DisasterMapLayer implements MapLayer { } @override - Widget buildTopTrailingChrome(BuildContext context) => - DisasterMapOverlayMenu(layer: this); + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + }) => DisasterMapOverlayMenu( + layer: this, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); @override Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); diff --git a/lib/features/map/presentation/layers/lightning_layer.dart b/lib/features/map/presentation/layers/lightning_layer.dart index 2e7f46624..fdb658e41 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'; @@ -18,6 +17,7 @@ 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'; @@ -85,8 +85,11 @@ class LightningMapLayer 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, + }) => const SizedBox.shrink(); @override Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); diff --git a/lib/features/map/presentation/layers/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart index 1db389ddd..89d7d4bb9 100644 --- a/lib/features/map/presentation/layers/qpesums_layer.dart +++ b/lib/features/map/presentation/layers/qpesums_layer.dart @@ -4,6 +4,7 @@ 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 @@ -57,9 +58,15 @@ class QpesumsMapLayer extends RasterTimelineLayer with ScanRangeOverlayChrome { AppLocalizations.of(context).mapTimelineForecast; @override - Widget buildTopTrailingChrome(BuildContext context) => ScanRangeOverlayMenu( + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + }) => ScanRangeOverlayMenu( layer: this, tooltip: AppLocalizations.of(context).qpesumsOverlayMenuTooltip, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, ); @override diff --git a/lib/features/map/presentation/layers/radar_layer.dart b/lib/features/map/presentation/layers/radar_layer.dart index ba4d4f5a4..378ebfe93 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -5,6 +5,7 @@ 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. @@ -42,8 +43,15 @@ class RadarMapLayer extends RasterTimelineLayer with ScanRangeOverlayChrome { String? get rasterBelowLayerId => null; @override - Widget buildTopTrailingChrome(BuildContext context) => - RadarOverlayMenu(layer: this); + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + }) => RadarOverlayMenu( + layer: this, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); @override String get id => 'radar'; diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index 9a9c0e686..e72b5b59a 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -16,7 +16,10 @@ 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/map/map_town_labels.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/map_color_legend.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'; @@ -214,11 +217,15 @@ class RainMapLayer implements MapLayer, StationSheetSource { } @override - Widget buildTopTrailingChrome(BuildContext context) { + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + }) { 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( @@ -239,14 +246,25 @@ class RainMapLayer implements MapLayer, StationSheetSource { ); }, 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, + ), + ], + ), ], ); }, diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 2659e9853..196d6d56c 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -18,6 +18,7 @@ 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/foundation.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -183,8 +184,11 @@ class RtsMapLayer implements MapLayer { Widget buildSheet(BuildContext context) => RtsMonitorPanel(feed: _feed); @override - Widget buildTopTrailingChrome(BuildContext context) => - const SizedBox.shrink(); + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + }) => const SizedBox.shrink(); @override Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); diff --git a/lib/features/map/presentation/layers/typhoon_layer.dart b/lib/features/map/presentation/layers/typhoon_layer.dart index e1a53c661..0b69908f9 100644 --- a/lib/features/map/presentation/layers/typhoon_layer.dart +++ b/lib/features/map/presentation/layers/typhoon_layer.dart @@ -33,6 +33,7 @@ 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'; @@ -1088,8 +1089,15 @@ 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, + }) => TyphoonOverlayMenu( + layer: this, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); @override Widget buildLegend(BuildContext context) { diff --git a/lib/features/map/presentation/layers/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart index 95effb7a1..7784285fb 100644 --- a/lib/features/map/presentation/layers/weather_station_layer.dart +++ b/lib/features/map/presentation/layers/weather_station_layer.dart @@ -173,8 +173,11 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { } @override - Widget buildTopTrailingChrome(BuildContext context) => - const SizedBox.shrink(); + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + }) => const SizedBox.shrink(); @override Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); 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/radar_overlay_menu.dart b/lib/features/map/presentation/widgets/radar_overlay_menu.dart index 5f30c1a8e..1448ff06c 100644 --- a/lib/features/map/presentation/widgets/radar_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/radar_overlay_menu.dart @@ -4,19 +4,29 @@ 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}); + 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/scan_range_overlay_menu.dart b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart index 866914306..84d2e5607 100644 --- a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart @@ -1,21 +1,30 @@ /// Frosted dropdown beside the layer switcher — a raster's reference-chrome -/// toggles (scan range, county borders, township borders). +/// 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. +/// 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; @@ -24,51 +33,66 @@ class ScanRangeOverlayMenu extends StatelessWidget { /// is configuring). final String tooltip; + final ValueListenable showTownLabels; + final ValueChanged onShowTownLabelsChanged; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return ListenableBuilder( - listenable: layer.chromeListenable, + listenable: Listenable.merge([layer.chromeListenable, showTownLabels]), builder: (context, _) { final showRange = layer.showScanRange.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". All three ship on, so it + // The dot marks "not the defaults". All four ship on, so it // lights up when one has been switched *off*. - active: !showRange || !showCounty || !showTown, + active: !showRange || !showCounty || !showTown || !showLabels, onTap: () => controller.isOpen ? controller.close() : controller.open(), ), menuChildren: [ - 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), + MapMenuScrollView( + children: [ + SectionHeader(l10n.mapOverlaySectionReference), + 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/typhoon_overlay_menu.dart b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart index 26b5d6874..293ef7259 100644 --- a/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/typhoon_overlay_menu.dart @@ -7,16 +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); @@ -24,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, @@ -35,6 +43,7 @@ class TyphoonOverlayMenu extends StatelessWidget { layer.showScanRange, layer.showCountyOutline, layer.showTownOutline, + showTownLabels, ]), builder: (context, _) { final band = layer.stormBand.value; @@ -46,10 +55,12 @@ class TyphoonOverlayMenu extends StatelessWidget { 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 @@ -74,119 +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), - ), - // 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), - ), - ], - _menuDivider(colors), - 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), + 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 { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cb18bbed1..4673343b3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1502,6 +1502,28 @@ "description": "Snackbar confirming the coordinates were copied" }, + "mapOverlaySectionReference": "Reference layers", + "@mapOverlaySectionReference": { + "description": "Section title in map overlay settings menus: the reference 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" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 6f586d4ab..3ff1650ca 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -337,6 +337,13 @@ "@mapAppCoordinatesCopied": { }, + "mapOverlaySectionReference": "Layer ng sanggunian", + "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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index dfecbbdac..1eeaae432 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -337,6 +337,13 @@ "@mapAppCoordinatesCopied": { }, + "mapOverlaySectionReference": "Lapisan referensi", + "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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 117c71762..f2777db8d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -337,6 +337,13 @@ "@mapAppCoordinatesCopied": { }, + "mapOverlaySectionReference": "参照レイヤー", + "mapOverlaySectionMap": "地図", + "rainIntervalSection": "集計時間", + + "mapTownLabels": "郷鎮名", + "mapTownLabelsHint": "拡大すると郷鎮名を表示", + "dpmSheetEmpty": "地図上のマーカーをタップして詳細を表示", "dpmAddress": "住所", "restroomTypeLabel": "種別", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index dd3c8ad49..89f8db165 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -337,6 +337,13 @@ "@mapAppCoordinatesCopied": { }, + "mapOverlaySectionReference": "참조 레이어", + "mapOverlaySectionMap": "지도", + "rainIntervalSection": "집계 시간", + + "mapTownLabels": "읍면동 이름", + "mapTownLabelsHint": "확대하면 읍면동 이름 표시", + "dpmSheetEmpty": "지도에서 마커를 눌러 상세 보기", "dpmAddress": "주소", "restroomTypeLabel": "유형", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 910eb487d..904062127 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -337,6 +337,13 @@ "@mapAppCoordinatesCopied": { }, + "mapOverlaySectionReference": "เลเยอร์อ้างอิง", + "mapOverlaySectionMap": "แผนที่", + "rainIntervalSection": "ช่วงเวลา", + + "mapTownLabels": "ชื่อตำบล", + "mapTownLabelsHint": "แสดงชื่อตำบลเมื่อขยายแผนที่", + "dpmSheetEmpty": "แตะเครื่องหมายบนแผนที่เพื่อดูรายละเอียด", "dpmAddress": "ที่อยู่", "restroomTypeLabel": "ประเภท", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 07146c76e..6aa18a183 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -337,6 +337,13 @@ "@mapAppCoordinatesCopied": { }, + "mapOverlaySectionReference": "Lớp tham chiếu", + "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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2161c1d79..c0c23821e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -357,6 +357,13 @@ "@mapAppCoordinatesCopied": { }, + "mapOverlaySectionReference": "參考圖層", + "mapOverlaySectionMap": "地圖", + "rainIntervalSection": "統計時間", + + "mapTownLabels": "鄉鎮名稱", + "mapTownLabelsHint": "放大時顯示鄉鎮名稱", + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 570cd5340..3ac593481 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -337,6 +337,13 @@ "@mapAppCoordinatesCopied": { }, + "mapOverlaySectionReference": "参考图层", + "mapOverlaySectionMap": "地图", + "rainIntervalSection": "统计时间", + + "mapTownLabels": "乡镇名称", + "mapTownLabelsHint": "放大时显示乡镇名称", + "dpmSheetEmpty": "点击地图上的标记查看详情", "dpmAddress": "地址", "restroomTypeLabel": "厕所类型", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 1bb9c3a34..7dec3a0c5 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -337,6 +337,13 @@ "@mapAppCoordinatesCopied": { }, + "mapOverlaySectionReference": "參考圖層", + "mapOverlaySectionMap": "地圖", + "rainIntervalSection": "統計時間", + + "mapTownLabels": "鄉鎮名稱", + "mapTownLabelsHint": "放大時顯示鄉鎮名稱", + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index d9fea49ef..8b066e04f 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -357,6 +357,13 @@ "@mapAppCoordinatesCopied": { }, + "mapOverlaySectionReference": "參考圖層", + "mapOverlaySectionMap": "地圖", + "rainIntervalSection": "統計時間", + + "mapTownLabels": "鄉鎮名稱", + "mapTownLabelsHint": "放大時顯示鄉鎮名稱", + "dpmSheetEmpty": "點選地圖上的標記查看詳情", "dpmAddress": "地址", "restroomTypeLabel": "廁所類型", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index df8f9dbf1..94b343b09 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2163,6 +2163,36 @@ abstract class AppLocalizations { /// **'Coordinates copied'** String get mapAppCoordinatesCopied; + /// 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 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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 690d37cf7..7f0fd8a42 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1128,6 +1128,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapAppCoordinatesCopied => 'Coordinates copied'; + @override + String get mapOverlaySectionReference => 'Reference layers'; + + @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'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 0ef816686..00ca84c94 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1135,6 +1135,22 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapAppCoordinatesCopied => 'Na-kopya ang coordinates'; + @override + String get mapOverlaySectionReference => 'Layer ng sanggunian'; + + @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'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 476ec46a7..3fd5cb987 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1131,6 +1131,21 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapAppCoordinatesCopied => 'Koordinat disalin'; + @override + String get mapOverlaySectionReference => 'Lapisan referensi'; + + @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'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 17dfa0ac4..3000c39cb 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1117,6 +1117,21 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapAppCoordinatesCopied => '座標をコピーしました'; + @override + String get mapOverlaySectionReference => '参照レイヤー'; + + @override + String get mapOverlaySectionMap => '地図'; + + @override + String get rainIntervalSection => '集計時間'; + + @override + String get mapTownLabels => '郷鎮名'; + + @override + String get mapTownLabelsHint => '拡大すると郷鎮名を表示'; + @override String get dpmSheetEmpty => '地図上のマーカーをタップして詳細を表示'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index e16d5dd20..b841b5215 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1117,6 +1117,21 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapAppCoordinatesCopied => '좌표가 복사되었습니다'; + @override + String get mapOverlaySectionReference => '참조 레이어'; + + @override + String get mapOverlaySectionMap => '지도'; + + @override + String get rainIntervalSection => '집계 시간'; + + @override + String get mapTownLabels => '읍면동 이름'; + + @override + String get mapTownLabelsHint => '확대하면 읍면동 이름 표시'; + @override String get dpmSheetEmpty => '지도에서 마커를 눌러 상세 보기'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index b31484e9d..c7d509b88 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1126,6 +1126,21 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapAppCoordinatesCopied => 'คัดลอกพิกัดแล้ว'; + @override + String get mapOverlaySectionReference => 'เลเยอร์อ้างอิง'; + + @override + String get mapOverlaySectionMap => 'แผนที่'; + + @override + String get rainIntervalSection => 'ช่วงเวลา'; + + @override + String get mapTownLabels => 'ชื่อตำบล'; + + @override + String get mapTownLabelsHint => 'แสดงชื่อตำบลเมื่อขยายแผนที่'; + @override String get dpmSheetEmpty => 'แตะเครื่องหมายบนแผนที่เพื่อดูรายละเอียด'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 255c765cd..a9bbb3d4f 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1128,6 +1128,21 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapAppCoordinatesCopied => 'Đã sao chép tọa độ'; + @override + String get mapOverlaySectionReference => 'Lớp tham chiếu'; + + @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'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index cc6264f3f..08bef3d88 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1111,6 +1111,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapAppCoordinatesCopied => '已複製座標'; + @override + String get mapOverlaySectionReference => '參考圖層'; + + @override + String get mapOverlaySectionMap => '地圖'; + + @override + String get rainIntervalSection => '統計時間'; + + @override + String get mapTownLabels => '鄉鎮名稱'; + + @override + String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -2592,6 +2607,21 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapAppCoordinatesCopied => '已复制坐标'; + @override + String get mapOverlaySectionReference => '参考图层'; + + @override + String get mapOverlaySectionMap => '地图'; + + @override + String get rainIntervalSection => '统计时间'; + + @override + String get mapTownLabels => '乡镇名称'; + + @override + String get mapTownLabelsHint => '放大时显示乡镇名称'; + @override String get dpmSheetEmpty => '点击地图上的标记查看详情'; @@ -4073,6 +4103,21 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapAppCoordinatesCopied => '已複製座標'; + @override + String get mapOverlaySectionReference => '參考圖層'; + + @override + String get mapOverlaySectionMap => '地圖'; + + @override + String get rainIntervalSection => '統計時間'; + + @override + String get mapTownLabels => '鄉鎮名稱'; + + @override + String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; @@ -5554,6 +5599,21 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapAppCoordinatesCopied => '已複製座標'; + @override + String get mapOverlaySectionReference => '參考圖層'; + + @override + String get mapOverlaySectionMap => '地圖'; + + @override + String get rainIntervalSection => '統計時間'; + + @override + String get mapTownLabels => '鄉鎮名稱'; + + @override + String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + @override String get dpmSheetEmpty => '點選地圖上的標記查看詳情'; diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart index 3d06dcba0..a21a59e44 100644 --- a/lib/shared/map/map_layer.dart +++ b/lib/shared/map/map_layer.dart @@ -1,4 +1,5 @@ import 'package:dpip/core/error/result.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -102,7 +103,17 @@ 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. + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + }); /// Flutter widgets painted over the map (screen-space callouts, etc.). /// diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 54d84e49e..5d6cf3857 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -17,6 +17,7 @@ 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'; @@ -117,6 +118,11 @@ class _MapScaffoldState extends State { /// 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; @@ -143,6 +149,7 @@ class _MapScaffoldState extends State { @override void dispose() { _bearing.dispose(); + _showTownLabels.dispose(); _basemapWarmer?.cancel(); _handoff?.removeListener(_onHandoff); _stationHandoff?.removeListener(_onStationHandoff); @@ -320,6 +327,27 @@ class _MapScaffoldState extends State { ); } + 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; @@ -373,6 +401,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 @@ -619,8 +649,15 @@ 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, + ); final hasChrome = chrome is! SizedBox; return Row( mainAxisSize: MainAxisSize.min, @@ -628,6 +665,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, diff --git a/lib/shared/map/map_style.dart b/lib/shared/map/map_style.dart index 2d99976b5..1c54073df 100644 --- a/lib/shared/map/map_style.dart +++ b/lib/shared/map/map_style.dart @@ -15,6 +15,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 +30,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 +46,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 +56,8 @@ abstract final class MapColors { fill: '#ADADAD', outline: '#6B6B6B', townOutline: '#9A9A9A', + label: '#141414', + labelHalo: '#FFFFFF', ); /// Palette for the given UI [brightness]. @@ -65,6 +77,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'; @@ -86,9 +110,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 +127,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 +142,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] + } } ] }'''; } 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/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index 3820b6c7d..87332b886 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -11,6 +11,7 @@ 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'; @@ -144,8 +145,11 @@ 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, + }) => const SizedBox.shrink(); @override Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); 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/test/features/map/radar_overlay_menu_test.dart b/test/features/map/radar_overlay_menu_test.dart index 7baf25bfa..5c21d4a40 100644 --- a/test/features/map/radar_overlay_menu_test.dart +++ b/test/features/map/radar_overlay_menu_test.dart @@ -3,6 +3,7 @@ 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'; @@ -16,7 +17,11 @@ class _FakeRadarRepository extends FakeRasterFrameSource String tileUrl(String frame) => 'https://host/$frame/{z}/{x}/{y}.webp'; } -Widget _wrap(RadarMapLayer layer) => MaterialApp( +Widget _wrap( + RadarMapLayer layer, { + ValueListenable? showTownLabels, + ValueChanged? onShowTownLabelsChanged, +}) => MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, locale: const Locale('en'), @@ -24,7 +29,11 @@ Widget _wrap(RadarMapLayer layer) => MaterialApp( home: Scaffold( body: Align( alignment: Alignment.topRight, - child: RadarOverlayMenu(layer: layer), + child: RadarOverlayMenu( + layer: layer, + showTownLabels: showTownLabels ?? ValueNotifier(true), + onShowTownLabelsChanged: onShowTownLabelsChanged ?? (_) {}, + ), ), ), ); @@ -43,7 +52,7 @@ void _useTallSurface(WidgetTester tester) { } void main() { - testWidgets('the chip opens a menu carrying all three overlay toggles', ( + testWidgets('the chip opens a menu carrying all four overlay toggles', ( tester, ) async { _useTallSurface(tester); @@ -60,8 +69,13 @@ void main() { expect(find.text(l10n.radarScanRange), findsOneWidget); expect(find.text(l10n.radarCountyOutline), findsOneWidget); expect(find.text(l10n.radarTownOutline), findsOneWidget); - // All three ship on, so every box starts ticked. - expect(find.byIcon(Icons.check_box), findsNWidgets(3)); + 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); + // All four ship on, so every box starts ticked. + expect(find.byIcon(Icons.check_box), findsNWidgets(4)); expect(find.byIcon(Icons.check_box_outline_blank), findsNothing); }); @@ -113,6 +127,32 @@ void main() { 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 { diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index c1d6d14f4..5fd2fd991 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -168,6 +168,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/typhoon_overlay_menu_test.dart b/test/features/map/typhoon_overlay_menu_test.dart index 8216c5e7c..0f4907c8c 100644 --- a/test/features/map/typhoon_overlay_menu_test.dart +++ b/test/features/map/typhoon_overlay_menu_test.dart @@ -47,7 +47,11 @@ Widget _wrap(TyphoonMapLayer layer) => MaterialApp( home: Scaffold( body: Align( alignment: Alignment.topRight, - child: TyphoonOverlayMenu(layer: layer), + child: TyphoonOverlayMenu( + layer: layer, + showTownLabels: ValueNotifier(true), + onShowTownLabelsChanged: (_) {}, + ), ), ), ); 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_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, + ); + }); +} From 96e3793de34a5796524c887bd27cabb800776cb9 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 03:25:10 +0800 Subject: [PATCH 33/92] fix: settle the compass needle on reset and park it lower A programmatic north reset may not emit a final camera event, so the needle could keep the old bearing after tapping the compass. Set the bearing to zero directly and give the compass breathing room under the layer switcher chip. The rain layer's interval chip now uses MapChipButton so every layer's chrome shares a height and the compass lines up. --- .../map/presentation/layers/rain_layer.dart | 27 ++++++++----------- lib/shared/map/map_scaffold.dart | 9 +++++-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index e72b5b59a..df60ca21e 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -229,22 +229,17 @@ class RainMapLayer implements MapLayer, StationSheetSource { 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: [ MapMenuScrollView( children: [ diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 5d6cf3857..e1a15c0ac 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -313,6 +313,11 @@ class _MapScaffoldState extends State { 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( @@ -704,14 +709,14 @@ class _MapScaffoldState extends State { ), // Compass on top of *everything* — a screen-space callout (typhoon // forecast tips) or an expanded sheet must never hide north. Parked - // where the native compass sat: under the layer switcher chip. + // 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, + top: layerChipBand + AppSpacing.sm, right: AppSpacing.lg, ), child: MapCompass(bearing: _bearing, onPressed: _resetNorth), From 0906bf9b333f1ba320c37769ec5525c9b477a8b7 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 03:25:21 +0800 Subject: [PATCH 34/92] feat: use the Flutter MapCompass on the report and replay maps The embedded maps still used the native MapLibre compass, which lives inside the platform view and can be covered by Flutter overlays (the report sheet). Switch both to the Flutter MapCompass, fed by the camera bearing, with the same instant-reset fix as the map tab. --- .../pages/report_detail_page.dart | 49 ++++++++++++++++ .../pages/report_replay_page.dart | 57 +++++++++++++++++-- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart index ad3a88f31..8e2a60a6c 100644 --- a/lib/features/earthquake/presentation/pages/report_detail_page.dart +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -21,6 +21,7 @@ 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'; @@ -219,10 +220,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 +356,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 +368,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), + ), + ), + ), ], ); } diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 48559de91..7feaa4ff0 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -32,6 +32,7 @@ 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'; @@ -248,6 +249,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 +274,7 @@ class _ReplayMapState extends State<_ReplayMap> { void dispose() { widget.rts.removeListener(_onRts); widget.tick.removeListener(_onTick); + _bearing.dispose(); super.dispose(); } @@ -276,6 +282,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 +622,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), + ), + ), + ), + ], ); } } From a430cc442fe4ae7e21ff68ada28b1530a5f86c15 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 03:25:41 +0800 Subject: [PATCH 35/92] feat: treat a dry rain-hour forecast as no rain An empty series in the rainforecast envelope (or an all-zero one) is the API's way of saying no rain this hour, so decode returns a dry trend instead of failing, the home trend card hides itself, and the chart paints minutes outside the window as one continuous grey band rather than full-height bars. A no-data pane labels the missing span. --- api.md | 2 +- .../widgets/home_rain_trend_section.dart | 346 +++++++++++++----- .../weather/domain/rain_hour_trend.dart | 36 +- lib/l10n/app_en.arb | 5 + lib/l10n/app_fil.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_th.arb | 2 + lib/l10n/app_vi.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_Hans.arb | 2 + lib/l10n/app_zh_Hant_HK.arb | 2 + lib/l10n/app_zh_TW.arb | 2 + lib/l10n/gen/app_localizations.dart | 6 + lib/l10n/gen/app_localizations_en.dart | 3 + lib/l10n/gen/app_localizations_fil.dart | 3 + lib/l10n/gen/app_localizations_id.dart | 3 + lib/l10n/gen/app_localizations_ja.dart | 3 + lib/l10n/gen/app_localizations_ko.dart | 3 + lib/l10n/gen/app_localizations_th.dart | 3 + lib/l10n/gen/app_localizations_vi.dart | 3 + lib/l10n/gen/app_localizations_zh.dart | 12 + .../weather/domain/rain_hour_trend_test.dart | 24 +- 24 files changed, 370 insertions(+), 102 deletions(-) diff --git a/api.md b/api.md index b0a1b4060..c121b22bc 100644 --- a/api.md +++ b/api.md @@ -183,7 +183,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]}]}`) | +| `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/lib/features/home/presentation/widgets/home_rain_trend_section.dart b/lib/features/home/presentation/widgets/home_rain_trend_section.dart index d755adeff..92c6f6725 100644 --- a/lib/features/home/presentation/widgets/home_rain_trend_section.dart +++ b/lib/features/home/presentation/widgets/home_rain_trend_section.dart @@ -79,6 +79,15 @@ class HomeRainTrendSection extends StatelessWidget { 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( @@ -230,7 +239,17 @@ class _NoData extends StatelessWidget { ], ); } else { - child = const SizedBox.shrink(); + 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, @@ -263,6 +282,13 @@ class _Chart extends StatelessWidget { /// 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( @@ -302,110 +328,242 @@ class _Chart extends StatelessWidget { return math.min(data.mm[source], _maxMm); } - // Minutes outside the data window carry no forecast — render their rods as - // full-height grey bars, so the missing span reads as a grey band drawn by - // the chart itself (no overlay to misalign with the plot area). + // 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 = 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) : _maxMm, - width: 2.5, - color: hasDataAt(i) - ? barColor - : secondary.withValues(alpha: 0.28), - borderRadius: const BorderRadius.vertical( - top: Radius.circular(1), - ), + 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), + ), ], ), - ], - // 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], + // 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, ), - ), - 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), + 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, ), - const SizedBox(width: AppSpacing.xs), - Text(label, style: style), - ], - ), - ); - }, + 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), + ], + ), + ); + }, + ), + ), ), ), - ), - ), + ); + + // 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, + ), + ], + ), + ); + }, + ); + } +} - return SizedBox(height: 120, child: chart); +/// 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; + + @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/weather/domain/rain_hour_trend.dart b/lib/features/weather/domain/rain_hour_trend.dart index 608648ae7..5dd04f1df 100644 --- a/lib/features/weather/domain/rain_hour_trend.dart +++ b/lib/features/weather/domain/rain_hour_trend.dart @@ -92,17 +92,26 @@ class RainHourTrend { /// 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 — or a series with a non-numeric `start`, non-list `rain`, - /// or wrong sample count — throws, which `guardResult` folds into a + /// 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) continue; + 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) continue; + if (start is! num || rain is! List) { + throw const FormatException('Malformed rainforecast series'); + } if (rain.length != 60) { throw const FormatException('Expected 60 rain samples'); } @@ -111,7 +120,8 @@ class RainHourTrend { mm: [for (final value in rain) (value as num).toDouble()], ); } - throw const FormatException('No rainforecast series in response'); + // Every series was empty — the API's `[]` for "no rain this hour". + return RainHourTrend.dry(); } /// Prediction origin, Unix **seconds**, aligned to a whole minute @@ -126,6 +136,22 @@ class RainHourTrend { 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 ?? DateTime.now().toUtc()).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}) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4673343b3..3b985e2fa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -731,6 +731,11 @@ } } }, + "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" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 3ff1650ca..d6f368695 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -153,6 +153,8 @@ "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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 1eeaae432..8c6297e45 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -153,6 +153,8 @@ "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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index f2777db8d..aee2f4f5c 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -153,6 +153,8 @@ "homeRainTrendTitle": "今後1時間の雨", "homeRainTrendMinute": "{minute}分", "homeRainTrendUpdated": "更新 {time}", + "homeRainTrendNoData": "データなし", + "homeRainTrendScattered": "にわか雨の可能性があります", "homeRainTrendLightSustained": "今後1時間は小雨が続きます", "homeRainTrendLightStopping": "{minutes}分後に小雨が止む見込みです", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 89f8db165..512d7e35f 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -153,6 +153,8 @@ "homeRainTrendTitle": "향후 1시간 강수", "homeRainTrendMinute": "{minute}분", "homeRainTrendUpdated": "업데이트 {time}", + "homeRainTrendNoData": "데이터 없음", + "homeRainTrendScattered": "약한 비가 올 수 있어요", "homeRainTrendLightSustained": "앞으로 1시간 동안 약한 비가 이어질 거예요", "homeRainTrendLightStopping": "{minutes}분 후에 비가 그칠 것으로 예상돼요", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 904062127..f668cc699 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -153,6 +153,8 @@ "homeRainTrendTitle": "ฝนชั่วโมงถัดไป", "homeRainTrendMinute": "{minute} นาที", "homeRainTrendUpdated": "อัปเดต {time}", + "homeRainTrendNoData": "ไม่มีข้อมูล", + "homeRainTrendScattered": "อาจมีฝนตกประปราย", "homeRainTrendLightSustained": "ฝนตกเล็กน้อยต่อเนื่องตลอดชั่วโมงหน้า", "homeRainTrendLightStopping": "คาดว่าฝนจะหยุดในอีก {minutes} นาที", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 6aa18a183..e5ebd1f2f 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -153,6 +153,8 @@ "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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c0c23821e..d1871c3b3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -153,6 +153,8 @@ "homeRainTrendTitle": "近 1 小時降水趨勢", "homeRainTrendMinute": "{minute}分", "homeRainTrendUpdated": "更新 {time}", + "homeRainTrendNoData": "無資料", + "homeRainTrendScattered": "可能會有零星降雨", "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 3ac593481..951cb480c 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -153,6 +153,8 @@ "homeRainTrendTitle": "近 1 小时降水趋势", "homeRainTrendMinute": "{minute}分", "homeRainTrendUpdated": "更新 {time}", + "homeRainTrendNoData": "无资料", + "homeRainTrendScattered": "可能会有零星降雨", "homeRainTrendLightSustained": "未来 1 小时会有持续小雨", "homeRainTrendLightStopping": "预计 {minutes} 分钟后停止下小雨", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 7dec3a0c5..145c13244 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -153,6 +153,8 @@ "homeRainTrendTitle": "近 1 小時降水趨勢", "homeRainTrendMinute": "{minute}分", "homeRainTrendUpdated": "更新 {time}", + "homeRainTrendNoData": "無資料", + "homeRainTrendScattered": "可能會有零星降雨", "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 8b066e04f..8f3aa2f57 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -153,6 +153,8 @@ "homeRainTrendTitle": "近 1 小時降水趨勢", "homeRainTrendMinute": "{minute}分", "homeRainTrendUpdated": "更新 {time}", + "homeRainTrendNoData": "無資料", + "homeRainTrendScattered": "可能會有零星降雨", "homeRainTrendLightSustained": "未來 1 小時會有持續小雨", "homeRainTrendLightStopping": "預計 {minutes} 分鐘後停止下小雨", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 94b343b09..ca966cbd1 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1035,6 +1035,12 @@ abstract class AppLocalizations { /// **'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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 7f0fd8a42..03ee7bd6b 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -516,6 +516,9 @@ class AppLocalizationsEn extends AppLocalizations { return 'Updated $time'; } + @override + String get homeRainTrendNoData => 'No data'; + @override String get homeRainTrendScattered => 'Light showers possible'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 00ca84c94..f28f87c41 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -521,6 +521,9 @@ class AppLocalizationsFil extends AppLocalizations { return 'Na-update $time'; } + @override + String get homeRainTrendNoData => 'Walang data'; + @override String get homeRainTrendScattered => 'Posibleng mahinang ulan'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 3fd5cb987..eca2802eb 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -517,6 +517,9 @@ class AppLocalizationsId extends AppLocalizations { return 'Diperbarui $time'; } + @override + String get homeRainTrendNoData => 'Tidak ada data'; + @override String get homeRainTrendScattered => 'Kemungkinan hujan ringan'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 3000c39cb..d312eee37 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -514,6 +514,9 @@ class AppLocalizationsJa extends AppLocalizations { return '更新 $time'; } + @override + String get homeRainTrendNoData => 'データなし'; + @override String get homeRainTrendScattered => 'にわか雨の可能性があります'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index b841b5215..23f045eab 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -514,6 +514,9 @@ class AppLocalizationsKo extends AppLocalizations { return '업데이트 $time'; } + @override + String get homeRainTrendNoData => '데이터 없음'; + @override String get homeRainTrendScattered => '약한 비가 올 수 있어요'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index c7d509b88..8d1f209ea 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -515,6 +515,9 @@ class AppLocalizationsTh extends AppLocalizations { return 'อัปเดต $time'; } + @override + String get homeRainTrendNoData => 'ไม่มีข้อมูล'; + @override String get homeRainTrendScattered => 'อาจมีฝนตกประปราย'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index a9bbb3d4f..748b7f129 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -516,6 +516,9 @@ class AppLocalizationsVi extends AppLocalizations { 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ẹ'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 08bef3d88..e8d622d6c 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -513,6 +513,9 @@ class AppLocalizationsZh extends AppLocalizations { return '更新 $time'; } + @override + String get homeRainTrendNoData => '無資料'; + @override String get homeRainTrendScattered => '可能會有零星降雨'; @@ -2009,6 +2012,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { return '更新 $time'; } + @override + String get homeRainTrendNoData => '无资料'; + @override String get homeRainTrendScattered => '可能会有零星降雨'; @@ -3505,6 +3511,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { return '更新 $time'; } + @override + String get homeRainTrendNoData => '無資料'; + @override String get homeRainTrendScattered => '可能會有零星降雨'; @@ -5001,6 +5010,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '更新 $time'; } + @override + String get homeRainTrendNoData => '無資料'; + @override String get homeRainTrendScattered => '可能會有零星降雨'; diff --git a/test/features/weather/domain/rain_hour_trend_test.dart b/test/features/weather/domain/rain_hour_trend_test.dart index 31bd8fea8..8053a0f81 100644 --- a/test/features/weather/domain/rain_hour_trend_test.dart +++ b/test/features/weather/domain/rain_hour_trend_test.dart @@ -38,8 +38,28 @@ void main() { expect(trend.mm.sublist(3), everyElement(0.0)); }); - test('decode aligns an empty response to FormatException', () { - expect(() => RainHourTrend.decode(const {}), throwsFormatException); + 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', () { From a0d69dd21150219a5fcc4ee284073bfa59679833 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 03:25:54 +0800 Subject: [PATCH 36/92] feat: grow the hero forecast card on a dry hour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the next-hour forecast is dry there is no rain chart to show, so the hero slot swaps the trend card for the 24-hour forecast card, which starts as a one-glance summary and expands into the full card as the sheet is pulled up — one widget, not two cards. --- .../presentation/widgets/home_content.dart | 88 ++++++++++--- .../widgets/home_forecast_section.dart | 91 ++++++++++---- .../widgets/home_content_test.dart | 118 +++++++++++++++++- 3 files changed, 250 insertions(+), 47 deletions(-) diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 6a6faff8a..b95189f09 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -5,10 +5,12 @@ 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'; @@ -72,6 +74,13 @@ class HomeContent extends StatelessWidget { final store = context.watch(); final areaIndex = store.selectedIndex; 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. @@ -99,6 +108,8 @@ class HomeContent extends StatelessWidget { showWeather, sky, _focus(offset), + offset, + hourTrend, ); }, ); @@ -122,7 +133,14 @@ class HomeContent extends StatelessWidget { bool showWeather, Color? sky, double focus, + double offset, + RainHourTrend? hourTrend, ) { + // 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 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 % @@ -222,19 +240,47 @@ class HomeContent extends StatelessWidget { // blurs it back in once the scroll below carries the // trend card past the top. const Expanded(child: SizedBox.shrink()), - 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), - ), + if (dryTrend) + // A dry hour has no rain chart, so the forecast + // card itself takes the hero slot. It sits at its + // one-glance summary (title + hour chips) at rest + // and grows into the full card as the sheet is + // pulled up — the summary and the complete card + // are the same widget, not two cards. Rains on + // the card like the trend card does. + ListenableBuilder( + listenable: scrollController, + builder: (context, _) { + final liveOffset = scrollController.hasClients + ? scrollController.offset + : 0.0; + return RainOnCard( + intensity: _cardRain(weatherMode), + opacity: reveal, + child: HomeForecastSection( + key: ValueKey('forecast-hero-$areaIndex'), + expansion: _forecastExpansion(liveOffset), + 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), + ), ], ), // Deflates the tight SizedBox height passed to the @@ -267,8 +313,9 @@ class HomeContent extends StatelessWidget { // 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). - if (heroHeight != null) ...[ + // (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, @@ -349,6 +396,17 @@ class HomeContent extends StatelessWidget { 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. diff --git a/lib/features/home/presentation/widgets/home_forecast_section.dart b/lib/features/home/presentation/widgets/home_forecast_section.dart index 93c4d8cfc..113c33ebc 100644 --- a/lib/features/home/presentation/widgets/home_forecast_section.dart +++ b/lib/features/home/presentation/widgets/home_forecast_section.dart @@ -2,6 +2,7 @@ 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'; @@ -22,12 +23,19 @@ 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, this.sky, this.weatherMode = WeatherMode.auto, + this.expansion = 1, }); /// Weather-backdrop reveal (0→1) — drives glass card opacity only; ink stays @@ -44,6 +52,12 @@ class HomeForecastSection extends StatefulWidget { /// 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(); } @@ -56,6 +70,7 @@ 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 skyIsLight = skyIsLightFrom(widget.sky, widget.weatherMode); @@ -89,7 +104,7 @@ class _HomeForecastSectionState extends State { return _Shell( color: cardColor, child: SizedBox( - height: 168, + height: lerpDouble(120, 168, expansion)!, child: Center( child: SizedBox( width: 24, @@ -170,20 +185,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( @@ -213,21 +242,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/test/features/home/presentation/widgets/home_content_test.dart b/test/features/home/presentation/widgets/home_content_test.dart index 501915acb..18d6415f7 100644 --- a/test/features/home/presentation/widgets/home_content_test.dart +++ b/test/features/home/presentation/widgets/home_content_test.dart @@ -12,6 +12,7 @@ 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'; @@ -38,11 +39,18 @@ class _FakeWeatherRepository implements MeteorWeatherRepository { dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); } -/// Stub next-hour rain trend. +/// 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(RainHourTrend(startSecond: 1786362600, mm: List.filled(60, 0.5))); + 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 { @@ -57,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, {bool expanded = false}) { - const directory = TownDirectory({}); +Widget _wrap( + RegionStore store, { + bool expanded = false, + RainHourTrendRepository? hourTrend, + TownDirectory directory = const TownDirectory({}), +}) { final events = _FakeEventRepository(); return MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -72,7 +84,7 @@ Widget _wrap(RegionStore store, {bool expanded = false}) { ChangeNotifierProvider( create: (_) => HomeWeatherController( _FakeWeatherRepository(), - _FakeHourTrendRepository(), + hourTrend ?? _FakeHourTrendRepository(), store, directory, ), @@ -231,6 +243,100 @@ void main() { 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 { From 70a4d8e58e3e53ce9e4cceeb36f74ef00799fc7d Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 03:33:00 +0800 Subject: [PATCH 37/92] feat: centre the home backdrop on GPS in current-location mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 所在地 is a place, not a region: frame the camera on the live GPS fix (a small span around it) instead of the whole township, while saved townships keep their bounds-centred framing. Falls back to the township box, or the whole island, when no fix is available. --- .../widgets/home_map_backdrop.dart | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/features/home/presentation/widgets/home_map_backdrop.dart b/lib/features/home/presentation/widgets/home_map_backdrop.dart index 7f83f4866..1eb7fa4c7 100644 --- a/lib/features/home/presentation/widgets/home_map_backdrop.dart +++ b/lib/features/home/presentation/widgets/home_map_backdrop.dart @@ -1,5 +1,6 @@ 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'; @@ -72,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; @@ -174,6 +181,12 @@ class _HomeMapBackdropState extends State final boundariesFuture = context.read>(); final code = _selectedCode; 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. Falls back to the township + // box — or the whole island when GPS is unavailable — if no fix is had. + final gpsCentred = _regions?.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; @@ -186,9 +199,25 @@ class _HomeMapBackdropState extends State bounds = boundaries.boundsFor(code); } + LatLngBounds frame; + if (gpsCentred) { + final fix = await location.currentFix(); + if (!mounted || gen != _selectionGen || styleEpoch != _styleEpoch) return; + frame = fix == null + ? _latLngBounds(bounds) + : _latLngBounds([ + fix.lng - _gpsSpan, + fix.lat - _gpsSpan, + fix.lng + _gpsSpan, + fix.lat + _gpsSpan, + ]); + } else { + frame = _latLngBounds(bounds); + } + // 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). From f006195ffa55877fb1209473cde2327ea32bde89 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 03:58:46 +0800 Subject: [PATCH 38/92] perf: square the radar scan-range terms by multiplication math.pow(x, 2) routes through the generic power path and needs an `as double` cast; x*x is a plain multiply on the already-double operands. Equivalent up to floating-point rounding. --- .../map/presentation/layers/radar_scan_range.dart | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/features/map/presentation/layers/radar_scan_range.dart b/lib/features/map/presentation/layers/radar_scan_range.dart index ac816aa38..508d0d1ab 100644 --- a/lib/features/map/presentation/layers/radar_scan_range.dart +++ b/lib/features/map/presentation/layers/radar_scan_range.dart @@ -75,10 +75,12 @@ abstract final class RadarScanRange { 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) - math.pow(math.sin(s.radiusKm / (2 * earthRadiusKm)), 2) as double, + for (final s in sites) _sq(math.sin(s.radiusKm / (2 * earthRadiusKm))), ]; /// `cos φᵢ` per site, precomputed. @@ -92,8 +94,8 @@ abstract final class RadarScanRange { final cosP = math.cos(lat * _deg); for (var i = 0; i < sites.length; i++) { final site = sites[i]; - final a = math.pow(math.sin((lat - site.lat) * _deg / 2), 2) as double; - final b = math.pow(math.sin((lon - site.lon) * _deg / 2), 2) as double; + 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; @@ -103,8 +105,7 @@ abstract final class RadarScanRange { /// 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] - (math.pow(math.sin((lat - site.lat) * _deg / 2), 2) as double); + 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; From 6f74601f30d73bb0426a6ab45a31d609798f2d58 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 03:58:53 +0800 Subject: [PATCH 39/92] perf: binary-search the wind chart's nearest sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nearestSample ran a linear scan for every axis tick and tooltip — a 7 d wind series is ~170 points, and the x-values are monotonic, so a binary search finds the same index in O(log n). Ties resolve to the earlier index, matching the old strict-< scan. --- .../presentation/widgets/station_sheet.dart | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/features/map/presentation/widgets/station_sheet.dart b/lib/features/map/presentation/widgets/station_sheet.dart index 85b8b56bb..383b7827b 100644 --- a/lib/features/map/presentation/widgets/station_sheet.dart +++ b/lib/features/map/presentation/widgets/station_sheet.dart @@ -935,17 +935,25 @@ class _TrendChart extends StatelessWidget { /// 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 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; + 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; } } - return sampleAt[best]; + 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). From 82f34cbb028d889be96180c618870fd3b43e5baf Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 03:59:01 +0800 Subject: [PATCH 40/92] refactor: share the wind arrow outline directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 8 offset neighbours that bake the black outline were declared twice — once in the map PNG renderer, once in WindArrowIcon — with identical values. Hoist one windOutlineDirs constant both use. --- .../map/presentation/layers/wind_layer.dart | 14 +-------- lib/features/map/presentation/wind_speed.dart | 30 ++++++++++--------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/lib/features/map/presentation/layers/wind_layer.dart b/lib/features/map/presentation/layers/wind_layer.dart index 63bb71dab..0b8b19dcf 100644 --- a/lib/features/map/presentation/layers/wind_layer.dart +++ b/lib/features/map/presentation/layers/wind_layer.dart @@ -185,16 +185,6 @@ class WindMapLayer extends WeatherStationLayer { const icon = Icons.navigation; // Outline thickness on the 96 px canvas — 8 offset copies around the glyph. const halo = 5.5; - const outlineDirs = <(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), - ]; final outline = TextPainter( textDirection: TextDirection.ltr, text: TextSpan( @@ -223,7 +213,6 @@ class WindMapLayer extends WeatherStationLayer { center: center, size: size, halo: halo, - outlineDirs: outlineDirs, ), ]; } @@ -237,7 +226,6 @@ class WindMapLayer extends WeatherStationLayer { required Offset center, required int size, required double halo, - required List<(double, double)> outlineDirs, }) async { final fillPainter = TextPainter( textDirection: TextDirection.ltr, @@ -253,7 +241,7 @@ class WindMapLayer extends WeatherStationLayer { )..layout(); final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); - for (final (dx, dy) in outlineDirs) { + for (final (dx, dy) in windOutlineDirs) { outline.paint(canvas, center + Offset(dx * halo, dy * halo)); } fillPainter.paint(canvas, center); diff --git a/lib/features/map/presentation/wind_speed.dart b/lib/features/map/presentation/wind_speed.dart index 08d30a900..c408976f3 100644 --- a/lib/features/map/presentation/wind_speed.dart +++ b/lib/features/map/presentation/wind_speed.dart @@ -26,6 +26,21 @@ int windSpeedBucket(double value) { /// 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. @@ -46,19 +61,6 @@ class WindArrowIcon extends StatelessWidget { /// Outline offset distance, in logical pixels. final double outline; - /// Offset copies that make up the outline — 8 neighbours (cardinal + - /// diagonal) at [outline] px. - static const List<(double, double)> _outlineDirs = [ - (1, 0), - (-1, 0), - (0, 1), - (0, -1), - (0.7071, 0.7071), - (0.7071, -0.7071), - (-0.7071, 0.7071), - (-0.7071, -0.7071), - ]; - @override Widget build(BuildContext context) { final pad = outline + 1; @@ -68,7 +70,7 @@ class WindArrowIcon extends StatelessWidget { child: Stack( alignment: Alignment.center, children: [ - for (final (dx, dy) in _outlineDirs) + for (final (dx, dy) in windOutlineDirs) Transform.translate( offset: Offset(dx * outline, dy * outline), child: Icon(Icons.navigation, size: size, color: Colors.black), From d6dc1bddcbed05d7b380a3d27063a93c9767e9da Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 03:59:10 +0800 Subject: [PATCH 41/92] refactor: drop the dead string sort in the lightning cache eviction The frame ids were sorted as strings, then immediately re-sorted as ints, wasting a pass and repeatedly int-parsing inside the comparator. Keep the int sort only. --- .../map/presentation/layers/lightning_layer.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/features/map/presentation/layers/lightning_layer.dart b/lib/features/map/presentation/layers/lightning_layer.dart index fdb658e41..b6cb58055 100644 --- a/lib/features/map/presentation/layers/lightning_layer.dart +++ b/lib/features/map/presentation/layers/lightning_layer.dart @@ -234,13 +234,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; From cf5b42c9bb8447e4778bbf09e5dd49d76a1cd14f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 03:59:19 +0800 Subject: [PATCH 42/92] refactor: tidy the tile-cache inject chunk boundary Clamp the chunk end with min() and always pass the same sublist form instead of branching on whether the last chunk is short. --- lib/shared/map/map_tile_cache.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/shared/map/map_tile_cache.dart b/lib/shared/map/map_tile_cache.dart index f824af4a2..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'; @@ -162,10 +163,8 @@ class MapTileCache { // 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; - final usage = 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 && From 1348358240ce264f64550a9d6edf25b66dc3465f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 04:03:21 +0800 Subject: [PATCH 43/92] perf: cut scroll-frame cost on the home sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draggable sheet re-rendered its whole backdrop every scroll tick: the sky shader repainted each frame even under the blur, the scroll blur re-computed every tick with nothing cached behind it, and card rain kept running on cards already scrolling off-screen. - Freeze the sky (active=false) the instant the list scrolls — the blur about to cover it hides the hold, and holding the last frame lets the blur layer cache; the state clock stops with the ticker, so resuming at the top restarts where it left off. - Quantise the scroll blur/dim to a 4-step ladder, so the full-screen blur re-renders only on level changes instead of every tick. - Rebuild HomeContent's ListView shell only when the sky re-bakes; the scroll-driven reveal/expansion lives on one inner builder, merging the hero's bottom-gap and dry-hour forecast builders. - Stop RainOnGlass and the card-water LiquidFun worlds the moment the position gate closes (cut the water outright instead of draining), and resume on the next rebuild once the gate reopens. --- .../presentation/widgets/home_content.dart | 307 ++++++++---------- .../home/presentation/widgets/home_sheet.dart | 43 ++- .../widgets/weather_sky/rain_on_card.dart | 50 ++- .../presentation/widgets/home_sheet_test.dart | 35 ++ 4 files changed, 260 insertions(+), 175 deletions(-) diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index b95189f09..029c56bff 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -86,30 +86,30 @@ class HomeContent extends StatelessWidget { // 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; - // Scroll and the sky bake change at very different rates and both feed the - // cards — the sky re-bakes rarely, the scroll focus dial moves on every - // tick — so merge the two listenables rather than nesting two builders - // (the inner ValueListenableBuilder would skip rebuilding on scroll, since - // the sky colour did not change, and the focus dial would stall). + // 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: Listenable.merge([ - scrollController, - SkyLutCache.panelAmbient, - ]), + listenable: SkyLutCache.panelAmbient, builder: (context, _) { final sky = SkyLutCache.panelAmbient.value; - final offset = scrollController.hasClients - ? scrollController.offset - : 0.0; return _build( context, areaIndex, located, showWeather, sky, - _focus(offset), - offset, hourTrend, + bottomSafeArea, ); }, ); @@ -132,21 +132,14 @@ class HomeContent extends StatelessWidget { bool located, bool showWeather, Color? sky, - double focus, - double offset, 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 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); // 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 @@ -166,18 +159,6 @@ class HomeContent extends StatelessWidget { // 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. - // - // MediaQuery.paddingOf(context).bottom is *not* the device's real bottom - // safe area here: MainShell's Scaffold(extendBody: true) overrides it for - // this whole subtree to the bottom-nav-bar's reserved height instead (114 - // logical px, measured on a real device — the bug that previously made - // this leave a stray gap under the trend card). MediaQueryData.fromView - // reads straight off the platform view, bypassing that override, and - // gives back the one inset that's real in every state the hero block is - // ever in: the home indicator. - final bottomSafeArea = MediaQueryData.fromView( - View.of(context), - ).padding.bottom; return LayoutBuilder( builder: (context, constraints) { final rawHeight = constraints.maxHeight; @@ -197,142 +178,144 @@ class HomeContent extends StatelessWidget { // The grab handle above stays put; only the per-area panel slides. _AreaSlide( index: areaIndex, - child: Column( - key: ValueKey(areaIndex), - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (heroHeight != null) - SizedBox( - height: heroHeight, - // Only the trailing gap depends on scroll offset, so - // that's the only thing this rebuilds per tick — the - // header/trend-card subtree below is built once and - // handed through as ListenableBuilder's child, same - // split `HomeSheet`'s own `_ScrollBlurredWeather` uses. - child: ListenableBuilder( - listenable: scrollController, - 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. - intensity: _cardRain(weatherMode), - opacity: reveal, - glass: false, - silhouette: true, - // Default gated: true — the header rises with - // the rest of the hero block once the sheet - // scrolls, so it closes the same short distance - // into the gesture the trend card now does. - child: HomeSheetHeader( - reveal: reveal, - expanded: expanded, - weatherMode: weatherMode, - sky: sky, - ), + // 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), ), - // 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. It sits at its - // one-glance summary (title + hour chips) at rest - // and grows into the full card as the sheet is - // pulled up — the summary and the complete card - // are the same widget, not two cards. Rains on - // the card like the trend card does. - ListenableBuilder( - listenable: scrollController, - builder: (context, _) { - final liveOffset = scrollController.hasClients - ? scrollController.offset - : 0.0; - return RainOnCard( + 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. Default gated: + // true — the header rises with the rest of the + // hero block once the sheet scrolls, so it + // closes the same short distance into the + // gesture the trend card now does. + intensity: _cardRain(weatherMode), + opacity: reveal, + glass: false, + silhouette: true, + 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(liveOffset), + 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), - ), - ], - ), - // Deflates the tight SizedBox height passed to the - // Column above, 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. - builder: (context, child) => Padding( - padding: EdgeInsets.only( - bottom: _heroBottomGap( - scrollController, - bottomSafeArea, + ) + 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), + ), + ], ), ), - child: child, + ) + 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, ), - ), - ) - 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, - ), - ], + const SizedBox(height: AppSpacing.lg), + ], + if (located) + HomeActiveEventsSection( + key: ValueKey('active-$areaIndex'), + reveal: reveal, + expanded: expanded, + sky: sky, + weatherMode: weatherMode, + ), + ], + ); + }, ), ), ], @@ -385,13 +368,9 @@ class HomeContent extends StatelessWidget { static const double _bottomGapRampExtent = 32; /// Current size of the hero block's trailing reserve — [_restBottomGap] - /// plus [bottomSafeArea] — for [controller]'s live scroll offset. - static double _heroBottomGap( - ScrollController controller, - double bottomSafeArea, - ) { + /// plus [bottomSafeArea] — for the live scroll [offset]. + static double _heroBottomGap(double offset, double bottomSafeArea) { final rest = _restBottomGap + bottomSafeArea; - final offset = controller.hasClients ? controller.offset : 0.0; final t = (offset / _bottomGapRampExtent).clamp(0.0, 1.0); return rest * (1 - t); } diff --git a/lib/features/home/presentation/widgets/home_sheet.dart b/lib/features/home/presentation/widgets/home_sheet.dart index e73454c18..ec2bf2d43 100644 --- a/lib/features/home/presentation/widgets/home_sheet.dart +++ b/lib/features/home/presentation/widgets/home_sheet.dart @@ -150,13 +150,29 @@ class HomeSheet extends StatelessWidget { opacity: weatherOpacity, child: _ScrollBlurredWeather( scrollController: scrollController, - child: WeatherSkyBackground( - mode: weatherMode, - rainIntensity: rainIntensity, - snowIntensity: snowIntensity, - humidity: humidity ?? 0.65, - timeMode: skyTimeMode, - active: HomeChrome.weatherActive(e), + // 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, + ); + }, ), ), ), @@ -238,8 +254,15 @@ class _ScrollBlurredWeather extends StatelessWidget { ? scrollController.offset : 0.0; final t = (offset / _rampExtent).clamp(0.0, 1.0); - final sigma = _maxSigma * t; - final dim = _maxDim * t; + // 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 @@ -256,7 +279,7 @@ class _ScrollBlurredWeather extends StatelessWidget { // 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: t > 0, + enabled: step > 0, child: child, ), // Dim sits *above* the blur so the backdrop darkens uniformly; the 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 index 9d57dfeee..518b82fd8 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -237,6 +237,11 @@ class _RainOnCardState extends State 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); @@ -253,6 +258,29 @@ class _RainOnCardState extends State 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() { @@ -360,6 +388,20 @@ class _RainOnCardState extends State _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. @@ -479,7 +521,13 @@ class _RainOnCardState extends State ? RainOnGlass( intensity: widget.intensity, opacity: widget.opacity, - active: widget.active, + // 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, diff --git a/test/features/home/presentation/widgets/home_sheet_test.dart b/test/features/home/presentation/widgets/home_sheet_test.dart index f7fa1caa9..ba69b5ebb 100644 --- a/test/features/home/presentation/widgets/home_sheet_test.dart +++ b/test/features/home/presentation/widgets/home_sheet_test.dart @@ -12,6 +12,7 @@ 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'; @@ -146,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)); From 55199aa349933e91483bc2b5b992134a0fc1ebde Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 05:08:14 +0800 Subject: [PATCH 44/92] perf: cut the home backdrop's per-frame cost and fix forced-look rain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sky screen pass was a full-screen shader whose output is a pure vertical gradient of a fixed LUT column, so it is now CPU-baked once per re-bake (4×1024) and stretched per frame. The cloud shader's LUT probes share a constant u, so the horizontal filtering moved to a CPU-extracted 1×141 column at bake time and each probe is a vertical 2-tap. The galaxy layer and starmap, never loaded in production, are gone. Base/haze sky colours arrive as baked uniforms, and dead uniforms (iFrustumA/C, iSunElevParam) are dropped. A parity test pins the baked gradient to the reference shader within its own dither. The rain particle field no longer allocates a Color or boxes a clamp num per drop each frame (up to 1792 drops at storm grade); the packed ARGB32 and branch clamps are bit-identical. Forcing an experimental weather mode now drops the live rain/snow/humidity overrides, so a forced clear sky no longer keeps raining. The resolution is a pure function with a regression test. --- .../home/presentation/pages/home_page.dart | 37 +++- .../weather_sky/precipitation_field.dart | 48 +++-- .../widgets/weather_sky/sky_lut_cache.dart | 202 ++++++++++++++++-- .../weather_sky/weather_sky_background.dart | 18 +- .../weather_sky/weather_sky_painter.dart | 145 ++++++------- shaders/README.md | 10 +- shaders/cloud/clouds.frag | 92 ++++---- shaders/sky/sky_view.frag | 6 + shaders/weather/card_water.frag | 12 +- shaders/weather/galaxy.frag | 3 +- shaders/weather/night.frag | 6 +- shaders/weather/sun_flare.frag | 20 +- .../home_backdrop_resolve_test.dart | 48 +++++ test/shaders/preview_render_test.dart | 72 ++++++- test/shaders/sky_gradient_parity_test.dart | 97 +++++++++ test/shaders/weather_sky_shader_test.dart | 7 +- 16 files changed, 630 insertions(+), 193 deletions(-) create mode 100644 test/features/home/presentation/home_backdrop_resolve_test.dart create mode 100644 test/shaders/sky_gradient_parity_test.dart diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index fc3c3b353..22b790633 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -11,6 +11,7 @@ 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'; @@ -108,13 +109,13 @@ class _HomePageState extends State { // 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 realtimeCode = data?.weatherCode; - final weatherMode = experimental.weatherMode == WeatherMode.auto - ? weatherModeFor(realtimeCode ?? 0) - : experimental.weatherMode; - final rainIntensity = weatherRainIntensity(realtimeCode ?? 0); - final snowIntensity = weatherSnowIntensity(realtimeCode ?? 0); - final humidity = data?.humidity; + 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, @@ -252,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/weather_sky/precipitation_field.dart b/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart index 9ecec3a2e..375c8d2db 100644 --- a/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart +++ b/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart @@ -109,6 +109,18 @@ class PrecipitationField { 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; @@ -116,7 +128,7 @@ class PrecipitationField { final depth = _p.depth[i]; // The reference's `particle_rain.comp`, verbatim: linear, depth-scaled. final speedScale = 0.12 + 0.88 * depth; - final fall = fallSpeed * size.height * speedScale * dt; + final fall = baseFall * speedScale; _p.y[i] += fall; _p.x[i] += fall * wind * 0.6; @@ -129,29 +141,37 @@ class PrecipitationField { _spawn(i, size, sizeMin, sizeMax, life); } - // `alpha = mix(1, 0, progress²)` from `updateRunState`. - final progress = (_p.age[i] / _p.life[i]).clamp(0.0, 1.0); + // `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; - final a = (lifeAlpha * depthAlpha * 0.4 * opacity).clamp(0.0, 1.0); + // 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] * size.height / cell.height; + final scale = _p.size[i] * heightRatio; final o = n * 4; if (tumble) { final spin = _p.spin[i] * (_p.age[i] + _p.phase[i]); - _p.transforms[o] = math.cos(spin) * scale; - _p.transforms[o + 1] = math.sin(spin) * scale; + final c = math.cos(spin) * scale; + final s = math.sin(spin) * scale; + _p.transforms[o] = c; + _p.transforms[o + 1] = s; } else { - _p.transforms[o] = cosT * scale; - _p.transforms[o + 1] = sinT * scale; + // 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. - final ax = cell.width * 0.5; - final ay = cell.height * 0.5; _p.transforms[o + 2] = _p.x[i] - (_p.transforms[o] * ax - _p.transforms[o + 1] * ay); _p.transforms[o + 3] = @@ -169,7 +189,11 @@ class PrecipitationField { _p.rects[o + 2] = (v + 1) * cell.width; _p.rects[o + 3] = cell.height; - _p.colors[n] = tint.withValues(alpha: a).toARGB32(); + // 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++; } 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 index 5bcbe3ef6..f08ca1f02 100644 --- a/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart +++ b/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart @@ -1,4 +1,5 @@ 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'; @@ -26,6 +27,23 @@ class SkyLutCache { 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); @@ -33,6 +51,14 @@ class SkyLutCache { /// 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); @@ -64,25 +90,157 @@ class SkyLutCache { _skyView?.dispose(); _skyView = _bakeSkyView(sky, _transmittance!); _baked = sky; - _publishPanelAmbient(_skyView!); + _publishReadback(_skyView!, sky); return true; } - /// Reads the ambient row back — async and once per bake, never per frame. - void _publishPanelAmbient(ui.Image skyView) { - final x = (_ambientSample.dx * (skyViewSize.width - 1)).round(); - final y = (_ambientSample.dy * (skyViewSize.height - 1)).round(); + /// 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; - final px = data.buffer.asUint8List(); - panelAmbient.value = Color.fromARGB(255, px[o], px[o + 1], px[o + 2]); + 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; @@ -154,15 +312,26 @@ class SkyLutCache { ); } - static ui.Image _rasterise(ui.FragmentShader shader, int width, int height) { + 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(); - ui.Canvas(recorder).drawRect( - Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), - Paint()..shader = shader, - ); + draw(ui.Canvas(recorder)); final picture = recorder.endRecording(); - // Synchronous and GPU-resident — no CPU round trip, which is what makes - // an in-frame bake affordable. final image = picture.toImageSync(width, height); picture.dispose(); return image; @@ -172,8 +341,13 @@ class SkyLutCache { void dispose() { _transmittance?.dispose(); _skyView?.dispose(); + _skyGradient?.dispose(); + _skyColumn?.dispose(); _transmittance = null; _skyView = null; + _skyGradient = null; + _skyColumn = null; + _skyViewBytes = null; _baked = null; } } 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 index 4da9e373b..ba6000201 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart @@ -100,7 +100,6 @@ class _WeatherSkyBackgroundState extends State ]; static const List _layerAssets = [ - WeatherSkyPainter.skyViewAsset, WeatherSkyPainter.nightAsset, WeatherSkyPainter.cloudsAsset, WeatherSkyPainter.lightningAsset, @@ -117,7 +116,6 @@ class _WeatherSkyBackgroundState extends State final Map _shaders = {}; final List _sprites = []; final List _sunTextures = []; - ui.Image? _starMap; SkyLutCache? _lutCache; PrecipitationField? _rain; PrecipitationField? _snow; @@ -189,15 +187,6 @@ class _WeatherSkyBackgroundState extends State } } - ui.Image? starMap; - try { - final data = await rootBundle.load('assets/weather/sky/starmap.webp'); - final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); - starMap = (await codec.getNextFrame()).image; - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'Failed to load starmap'); - } - final sprites = []; for (var i = 0; i < _spriteCount; i++) { final path = 'assets/weather/clouds/${i.toString().padLeft(2, '0')}.webp'; @@ -222,7 +211,6 @@ class _WeatherSkyBackgroundState extends State for (final t in sunTex) { t.dispose(); } - starMap?.dispose(); return; } @@ -236,7 +224,6 @@ class _WeatherSkyBackgroundState extends State ..addAll(bakers); _sprites.addAll(sprites); _sunTextures.addAll(sunTex); - _starMap = starMap; // Without both bake shaders there is no sky; the fallback colour shows. _lutCache = (transmittance != null && skyLut != null) ? SkyLutCache(transmittance, skyLut) @@ -280,8 +267,6 @@ class _WeatherSkyBackgroundState extends State t.dispose(); } _sunTextures.clear(); - _starMap?.dispose(); - _starMap = null; _rain?.atlas.dispose(); _snow?.atlas.dispose(); _rain = null; @@ -337,10 +322,9 @@ class _WeatherSkyBackgroundState extends State size: Size.infinite, painter: WeatherSkyPainter( shaders: _shaders, - skyView: cache.skyView, + lutCache: cache, cloudSprites: _sprites, sunTextures: _sunTextures, - starMap: _starMap, frame: frame, rainField: _rain, snowField: _snow, 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 index 09c3c9885..0140593a8 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart @@ -124,14 +124,16 @@ class LightningFrame { /// the weather is calm. class WeatherSkyPainter extends CustomPainter { final Map shaders; - final ui.Image? skyView; 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; - - /// Equirectangular Milky Way, or `null` if it failed to load. - final ui.Image? starMap; final SkyFrame frame; /// Rain and snow particle fields, or `null` before their sprites load. @@ -143,10 +145,9 @@ class WeatherSkyPainter extends CustomPainter { const WeatherSkyPainter({ required this.shaders, - required this.skyView, + required this.lutCache, required this.cloudSprites, required this.sunTextures, - required this.starMap, required this.frame, required this.rainField, required this.snowField, @@ -154,9 +155,7 @@ class WeatherSkyPainter extends CustomPainter { }); // Asset keys, matching `pubspec.yaml`. - static const String skyViewAsset = 'shaders/sky/sky_view.frag'; static const String cloudsAsset = 'shaders/cloud/clouds.frag'; - static const String galaxyAsset = 'shaders/weather/galaxy.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'; @@ -165,7 +164,6 @@ class WeatherSkyPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { _paintSky(canvas, size); - _paintGalaxy(canvas, size); _paintNight(canvas, size); _paintClouds(canvas, size); _paintRain(canvas, size); @@ -177,9 +175,7 @@ class WeatherSkyPainter extends CustomPainter { @override bool shouldRepaint(WeatherSkyPainter old) => - old.frame.time != frame.time || - !identical(old.frame, frame) || - !identical(old.skyView, skyView); + old.frame.time != frame.time || !identical(old.frame, frame); void _fill( Canvas canvas, @@ -196,56 +192,31 @@ class WeatherSkyPainter extends CustomPainter { } // --- 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 shader = shaders[skyViewAsset]; - final lut = skyView; - if (shader == null || lut == null) return; - - final fr = buildFrustum(frame.sky.cameraYaw); - var i = 0; - void set(double v) => shader.setFloat(i++, v); - set(size.width); - set(size.height); - 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(frame.sky.sunAngleY); - set(frame.time); - set(SkyLutCache.skyViewSize.width); - set(SkyLutCache.skyViewSize.height); - shader.setImageSampler(0, lut); - - _fill(canvas, size, shader); - } - - // --- galaxy --------------------------------------------------------------- - void _paintGalaxy(Canvas canvas, Size size) { - final shader = shaders[galaxyAsset]; - final map = starMap; - final night = frame.nightAmount; - if (shader == null || map == null || night < 0.01) return; - - var i = 0; - void set(double v) => shader.setFloat(i++, v); - set(size.width); - set(size.height); - // The engine's own orientation. Note the first is - // a rotation angle, not an opacity, despite its name. - set(0.38); - set(0.64); - set(0.16); - set(0.27); // fov - set(frame.time); - set(0.9); - // Cloud hides the galaxy faster than it hides the brighter stars. - set(night * (1.0 - 0.95 * frame.cloudCoverage)); - shader.setImageSampler(0, map); - - // `SRC_ALPHA, ONE` in the engine. - _fill(canvas, size, shader, BlendMode.plus); + 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 -------------------------------------------------------------- @@ -265,7 +236,8 @@ class WeatherSkyPainter extends CustomPainter { set(night); set(0.0); // scroll set(frame.cloudCoverage); - // The real Milky Way is its own layer now; the procedural band is off. + // The dedicated galaxy layer was removed (it never loaded in production); + // the procedural band stays off. set(0.0); _fill(canvas, size, shader); @@ -274,12 +246,10 @@ class WeatherSkyPainter extends CustomPainter { // --- clouds ------------------------------------------------------------- void _paintClouds(Canvas canvas, Size size) { final shader = shaders[cloudsAsset]; - final lut = skyView; - if (shader == null || lut == null || cloudSprites.isEmpty) return; + final skyColumn = lutCache.skyColumn; + if (shader == null || skyColumn == null || cloudSprites.isEmpty) return; if (frame.cloudCoverage < 0.02) return; - // Clouds probe the same frustum the sky is drawn through. - final fr = buildFrustum(frame.sky.cameraYaw); final lighting = cloudLighting(sunAngleY: frame.sky.sunAngleY); final placed = placeClouds( frame.cloudLayout, @@ -295,6 +265,21 @@ class WeatherSkyPainter extends CustomPainter { 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; @@ -322,7 +307,6 @@ class WeatherSkyPainter extends CustomPainter { set(lighting.ambient.$1); set(lighting.ambient.$2); set(lighting.ambient.$3); - set(frame.sky.sunAngleY); set(p.opacity); set(0.05); // start edge set(0.23); // end edge @@ -333,18 +317,20 @@ class WeatherSkyPainter extends CustomPainter { set(frame.fog * 0.5); set(frame.lightning.flash * 0.8); set(lighting.whitePer); - set(SkyLutCache.skyViewSize.width); + // 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(fr.top[0]); - set(fr.top[1]); - set(fr.top[2]); - set(fr.bottom[0]); - set(fr.bottom[1]); - set(fr.bottom[2]); + 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, lut); + shader.setImageSampler(1, skyColumn); canvas.save(); canvas.translate(p.left, p.top); @@ -356,6 +342,15 @@ class WeatherSkyPainter extends CustomPainter { } } + /// 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 diff --git a/shaders/README.md b/shaders/README.md index 7dc794dba..d0aec408a 100644 --- a/shaders/README.md +++ b/shaders/README.md @@ -24,7 +24,13 @@ Three stages: |---|---|---|---| | transmittance LUT | `sky/transmittance.frag` | 256×256 | on atmosphere change | | sky-view LUT | `sky/sky_lut.frag` | 256×141 | on atmosphere change | -| screen pass | `sky/sky_view.frag` | full screen | every frame | +| 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 @@ -43,7 +49,7 @@ change available to the look. ## Layer stack (paint order) ``` -sky_view sky gradient from the LUT → sky/sky_view.frag +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 diff --git a/shaders/cloud/clouds.frag b/shaders/cloud/clouds.frag index 2b7819f34..e6e7c63b2 100644 --- a/shaders/cloud/clouds.frag +++ b/shaders/cloud/clouds.frag @@ -44,21 +44,27 @@ // iSunCfg (14..16) sun light: picker, multi, intensity // iGroundCfg (17..19) ground light: picker, multi, intensity // iAmbientCfg (20..22) ambient light: picker, multi, intensity -// iSunElevParam (23) sky-LUT u coordinate for the current keyframe -// iOpacity (24) instance opacity -// iStartEdge (25) soft-edge start (cloud profile `uStartEdge`) -// iEndEdge (26) soft-edge end (cloud profile `uEndEdge`) -// iProgress (27) deck reveal 0..2 (the reference's `uProgress`) -// iSmooth (28) reveal softness (the reference's `uSmooth`) -// iCloudDepth (29) depth weighting 0..1 -// iSunIntensity (30) direct-sun strength 0..1 -// iFogDensity (31) haze blending the sprite toward the sky -// iInner (32) internal illumination 0..1 (lightning inside) -// iWhitePer (33) noon whitening (the reference's `whitePer`) -// iLutSize (34..35) sky LUT size in texels -// iTexSize (36..37) sprite size in texels +// 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 sky LUT (`sky/sky_lut.frag`) +// sampler 1: iSkyView the 1×141 sky-LUT column (`sky/sky_lut.frag`, +// extracted by `SkyLutCache._bakeSkyColumn`) #include precision highp float; @@ -71,7 +77,6 @@ uniform vec3 iBaseCfg; uniform vec3 iSunCfg; uniform vec3 iGroundCfg; uniform vec3 iAmbientCfg; -uniform float iSunElevParam; uniform float iOpacity; uniform float iStartEdge; uniform float iEndEdge; @@ -83,9 +88,9 @@ uniform float iFogDensity; uniform float iInner; uniform float iWhitePer; uniform vec2 iLutSize; -uniform vec3 iFrustumA; -uniform vec3 iFrustumC; uniform vec2 iTexSize; +uniform vec3 iBaseSky; +uniform vec3 iHazeSky; uniform sampler2D iSprite; uniform sampler2D iSkyView; @@ -94,20 +99,23 @@ out vec4 fragColor; #define PI 3.14159265359 #define ONE_OVER_PI 0.3183098861837907 -/// Bilinear sky-LUT fetch. Flutter binds samplers as NEAREST with no way to -/// request linear, so without this the cloud lighting steps as the sun moves. -vec3 sampleLut(vec2 uv) { - vec2 texel = 1.0 / iLutSize; - vec2 st = uv * iLutSize - 0.5; - vec2 base = floor(st); - vec2 f = st - base; - vec2 uv0 = (base + 0.5) * texel; - - vec3 c00 = texture(iSkyView, uv0).rgb; - vec3 c10 = texture(iSkyView, uv0 + vec2(texel.x, 0.0)).rgb; - vec3 c01 = texture(iSkyView, uv0 + vec2(0.0, texel.y)).rgb; - vec3 c11 = texture(iSkyView, uv0 + texel).rgb; - return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y); +/// 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. @@ -153,10 +161,10 @@ vec4 sampleSprite(vec2 uv) { /// 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) { - float elevation = clamp(picker, 0.0, 1.0) * (PI / 2.0); - float m = sqrt(elevation / (PI / 2.0)); + // 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(vec2(clamp(iSunElevParam, 0.0, 1.0), v)); + return sampleLut(v); } /// The reference's `readColorConfig`: sample the sky at the config's probe, then gain. @@ -239,6 +247,10 @@ void main() { 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); @@ -246,9 +258,8 @@ void main() { // 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.x = mix(0.05, baseCfg.x, iCloudDepth); baseCfg.y = mix(1.0, baseCfg.y, iCloudDepth); - vec3 baseColor = readColorConfig(baseCfg); + 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. @@ -286,7 +297,8 @@ void main() { vec3 diffuse = mix(vec3(texDepth), vec3(1.0), 0.8); diffuse = mix(diffuse, vec3(0.8), iFogDensity); - float fogMulti = 1.0 - pow(max(0.0, iFogDensity), 2.0); + float fog = max(0.0, iFogDensity); + float fogMulti = 1.0 - fog * fog; vec3 color = baseColor; color += l0 * sunColor * sunI * fogMulti; @@ -300,8 +312,10 @@ void main() { color *= mix(1.0, 1.5, iInner); color *= diffuse; - // Haze dissolves the sprite into the sky behind it. - color = mix(color, skyAt(0.95), iFogDensity); + // 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/sky/sky_view.frag b/shaders/sky/sky_view.frag index 2ccf2b5e0..e1d759f91 100644 --- a/shaders/sky/sky_view.frag +++ b/shaders/sky/sky_view.frag @@ -1,5 +1,11 @@ // 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 diff --git a/shaders/weather/card_water.frag b/shaders/weather/card_water.frag index 79ba8cc74..5dbc99f7f 100644 --- a/shaders/weather/card_water.frag +++ b/shaders/weather/card_water.frag @@ -59,10 +59,8 @@ 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. -const float kGloss = 10.0; - -/// `float alpha = 0.6;` — the fixed surface opacity past the threshold. +/// `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 @@ -123,7 +121,11 @@ void main() { vec3 viewDir = vec3(0.0, 0.0, 1.0); vec3 halfDir = normalize(viewDir + lightDir); - float specular = pow(max(dot(normal, halfDir), 0.0), kGloss); + // 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; diff --git a/shaders/weather/galaxy.frag b/shaders/weather/galaxy.frag index f62c55ef8..19719b472 100644 --- a/shaders/weather/galaxy.frag +++ b/shaders/weather/galaxy.frag @@ -119,7 +119,8 @@ void main() { color.a *= smoothstep(0.1, 0.2, luminance(color.rgb)); // Fade out toward the horizon and the very top of the frame. - float fb = clamp(mix(1.0, 0.0, 1.0 / exp(4.0 * uv.y * uv.y)), 0.0, 1.0); + // `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; diff --git a/shaders/weather/night.frag b/shaders/weather/night.frag index 0e2dde1db..72bd7d80e 100644 --- a/shaders/weather/night.frag +++ b/shaders/weather/night.frag @@ -107,13 +107,13 @@ float drawStars(vec2 coord, float numCells, float size, float br, // 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, pow(angleOffset, 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) { - glow += pow(clamp(br * smoothstep(0.895, 1.0, sqrtd), 0.0, 2.0), 2.0) * - 0.33333333; + 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. diff --git a/shaders/weather/sun_flare.frag b/shaders/weather/sun_flare.frag index b86ca9e46..2a837b4ee 100644 --- a/shaders/weather/sun_flare.frag +++ b/shaders/weather/sun_flare.frag @@ -111,9 +111,12 @@ vec3 ghost(int index, vec2 uv, vec2 sunPos, float size, float dist, /// The reference's `ring`: the chromatic halo, offset per channel. vec3 ring(vec2 uv, vec2 pos, float dist) { vec2 uvd = uv * length(uv); - float r = max(2.0 / (1.0 + 32.0 * pow(length(uvd + (dist - 0.05) * pos), 2.0)), 0.0) * 0.25; - float g = max(2.0 / (1.0 + 32.0 * pow(length(uvd + dist * pos), 2.0)), 0.0) * 0.23; - float b = max(2.0 / (1.0 + 32.0 * pow(length(uvd + (dist + 0.05) * pos), 2.0)), 0.0) * 0.21; + 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); } @@ -158,10 +161,11 @@ void main() { vec3 staticRays = texture(iRaysTex, st2).rgb; // Forward-facing weight: brightest looking straight into the sun. - float fan = min(max(0.3, pow(clamp(perFromVal( - dot(normalize(sunPos + 1e-5), normalize(sunPos - uv + 1e-5)), - -1.0, 1.0), 0.0, 1.0), 3.0)), 0.8); - fan = max(pow(max(1.0 - dist, 0.0), 3.0), fan); + 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 ------------------------------------------------------ @@ -216,7 +220,7 @@ void main() { // --- 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); + (0.5 * exp(-x * x)) * clamp(iGlowAlpha, 0.0, 1.0); // --- falloff ------------------------------------------------------------- color *= exp(1.0 - dist) / kDecay; 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/shaders/preview_render_test.dart b/test/shaders/preview_render_test.dart index 4550a1d3a..ea5f3cd8a 100644 --- a/test/shaders/preview_render_test.dart +++ b/test/shaders/preview_render_test.dart @@ -175,7 +175,7 @@ void main() { final sunProg = await ui.FragmentProgram.fromAsset( 'shaders/weather/sun_flare.frag', ); - final rainAtlas = await loadPng('assets/weather/particles/rain_drop.png'); + 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', @@ -197,7 +197,7 @@ void main() { final trans = _bakeTransmittance(transProgram, sky); final lut = _bakeSkyLut(lutProgram, sky, trans); - final image = _renderScene( + final image = await _renderScene( viewProgram, cloudProgram, sprites, @@ -330,7 +330,7 @@ ui.Image _bakeSkyLut( return _rasterise(s, SkyConstants.skyLutWidth, SkyConstants.skyLutHeight); } -ui.Image _renderScene( +Future _renderScene( ui.FragmentProgram viewProgram, ui.FragmentProgram cloudProgram, List sprites, @@ -348,7 +348,7 @@ ui.Image _renderScene( double keyframePos, ui.FragmentProgram galaxyProgram, ui.Image starMapImage, -) { +) async { final recorder = ui.PictureRecorder(); final canvas = ui.Canvas(recorder); @@ -440,6 +440,20 @@ ui.Image _renderScene( 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(); @@ -470,7 +484,6 @@ ui.Image _renderScene( f(lighting.ambient.$1); f(lighting.ambient.$2); f(lighting.ambient.$3); - f(sky.sunAngleY); f(p.opacity); f(0.05); // start edge f(0.23); // end edge @@ -481,10 +494,17 @@ ui.Image _renderScene( f(0.0); // fog f(0.0); // inner f(lighting.whitePer); - f(SkyConstants.skyLutWidth.toDouble()); + // 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); @@ -613,6 +633,46 @@ ui.FragmentShader _skyShader( 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( 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 index 538f69991..3b14f0fb4 100644 --- a/test/shaders/weather_sky_shader_test.dart +++ b/test/shaders/weather_sky_shader_test.dart @@ -74,17 +74,16 @@ void main() { 0.55, 1.20, 1.0, // iSunCfg 0.02, 0.70, 0.5, // iGroundCfg 0.90, 0.80, 0.6, // iAmbientCfg - 0.5, // iSunElevParam 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 - 0.0985, 0.0416, 0.0, // iFrustumA - 0.1010, 0.0064, 0.0, // iFrustumC + 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, ), From e9c750dabc69d6776169ce4b008efa0e0547bd7b Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 05:42:52 +0800 Subject: [PATCH 45/92] feat: custom layer order in the map layer picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker's header gains a reorder button that opens a fixed-height editor over the sheet (two stacked drag sheets felt broken). The saved order is a list of layer ids resolved against each surface's layers via orderedLayers(), so a layer added after the order was saved appends at the bottom, a stale id is ignored, and duplicates count once — future layers can never throw. Every drop persists; a reset button restores the declared order. --- lib/bootstrap.dart | 3 + lib/core/di/core_providers.dart | 4 + lib/core/di/shared_deps.dart | 5 + .../settings/map_layer_order_controller.dart | 39 +++ lib/core/settings/preference_keys.dart | 7 + lib/l10n/app_en.arb | 8 + lib/l10n/app_fil.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_th.arb | 2 + lib/l10n/app_vi.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_Hans.arb | 2 + lib/l10n/app_zh_Hant_HK.arb | 2 + lib/l10n/app_zh_TW.arb | 2 + lib/l10n/gen/app_localizations.dart | 12 + lib/l10n/gen/app_localizations_en.dart | 6 + lib/l10n/gen/app_localizations_fil.dart | 6 + lib/l10n/gen/app_localizations_id.dart | 6 + lib/l10n/gen/app_localizations_ja.dart | 6 + lib/l10n/gen/app_localizations_ko.dart | 6 + lib/l10n/gen/app_localizations_th.dart | 6 + lib/l10n/gen/app_localizations_vi.dart | 6 + lib/l10n/gen/app_localizations_zh.dart | 24 ++ lib/shared/map/map_layer_order.dart | 34 +++ lib/shared/map/map_layer_switcher.dart | 289 ++++++++++++++++-- .../map_layer_order_controller_test.dart | 72 +++++ test/shared/map/map_layer_order_test.dart | 80 +++++ test/shared/map/map_layer_switcher_test.dart | 164 ++++++++++ 30 files changed, 773 insertions(+), 30 deletions(-) create mode 100644 lib/core/settings/map_layer_order_controller.dart create mode 100644 lib/shared/map/map_layer_order.dart create mode 100644 test/core/settings/map_layer_order_controller_test.dart create mode 100644 test/shared/map/map_layer_order_test.dart create mode 100644 test/shared/map/map_layer_switcher_test.dart 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/settings/map_layer_order_controller.dart b/lib/core/settings/map_layer_order_controller.dart new file mode 100644 index 000000000..da40af8d6 --- /dev/null +++ b/lib/core/settings/map_layer_order_controller.dart @@ -0,0 +1,39 @@ +/// Persisted custom order for the map layer picker. +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 as a list of layer ids, persisted across +/// launches and shared by every map surface (each surface resolves it against +/// its own layer set — see `orderedLayers` in `shared/map/map_layer_order.dart`). +class MapLayerOrderController extends ChangeNotifier { + MapLayerOrderController(this._prefs) + : _order = _prefs.getStringList(PreferenceKeys.mapLayerOrder) ?? const []; + + final Prefs _prefs; + List _order; + + /// 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); + + /// 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(); + } + + /// Clears the saved order — every surface falls back to its declared order. + Future reset() async { + if (_order.isEmpty) return; + _order = const []; + await _prefs.remove(PreferenceKeys.mapLayerOrder); + notifyListeners(); + } +} diff --git a/lib/core/settings/preference_keys.dart b/lib/core/settings/preference_keys.dart index 9628d4500..0eb597dcf 100644 --- a/lib/core/settings/preference_keys.dart +++ b/lib/core/settings/preference_keys.dart @@ -54,6 +54,13 @@ 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', + ); + /// Saved Home township codes (ordered list). See `RegionStore`. static const PrefKey> savedRegionCodes = PrefKey>._( 'home.savedRegionCodes', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3b985e2fa..22522d395 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -770,6 +770,14 @@ "@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" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index d6f368695..9702f9afa 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -161,6 +161,8 @@ "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", "mapLayerQpesums": "Pagtaya ng ulan sa susunod na 1 oras", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 8c6297e45..5f9a9a13f 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -161,6 +161,8 @@ "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", "mapLayerQpesums": "Prakiraan hujan 1 jam ke depan", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index aee2f4f5c..e84dbb1dd 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -161,6 +161,8 @@ "homeRainTrendHeavySustained": "今後1時間は大雨が続きます", "homeRainTrendHeavyStopping": "{minutes}分後に大雨が止む見込みです", "mapLayers": "レイヤー", + "mapLayerOrderTitle": "レイヤーの順番", + "mapLayerOrderReset": "既定の順序に戻す", "mapLayerRadar": "レーダー合成エコー図", "mapLayerSatellite": "ひまわり 赤外線図", "mapLayerQpesums": "1時間降水量予報", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 512d7e35f..6f83df1a5 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -161,6 +161,8 @@ "homeRainTrendHeavySustained": "앞으로 1시간 동안 강한 비가 이어질 거예요", "homeRainTrendHeavyStopping": "{minutes}분 후에 강한 비가 그칠 것으로 예상돼요", "mapLayers": "레이어", + "mapLayerOrderTitle": "레이어 순서", + "mapLayerOrderReset": "기본 순서로 재설정", "mapLayerRadar": "레이더 합성 에코", "mapLayerSatellite": "히마와리 적외", "mapLayerQpesums": "1시간 강수 예보", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index f668cc699..7633113a8 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -161,6 +161,8 @@ "homeRainTrendHeavySustained": "ฝนตกหนักต่อเนื่องตลอดชั่วโมงหน้า", "homeRainTrendHeavyStopping": "คาดว่าฝนตกหนักจะหยุดในอีก {minutes} นาที", "mapLayers": "ชั้นข้อมูล", + "mapLayerOrderTitle": "จัดเรียงเลเยอร์", + "mapLayerOrderReset": "รีเซ็ตลำดับ", "mapLayerRadar": "เรดาร์สะท้อนสังเคราะห์", "mapLayerSatellite": "ฮิมาวาริ อินฟราเรด", "mapLayerQpesums": "พยากรณ์ฝน 1 ชั่วโมงข้างหน้า", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index e5ebd1f2f..54c6685ca 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -161,6 +161,8 @@ "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", "mapLayerQpesums": "Dự báo mưa 1 giờ tới", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d1871c3b3..42aed27c0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -161,6 +161,8 @@ "homeRainTrendHeavySustained": "未來 1 小時會有持續大雨", "homeRainTrendHeavyStopping": "預計 {minutes} 分鐘後停止下大雨", "mapLayers": "圖層", + "mapLayerOrderTitle": "調整圖層順序", + "mapLayerOrderReset": "回復預設順序", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", "mapLayerQpesums": "未來 1 小時降水預報", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 951cb480c..f9b34677e 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -161,6 +161,8 @@ "homeRainTrendHeavySustained": "未来 1 小时会有持续大雨", "homeRainTrendHeavyStopping": "预计 {minutes} 分钟后停止下大雨", "mapLayers": "图层", + "mapLayerOrderTitle": "调整图层顺序", + "mapLayerOrderReset": "恢复默认顺序", "mapLayerRadar": "雷达合成回波图", "mapLayerSatellite": "ひまわり 红外线图", "mapLayerQpesums": "未来 1 小时降水预报", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 145c13244..7558dfaaf 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -161,6 +161,8 @@ "homeRainTrendHeavySustained": "未來 1 小時會有持續大雨", "homeRainTrendHeavyStopping": "預計 {minutes} 分鐘後停止下大雨", "mapLayers": "圖層", + "mapLayerOrderTitle": "調整圖層順序", + "mapLayerOrderReset": "回復預設順序", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", "mapLayerQpesums": "未來 1 小時降水預報", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 8f3aa2f57..7d7b77f6e 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -161,6 +161,8 @@ "homeRainTrendHeavySustained": "未來 1 小時會有持續大雨", "homeRainTrendHeavyStopping": "預計 {minutes} 分鐘後停止下大雨", "mapLayers": "圖層", + "mapLayerOrderTitle": "調整圖層順序", + "mapLayerOrderReset": "回復預設順序", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線圖", "mapLayerQpesums": "未來 1 小時降水預報", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index ca966cbd1..a92f92368 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1077,6 +1077,18 @@ abstract class AppLocalizations { /// **'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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 03ee7bd6b..62c2d456a 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -543,6 +543,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapLayers => 'Layers'; + @override + String get mapLayerOrderTitle => 'Reorder layers'; + + @override + String get mapLayerOrderReset => 'Reset order'; + @override String get mapLayerRadar => 'Composite Radar Reflectivity'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index f28f87c41..b2078445b 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -548,6 +548,12 @@ class AppLocalizationsFil extends AppLocalizations { @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'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index eca2802eb..e44b84f4f 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -544,6 +544,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapLayers => 'Lapisan'; + @override + String get mapLayerOrderTitle => 'Urutkan lapisan'; + + @override + String get mapLayerOrderReset => 'Atur ulang urutan'; + @override String get mapLayerRadar => 'Radar Komposit'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index d312eee37..059d4a71e 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -539,6 +539,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapLayers => 'レイヤー'; + @override + String get mapLayerOrderTitle => 'レイヤーの順番'; + + @override + String get mapLayerOrderReset => '既定の順序に戻す'; + @override String get mapLayerRadar => 'レーダー合成エコー図'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 23f045eab..400226289 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -539,6 +539,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapLayers => '레이어'; + @override + String get mapLayerOrderTitle => '레이어 순서'; + + @override + String get mapLayerOrderReset => '기본 순서로 재설정'; + @override String get mapLayerRadar => '레이더 합성 에코'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 8d1f209ea..f5ee25834 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -541,6 +541,12 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapLayers => 'ชั้นข้อมูล'; + @override + String get mapLayerOrderTitle => 'จัดเรียงเลเยอร์'; + + @override + String get mapLayerOrderReset => 'รีเซ็ตลำดับ'; + @override String get mapLayerRadar => 'เรดาร์สะท้อนสังเคราะห์'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 748b7f129..5dd3ff097 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -541,6 +541,12 @@ class AppLocalizationsVi extends AppLocalizations { @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'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index e8d622d6c..ec14af152 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -538,6 +538,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapLayers => '圖層'; + @override + String get mapLayerOrderTitle => '調整圖層順序'; + + @override + String get mapLayerOrderReset => '回復預設順序'; + @override String get mapLayerRadar => '雷達合成回波圖'; @@ -2037,6 +2043,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapLayers => '图层'; + @override + String get mapLayerOrderTitle => '调整图层顺序'; + + @override + String get mapLayerOrderReset => '恢复默认顺序'; + @override String get mapLayerRadar => '雷达合成回波图'; @@ -3536,6 +3548,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapLayers => '圖層'; + @override + String get mapLayerOrderTitle => '調整圖層順序'; + + @override + String get mapLayerOrderReset => '回復預設順序'; + @override String get mapLayerRadar => '雷達合成回波圖'; @@ -5035,6 +5053,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapLayers => '圖層'; + @override + String get mapLayerOrderTitle => '調整圖層順序'; + + @override + String get mapLayerOrderReset => '回復預設順序'; + @override String get mapLayerRadar => '雷達合成回波圖'; diff --git a/lib/shared/map/map_layer_order.dart b/lib/shared/map/map_layer_order.dart new file mode 100644 index 000000000..15c7b4af2 --- /dev/null +++ b/lib/shared/map/map_layer_order.dart @@ -0,0 +1,34 @@ +/// Safe ordering of a map surface's layers by a persisted preference. +library; + +import 'package:dpip/shared/map/map_layer.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, + ]; +} diff --git a/lib/shared/map/map_layer_switcher.dart b/lib/shared/map/map_layer_switcher.dart index a153f4c15..b4188d394 100644 --- a/lib/shared/map/map_layer_switcher.dart +++ b/lib/shared/map/map_layer_switcher.dart @@ -1,12 +1,18 @@ /// 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_order.dart'; import 'package:dpip/shared/widgets/frosted_surface.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 +90,7 @@ class MapLayerSwitcher extends StatelessWidget { } Future _pick(BuildContext context) async { + final orderController = context.read(); final selected = await showModalBottomSheet( context: context, isScrollControlled: true, @@ -106,40 +113,48 @@ 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, + ); + return ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md + bottomInset, ), - ], + children: [ + for (final layer in ordered) + _LayerTile( + layer: layer, + selected: layer.id == active.id, + colors: colors, + onTap: () => + Navigator.of(sheetContext).pop(layer), + ), + ], + ); + }, ), ), ], @@ -151,6 +166,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 拖盤. @@ -237,3 +268,201 @@ 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. +/// +/// Seeded from [MapLayerOrderController.order] resolved against the surface's +/// actual layers ([orderedLayers]), so a layer 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 the saved order so the list +/// falls back to the surface's 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> { + late List _ids = [ + for (final layer in orderedLayers(widget.layers, widget.controller.order)) + layer.id, + ]; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final defaultOrder = [for (final layer in widget.layers) layer.id]; + 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: [ + _CenteredHeader( + title: l10n.mapLayerOrderTitle, + left: TextButton( + onPressed: listEquals(_ids, defaultOrder) ? null : _reset, + child: Text(l10n.mapLayerOrderReset), + ), + right: IconButton( + icon: const Icon(Icons.close), + tooltip: l10n.commonClose, + onPressed: () => Navigator.of(context).pop(), + ), + ), + Flexible( + child: ReorderableListView.builder( + buildDefaultDragHandles: false, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + itemCount: _ids.length, + onReorderItem: _reorder, + itemBuilder: (context, index) { + final layer = widget.layers.firstWhere( + (layer) => layer.id == _ids[index], + ); + return _ReorderTile( + key: ValueKey(layer.id), + layer: layer, + index: index, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + + void _reorder(int oldIndex, int newIndex) { + setState(() { + // `onReorderItem` already adjusted newIndex for the removed slot. + final id = _ids.removeAt(oldIndex); + _ids.insert(newIndex, id); + }); + // Fire-and-forget: the next drop supersedes this write anyway, and the + // picker below reads the controller's latest order when it rebuilds. + unawaited(widget.controller.setOrder(_ids)); + } + + void _reset() { + setState(() { + _ids = [for (final layer in widget.layers) layer.id]; + }); + unawaited(widget.controller.reset()); + } +} + +/// 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/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..483418d4e --- /dev/null +++ b/test/core/settings/map_layer_order_controller_test.dart @@ -0,0 +1,72 @@ +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'], + }); + expect(controller.order, ['rain', 'radar']); + }); + + 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); + }); +} 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..6f0f472a5 --- /dev/null +++ b/test/shared/map/map_layer_order_test.dart @@ -0,0 +1,80 @@ +import 'package:dpip/shared/map/map_layer.dart'; +import 'package:dpip/shared/map/map_layer_order.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 + 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', + ]); + }); + }); +} 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..6bc948b2c --- /dev/null +++ b/test/shared/map/map_layer_switcher_test.dart @@ -0,0 +1,164 @@ +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 only. +class _FakeLayer implements MapLayer { + _FakeLayer(this.id, this._label); + + @override + final String id; + final String _label; + + @override + String label(BuildContext context) => _label; + + @override + IconData get icon => Icons.layers_outlined; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final radar = _FakeLayer('radar', 'Radar'); + final rain = _FakeLayer('rain', 'Rain'); + final typhoon = _FakeLayer('typhoon', 'Typhoon'); + final layers = [radar, rain, typhoon]; + + Future pumpSwitcher( + WidgetTester tester, + Map initial, { + MapLayer? active, + }) async { + 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 lists layers in the saved order', (tester) async { + await pumpSwitcher(tester, { + 'map.layerOrder': ['rain', 'typhoon', 'radar'], + }); + await tester.tap(find.text('Radar')); + 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), + ); + expect(topOf(tester, 'Rain'), lessThan(topOf(tester, 'Typhoon'))); + expect(topOf(tester, 'Typhoon'), lessThan(topOf(tester, 'Radar'))); + }); + + testWidgets( + 'the picker puts a layer added after the saved order at the bottom', + (tester) async { + await pumpSwitcher(tester, { + 'map.layerOrder': ['typhoon'], + }); + await tester.tap(find.text('Radar')); + await tester.pumpAndSettle(); + + expect(topOf(tester, 'Typhoon'), lessThan(topOf(tester, 'Radar'))); + expect(topOf(tester, 'Radar'), lessThan(topOf(tester, 'Rain'))); + }, + ); + + testWidgets('the reorder button opens the order editor', (tester) async { + await pumpSwitcher(tester, {}); + await tester.tap(find.text('Radar')); + 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), + ); + expect(find.byIcon(Icons.drag_handle), findsNWidgets(3)); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('reset restores the declared order and clears the preference', ( + tester, + ) async { + final controller = await pumpSwitcher(tester, { + 'map.layerOrder': ['rain', 'typhoon', 'radar'], + }); + await tester.tap(find.text('Radar')); + 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 editor's own list falls back to the declared order… + expect(topOf(tester, 'Radar'), lessThan(topOf(tester, 'Rain'))); + expect(topOf(tester, 'Rain'), lessThan(topOf(tester, 'Typhoon'))); + // …and the preference is cleared. + expect(controller.order, isEmpty); + }); +} From d918991eeecdd92860271b89efec878eb25d6c19 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 06:36:07 +0800 Subject: [PATCH 46/92] feat: add QPESUMS as a default map layer choice The bottom-nav More tab's default-layer picker was missing the QPESUMS layer. Add it alongside radar, with its own nav label and icon, so users can default the Map tab to the 1-hour precipitation forecast. --- lib/core/settings/default_map_layer.dart | 1 + lib/l10n/app_en.arb | 4 ++++ lib/l10n/app_fil.arb | 8 +++++++- lib/l10n/app_id.arb | 8 +++++++- lib/l10n/app_ja.arb | 8 +++++++- lib/l10n/app_ko.arb | 8 +++++++- lib/l10n/app_th.arb | 8 +++++++- lib/l10n/app_vi.arb | 8 +++++++- lib/l10n/app_zh.arb | 8 +++++++- lib/l10n/app_zh_Hans.arb | 8 +++++++- lib/l10n/app_zh_Hant_HK.arb | 8 +++++++- lib/l10n/app_zh_TW.arb | 8 +++++++- lib/l10n/gen/app_localizations.dart | 6 ++++++ lib/l10n/gen/app_localizations_en.dart | 3 +++ lib/l10n/gen/app_localizations_fil.dart | 3 +++ lib/l10n/gen/app_localizations_id.dart | 3 +++ lib/l10n/gen/app_localizations_ja.dart | 3 +++ lib/l10n/gen/app_localizations_ko.dart | 3 +++ lib/l10n/gen/app_localizations_th.dart | 3 +++ lib/l10n/gen/app_localizations_vi.dart | 3 +++ lib/l10n/gen/app_localizations_zh.dart | 12 ++++++++++++ lib/shared/map/default_map_layer_ui.dart | 6 +++++- 22 files changed, 119 insertions(+), 11 deletions(-) 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/l10n/app_en.arb b/lib/l10n/app_en.arb index 22522d395..57dcb3eec 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1104,6 +1104,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" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 9702f9afa..2ac273449 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -242,6 +242,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", @@ -342,7 +343,12 @@ }, "mapOverlaySectionReference": "Layer ng sanggunian", - "mapOverlaySectionMap": "Mapa", + + + + + + "mapOverlaySectionMap": "Mapa", "rainIntervalSection": "Window ng oras", "mapTownLabels": "Mga pangalan ng bayan", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 5f9a9a13f..55b8ea64e 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -242,6 +242,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", @@ -342,7 +343,12 @@ }, "mapOverlaySectionReference": "Lapisan referensi", - "mapOverlaySectionMap": "Peta", + + + + + + "mapOverlaySectionMap": "Peta", "rainIntervalSection": "Jendela waktu", "mapTownLabels": "Nama kecamatan", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index e84dbb1dd..c5d4f864a 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -242,6 +242,7 @@ "defaultMapLayerSettings": "地図の初期レイヤー", "defaultMapLayerSubtitle": "地図タブを開いたときに表示するレイヤーです。下部ナビのアイコンとラベルもこれに合わせます。", "mapNavRadar": "レーダー", + "mapNavQpesums": "予報", "mapNavSatellite": "衛星", "mapNavLightning": "稲妻", "mapNavTyphoon": "台風", @@ -342,7 +343,12 @@ }, "mapOverlaySectionReference": "参照レイヤー", - "mapOverlaySectionMap": "地図", + + + + + + "mapOverlaySectionMap": "地図", "rainIntervalSection": "集計時間", "mapTownLabels": "郷鎮名", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6f83df1a5..5c3920000 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -242,6 +242,7 @@ "defaultMapLayerSettings": "지도 기본 레이어", "defaultMapLayerSubtitle": "지도 탭을 열 때 표시할 레이어입니다. 하단 탐색 아이콘과 라벨도 함께 바뀝니다.", "mapNavRadar": "레이더", + "mapNavQpesums": "예보", "mapNavSatellite": "위성", "mapNavLightning": "번개", "mapNavTyphoon": "태풍", @@ -342,7 +343,12 @@ }, "mapOverlaySectionReference": "참조 레이어", - "mapOverlaySectionMap": "지도", + + + + + + "mapOverlaySectionMap": "지도", "rainIntervalSection": "집계 시간", "mapTownLabels": "읍면동 이름", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 7633113a8..9830d1f75 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -242,6 +242,7 @@ "defaultMapLayerSettings": "ชั้นแผนที่เริ่มต้น", "defaultMapLayerSubtitle": "แท็บแผนที่จะเปิดชั้นนี้ ไอคอนและป้ายนำทางด้านล่างจะเปลี่ยนตาม", "mapNavRadar": "เรดาร์", + "mapNavQpesums": "พยากรณ์", "mapNavSatellite": "ดาวเทียม", "mapNavLightning": "ฟ้าผ่า", "mapNavTyphoon": "ไต้ฝุ่น", @@ -342,7 +343,12 @@ }, "mapOverlaySectionReference": "เลเยอร์อ้างอิง", - "mapOverlaySectionMap": "แผนที่", + + + + + + "mapOverlaySectionMap": "แผนที่", "rainIntervalSection": "ช่วงเวลา", "mapTownLabels": "ชื่อตำบล", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 54c6685ca..0202b044e 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -242,6 +242,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", @@ -342,7 +343,12 @@ }, "mapOverlaySectionReference": "Lớp tham chiếu", - "mapOverlaySectionMap": "Bản đồ", + + + + + + "mapOverlaySectionMap": "Bản đồ", "rainIntervalSection": "Khoảng thời gian", "mapTownLabels": "Tên hương trấn", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 42aed27c0..a8cc75aab 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -242,6 +242,7 @@ "defaultMapLayerSettings": "地圖預設圖層", "defaultMapLayerSubtitle": "開啟地圖分頁時顯示此圖層,底部導覽列圖示與文字會一併更新。", "mapNavRadar": "雷達", + "mapNavQpesums": "預報", "mapNavSatellite": "衛星", "mapNavLightning": "閃電", "mapNavTyphoon": "颱風", @@ -362,7 +363,12 @@ }, "mapOverlaySectionReference": "參考圖層", - "mapOverlaySectionMap": "地圖", + + + + + + "mapOverlaySectionMap": "地圖", "rainIntervalSection": "統計時間", "mapTownLabels": "鄉鎮名稱", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index f9b34677e..78cbb5ecf 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -242,6 +242,7 @@ "defaultMapLayerSettings": "地图默认图层", "defaultMapLayerSubtitle": "打开地图标签页时显示此图层,底部导航栏图标与文字会一并更新。", "mapNavRadar": "雷达", + "mapNavQpesums": "预报", "mapNavSatellite": "卫星", "mapNavLightning": "闪电", "mapNavTyphoon": "台风", @@ -342,7 +343,12 @@ }, "mapOverlaySectionReference": "参考图层", - "mapOverlaySectionMap": "地图", + + + + + + "mapOverlaySectionMap": "地图", "rainIntervalSection": "统计时间", "mapTownLabels": "乡镇名称", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 7558dfaaf..1aef32252 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -242,6 +242,7 @@ "defaultMapLayerSettings": "地圖預設圖層", "defaultMapLayerSubtitle": "開啟地圖分頁時顯示此圖層,底部導覽列圖示與文字會一併更新。", "mapNavRadar": "雷達", + "mapNavQpesums": "預報", "mapNavSatellite": "衛星", "mapNavLightning": "閃電", "mapNavTyphoon": "颱風", @@ -342,7 +343,12 @@ }, "mapOverlaySectionReference": "參考圖層", - "mapOverlaySectionMap": "地圖", + + + + + + "mapOverlaySectionMap": "地圖", "rainIntervalSection": "統計時間", "mapTownLabels": "鄉鎮名稱", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7d7b77f6e..51d76af94 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -242,6 +242,7 @@ "defaultMapLayerSettings": "地圖預設圖層", "defaultMapLayerSubtitle": "開啟地圖分頁時顯示此圖層,底部導覽列圖示與文字會一併更新。", "mapNavRadar": "雷達", + "mapNavQpesums": "預報", "mapNavSatellite": "衛星", "mapNavLightning": "閃電", "mapNavTyphoon": "颱風", @@ -362,7 +363,12 @@ }, "mapOverlaySectionReference": "參考圖層", - "mapOverlaySectionMap": "地圖", + + + + + + "mapOverlaySectionMap": "地圖", "rainIntervalSection": "統計時間", "mapTownLabels": "鄉鎮名稱", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index a92f92368..9cce60950 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1563,6 +1563,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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 62c2d456a..32d222376 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -804,6 +804,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapNavRadar => 'Radar'; + @override + String get mapNavQpesums => 'Forecast'; + @override String get mapNavSatellite => 'Satellite'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index b2078445b..f022bd9e1 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -810,6 +810,9 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapNavRadar => 'Radar'; + @override + String get mapNavQpesums => 'Pagtaya'; + @override String get mapNavSatellite => 'Satellite'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index e44b84f4f..f9c2f5be9 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -807,6 +807,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapNavRadar => 'Radar'; + @override + String get mapNavQpesums => 'Prakiraan'; + @override String get mapNavSatellite => 'Satelit'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 059d4a71e..2f001a375 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -794,6 +794,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapNavRadar => 'レーダー'; + @override + String get mapNavQpesums => '予報'; + @override String get mapNavSatellite => '衛星'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 400226289..f87f8de30 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -794,6 +794,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapNavRadar => '레이더'; + @override + String get mapNavQpesums => '예보'; + @override String get mapNavSatellite => '위성'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index f5ee25834..f156e0ea2 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -802,6 +802,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapNavRadar => 'เรดาร์'; + @override + String get mapNavQpesums => 'พยากรณ์'; + @override String get mapNavSatellite => 'ดาวเทียม'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 5dd3ff097..ca7e80404 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -803,6 +803,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapNavRadar => 'Radar'; + @override + String get mapNavQpesums => 'Dự báo'; + @override String get mapNavSatellite => 'Vệ tinh'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index ec14af152..a09bdef98 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -788,6 +788,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapNavRadar => '雷達'; + @override + String get mapNavQpesums => '預報'; + @override String get mapNavSatellite => '衛星'; @@ -2293,6 +2296,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapNavRadar => '雷达'; + @override + String get mapNavQpesums => '预报'; + @override String get mapNavSatellite => '卫星'; @@ -3798,6 +3804,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapNavRadar => '雷達'; + @override + String get mapNavQpesums => '預報'; + @override String get mapNavSatellite => '衛星'; @@ -5303,6 +5312,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapNavRadar => '雷達'; + @override + String get mapNavQpesums => '預報'; + @override String get mapNavSatellite => '衛星'; 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, From a451a02765397f3ffc6154ea2c69fd230d87bdaa Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 06:36:27 +0800 Subject: [PATCH 47/92] feat: group map layers into six ordered categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the broad three-way split (reference / weather / hazard) with the six categories the app's data actually falls into — earthquake, typhoon, weather observations, satellite, radar, and daily life. The layer picker and the default-layer settings page both group under these headers, and `categoryOf` falls back deterministically so a layer added later still resolves without crashing. --- .../pages/default_map_layer_page.dart | 30 ++++---- lib/l10n/app_en.arb | 30 +++++++- lib/l10n/app_fil.arb | 12 ++-- lib/l10n/app_id.arb | 12 ++-- lib/l10n/app_ja.arb | 12 ++-- lib/l10n/app_ko.arb | 12 ++-- lib/l10n/app_th.arb | 12 ++-- lib/l10n/app_vi.arb | 12 ++-- lib/l10n/app_zh.arb | 12 ++-- lib/l10n/app_zh_Hans.arb | 12 ++-- lib/l10n/app_zh_Hant_HK.arb | 12 ++-- lib/l10n/app_zh_TW.arb | 12 ++-- lib/l10n/gen/app_localizations.dart | 36 ++++++++++ lib/l10n/gen/app_localizations_en.dart | 18 +++++ lib/l10n/gen/app_localizations_fil.dart | 18 +++++ lib/l10n/gen/app_localizations_id.dart | 18 +++++ lib/l10n/gen/app_localizations_ja.dart | 18 +++++ lib/l10n/gen/app_localizations_ko.dart | 18 +++++ lib/l10n/gen/app_localizations_th.dart | 18 +++++ lib/l10n/gen/app_localizations_vi.dart | 18 +++++ lib/l10n/gen/app_localizations_zh.dart | 72 +++++++++++++++++++ lib/shared/map/map_layer_category.dart | 58 +++++++++++++++ test/shared/map/map_layer_category_test.dart | 57 +++++++++++++++ 23 files changed, 456 insertions(+), 73 deletions(-) create mode 100644 lib/shared/map/map_layer_category.dart create mode 100644 test/shared/map/map_layer_category_test.dart 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/l10n/app_en.arb b/lib/l10n/app_en.arb index 57dcb3eec..1f7e539ce 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1523,7 +1523,35 @@ "@mapOverlaySectionReference": { "description": "Section title in map overlay settings menus: the reference overlays" }, - "mapOverlaySectionMap": "Map", + "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" + }, "mapOverlaySectionMap": "Map", "@mapOverlaySectionMap": { "description": "Section title in map overlay settings menus: base-map settings" }, diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 2ac273449..a425ed783 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -343,12 +343,12 @@ }, "mapOverlaySectionReference": "Layer ng sanggunian", - - - - - - "mapOverlaySectionMap": "Mapa", + "mapLayerCategoryEarthquake": "Lindol", + "mapLayerCategoryTyphoon": "Bagyo", + "mapLayerCategoryWeather": "Obserbasyon sa panahon", + "mapLayerCategorySatellite": "Satellite", + "mapLayerCategoryRadar": "Radar", + "mapLayerCategoryLife": "Pang-araw-araw na buhay", "mapOverlaySectionMap": "Mapa", "rainIntervalSection": "Window ng oras", "mapTownLabels": "Mga pangalan ng bayan", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 55b8ea64e..5647e974d 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -343,12 +343,12 @@ }, "mapOverlaySectionReference": "Lapisan referensi", - - - - - - "mapOverlaySectionMap": "Peta", + "mapLayerCategoryEarthquake": "Gempa", + "mapLayerCategoryTyphoon": "Topan", + "mapLayerCategoryWeather": "Pengamatan cuaca", + "mapLayerCategorySatellite": "Satelit", + "mapLayerCategoryRadar": "Radar", + "mapLayerCategoryLife": "Kehidupan sehari-hari", "mapOverlaySectionMap": "Peta", "rainIntervalSection": "Jendela waktu", "mapTownLabels": "Nama kecamatan", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index c5d4f864a..62b07827c 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -343,12 +343,12 @@ }, "mapOverlaySectionReference": "参照レイヤー", - - - - - - "mapOverlaySectionMap": "地図", + "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryTyphoon": "台風", + "mapLayerCategoryWeather": "気象観測", + "mapLayerCategorySatellite": "衛星", + "mapLayerCategoryRadar": "レーダー", + "mapLayerCategoryLife": "生活", "mapOverlaySectionMap": "地図", "rainIntervalSection": "集計時間", "mapTownLabels": "郷鎮名", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5c3920000..fdf07433a 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -343,12 +343,12 @@ }, "mapOverlaySectionReference": "참조 레이어", - - - - - - "mapOverlaySectionMap": "지도", + "mapLayerCategoryEarthquake": "지진", + "mapLayerCategoryTyphoon": "태풍", + "mapLayerCategoryWeather": "기상 관측", + "mapLayerCategorySatellite": "위성", + "mapLayerCategoryRadar": "레이더", + "mapLayerCategoryLife": "생활", "mapOverlaySectionMap": "지도", "rainIntervalSection": "집계 시간", "mapTownLabels": "읍면동 이름", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 9830d1f75..5bc2ec468 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -343,12 +343,12 @@ }, "mapOverlaySectionReference": "เลเยอร์อ้างอิง", - - - - - - "mapOverlaySectionMap": "แผนที่", + "mapLayerCategoryEarthquake": "แผ่นดินไหว", + "mapLayerCategoryTyphoon": "พายุไต้ฝุ่น", + "mapLayerCategoryWeather": "การสังเกตสภาพอากาศ", + "mapLayerCategorySatellite": "ดาวเทียม", + "mapLayerCategoryRadar": "เรดาร์", + "mapLayerCategoryLife": "ชีวิตประจำวัน", "mapOverlaySectionMap": "แผนที่", "rainIntervalSection": "ช่วงเวลา", "mapTownLabels": "ชื่อตำบล", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 0202b044e..c455b5eba 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -343,12 +343,12 @@ }, "mapOverlaySectionReference": "Lớp tham chiếu", - - - - - - "mapOverlaySectionMap": "Bản đồ", + "mapLayerCategoryEarthquake": "Động đất", + "mapLayerCategoryTyphoon": "Bão", + "mapLayerCategoryWeather": "Quan sát thời tiết", + "mapLayerCategorySatellite": "Vệ tinh", + "mapLayerCategoryRadar": "Ra đa", + "mapLayerCategoryLife": "Đời sống", "mapOverlaySectionMap": "Bản đồ", "rainIntervalSection": "Khoảng thời gian", "mapTownLabels": "Tên hương trấn", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a8cc75aab..bd296bff9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -363,12 +363,12 @@ }, "mapOverlaySectionReference": "參考圖層", - - - - - - "mapOverlaySectionMap": "地圖", + "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryTyphoon": "颱風", + "mapLayerCategoryWeather": "氣象觀測", + "mapLayerCategorySatellite": "衛星", + "mapLayerCategoryRadar": "雷達", + "mapLayerCategoryLife": "生活", "mapOverlaySectionMap": "地圖", "rainIntervalSection": "統計時間", "mapTownLabels": "鄉鎮名稱", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 78cbb5ecf..36ffbc009 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -343,12 +343,12 @@ }, "mapOverlaySectionReference": "参考图层", - - - - - - "mapOverlaySectionMap": "地图", + "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryTyphoon": "台风", + "mapLayerCategoryWeather": "气象观测", + "mapLayerCategorySatellite": "卫星", + "mapLayerCategoryRadar": "雷达", + "mapLayerCategoryLife": "生活", "mapOverlaySectionMap": "地图", "rainIntervalSection": "统计时间", "mapTownLabels": "乡镇名称", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 1aef32252..2b2af3725 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -343,12 +343,12 @@ }, "mapOverlaySectionReference": "參考圖層", - - - - - - "mapOverlaySectionMap": "地圖", + "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryTyphoon": "颱風", + "mapLayerCategoryWeather": "氣象觀測", + "mapLayerCategorySatellite": "衛星", + "mapLayerCategoryRadar": "雷達", + "mapLayerCategoryLife": "生活", "mapOverlaySectionMap": "地圖", "rainIntervalSection": "統計時間", "mapTownLabels": "鄉鎮名稱", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 51d76af94..08adf8478 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -363,12 +363,12 @@ }, "mapOverlaySectionReference": "參考圖層", - - - - - - "mapOverlaySectionMap": "地圖", + "mapLayerCategoryEarthquake": "地震", + "mapLayerCategoryTyphoon": "颱風", + "mapLayerCategoryWeather": "氣象觀測", + "mapLayerCategorySatellite": "衛星", + "mapLayerCategoryRadar": "雷達", + "mapLayerCategoryLife": "生活", "mapOverlaySectionMap": "地圖", "rainIntervalSection": "統計時間", "mapTownLabels": "鄉鎮名稱", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 9cce60950..059dbbe3c 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2193,6 +2193,42 @@ abstract class AppLocalizations { /// **'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 settings menus: base-map settings /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 32d222376..eb274ead9 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1143,6 +1143,24 @@ class AppLocalizationsEn extends AppLocalizations { @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 mapOverlaySectionMap => 'Map'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index f022bd9e1..e03682d02 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1150,6 +1150,24 @@ class AppLocalizationsFil extends AppLocalizations { @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 mapOverlaySectionMap => 'Mapa'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index f9c2f5be9..1364e09b4 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1146,6 +1146,24 @@ class AppLocalizationsId extends AppLocalizations { @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 mapOverlaySectionMap => 'Peta'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 2f001a375..e77c649ee 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1132,6 +1132,24 @@ class AppLocalizationsJa extends AppLocalizations { @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 mapOverlaySectionMap => '地図'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index f87f8de30..0ec3b59c2 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1132,6 +1132,24 @@ class AppLocalizationsKo extends AppLocalizations { @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 mapOverlaySectionMap => '지도'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index f156e0ea2..e1f2a7555 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1141,6 +1141,24 @@ class AppLocalizationsTh extends AppLocalizations { @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 mapOverlaySectionMap => 'แผนที่'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index ca7e80404..036709b56 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1143,6 +1143,24 @@ class AppLocalizationsVi extends AppLocalizations { @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 mapOverlaySectionMap => 'Bản đồ'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index a09bdef98..ae7372dae 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1126,6 +1126,24 @@ class AppLocalizationsZh extends AppLocalizations { @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 mapOverlaySectionMap => '地圖'; @@ -2634,6 +2652,24 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @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 mapOverlaySectionMap => '地图'; @@ -4142,6 +4178,24 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @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 mapOverlaySectionMap => '地圖'; @@ -5650,6 +5704,24 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @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 mapOverlaySectionMap => '地圖'; diff --git a/lib/shared/map/map_layer_category.dart b/lib/shared/map/map_layer_category.dart new file mode 100644 index 000000000..fec7ae270 --- /dev/null +++ b/lib/shared/map/map_layer_category.dart @@ -0,0 +1,58 @@ +/// 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 six groups the overlays fall into, in display order. +enum MapLayerCategory { + /// Seismic-monitor events (RTS). + earthquake, + + /// Typhoon tracks. + typhoon, + + /// Continuous meteorological fields + convective phenomena (temperature, + /// humidity, pressure, wind, rain stations, lightning). + weather, + + /// Geostationary-satellite imagery (Himawari infrared). + satellite, + + /// Ground-radar imagery + short-term precipitation estimate. + radar, + + /// 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. +MapLayerCategory categoryOf(String layerId) => switch (layerId) { + 'monitor' => MapLayerCategory.earthquake, + 'typhoon' => MapLayerCategory.typhoon, + 'temperature' || + 'humidity' || + 'pressure' || + 'wind' || + 'rain' || + 'lightning' => MapLayerCategory.weather, + 'satellite' => MapLayerCategory.satellite, + 'radar' || 'qpesums' => MapLayerCategory.radar, + '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.radar => l10n.mapLayerCategoryRadar, + MapLayerCategory.life => l10n.mapLayerCategoryLife, + }; 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..b4ece9fac --- /dev/null +++ b/test/shared/map/map_layer_category_test.dart @@ -0,0 +1,57 @@ +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('radar and the precipitation forecast share a group', () { + expect(categoryOf('radar'), MapLayerCategory.radar); + expect(categoryOf('qpesums'), 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'); + } + } + }); +} From 0bf2b48afa1680bf3e4041293d748fdac609523f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 06:36:34 +0800 Subject: [PATCH 48/92] feat: let the layer order editor reorder categories too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker's order sheet previously only reordered layers within a category. Persist the category order alongside the layer order (same tolerant merge, same reset) and make a category header draggable — it moves its whole block, while a layer drag still clamps to its category and a drop always snaps to a group boundary so a block can't land inside another group. --- .../settings/map_layer_order_controller.dart | 53 +++- lib/core/settings/preference_keys.dart | 6 + lib/shared/map/map_layer_order.dart | 32 +++ lib/shared/map/map_layer_switcher.dart | 245 ++++++++++++++++-- .../map_layer_order_controller_test.dart | 50 ++++ test/shared/map/map_layer_order_test.dart | 45 ++++ test/shared/map/map_layer_switcher_test.dart | 172 ++++++++++-- 7 files changed, 546 insertions(+), 57 deletions(-) diff --git a/lib/core/settings/map_layer_order_controller.dart b/lib/core/settings/map_layer_order_controller.dart index da40af8d6..e7b01ac71 100644 --- a/lib/core/settings/map_layer_order_controller.dart +++ b/lib/core/settings/map_layer_order_controller.dart @@ -1,24 +1,35 @@ -/// Persisted custom order for the map layer picker. +/// 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 as a list of layer ids, persisted across -/// launches and shared by every map surface (each surface resolves it against -/// its own layer set — see `orderedLayers` in `shared/map/map_layer_order.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 []; + : _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. @@ -29,11 +40,33 @@ class MapLayerOrderController extends ChangeNotifier { notifyListeners(); } - /// Clears the saved order — every surface falls back to its declared order. - Future reset() async { - if (_order.isEmpty) return; - _order = const []; - await _prefs.remove(PreferenceKeys.mapLayerOrder); + /// 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 0eb597dcf..af7605952 100644 --- a/lib/core/settings/preference_keys.dart +++ b/lib/core/settings/preference_keys.dart @@ -61,6 +61,12 @@ abstract final class PreferenceKeys { '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', diff --git a/lib/shared/map/map_layer_order.dart b/lib/shared/map/map_layer_order.dart index 15c7b4af2..7e9c29177 100644 --- a/lib/shared/map/map_layer_order.dart +++ b/lib/shared/map/map_layer_order.dart @@ -2,6 +2,7 @@ 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). /// @@ -32,3 +33,34 @@ List orderedLayers(List layers, List order) { 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 b4188d394..831d0d083 100644 --- a/lib/shared/map/map_layer_switcher.dart +++ b/lib/shared/map/map_layer_switcher.dart @@ -8,8 +8,10 @@ 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'; @@ -135,6 +137,10 @@ class MapLayerSwitcher extends StatelessWidget { layers, orderController.order, ); + final categories = orderedCategories( + MapLayerCategory.values, + orderController.categoryOrder, + ); return ListView( controller: scrollController, padding: EdgeInsets.fromLTRB( @@ -144,14 +150,18 @@ class MapLayerSwitcher extends StatelessWidget { AppSpacing.md + bottomInset, ), children: [ - for (final layer in ordered) - _LayerTile( - layer: layer, - selected: layer.id == active.id, - colors: colors, - onTap: () => - Navigator.of(sheetContext).pop(layer), - ), + 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), + ), + ], ], ); }, @@ -273,11 +283,14 @@ class _LayerTile extends StatelessWidget { /// 拖盤), since it opens over the picker and two stacked drag sheets are /// confusing. /// -/// Seeded from [MapLayerOrderController.order] resolved against the surface's -/// actual layers ([orderedLayers]), so a layer 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 the saved order so the list -/// falls back to the surface's declared order. +/// Seeded from [MapLayerOrderController.order] / `.categoryOrder` resolved +/// against the surface's actual layers and the current category set +/// (`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. A category header drags its whole block (every layer under it); +/// a layer drag is clamped to its category. 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}); @@ -289,15 +302,39 @@ class _LayerOrderSheet extends StatefulWidget { } class _LayerOrderSheetState extends State<_LayerOrderSheet> { - late List _ids = [ - for (final layer in orderedLayers(widget.layers, widget.controller.order)) - layer.id, + // Category blocks — header + its layer ids. Dragging a header reorders the + // blocks; dragging a layer reorders within one block's ids. + late List<_Block> _blocks = _buildBlocks( + widget.layers, + widget.controller.order, + widget.controller.categoryOrder, + ); + + /// Layer ids in current block order (headers excluded) — 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 l10n = AppLocalizations.of(context); - final defaultOrder = [for (final layer in widget.layers) layer.id]; + 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, + ]; + final pristine = + listEquals(_ids, defaultIds) && + listEquals(_categoryIds, defaultCategoryIds); return SafeArea( top: false, child: ConstrainedBox( @@ -312,7 +349,7 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { _CenteredHeader( title: l10n.mapLayerOrderTitle, left: TextButton( - onPressed: listEquals(_ids, defaultOrder) ? null : _reset, + onPressed: pristine ? null : _reset, child: Text(l10n.mapLayerOrderReset), ), right: IconButton( @@ -330,14 +367,23 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { AppSpacing.md, AppSpacing.md, ), - itemCount: _ids.length, + itemCount: _flatten().length, onReorderItem: _reorder, itemBuilder: (context, index) { + final row = _flatten()[index]; + final id = row.id; + if (id == null) { + return _ReorderHeader( + key: ValueKey('header-${row.category.name}'), + category: row.category, + index: index, + ); + } final layer = widget.layers.firstWhere( - (layer) => layer.id == _ids[index], + (layer) => layer.id == id, ); return _ReorderTile( - key: ValueKey(layer.id), + key: ValueKey(id), layer: layer, index: index, ); @@ -353,21 +399,168 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { void _reorder(int oldIndex, int newIndex) { setState(() { - // `onReorderItem` already adjusted newIndex for the removed slot. - final id = _ids.removeAt(oldIndex); - _ids.insert(newIndex, id); + final rows = _flatten(); + final row = rows.removeAt(oldIndex); + if (row.id == null) { + // Category header — drags its whole block (header + every layer under + // it). `onReorderItem` already adjusted newIndex for the removed slot. + final block = <_Row>[row]; + while (rows.isNotEmpty && rows.first.category == row.category) { + block.add(rows.removeAt(0)); + } + // Snap the drop to a block boundary: a block must land before a + // header (or at the very end), never inside a group. + while (newIndex < rows.length && rows[newIndex].id != null) { + newIndex++; + } + newIndex = newIndex.clamp(0, rows.length); + rows.insertAll(newIndex, block); + } else { + // Layer — clamp to its category's band of the remaining rows. A + // category that emptied out just goes back to its old slot. + final (start, last) = _band(row.category, rows); + newIndex = start == -1 ? oldIndex : newIndex.clamp(start, last + 1); + rows.insert(newIndex, row); + } + _blocks = _condense(rows); }); // Fire-and-forget: the next drop supersedes this write anyway, and the - // picker below reads the controller's latest order when it rebuilds. + // picker below reads the controller's latest orders when it rebuilds. unawaited(widget.controller.setOrder(_ids)); + unawaited(widget.controller.setCategoryOrder(_categoryIds)); } void _reset() { setState(() { - _ids = [for (final layer in widget.layers) layer.id]; + _blocks = _buildBlocks(widget.layers, const [], const []); }); unawaited(widget.controller.reset()); } + + /// Rows in current block order — a header row before each block's layers. + List<_Row> _flatten() => [ + for (final block in _blocks) ...[ + _Row(block.category, null), + for (final id in block.ids) _Row(block.category, id), + ], + ]; + + /// Collapses a flattened row list back into blocks. [rows] always begins a + /// block with its header row, so a `_Block` is created there and filled by + /// the layer rows that follow. + List<_Block> _condense(List<_Row> rows) { + final blocks = <_Block>[]; + _Block? current; + for (final row in rows) { + if (row.id == null) { + blocks.add(current = _Block(row.category)); + } else { + (current ??= _Block(row.category)).ids.add(row.id!); + } + } + return blocks; + } + + /// Index band (inclusive) of [category]'s **layer rows** in [rows] — headers + /// are not part of the band, so a row can be dropped anywhere between its + /// group's first and last layer. `(-1, -1)` when the group has no rows left. + static (int, int) _band(MapLayerCategory category, List<_Row> rows) { + var start = -1, last = -1; + for (var i = 0; i < rows.length; i++) { + final row = rows[i]; + if (row.id != null && row.category == category) { + if (start == -1) start = i; + last = i; + } + } + return (start, last); + } +} + +/// 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 header block before each 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 reorder-list entry — a layer row or a category section header. +class _Row { + const _Row(this.category, this.id); + + final MapLayerCategory category; + + /// The layer id; null marks a section header row. + final String? id; +} + +/// Category header inside the reorder list. Dragging it moves the whole block. +class _ReorderHeader extends StatelessWidget { + const _ReorderHeader({ + super.key, + required this.category, + required this.index, + }); + + final MapLayerCategory category; + final int index; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final l10n = AppLocalizations.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm, + AppSpacing.md, + AppSpacing.xs, + AppSpacing.xs, + ), + child: Row( + children: [ + Expanded( + child: Text( + categoryLabel(category, l10n), + style: theme.textTheme.labelMedium?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + 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]. diff --git a/test/core/settings/map_layer_order_controller_test.dart b/test/core/settings/map_layer_order_controller_test.dart index 483418d4e..81bd976ab 100644 --- a/test/core/settings/map_layer_order_controller_test.dart +++ b/test/core/settings/map_layer_order_controller_test.dart @@ -21,8 +21,10 @@ void main() { 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 { @@ -69,4 +71,52 @@ void main() { 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/shared/map/map_layer_order_test.dart b/test/shared/map/map_layer_order_test.dart index 6f0f472a5..57d84e57a 100644 --- a/test/shared/map/map_layer_order_test.dart +++ b/test/shared/map/map_layer_order_test.dart @@ -1,4 +1,5 @@ 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_test/flutter_test.dart'; @@ -77,4 +78,48 @@ void main() { ]); }); }); + + 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', 'weather', 'satellite', 'life'], + ); + }, + ); + + test('a stale name is ignored without dropping any category', () { + expect( + names(orderedCategories(MapLayerCategory.values, ['gone', 'life'])), + ['life', 'earthquake', 'typhoon', 'weather', 'satellite', 'radar'], + ); + }); + + test('duplicate names count once', () { + expect( + names( + orderedCategories(MapLayerCategory.values, [ + 'typhoon', + 'typhoon', + 'radar', + ]), + ), + ['typhoon', 'radar', 'earthquake', 'weather', 'satellite', 'life'], + ); + }); + }); } diff --git a/test/shared/map/map_layer_switcher_test.dart b/test/shared/map/map_layer_switcher_test.dart index 6bc948b2c..479f04ee2 100644 --- a/test/shared/map/map_layer_switcher_test.dart +++ b/test/shared/map/map_layer_switcher_test.dart @@ -29,16 +29,23 @@ class _FakeLayer implements MapLayer { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final radar = _FakeLayer('radar', 'Radar'); + final radar = _FakeLayer('radar', 'Radar echo'); + final qpesums = _FakeLayer('qpesums', 'Precip'); + final satellite = _FakeLayer('satellite', 'Satellite IR'); final rain = _FakeLayer('rain', 'Rain'); - final typhoon = _FakeLayer('typhoon', 'Typhoon'); - final layers = [radar, rain, typhoon]; + final typhoon = _FakeLayer('typhoon', 'Typhoon path'); + final layers = [radar, qpesums, 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()), @@ -85,11 +92,13 @@ void main() { double screenWidth(WidgetTester tester) => tester.view.physicalSize.width / tester.view.devicePixelRatio; - testWidgets('the picker lists layers in the saved order', (tester) async { + testWidgets('the picker groups layers by category, ordered within', ( + tester, + ) async { await pumpSwitcher(tester, { - 'map.layerOrder': ['rain', 'typhoon', 'radar'], + 'map.layerOrder': ['rain', 'typhoon', 'radar', 'qpesums', 'satellite'], }); - await tester.tap(find.text('Radar')); + await tester.tap(find.text('Radar echo')); await tester.pumpAndSettle(); final l10n = AppLocalizations.of( @@ -100,27 +109,47 @@ void main() { centreX(tester, find.text(l10n.mapLayers)), closeTo(screenWidth(tester) / 2, 1), ); - expect(topOf(tester, 'Rain'), lessThan(topOf(tester, 'Typhoon'))); - expect(topOf(tester, 'Typhoon'), lessThan(topOf(tester, 'Radar'))); + // Group order: typhoon → weather → satellite → radar; the two radar-group + // layers keep their saved relative order (radar echo before precip). + expect( + topOf(tester, l10n.mapLayerCategoryTyphoon), + lessThan(topOf(tester, l10n.mapLayerCategoryWeather)), + ); + expect( + topOf(tester, l10n.mapLayerCategoryWeather), + lessThan(topOf(tester, l10n.mapLayerCategorySatellite)), + ); + expect( + topOf(tester, l10n.mapLayerCategorySatellite), + lessThan(topOf(tester, l10n.mapLayerCategoryRadar)), + ); + expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Precip'))); }); testWidgets( - 'the picker puts a layer added after the saved order at the bottom', + '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')); + await tester.tap(find.text('Radar echo')); await tester.pumpAndSettle(); - expect(topOf(tester, 'Typhoon'), lessThan(topOf(tester, 'Radar'))); - expect(topOf(tester, 'Radar'), lessThan(topOf(tester, 'Rain'))); + // Unsaved layers keep the declared order within their groups: Rain + // (weather) above Satellite above Radar group (radar echo, precip). + expect(topOf(tester, 'Rain'), lessThan(topOf(tester, 'Satellite IR'))); + expect( + topOf(tester, 'Satellite IR'), + lessThan(topOf(tester, 'Radar echo')), + ); + expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Precip'))); }, ); testWidgets('the reorder button opens the order editor', (tester) async { await pumpSwitcher(tester, {}); - await tester.tap(find.text('Radar')); + await tester.tap(find.text('Radar echo')); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.tune)); @@ -134,17 +163,117 @@ void main() { centreX(tester, find.text(l10n.mapLayerOrderTitle)), closeTo(screenWidth(tester) / 2, 1), ); - expect(find.byIcon(Icons.drag_handle), findsNWidgets(3)); + // One drag handle per layer row *and* per non-empty category header (the + // picker behind also shows headers — scope to the editor's own list). + expect(find.byIcon(Icons.drag_handle), findsNWidgets(9)); + Finder inEditor(String text) => find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(text), + ); + expect(inEditor(l10n.mapLayerCategoryTyphoon), findsOneWidget); + expect(inEditor(l10n.mapLayerCategoryWeather), findsOneWidget); + expect(inEditor(l10n.mapLayerCategorySatellite), findsOneWidget); + expect(inEditor(l10n.mapLayerCategoryRadar), findsOneWidget); expect(find.byIcon(Icons.close), findsOneWidget); }); - testWidgets('reset restores the declared order and clears the preference', ( + testWidgets('dragging a layer clamps to 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(); + + // Precip sits in the bottom (radar) group. Drag its handle far up — past + // the typhoon block — and it must land back next to radar, not escape the + // group. + final handles = find.descendant( + of: find.byType(ReorderableListView), + matching: find.byIcon(Icons.drag_handle), + ); + await tester.drag(handles.last, const Offset(0, -700)); + await tester.pumpAndSettle(); + + // Precip stays inside the radar group — dragged up it clamps to the + // group's head, above radar echo but below the radar header, never + // escaping into another category. + final l10n = AppLocalizations.of( + tester.element(find.byType(MapLayerSwitcher)), + ); + expect( + topOf(tester, l10n.mapLayerCategoryRadar), + lessThan(topOf(tester, 'Precip')), + ); + expect(topOf(tester, 'Precip'), lessThan(topOf(tester, 'Radar echo'))); + // The persisted order is the grouped snapshot — precip flipped above radar + // within the radar group, nothing escaped to another category. + expect(controller.order, [ + 'typhoon', + 'rain', + 'satellite', + 'qpesums', + 'radar', + ]); + }); + + testWidgets('dragging a category header moves its whole block', ( + 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)), + ); + // Typhoon is the top block. Drag its header down past the radar block so + // the whole group (header + its layer) lands at the bottom. + final handles = find.descendant( + of: find.byType(ReorderableListView), + matching: find.byIcon(Icons.drag_handle), + ); + final start = tester.getCenter(handles.first); + final end = tester.getCenter(handles.last); + await tester.drag(handles.first, end - start + const Offset(0, 30)); + await tester.pumpAndSettle(); + + // The typhoon block now sits below radar echo, header above its layer. + expect( + topOf(tester, 'Radar echo'), + lessThan(topOf(tester, l10n.mapLayerCategoryTyphoon)), + ); + expect( + topOf(tester, l10n.mapLayerCategoryTyphoon), + lessThan(topOf(tester, 'Typhoon path')), + ); + // Both persisted orders reflect the move: radar group first, typhoon last. + expect(controller.categoryOrder, [ + 'weather', + 'satellite', + 'radar', + 'typhoon', + ]); + expect(controller.order, [ + 'rain', + 'satellite', + 'radar', + 'qpesums', + 'typhoon', + ]); + }); + + testWidgets('reset restores the grouped default and clears the preference', ( tester, ) async { + // A saved order that flips the radar pair and moves a category — a + // grouped-default order would disable the reset button (nothing to restore). final controller = await pumpSwitcher(tester, { - 'map.layerOrder': ['rain', 'typhoon', 'radar'], + 'map.layerOrder': ['qpesums', 'satellite', 'rain', 'typhoon', 'radar'], + 'map.layerCategoryOrder': ['radar', 'typhoon'], }); - await tester.tap(find.text('Radar')); + await tester.tap(find.text('Radar echo')); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.tune)); await tester.pumpAndSettle(); @@ -155,10 +284,11 @@ void main() { await tester.tap(find.text(l10n.mapLayerOrderReset)); await tester.pumpAndSettle(); - // The editor's own list falls back to the declared order… - expect(topOf(tester, 'Radar'), lessThan(topOf(tester, 'Rain'))); - expect(topOf(tester, 'Rain'), lessThan(topOf(tester, 'Typhoon'))); - // …and the preference is cleared. + // The editor's own list falls back to the grouped default — radar echo + // before precip within the bottom group… + expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Precip'))); + // …and both preferences are cleared. expect(controller.order, isEmpty); + expect(controller.categoryOrder, isEmpty); }); } From a934af4ee3dda6189348e6e90cc9339749618228 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 06:44:17 +0800 Subject: [PATCH 49/92] refactor: split the layer order editor into two levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reordering a flat list of categories and layers mixed together got confusing once categories were sortable — a header drag moved a whole block but a stray drop could still land mid-list. Make the editor two-level instead: level 1 reorders the categories (draggable, persisted to the category order), and tapping a category drills into level 2 to reorder just its layers. Each level is a homogeneous list, so no block banding or boundary snapping is needed, and a single-layer category isn't drill-in-able at all. --- lib/shared/map/map_layer_switcher.dart | 321 +++++++++---------- test/shared/map/map_layer_switcher_test.dart | 140 ++++---- 2 files changed, 246 insertions(+), 215 deletions(-) diff --git a/lib/shared/map/map_layer_switcher.dart b/lib/shared/map/map_layer_switcher.dart index 831d0d083..b8e043eeb 100644 --- a/lib/shared/map/map_layer_switcher.dart +++ b/lib/shared/map/map_layer_switcher.dart @@ -283,14 +283,15 @@ class _LayerTile extends StatelessWidget { /// 拖盤), since it opens over the picker and two stacked drag sheets are /// confusing. /// -/// Seeded from [MapLayerOrderController.order] / `.categoryOrder` resolved -/// against the surface's actual layers and the current category set -/// (`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. A category header drags its whole block (every layer under it); -/// a layer drag is clamped to its category. Every drop persists immediately; -/// the reset button clears both saved orders so the list falls back to the -/// declared order. +/// 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}); @@ -302,15 +303,17 @@ class _LayerOrderSheet extends StatefulWidget { } class _LayerOrderSheetState extends State<_LayerOrderSheet> { - // Category blocks — header + its layer ids. Dragging a header reorders the - // blocks; dragging a layer reorders within one block's ids. + // Category blocks — the category order plus each category's layer ids. late List<_Block> _blocks = _buildBlocks( widget.layers, widget.controller.order, widget.controller.categoryOrder, ); - /// Layer ids in current block order (headers excluded) — what gets persisted. + /// 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, @@ -323,18 +326,13 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - 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, - ]; - final pristine = - listEquals(_ids, defaultIds) && - listEquals(_categoryIds, defaultCategoryIds); + 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( @@ -346,49 +344,33 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { child: Column( mainAxisSize: MainAxisSize.min, children: [ - _CenteredHeader( - title: l10n.mapLayerOrderTitle, - left: TextButton( - onPressed: pristine ? null : _reset, - child: Text(l10n.mapLayerOrderReset), - ), - right: IconButton( - icon: const Icon(Icons.close), - tooltip: l10n.commonClose, - onPressed: () => Navigator.of(context).pop(), - ), - ), - Flexible( - child: ReorderableListView.builder( - buildDefaultDragHandles: false, - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - 0, - AppSpacing.md, - AppSpacing.md, + 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), ), - itemCount: _flatten().length, - onReorderItem: _reorder, - itemBuilder: (context, index) { - final row = _flatten()[index]; - final id = row.id; - if (id == null) { - return _ReorderHeader( - key: ValueKey('header-${row.category.name}'), - category: row.category, - index: index, - ); - } - final layer = widget.layers.firstWhere( - (layer) => layer.id == id, - ); - return _ReorderTile( - key: ValueKey(id), - layer: layer, - index: index, - ); - }, + right: closeButton, ), + Flexible( + child: editing == null + ? _categoryList(context) + : _layerList(context, editingBlock!), ), ], ), @@ -397,32 +379,57 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { ); } - void _reorder(int oldIndex, int newIndex) { + 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 rows = _flatten(); - final row = rows.removeAt(oldIndex); - if (row.id == null) { - // Category header — drags its whole block (header + every layer under - // it). `onReorderItem` already adjusted newIndex for the removed slot. - final block = <_Row>[row]; - while (rows.isNotEmpty && rows.first.category == row.category) { - block.add(rows.removeAt(0)); - } - // Snap the drop to a block boundary: a block must land before a - // header (or at the very end), never inside a group. - while (newIndex < rows.length && rows[newIndex].id != null) { - newIndex++; - } - newIndex = newIndex.clamp(0, rows.length); - rows.insertAll(newIndex, block); - } else { - // Layer — clamp to its category's band of the remaining rows. A - // category that emptied out just goes back to its old slot. - final (start, last) = _band(row.category, rows); - newIndex = start == -1 ? oldIndex : newIndex.clamp(start, last + 1); - rows.insert(newIndex, row); - } - _blocks = _condense(rows); + 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. @@ -430,6 +437,14 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { 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 []); @@ -437,43 +452,24 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> { unawaited(widget.controller.reset()); } - /// Rows in current block order — a header row before each block's layers. - List<_Row> _flatten() => [ - for (final block in _blocks) ...[ - _Row(block.category, null), - for (final id in block.ids) _Row(block.category, id), - ], - ]; - - /// Collapses a flattened row list back into blocks. [rows] always begins a - /// block with its header row, so a `_Block` is created there and filled by - /// the layer rows that follow. - List<_Block> _condense(List<_Row> rows) { - final blocks = <_Block>[]; - _Block? current; - for (final row in rows) { - if (row.id == null) { - blocks.add(current = _Block(row.category)); - } else { - (current ??= _Block(row.category)).ids.add(row.id!); - } - } - return blocks; + 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); } - /// Index band (inclusive) of [category]'s **layer rows** in [rows] — headers - /// are not part of the band, so a row can be dropped anywhere between its - /// group's first and last layer. `(-1, -1)` when the group has no rows left. - static (int, int) _band(MapLayerCategory category, List<_Row> rows) { - var start = -1, last = -1; - for (var i = 0; i < rows.length; i++) { - final row = rows[i]; - if (row.id != null && row.category == category) { - if (start == -1) start = i; - last = i; - } + _Block? _blockOf(MapLayerCategory category) { + for (final block in _blocks) { + if (block.category == category) return block; } - return (start, last); + return null; } } @@ -486,8 +482,7 @@ class _Block { } /// Groups [layers] (in [order]'s relative sequence) by category, in -/// [categoryOrder]'s relative sequence — a header block before each non-empty -/// group. +/// [categoryOrder]'s relative sequence — a block per non-empty group. List<_Block> _buildBlocks( List layers, List order, @@ -506,58 +501,62 @@ List<_Block> _buildBlocks( ]; } -/// One reorder-list entry — a layer row or a category section header. -class _Row { - const _Row(this.category, this.id); - - final MapLayerCategory category; - - /// The layer id; null marks a section header row. - final String? id; -} - -/// Category header inside the reorder list. Dragging it moves the whole block. -class _ReorderHeader extends StatelessWidget { - const _ReorderHeader({ +/// 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; - final l10n = AppLocalizations.of(context); return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.sm, - AppSpacing.md, - AppSpacing.xs, - AppSpacing.xs, - ), - child: Row( - children: [ - Expanded( - child: Text( - categoryLabel(category, l10n), - style: theme.textTheme.labelMedium?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w600, - ), + 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, ), - ), - ReorderableDragStartListener( - index: index, - child: const Padding( - padding: EdgeInsets.all(4), - child: Icon(Icons.drag_handle), + 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), + ), + ), + ], ), ), - ], + ), ), ); } diff --git a/test/shared/map/map_layer_switcher_test.dart b/test/shared/map/map_layer_switcher_test.dart index 479f04ee2..9676fe257 100644 --- a/test/shared/map/map_layer_switcher_test.dart +++ b/test/shared/map/map_layer_switcher_test.dart @@ -147,7 +147,7 @@ void main() { }, ); - testWidgets('the reorder button opens the order editor', (tester) async { + testWidgets('the reorder button opens the category editor', (tester) async { await pumpSwitcher(tester, {}); await tester.tap(find.text('Radar echo')); await tester.pumpAndSettle(); @@ -163,9 +163,9 @@ void main() { centreX(tester, find.text(l10n.mapLayerOrderTitle)), closeTo(screenWidth(tester) / 2, 1), ); - // One drag handle per layer row *and* per non-empty category header (the - // picker behind also shows headers — scope to the editor's own list). - expect(find.byIcon(Icons.drag_handle), findsNWidgets(9)); + // Level 1 is the category list: one drag handle per category, no layer + // rows, no chevron on a single-layer category (typhoon). + expect(find.byIcon(Icons.drag_handle), findsNWidgets(4)); Finder inEditor(String text) => find.descendant( of: find.byType(ReorderableListView), matching: find.text(text), @@ -174,51 +174,84 @@ void main() { expect(inEditor(l10n.mapLayerCategoryWeather), findsOneWidget); expect(inEditor(l10n.mapLayerCategorySatellite), findsOneWidget); expect(inEditor(l10n.mapLayerCategoryRadar), findsOneWidget); + // The picker behind still shows the active layer's tile — the editor's + // level-1 list must not. Only radar 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('dragging a layer clamps to its category', (tester) async { + 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 radarCategory = find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(l10n.mapLayerCategoryRadar), + ); + await tester.tap(radarCategory); + await tester.pumpAndSettle(); + + // Level 2 shows just the radar-group layers, each with a drag handle. + expect(editorText('Radar echo'), findsOneWidget); + expect(editorText('Precip'), findsOneWidget); + expect(editorText('Rain'), findsNothing); + expect(find.byIcon(Icons.drag_handle), findsNWidgets(2)); + // 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('Radar echo'), findsNothing); + expect(editorText(l10n.mapLayerCategoryTyphoon), 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(); - // Precip sits in the bottom (radar) group. Drag its handle far up — past - // the typhoon block — and it must land back next to radar, not escape the - // group. + // Radar 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, -700)); + await tester.drag(handles.last, const Offset(0, -600)); await tester.pumpAndSettle(); - // Precip stays inside the radar group — dragged up it clamps to the - // group's head, above radar echo but below the radar header, never - // escaping into another category. - final l10n = AppLocalizations.of( - tester.element(find.byType(MapLayerSwitcher)), - ); - expect( - topOf(tester, l10n.mapLayerCategoryRadar), - lessThan(topOf(tester, 'Precip')), - ); - expect(topOf(tester, 'Precip'), lessThan(topOf(tester, 'Radar echo'))); - // The persisted order is the grouped snapshot — precip flipped above radar - // within the radar group, nothing escaped to another category. + // Category order moved radar to the top; the layer snapshot follows the + // block order (radar group, then the rest in declared order). + expect(controller.categoryOrder, [ + 'radar', + 'typhoon', + 'weather', + 'satellite', + ]); expect(controller.order, [ + 'radar', + 'qpesums', 'typhoon', 'rain', 'satellite', - 'qpesums', - 'radar', ]); }); - testWidgets('dragging a category header moves its whole block', ( - tester, - ) async { + 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(); @@ -228,43 +261,36 @@ void main() { final l10n = AppLocalizations.of( tester.element(find.byType(MapLayerSwitcher)), ); - // Typhoon is the top block. Drag its header down past the radar block so - // the whole group (header + its layer) lands at the bottom. + await tester.tap( + find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(l10n.mapLayerCategoryRadar), + ), + ); + await tester.pumpAndSettle(); + + // Precip sits below radar echo. Drag its handle far up to flip them. final handles = find.descendant( of: find.byType(ReorderableListView), matching: find.byIcon(Icons.drag_handle), ); - final start = tester.getCenter(handles.first); - final end = tester.getCenter(handles.last); - await tester.drag(handles.first, end - start + const Offset(0, 30)); + await tester.drag(handles.last, const Offset(0, -300)); await tester.pumpAndSettle(); - // The typhoon block now sits below radar echo, header above its layer. - expect( - topOf(tester, 'Radar echo'), - lessThan(topOf(tester, l10n.mapLayerCategoryTyphoon)), - ); - expect( - topOf(tester, l10n.mapLayerCategoryTyphoon), - lessThan(topOf(tester, 'Typhoon path')), - ); - // Both persisted orders reflect the move: radar group first, typhoon last. - expect(controller.categoryOrder, [ - 'weather', - 'satellite', - 'radar', - 'typhoon', - ]); + // Only the layer ids changed — precip now before radar echo within the + // radar group; the category order is untouched (declared order, so the + // flattened snapshot leads with the typhoon block). expect(controller.order, [ + 'typhoon', 'rain', 'satellite', - 'radar', 'qpesums', - 'typhoon', + 'radar', ]); + expect(controller.categoryOrder, isEmpty); }); - testWidgets('reset restores the grouped default and clears the preference', ( + testWidgets('reset restores the grouped default and clears both preferences', ( tester, ) async { // A saved order that flips the radar pair and moves a category — a @@ -284,10 +310,16 @@ void main() { await tester.tap(find.text(l10n.mapLayerOrderReset)); await tester.pumpAndSettle(); - // The editor's own list falls back to the grouped default — radar echo - // before precip within the bottom group… + // The category list falls back to the grouped default… + final inEditor = find.descendant( + of: find.byType(ReorderableListView), + matching: find.text(l10n.mapLayerCategoryRadar), + ); + await tester.tap(inEditor); + await tester.pumpAndSettle(); + // …and radar echo is back before precip within the radar group. expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Precip'))); - // …and both preferences are cleared. + // Both preferences are cleared. expect(controller.order, isEmpty); expect(controller.categoryOrder, isEmpty); }); From 00cbb55f7f1b3ed74baf7bb4089578b2d9d39645 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 07:00:50 +0800 Subject: [PATCH 50/92] feat: reorder default map-layer categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set the default category order to earthquake → radar → typhoon → weather → satellite → life, moving the radar group up past typhoon. This is the declared order `orderedCategories` falls back to when the user hasn't saved a custom category order. --- lib/shared/map/map_layer_category.dart | 6 ++-- test/shared/map/map_layer_order_test.dart | 2 +- test/shared/map/map_layer_switcher_test.dart | 36 +++++++++----------- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/lib/shared/map/map_layer_category.dart b/lib/shared/map/map_layer_category.dart index fec7ae270..28a494980 100644 --- a/lib/shared/map/map_layer_category.dart +++ b/lib/shared/map/map_layer_category.dart @@ -9,6 +9,9 @@ enum MapLayerCategory { /// Seismic-monitor events (RTS). earthquake, + /// Ground-radar imagery + short-term precipitation estimate. + radar, + /// Typhoon tracks. typhoon, @@ -19,9 +22,6 @@ enum MapLayerCategory { /// Geostationary-satellite imagery (Himawari infrared). satellite, - /// Ground-radar imagery + short-term precipitation estimate. - radar, - /// Everyday-life facilities (disaster-prevention map). life, } diff --git a/test/shared/map/map_layer_order_test.dart b/test/shared/map/map_layer_order_test.dart index 57d84e57a..54a22da9d 100644 --- a/test/shared/map/map_layer_order_test.dart +++ b/test/shared/map/map_layer_order_test.dart @@ -105,7 +105,7 @@ void main() { test('a stale name is ignored without dropping any category', () { expect( names(orderedCategories(MapLayerCategory.values, ['gone', 'life'])), - ['life', 'earthquake', 'typhoon', 'weather', 'satellite', 'radar'], + ['life', 'earthquake', 'radar', 'typhoon', 'weather', 'satellite'], ); }); diff --git a/test/shared/map/map_layer_switcher_test.dart b/test/shared/map/map_layer_switcher_test.dart index 9676fe257..bcc5a924c 100644 --- a/test/shared/map/map_layer_switcher_test.dart +++ b/test/shared/map/map_layer_switcher_test.dart @@ -109,8 +109,12 @@ void main() { centreX(tester, find.text(l10n.mapLayers)), closeTo(screenWidth(tester) / 2, 1), ); - // Group order: typhoon → weather → satellite → radar; the two radar-group + // Group order: radar → typhoon → weather → satellite; the two radar-group // layers keep their saved relative order (radar echo before precip). + expect( + topOf(tester, l10n.mapLayerCategoryRadar), + lessThan(topOf(tester, l10n.mapLayerCategoryTyphoon)), + ); expect( topOf(tester, l10n.mapLayerCategoryTyphoon), lessThan(topOf(tester, l10n.mapLayerCategoryWeather)), @@ -119,10 +123,6 @@ void main() { topOf(tester, l10n.mapLayerCategoryWeather), lessThan(topOf(tester, l10n.mapLayerCategorySatellite)), ); - expect( - topOf(tester, l10n.mapLayerCategorySatellite), - lessThan(topOf(tester, l10n.mapLayerCategoryRadar)), - ); expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Precip'))); }); @@ -136,13 +136,11 @@ void main() { await tester.tap(find.text('Radar echo')); await tester.pumpAndSettle(); - // Unsaved layers keep the declared order within their groups: Rain - // (weather) above Satellite above Radar group (radar echo, precip). + // Unsaved layers keep the declared order within their groups: Radar + // group (radar echo, precip) at the top, then weather (rain) above + // satellite (satellite IR). + expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Rain'))); expect(topOf(tester, 'Rain'), lessThan(topOf(tester, 'Satellite IR'))); - expect( - topOf(tester, 'Satellite IR'), - lessThan(topOf(tester, 'Radar echo')), - ); expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Precip'))); }, ); @@ -226,7 +224,7 @@ void main() { await tester.tap(find.byIcon(Icons.tune)); await tester.pumpAndSettle(); - // Radar is the bottom category. Drag its handle far up to the top. + // 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), @@ -234,20 +232,20 @@ void main() { await tester.drag(handles.last, const Offset(0, -600)); await tester.pumpAndSettle(); - // Category order moved radar to the top; the layer snapshot follows the - // block order (radar group, then the rest in declared order). + // 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', 'typhoon', 'weather', - 'satellite', ]); expect(controller.order, [ + 'satellite', 'radar', 'qpesums', 'typhoon', 'rain', - 'satellite', ]); }); @@ -279,13 +277,13 @@ void main() { // Only the layer ids changed — precip now before radar echo within the // radar group; the category order is untouched (declared order, so the - // flattened snapshot leads with the typhoon block). + // flattened snapshot leads with the radar block). expect(controller.order, [ + 'qpesums', + 'radar', 'typhoon', 'rain', 'satellite', - 'qpesums', - 'radar', ]); expect(controller.categoryOrder, isEmpty); }); From c0bf2e1746dd110dc95b04d98550469e41c5eea5 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 07:01:49 +0800 Subject: [PATCH 51/92] chore: bump flutter-maplibre-gl to 7c2e249 Advance the pinned maplibre-gl ref from d3deabf to 7c2e249 for the newer native tile-bridge changes. --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 14a81bb90..f06e39b3e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,7 @@ dependencies: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl - ref: d3deabfdbe8e99fa3f8c04247e4d404c8c22ecf9 + ref: 7c2e249149ff3023c54c86a1f4c0dca5fb43d6a1 package_info_plus: ^10.2.0 path_provider: ^2.1.6 provider: ^6.1.5+1 From 1e5f950de12b6ee4739b5473feb3a8bdd4c927f1 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 07:01:49 +0800 Subject: [PATCH 52/92] perf: render weather/cloud shaders at mediump precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop highp float to mediump in the weather sky, card-water, rain-on-glass, and sun-flare fragments — mediump is sufficient for their colour work and is cheaper on tile-based GPUs. --- shaders/cloud/clouds.frag | 2 +- shaders/weather/card_water.frag | 2 +- shaders/weather/rain_on_glass.frag | 2 +- shaders/weather/sun_flare.frag | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/shaders/cloud/clouds.frag b/shaders/cloud/clouds.frag index e6e7c63b2..ef3520465 100644 --- a/shaders/cloud/clouds.frag +++ b/shaders/cloud/clouds.frag @@ -67,7 +67,7 @@ // extracted by `SkyLutCache._bakeSkyColumn`) #include -precision highp float; +precision mediump float; uniform vec2 iSpriteSize; uniform vec3 iLightDir0; diff --git a/shaders/weather/card_water.frag b/shaders/weather/card_water.frag index 5dbc99f7f..c9d068d6c 100644 --- a/shaders/weather/card_water.frag +++ b/shaders/weather/card_water.frag @@ -43,7 +43,7 @@ // Both sprites are opaque and every datum lives in a colour channel. #include -precision highp float; +precision mediump float; uniform vec2 iResolution; uniform vec3 iAmbient; diff --git a/shaders/weather/rain_on_glass.frag b/shaders/weather/rain_on_glass.frag index 0be987f25..a5dc87a6c 100644 --- a/shaders/weather/rain_on_glass.frag +++ b/shaders/weather/rain_on_glass.frag @@ -42,7 +42,7 @@ // sampler 0: uTex the content being filtered — SET BY THE ENGINE #include -precision highp float; +precision mediump float; uniform vec2 uSize; uniform vec2 uResolution; diff --git a/shaders/weather/sun_flare.frag b/shaders/weather/sun_flare.frag index 2a837b4ee..ae7e85f14 100644 --- a/shaders/weather/sun_flare.frag +++ b/shaders/weather/sun_flare.frag @@ -46,7 +46,7 @@ // sampler 2: iAnnulus radial ramp for the ring #include -precision highp float; +precision mediump float; uniform vec2 iResolution; uniform vec2 iSunPos; From 6354040f8c6024a32ccf683fcac56059c3a0136b Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 07:16:58 +0800 Subject: [PATCH 53/92] refactor: merge the three v2 tile APIs and repositories Radar, satellite, and QPESUMS each had a near-verbatim API class and repository impl differing only in the URL path segment. Collapse them into a parameterised `FrameTileApi` (path + shared delta-frame decode) and a single `FrameTileRepositoryImpl` that implements all three empty domain interfaces; the provider layer still injects each overlay under its own interface. --- lib/features/weather/data/frame_tile_api.dart | 58 +++++++++++++++++++ .../weather/data/frame_tile_repository.dart | 41 +++++++++++-- lib/features/weather/data/qpesums_api.dart | 51 ---------------- .../weather/data/qpesums_repository_impl.dart | 26 --------- lib/features/weather/data/radar_api.dart | 49 ---------------- .../weather/data/radar_repository_impl.dart | 26 --------- lib/features/weather/data/satellite_api.dart | 49 ---------------- .../data/satellite_repository_impl.dart | 26 --------- lib/features/weather/weather_providers.dart | 20 +++---- .../features/weather/frame_tile_api_test.dart | 40 +++++++++++++ 10 files changed, 143 insertions(+), 243 deletions(-) create mode 100644 lib/features/weather/data/frame_tile_api.dart delete mode 100644 lib/features/weather/data/qpesums_api.dart delete mode 100644 lib/features/weather/data/qpesums_repository_impl.dart delete mode 100644 lib/features/weather/data/radar_api.dart delete mode 100644 lib/features/weather/data/radar_repository_impl.dart delete mode 100644 lib/features/weather/data/satellite_api.dart delete mode 100644 lib/features/weather/data/satellite_repository_impl.dart create mode 100644 test/features/weather/frame_tile_api_test.dart 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..db913b0ee --- /dev/null +++ b/lib/features/weather/data/frame_tile_api.dart @@ -0,0 +1,58 @@ +/// Shared frame-keyed tile API for the v2 raster overlays (radar, satellite, +/// QPESUMS). +library; + +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_region.dart'; + +/// Tile endpoints for one v2 raster overlay — radar / satellite / QPESUMS 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 { + const FrameTileApi(this._client, this.path); + + final ApiClient _client; + + /// The overlay's URL path segment: `radar`, `satellite`, or `qpesums`. + final String path; + + /// 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` + Future> getFrames() async => framesFromList( + await _client.get(_listTier, '/api/v2/tiles/$path/list') 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` + String tileUrl(String frame) => + '${_client.hostsFor(_tileTier).first}' + '/api/v2/tiles/$path/$frame/{z}/{x}/{y}.webp'; + + /// 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) { + if (deltas.isEmpty) return const []; + var value = (deltas.first as num).toInt(); + final values = [value]; + for (var i = 1; i < deltas.length; i++) { + value += (deltas[i] as num).toInt(); + values.add(value); + } + return [for (final v in values.reversed) v.toString()]; + } +} diff --git a/lib/features/weather/data/frame_tile_repository.dart b/lib/features/weather/data/frame_tile_repository.dart index fb1a2df20..1185652f5 100644 --- a/lib/features/weather/data/frame_tile_repository.dart +++ b/lib/features/weather/data/frame_tile_repository.dart @@ -1,15 +1,23 @@ -/// 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/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/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 @@ -82,3 +90,28 @@ 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 three +/// 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 { + FrameTileRepositoryImpl(this._api, super.warmer); + + final FrameTileApi _api; + + @override + int get maxZoom => 11; + + @override + String get tilePathPrefix => '/api/v2/tiles/${_api.path}/'; + + @override + Future>> frames() => guardResult(_api.getFrames); + + @override + String tileUrl(String frame) => _api.tileUrl(frame); +} diff --git a/lib/features/weather/data/qpesums_api.dart b/lib/features/weather/data/qpesums_api.dart deleted file mode 100644 index 0d07b882a..000000000 --- a/lib/features/weather/data/qpesums_api.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:dpip/core/network/api_client.dart'; -import 'package:dpip/core/network/api_region.dart'; - -/// QPESUMS next-1-hour precipitation forecast (未來一小時降水預報) tile -/// endpoints — **API v2**, region topology. -/// -/// Everything is keyed by 13-digit Unix **milliseconds**; a frame id *is* the -/// millisecond. The frame list is delta-encoded (`[baseMs, Δ, Δ, …]`) and -/// served from `api.core-tnn1`; tiles are WebP on `static.core-tnn1` -/// ([ApiTier.coreStaticExclusive]); [tileUrl] feeds MapLibre. Same dual-host + -/// delta-list shape as the v2 radar/satellite tiles. -class QpesumsApi { - const QpesumsApi(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 forecast frame milliseconds, **newest first**. - /// - /// `https://api.core-tnn1.exptech.dev/api/v2/tiles/qpesums/list` - Future> getFrames() async => framesFromList( - await _client.get(_listTier, '/api/v2/tiles/qpesums/list') as List, - ); - - /// XYZ WebP raster tile URL template for a QPESUMS [frame] (a Unix - /// millisecond). - /// - /// `https://static.core-tnn1.exptech.dev/api/v2/tiles/qpesums//{z}/{x}/{y}.webp` - String tileUrl(String frame) => - '${_client.hostsFor(_tileTier).first}' - '/api/v2/tiles/qpesums/$frame/{z}/{x}/{y}.webp'; - - /// Restores the delta-encoded list `[baseMs, Δ, Δ, …]` to absolute - /// milliseconds 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 millis = (deltas.first as num).toInt(); - final values = [millis]; - for (var i = 1; i < deltas.length; i++) { - millis += (deltas[i] as num).toInt(); - values.add(millis); - } - return [for (final v in values.reversed) v.toString()]; - } -} diff --git a/lib/features/weather/data/qpesums_repository_impl.dart b/lib/features/weather/data/qpesums_repository_impl.dart deleted file mode 100644 index 711c718b9..000000000 --- a/lib/features/weather/data/qpesums_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/qpesums_api.dart'; -import 'package:dpip/features/weather/domain/qpesums_repository.dart'; - -/// [QpesumsRepository] backed by [QpesumsApi], with tile warming from -/// [FrameTileRepository]. -final class QpesumsRepositoryImpl extends FrameTileRepository - implements QpesumsRepository { - QpesumsRepositoryImpl(this._api, super.warmer); - - final QpesumsApi _api; - - @override - int get maxZoom => 11; - - @override - String get tilePathPrefix => '/api/v2/tiles/qpesums/'; - - @override - Future>> frames() => guardResult(_api.getFrames); - - @override - String tileUrl(String frame) => _api.tileUrl(frame); -} 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/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/weather_providers.dart b/lib/features/weather/weather_providers.dart index 6fd624ca2..326e5cd56 100644 --- a/lib/features/weather/weather_providers.dart +++ b/lib/features/weather/weather_providers.dart @@ -1,18 +1,14 @@ import 'package:dpip/core/di/shared_deps.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_api.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_weather_api.dart'; import 'package:dpip/features/weather/data/meteor_weather_repository_impl.dart'; -import 'package:dpip/features/weather/data/qpesums_api.dart'; -import 'package:dpip/features/weather/data/qpesums_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/rain_hour_trend_api.dart'; import 'package:dpip/features/weather/data/rain_hour_trend_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/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'; @@ -32,20 +28,20 @@ import 'package:provider/single_child_widget.dart'; List weatherProviders(SharedDeps deps) { return [ Provider.value( - value: RadarRepositoryImpl( - RadarApi(deps.apiClient), + value: FrameTileRepositoryImpl( + FrameTileApi(deps.apiClient, 'radar'), deps.mapTileWarmer(), ), ), Provider.value( - value: QpesumsRepositoryImpl( - QpesumsApi(deps.apiClient), + value: FrameTileRepositoryImpl( + FrameTileApi(deps.apiClient, 'qpesums'), deps.mapTileWarmer(), ), ), Provider.value( - value: SatelliteRepositoryImpl( - SatelliteApi(deps.apiClient), + value: FrameTileRepositoryImpl( + FrameTileApi(deps.apiClient, 'satellite'), deps.mapTileWarmer(), ), ), 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..a8bcfc53d --- /dev/null +++ b/test/features/weather/frame_tile_api_test.dart @@ -0,0 +1,40 @@ +import 'package:dpip/features/weather/data/frame_tile_api.dart'; +import 'package:flutter_test/flutter_test.dart'; + +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); + }); + }); +} From 82ac382cef55ca6cafaed3e45db407e64162ed9f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 07:24:57 +0800 Subject: [PATCH 54/92] refactor: share degToRad, rampColor, and drop dead ramp code Centralise the `* pi / 180` conversion (11 sites) into `core/geo/geo_math.dart`, share the value-ramp interpolation `rampColor` between the rain and weather-station layers, and let `LatLng` reuse degToRad instead of a private alias. --- lib/core/geo/geo_math.dart | 8 +++++ lib/core/geo/town_directory.dart | 3 +- lib/core/models/lat_lng.dart | 18 ++++++------ .../presentation/widgets/home_content.dart | 18 ++++++++---- .../map/presentation/layers/rain_layer.dart | 23 ++------------- .../presentation/layers/typhoon_layer.dart | 3 +- .../layers/weather_station_layer.dart | 27 ++--------------- .../map/presentation/layers/wind_layer.dart | 4 +-- .../presentation/widgets/station_sheet.dart | 3 +- .../typhoon/domain/typhoon_overlay.dart | 7 +++-- .../pages/weather_ranking_page.dart | 5 ++-- lib/shared/color_hex.dart | 23 +++++++++++++++ lib/shared/map/camera_fit.dart | 3 +- lib/shared/map/map_compass.dart | 5 ++-- lib/shared/map/xyz_tiles.dart | 4 ++- test/features/weather/qpesums_api_test.dart | 29 ------------------- test/features/weather/radar_api_test.dart | 28 ------------------ test/features/weather/satellite_api_test.dart | 27 ----------------- 18 files changed, 80 insertions(+), 158 deletions(-) create mode 100644 lib/core/geo/geo_math.dart delete mode 100644 test/features/weather/qpesums_api_test.dart delete mode 100644 test/features/weather/radar_api_test.dart delete mode 100644 test/features/weather/satellite_api_test.dart 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/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/models/lat_lng.dart b/lib/core/models/lat_lng.dart index fe75f38cf..36e86049e 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 diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 029c56bff..3adc8c07d 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -224,15 +224,23 @@ class HomeContent extends StatelessWidget { // 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. Default gated: - // true — the header rises with the rest of the - // hero block once the sheet scrolls, so it - // closes the same short distance into the - // gesture the trend card now does. + // 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, diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index df60ca21e..3cb2f616b 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -6,6 +6,7 @@ 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_rain_repository.dart'; import 'package:dpip/features/weather/domain/rain_interval.dart'; @@ -190,7 +191,7 @@ 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 cosLat = math.cos(degToRad(latLng.latitude)); final window = interval.value; final zoom = controller.cameraPosition?.zoom ?? 0; final showZero = zoom > 8; @@ -349,7 +350,7 @@ class RainMapLayer implements MapLayer, StationSheetSource { final value = observation == null ? null : interval.value.valueOf(observation); - return value == null ? null : _rampColor(value); + return value == null ? null : rampColor(colorStops, value); } @override @@ -421,24 +422,6 @@ class RainMapLayer implements MapLayer, StationSheetSource { 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 { diff --git a/lib/features/map/presentation/layers/typhoon_layer.dart b/lib/features/map/presentation/layers/typhoon_layer.dart index 0b69908f9..8a0fd1d81 100644 --- a/lib/features/map/presentation/layers/typhoon_layer.dart +++ b/lib/features/map/presentation/layers/typhoon_layer.dart @@ -6,6 +6,7 @@ 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'; @@ -1019,7 +1020,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) { diff --git a/lib/features/map/presentation/layers/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart index 7784285fb..4a539bda9 100644 --- a/lib/features/map/presentation/layers/weather_station_layer.dart +++ b/lib/features/map/presentation/layers/weather_station_layer.dart @@ -5,6 +5,7 @@ library; import 'dart:math' as math; 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'; @@ -151,7 +152,7 @@ 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)); String? best; var bestDistance = threshold; for (final entry in _stations.entries) { @@ -246,29 +247,7 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { 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 diff --git a/lib/features/map/presentation/layers/wind_layer.dart b/lib/features/map/presentation/layers/wind_layer.dart index 0b8b19dcf..b7c3d888e 100644 --- a/lib/features/map/presentation/layers/wind_layer.dart +++ b/lib/features/map/presentation/layers/wind_layer.dart @@ -3,10 +3,10 @@ /// degrees and the sheet chart colours the curve by the same speed ramp. library; -import 'dart:math' as math; 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'; @@ -273,7 +273,7 @@ class WindMapLayer extends WeatherStationLayer { final from = observation?.windDirection; if (speed == null || from == null) return null; return Transform.rotate( - angle: (from + 180) * math.pi / 180, + angle: degToRad(from + 180), child: WindArrowIcon( size: 26, outline: 1.5, diff --git a/lib/features/map/presentation/widgets/station_sheet.dart b/lib/features/map/presentation/widgets/station_sheet.dart index 383b7827b..0ee75bf6d 100644 --- a/lib/features/map/presentation/widgets/station_sheet.dart +++ b/lib/features/map/presentation/widgets/station_sheet.dart @@ -8,6 +8,7 @@ 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/geo/geo_math.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'; @@ -1048,7 +1049,7 @@ class _TrendChart extends StatelessWidget { final Widget arrow; if (from != null && speed != null) { arrow = Transform.rotate( - angle: (from + 180) * math.pi / 180, + angle: degToRad(from + 180), child: WindArrowIcon(size: 12, color: windSpeedColor(speed)), ); } else { 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/weather/presentation/pages/weather_ranking_page.dart b/lib/features/weather/presentation/pages/weather_ranking_page.dart index 7e1e21327..29bd057ce 100644 --- a/lib/features/weather/presentation/pages/weather_ranking_page.dart +++ b/lib/features/weather/presentation/pages/weather_ranking_page.dart @@ -1,10 +1,9 @@ /// 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/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'; @@ -647,7 +646,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/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/camera_fit.dart b/lib/shared/map/camera_fit.dart index a57fcb5a7..1d2aad82b 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); } diff --git a/lib/shared/map/map_compass.dart b/lib/shared/map/map_compass.dart index 5ce24fc1e..f6f7be1eb 100644 --- a/lib/shared/map/map_compass.dart +++ b/lib/shared/map/map_compass.dart @@ -7,8 +7,7 @@ /// keeps pointing north while the map turns. library; -import 'dart:math' as math; - +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'; @@ -54,7 +53,7 @@ class MapCompass extends StatelessWidget { // `bearing` means north now sits `-bearing` clockwise from // the screen top, so the needle rotates by `-bearing`. child: Transform.rotate( - angle: -value * math.pi / 180, + angle: -degToRad(value), child: Icon( Icons.navigation, size: 22, 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/test/features/weather/qpesums_api_test.dart b/test/features/weather/qpesums_api_test.dart deleted file mode 100644 index ec1c00d24..000000000 --- a/test/features/weather/qpesums_api_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:dpip/features/weather/data/qpesums_api.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('QpesumsApi.framesFromList', () { - test('decodes delta-millis and returns them newest first', () { - // [baseMs, +600000, +600000] → 1786208400000, 1786209000000, - // 1786209600000 (10-min steps) - expect(QpesumsApi.framesFromList([1786208400000, 600000, 600000]), [ - '1786209600000', - '1786209000000', - '1786208400000', - ]); - }); - - test('an empty list yields no frames', () { - expect(QpesumsApi.framesFromList(const []), isEmpty); - }); - - test('a single-frame list is just the base millisecond', () { - expect(QpesumsApi.framesFromList([1786208400000]), ['1786208400000']); - }); - - test('frame ids are 13-digit millis, usable straight in a tile URL', () { - final frames = QpesumsApi.framesFromList([1786208400000, 600000]); - expect(frames.first.length, 13); - }); - }); -} 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); - }); - }); -} From 9ff5ab06d22944a12f588415974b4f9a618df71f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 07:33:37 +0800 Subject: [PATCH 55/92] refactor: give MapLayer a defaults mixin for unused members Sheet layers (weather stations, rain, lightning, RTS, typhoon, DPM) each hand-wrote the same ten-plus no-op bodies for the MapLayer members they don't use. A `MapLayerDefaults` mixin provides them once; layers override only what they actually do. Also folds the shared 4/maxZoom floor into the defaults. --- .../layers/disaster_map_layer.dart | 30 +------- .../presentation/layers/lightning_layer.dart | 46 +----------- .../map/presentation/layers/rain_layer.dart | 42 +---------- .../map/presentation/layers/rts_layer.dart | 55 +------------- .../presentation/layers/typhoon_layer.dart | 27 +------ .../layers/weather_station_layer.dart | 49 +----------- lib/shared/map/map_layer.dart | 75 +++++++++++++++++++ 7 files changed, 85 insertions(+), 239 deletions(-) diff --git a/lib/features/map/presentation/layers/disaster_map_layer.dart b/lib/features/map/presentation/layers/disaster_map_layer.dart index 45a75aeb3..1cc864ce6 100644 --- a/lib/features/map/presentation/layers/disaster_map_layer.dart +++ b/lib/features/map/presentation/layers/disaster_map_layer.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) { @@ -633,15 +617,6 @@ class DisasterMapLayer implements MapLayer { onShowTownLabelsChanged: onShowTownLabelsChanged, ); - @override - Widget buildMapOverlay(BuildContext context) => const SizedBox.shrink(); - - @override - void onMapGestureStart() {} - - @override - void onMapGestureEnd() {} - @override Future onCameraIdle(MapLibreMapController controller) => _prefetchViewport(controller); @@ -686,9 +661,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/lightning_layer.dart b/lib/features/map/presentation/layers/lightning_layer.dart index b6cb58055..24bdf9c70 100644 --- a/lib/features/map/presentation/layers/lightning_layer.dart +++ b/lib/features/map/presentation/layers/lightning_layer.dart @@ -14,14 +14,13 @@ 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; @@ -66,46 +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, { - required ValueListenable showTownLabels, - required ValueChanged onShowTownLabelsChanged, - }) => 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); @@ -215,9 +174,6 @@ class LightningMapLayer implements MapLayer { _shownFrameId = null; } - @override - void selectFeature(String id) {} - @override void onStyleReset() { _mounted = false; diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index 3cb2f616b..ffea83aa5 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -14,7 +14,6 @@ import 'package:dpip/features/weather/domain/rain_snapshot.dart'; import 'package:dpip/features/weather/domain/weather_station.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/map/map_town_labels.dart'; @@ -40,7 +39,9 @@ extension RainIntervalL10n on RainInterval { }; } -class RainMapLayer implements MapLayer, StationSheetSource { +class RainMapLayer + with MapLayerDefaults + implements MapLayer, StationSheetSource { RainMapLayer(this._repository); final MeteorRainRepository _repository; @@ -105,25 +106,6 @@ class RainMapLayer implements MapLayer, StationSheetSource { @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 {} - - @override - Future show( - MapLibreMapController c, - MapFrame frame, { - bool scrubbing = false, - }) async {} - @override Future render(MapLibreMapController controller) async { _controller = controller; @@ -267,21 +249,6 @@ class RainMapLayer implements MapLayer, StationSheetSource { ); } - @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, @@ -320,9 +287,6 @@ class RainMapLayer implements MapLayer, StationSheetSource { @override void selectFeature(String id) => select(id); - @override - void onStyleReset() {} - @override ValueListenable get selection => _selected; diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 196d6d56c..160fecd99 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -11,14 +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/foundation.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -26,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; @@ -72,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; @@ -174,37 +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, { - required ValueListenable showTownLabels, - required ValueChanged onShowTownLabelsChanged, - }) => 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), @@ -220,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/typhoon_layer.dart b/lib/features/map/presentation/layers/typhoon_layer.dart index 8a0fd1d81..1e6bede3a 100644 --- a/lib/features/map/presentation/layers/typhoon_layer.dart +++ b/lib/features/map/presentation/layers/typhoon_layer.dart @@ -50,7 +50,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, @@ -191,22 +191,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; @@ -418,12 +402,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; @@ -1296,9 +1274,6 @@ class TyphoonMapLayer implements MapLayer { _controller = null; } - @override - void selectFeature(String id) {} - @override void onStyleReset() { // The reload dropped every runtime layer, the chrome included; forget it so diff --git a/lib/features/map/presentation/layers/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart index 4a539bda9..7ffadf15b 100644 --- a/lib/features/map/presentation/layers/weather_station_layer.dart +++ b/lib/features/map/presentation/layers/weather_station_layer.dart @@ -12,7 +12,6 @@ import 'package:dpip/features/weather/domain/weather_snapshot.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'; @@ -28,7 +27,9 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// 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 + with MapLayerDefaults + implements MapLayer, StationSheetSource { WeatherStationLayer(this._repository); final MeteorWeatherRepository _repository; @@ -90,25 +91,6 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { @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 {} - - @override - Future show( - MapLibreMapController c, - MapFrame frame, { - bool scrubbing = false, - }) async {} - @override Future render(MapLibreMapController controller) async { await _ensureData(); @@ -173,28 +155,6 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { } } - @override - Widget buildTopTrailingChrome( - BuildContext context, { - required ValueListenable showTownLabels, - required ValueChanged onShowTownLabelsChanged, - }) => 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); @@ -216,10 +176,7 @@ abstract class WeatherStationLayer implements MapLayer, StationSheetSource { void selectFeature(String id) => select(id); @override - void onStyleReset() {} - // --- StationSheetSource --- - @override ValueListenable get selection => _selected; diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart index a21a59e44..96def1416 100644 --- a/lib/shared/map/map_layer.dart +++ b/lib/shared/map/map_layer.dart @@ -1,4 +1,5 @@ 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'; @@ -165,3 +166,77 @@ 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 + 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, + }) => 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() {} +} From 589ff91b973b582a49caa387def1ab0f5cec9e8d Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 07:40:22 +0800 Subject: [PATCH 56/92] refactor: share meteor repo decodes, bounds, and calibrated time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract the code→station map and delta-seconds history decodes shared by the meteor weather/rain/lightning repositories. - Move boundsFromPoints into camera_fit and reuse it from the report detail page instead of a second min/max loop. - Use AppTime.utc for the rain-trend dry/placeholder "now" instead of DateTime.now, per the calibrated-time convention. --- .../pages/report_detail_page.dart | 22 ++++------------- .../presentation/layers/typhoon_layer.dart | 19 --------------- .../meteor_lightning_repository_impl.dart | 5 ++-- .../data/meteor_rain_repository_impl.dart | 15 +++--------- .../weather/data/meteor_station_decode.dart | 24 +++++++++++++++++++ .../data/meteor_weather_repository_impl.dart | 15 +++--------- .../weather/domain/rain_hour_trend.dart | 8 +++---- lib/shared/map/camera_fit.dart | 19 +++++++++++++++ 8 files changed, 60 insertions(+), 67 deletions(-) create mode 100644 lib/features/weather/data/meteor_station_decode.dart diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart index 8e2a60a6c..b5e96cdf1 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'; @@ -112,22 +111,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 diff --git a/lib/features/map/presentation/layers/typhoon_layer.dart b/lib/features/map/presentation/layers/typhoon_layer.dart index 1e6bede3a..1c9d06e55 100644 --- a/lib/features/map/presentation/layers/typhoon_layer.dart +++ b/lib/features/map/presentation/layers/typhoon_layer.dart @@ -1396,25 +1396,6 @@ class _DotSwatch extends StatelessWidget { } } -/// 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/weather/data/meteor_lightning_repository_impl.dart b/lib/features/weather/data/meteor_lightning_repository_impl.dart index 9be92411d..6050538a9 100644 --- a/lib/features/weather/data/meteor_lightning_repository_impl.dart +++ b/lib/features/weather/data/meteor_lightning_repository_impl.dart @@ -3,8 +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_lightning_api.dart'; +import 'package:dpip/features/weather/data/meteor_station_decode.dart'; import 'package:dpip/features/weather/domain/lightning_snapshot.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; @@ -20,8 +20,7 @@ class MeteorLightningRepositoryImpl implements MeteorLightningRepository { guardResult(() async => LightningSnapshot.decode(await _api.getLatest())); @override - Future>> history() => - guardResult(() async => MeteorDecode.deltaSeconds(await _api.getList())); + Future>> history() => fetchHistory(_api.getList); @override Future> at(int second) => guardResult( diff --git a/lib/features/weather/data/meteor_rain_repository_impl.dart b/lib/features/weather/data/meteor_rain_repository_impl.dart index 255135abe..d5e0b602f 100644 --- a/lib/features/weather/data/meteor_rain_repository_impl.dart +++ b/lib/features/weather/data/meteor_rain_repository_impl.dart @@ -3,8 +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_rain_api.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'; @@ -19,23 +19,14 @@ class MeteorRainRepositoryImpl implements MeteorRainRepository { @override Future>> stations() => - guardResult(() async { - final raw = await _api.getStation(); - return raw.map( - (code, value) => MapEntry( - code, - WeatherStation.fromJson(value as Map), - ), - ); - }); + fetchStations(_api.getStation); @override Future> latest() => guardResult(() async => RainSnapshot.decode(await _api.getLatest())); @override - Future>> history() => - guardResult(() async => MeteorDecode.deltaSeconds(await _api.getList())); + Future>> history() => fetchHistory(_api.getList); @override Future> at(int 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_repository_impl.dart b/lib/features/weather/data/meteor_weather_repository_impl.dart index f9d3fa809..99cad895b 100644 --- a/lib/features/weather/data/meteor_weather_repository_impl.dart +++ b/lib/features/weather/data/meteor_weather_repository_impl.dart @@ -3,7 +3,7 @@ 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_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'; @@ -21,23 +21,14 @@ class MeteorWeatherRepositoryImpl implements MeteorWeatherRepository { @override Future>> stations() => - guardResult(() async { - final raw = await _api.getStation(); - return raw.map( - (code, value) => MapEntry( - code, - WeatherStation.fromJson(value as Map), - ), - ); - }); + fetchStations(_api.getStation); @override Future> latest() => guardResult(() async => WeatherSnapshot.decode(await _api.getLatest())); @override - Future>> history() => - guardResult(() async => MeteorDecode.deltaSeconds(await _api.getList())); + Future>> history() => fetchHistory(_api.getList); @override Future> at(int second) => diff --git a/lib/features/weather/domain/rain_hour_trend.dart b/lib/features/weather/domain/rain_hour_trend.dart index 5dd04f1df..87d2cd11f 100644 --- a/lib/features/weather/domain/rain_hour_trend.dart +++ b/lib/features/weather/domain/rain_hour_trend.dart @@ -10,6 +10,8 @@ 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. @@ -146,8 +148,7 @@ class RainHourTrend { /// "now", minute-aligned). Used by [decode] when the API returns no forecast /// series at all. factory RainHourTrend.dry({DateTime? startUtc}) { - final seconds = - (startUtc ?? DateTime.now().toUtc()).millisecondsSinceEpoch ~/ 1000; + final seconds = (startUtc ?? AppTime.utc).millisecondsSinceEpoch ~/ 1000; final aligned = seconds - (seconds % 60); return RainHourTrend(startSecond: aligned, mm: List.filled(60, 0)); } @@ -155,8 +156,7 @@ class RainHourTrend { /// 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/shared/map/camera_fit.dart b/lib/shared/map/camera_fit.dart index 1d2aad82b..b18ab8d87 100644 --- a/lib/shared/map/camera_fit.dart +++ b/lib/shared/map/camera_fit.dart @@ -128,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!), + ); +} From d08cde6536dd9c591b6a2ca5fca582e661dc3872 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 10:55:47 +0800 Subject: [PATCH 57/92] refactor: fold RainMapLayer into a generic station layer Generalize WeatherStationLayer over the snapshot/trend/observation types so the rain overlay (which only differs in its interval-scrubbing source) is a thin subclass instead of a parallel copy. --- .../presentation/layers/humidity_layer.dart | 4 +- .../presentation/layers/pressure_layer.dart | 4 +- .../map/presentation/layers/rain_layer.dart | 296 +++--------------- .../layers/temperature_layer.dart | 4 +- .../layers/weather_station_layer.dart | 139 ++++++-- .../map/presentation/layers/wind_layer.dart | 4 +- .../domain/meteor_rain_repository.dart | 7 +- .../domain/meteor_weather_repository.dart | 7 +- .../weather/domain/rain_snapshot.dart | 9 +- lib/features/weather/domain/rain_trend.dart | 3 +- .../domain/station_value_repository.dart | 41 +++ .../weather/domain/weather_snapshot.dart | 9 +- .../weather/domain/weather_trend.dart | 3 +- 13 files changed, 228 insertions(+), 302 deletions(-) create mode 100644 lib/features/weather/domain/station_value_repository.dart 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/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/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index ffea83aa5..c47e22990 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -2,27 +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/core/geo/geo_math.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/map_layer.dart'; -import 'package:dpip/shared/map/map_station_labels.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_color_legend.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 { @@ -39,29 +28,15 @@ extension RainIntervalL10n on RainInterval { }; } +/// Shares [WeatherStationLayer]'s dots/sheet/trend machinery; only the value +/// source (the accumulation window) and its chrome differ. class RainMapLayer - with MapLayerDefaults - implements MapLayer, StationSheetSource { - RainMapLayer(this._repository); - - final MeteorRainRepository _repository; + 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'; @@ -76,15 +51,16 @@ class RainMapLayer 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'), @@ -98,51 +74,16 @@ class RainMapLayer (2000, '#000000'), ]; - static const int _decimals = 1; - @override - bool get usesTimeline => false; + double? valueOf(RainObservation observation) => + interval.value.valueOf(observation); @override - double get bottomChromeFraction => StationSheet.peekExtent; + 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', [ '>', @@ -156,49 +97,42 @@ class RainMapLayer ], ]; + @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. } } } - @override - Future onMapTap(LatLng latLng, MapLibreMapController controller) async { - const threshold = 0.18 * 0.18; - final cosLat = math.cos(degToRad(latLng.latitude)); - 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, { @@ -248,156 +182,4 @@ class RainMapLayer }, ); } - - @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 - 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(colorStops, 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 - Widget? readingIcon(String id) => null; - - @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], - ]; - - 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/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/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart index 7ffadf15b..8ee5f971a 100644 --- a/lib/features/map/presentation/layers/weather_station_layer.dart +++ b/lib/features/map/presentation/layers/weather_station_layer.dart @@ -1,16 +1,19 @@ -/// 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/map_layer.dart'; import 'package:dpip/shared/map/map_station_labels.dart'; @@ -23,23 +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 +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; @@ -55,8 +57,7 @@ abstract class WeatherStationLayer /// 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 @@ -71,6 +72,25 @@ abstract class WeatherStationLayer @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 @@ -78,13 +98,28 @@ abstract class WeatherStationLayer /// 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'; + /// The source id this layer's dots + labels live on. + @protected + String get sourceId => _sourceId; + + /// 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 bool get usesTimeline => false; @@ -113,6 +148,7 @@ abstract class WeatherStationLayer // 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, ); } @@ -123,8 +159,10 @@ abstract class WeatherStationLayer _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); } @@ -135,11 +173,16 @@ abstract class WeatherStationLayer // sea doesn't select anything. const threshold = 0.18 * 0.18; 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; @@ -156,19 +199,45 @@ abstract class WeatherStationLayer } @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. + /// Colour scale from [colorStops] + [unit], with an optional [legendHeader]. @override - Widget buildLegend(BuildContext context) => MapLegendCard( - child: ColorScaleLegend(stops: colorStops, unit: unit, appendUnit: true), - ); + Widget buildLegend(BuildContext context) { + final header = legendHeader(context); + final scale = ColorScaleLegend( + stops: colorStops, + unit: unit, + appendUnit: true, + ); + 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; } @@ -198,7 +267,7 @@ abstract class WeatherStationLayer /// 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) { @@ -233,10 +302,10 @@ abstract class WeatherStationLayer 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 @@ -255,12 +324,16 @@ abstract class WeatherStationLayer 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_layer.dart b/lib/features/map/presentation/layers/wind_layer.dart index b7c3d888e..27c009ea1 100644 --- a/lib/features/map/presentation/layers/wind_layer.dart +++ b/lib/features/map/presentation/layers/wind_layer.dart @@ -18,7 +18,9 @@ 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 ids (registered once per render) and the arrow layer 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/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/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, From c9d68ce6de0b5c388591b47bd01dcdd9322f4073 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 10:56:18 +0800 Subject: [PATCH 58/92] refactor: generalize the meteor snapshot API and repositories The weather / rain / lightning datasets share the station, latest, list, at, and trend surface; fold the three near-identical clients and impls into one parameterized MeteorSnapshotApi / MeteorSnapshotRepositoryImpl. --- .../weather/data/meteor_lightning_api.dart | 39 ------------- .../meteor_lightning_repository_impl.dart | 27 +++------ .../data/meteor_rain_repository_impl.dart | 29 ++++------ ...rain_api.dart => meteor_snapshot_api.dart} | 21 ++++--- .../data/meteor_snapshot_repository_impl.dart | 38 +++++++++++++ .../weather/data/meteor_weather_api.dart | 56 ++++++------------- .../data/meteor_weather_repository_impl.dart | 32 +++++------ lib/features/weather/weather_providers.dart | 11 ++-- 8 files changed, 108 insertions(+), 145 deletions(-) delete mode 100644 lib/features/weather/data/meteor_lightning_api.dart rename lib/features/weather/data/{meteor_rain_api.dart => meteor_snapshot_api.dart} (74%) create mode 100644 lib/features/weather/data/meteor_snapshot_repository_impl.dart 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 6050538a9..932412012 100644 --- a/lib/features/weather/data/meteor_lightning_repository_impl.dart +++ b/lib/features/weather/data/meteor_lightning_repository_impl.dart @@ -1,29 +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/features/weather/data/meteor_lightning_api.dart'; -import 'package:dpip/features/weather/data/meteor_station_decode.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() => fetchHistory(_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 d5e0b602f..7f5fad2c6 100644 --- a/lib/features/weather/data/meteor_rain_repository_impl.dart +++ b/lib/features/weather/data/meteor_rain_repository_impl.dart @@ -1,9 +1,9 @@ -/// [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/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'; @@ -12,27 +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() => - fetchStations(_api.getStation); +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() => fetchHistory(_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_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 99cad895b..904bc1ba8 100644 --- a/lib/features/weather/data/meteor_weather_repository_impl.dart +++ b/lib/features/weather/data/meteor_weather_repository_impl.dart @@ -3,6 +3,7 @@ library; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/network/api_exception.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'; @@ -14,30 +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() => - fetchStations(_api.getStation); - - @override - Future> latest() => - guardResult(() async => WeatherSnapshot.decode(await _api.getLatest())); + WeatherSnapshot decodeSnapshot(Map json) => + WeatherSnapshot.decode(json); @override - Future>> history() => fetchHistory(_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 @@ -45,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/weather_providers.dart b/lib/features/weather/weather_providers.dart index 326e5cd56..cc90dac75 100644 --- a/lib/features/weather/weather_providers.dart +++ b/lib/features/weather/weather_providers.dart @@ -1,10 +1,9 @@ import 'package:dpip/core/di/shared_deps.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_api.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/rain_hour_trend_api.dart'; @@ -49,10 +48,14 @@ List weatherProviders(SharedDeps deps) { 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)), From 17199cf785474cb04212d98c25f225406f8ddc0a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 10:56:27 +0800 Subject: [PATCH 59/92] refactor: share typhoon payload decoding Three models each decoded {updated, cyclones} payloads and blank/coord helpers by hand; pull the common decodes into typhoon_decode and lat_lng pair helpers. --- lib/core/models/lat_lng.dart | 10 ++++ .../typhoon/domain/typhoon_decode.dart | 20 +++++++ .../typhoon/domain/typhoon_potential.dart | 56 +++++++------------ .../typhoon/domain/typhoon_probability.dart | 42 +++++--------- .../typhoon/domain/typhoon_warning.dart | 26 ++++----- 5 files changed, 76 insertions(+), 78 deletions(-) create mode 100644 lib/features/typhoon/domain/typhoon_decode.dart diff --git a/lib/core/models/lat_lng.dart b/lib/core/models/lat_lng.dart index 36e86049e..89b13c283 100644 --- a/lib/core/models/lat_lng.dart +++ b/lib/core/models/lat_lng.dart @@ -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/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_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), - ], - ); - } } From 0f749f161163d29dda4c02eba8be3ac685591833 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 10:56:40 +0800 Subject: [PATCH 60/92] refactor: route Taipei wall-clock conversions through AppTime.taipei Replace the ad-hoc +8h DateTime arithmetic with one calibrated facade so every wall-clock conversion reads the same way. --- lib/core/realtime/app_time.dart | 8 +++++++- .../presentation/pages/weather_ranking_page.dart | 15 +++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) 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/features/weather/presentation/pages/weather_ranking_page.dart b/lib/features/weather/presentation/pages/weather_ranking_page.dart index 29bd057ce..3d4f1abb9 100644 --- a/lib/features/weather/presentation/pages/weather_ranking_page.dart +++ b/lib/features/weather/presentation/pages/weather_ranking_page.dart @@ -4,6 +4,7 @@ library; 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'; @@ -314,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); } From 2c732434953084d24ce991635d2b1eb4bb28535f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 10:56:54 +0800 Subject: [PATCH 61/92] refactor: share small inline UI building blocks Add LegendDot, InlineLoading, trimTrailingZero, and an EventType icon key + resolver so the dozen hand-rolled spinners, status dots, axis labels, and event icons render from one place. --- lib/core/format.dart | 10 ++++++ .../pages/report_detail_page.dart | 18 ++++------ .../presentation/pages/report_list_page.dart | 12 ++----- .../pages/report_replay_page.dart | 10 +++--- lib/features/events/domain/event.dart | 13 ++++++++ .../presentation/widgets/event_timeline.dart | 14 ++------ .../widgets/home_active_events_section.dart | 25 +++----------- .../widgets/home_forecast_section.dart | 12 ++----- .../widgets/home_rain_trend_section.dart | 9 ++--- .../presentation/layers/typhoon_layer.dart | 31 +++++------------ .../widgets/rts_monitor_panel.dart | 26 +++------------ .../presentation/pages/notify_page.dart | 6 +--- .../presentation/pages/developer_page.dart | 7 +--- .../presentation/pages/sponsor_page.dart | 6 +--- lib/shared/widgets/event_icon.dart | 19 +++++++++++ lib/shared/widgets/loading_view.dart | 22 +++++++++++++ lib/shared/widgets/map_color_legend.dart | 33 +++++++++++++++++++ 17 files changed, 139 insertions(+), 134 deletions(-) create mode 100644 lib/core/format.dart create mode 100644 lib/shared/widgets/event_icon.dart 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/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart index b5e96cdf1..c34f6c552 100644 --- a/lib/features/earthquake/presentation/pages/report_detail_page.dart +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -12,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'; @@ -27,6 +28,8 @@ 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'; @@ -613,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( @@ -826,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・' @@ -887,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( @@ -1444,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 7feaa4ff0..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'; @@ -37,6 +38,7 @@ 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'; @@ -682,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) { @@ -711,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/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/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/presentation/widgets/home_active_events_section.dart b/lib/features/home/presentation/widgets/home_active_events_section.dart index 7b4755c9e..1c083854b 100644 --- a/lib/features/home/presentation/widgets/home_active_events_section.dart +++ b/lib/features/home/presentation/widgets/home_active_events_section.dart @@ -8,6 +8,8 @@ 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'; @@ -85,15 +87,7 @@ 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), @@ -167,7 +161,7 @@ class _ActiveEventRow extends StatelessWidget { shape: BoxShape.circle, ), child: Icon( - _iconFor(event.type), + eventTypeIcon(event.type.iconKey), size: 18, color: colors.onPrimaryContainer, ), @@ -215,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_forecast_section.dart b/lib/features/home/presentation/widgets/home_forecast_section.dart index 113c33ebc..7283fc5d5 100644 --- a/lib/features/home/presentation/widgets/home_forecast_section.dart +++ b/lib/features/home/presentation/widgets/home_forecast_section.dart @@ -13,6 +13,7 @@ import 'package:dpip/features/home/presentation/home_weather_controller.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'; @@ -105,16 +106,7 @@ class _HomeForecastSectionState extends State { color: cardColor, child: SizedBox( height: lerpDouble(120, 168, expansion)!, - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: secondary, - ), - ), - ), + child: Center(child: InlineLoading(color: secondary)), ), ); } 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 92c6f6725..8a870df24 100644 --- a/lib/features/home/presentation/widgets/home_rain_trend_section.dart +++ b/lib/features/home/presentation/widgets/home_rain_trend_section.dart @@ -13,6 +13,7 @@ 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 @@ -192,7 +193,7 @@ class HomeRainTrendSection extends StatelessWidget { /// 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 = data.startUtc.add(const Duration(hours: 8)); + final taipei = AppTime.taipei(data.startUtc); return DateFormat('HH:mm').format(taipei); } } @@ -218,11 +219,7 @@ class _NoData extends StatelessWidget { final colors = theme.colorScheme; final Widget child; if (loading) { - child = SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2, color: secondary), - ); + child = InlineLoading(color: secondary); } else if (failure != null) { child = Row( children: [ diff --git a/lib/features/map/presentation/layers/typhoon_layer.dart b/lib/features/map/presentation/layers/typhoon_layer.dart index 1c9d06e55..154e30a0d 100644 --- a/lib/features/map/presentation/layers/typhoon_layer.dart +++ b/lib/features/map/presentation/layers/typhoon_layer.dart @@ -1137,11 +1137,18 @@ class TyphoonMapLayer with MapLayerDefaults 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) @@ -1376,26 +1383,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), - ), - ); - } -} - /// 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/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/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/developer_page.dart b/lib/features/settings/presentation/pages/developer_page.dart index e29860dd1..3ccadc39d 100644 --- a/lib/features/settings/presentation/pages/developer_page.dart +++ b/lib/features/settings/presentation/pages/developer_page.dart @@ -378,12 +378,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, ), ], 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/shared/widgets/event_icon.dart b/lib/shared/widgets/event_icon.dart new file mode 100644 index 000000000..f39e8377d --- /dev/null +++ b/lib/shared/widgets/event_icon.dart @@ -0,0 +1,19 @@ +/// Resolves the Material icon for an event kind — the leading mark on event +/// timeline rows and home cards. +library; + +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' => Icons.water_drop_outlined, + '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_color_legend.dart b/lib/shared/widgets/map_color_legend.dart index db6d9747d..4a90c0faa 100644 --- a/lib/shared/widgets/map_color_legend.dart +++ b/lib/shared/widgets/map_color_legend.dart @@ -183,6 +183,39 @@ 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). /// From 3ed307d291e82f6f17733cc954ece25d85a88525 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 10:57:08 +0800 Subject: [PATCH 62/92] refactor: share the map sheets' extent plumbing and chrome Centralize extent listening, the at-top flag, the grip, the frosted surface, and the full draggable-sheet chrome (ExtentSheetChrome) that four sheets used to copy verbatim. --- .../widgets/report_filter_sheet.dart | 113 +------- .../map/presentation/widgets/dpm_sheet.dart | 196 ++------------ .../presentation/widgets/station_sheet.dart | 253 +++--------------- .../presentation/widgets/typhoon_panel.dart | 228 +++------------- lib/shared/widgets/sheet_extent.dart | 213 +++++++++++++++ lib/shared/widgets/sheet_surface.dart | 38 +++ 6 files changed, 352 insertions(+), 689 deletions(-) create mode 100644 lib/shared/widgets/sheet_extent.dart create mode 100644 lib/shared/widgets/sheet_surface.dart 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/map/presentation/widgets/dpm_sheet.dart b/lib/features/map/presentation/widgets/dpm_sheet.dart index c31cd132f..bfeafa597 100644 --- a/lib/features/map/presentation/widgets/dpm_sheet.dart +++ b/lib/features/map/presentation/widgets/dpm_sheet.dart @@ -3,7 +3,6 @@ /// 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'; @@ -12,9 +11,10 @@ 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/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 { @@ -34,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(); @@ -62,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([ @@ -97,76 +92,26 @@ 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(), + _Filters(restroom: widget.restroom, shelter: widget.shelter), + ] else + _dispatchBody(active!), + ], ), ); }, @@ -199,97 +144,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) { @@ -745,13 +599,7 @@ class _Loading extends StatelessWidget { 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()), ); } } diff --git a/lib/features/map/presentation/widgets/station_sheet.dart b/lib/features/map/presentation/widgets/station_sheet.dart index 0ee75bf6d..2a2515904 100644 --- a/lib/features/map/presentation/widgets/station_sheet.dart +++ b/lib/features/map/presentation/widgets/station_sheet.dart @@ -8,15 +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 @@ -112,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(); @@ -150,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( @@ -170,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, + ), ); }, ), @@ -257,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, @@ -285,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, @@ -447,7 +305,7 @@ 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), @@ -558,53 +416,26 @@ class _StationHero extends StatefulWidget { State<_StationHero> createState() => _StationHeroState(); } -class _StationHeroState extends State<_StationHero> { - late bool _expanded = StationSheet._isAtTop(widget.extent.value); - +class _StationHeroState extends State<_StationHero> with SheetExtentFlag { @override - void initState() { - super.initState(); - widget.extent.addListener(_onExtent); - } - - @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, @@ -615,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, @@ -634,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( @@ -679,7 +510,7 @@ class _StationHeroState extends State<_StationHero> { ], ), if (widget.reading case final reading?) ...[ - SizedBox(height: _expanded ? AppSpacing.md : AppSpacing.xs), + SizedBox(height: expanded ? AppSpacing.md : AppSpacing.xs), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -916,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); @@ -1635,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_panel.dart b/lib/features/map/presentation/widgets/typhoon_panel.dart index 065a7ec32..d37eb53d4 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( @@ -399,10 +234,12 @@ class _BulletinState extends State<_Bulletin> { 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, + ), + ); final options = <({String key, String title})>[ for (final t in layer.track.value?.cyclones ?? const []) @@ -630,10 +467,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( 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), + ); + } +} From bdcefa0ff2555da4f6310202aff54d2f829cfe0f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 10:57:22 +0800 Subject: [PATCH 63/92] refactor: centralize API path constants and drop the dead v1 report endpoint Put the wire paths shared by the ETag cache and the API clients in one ApiPaths registry so they can't drift, reuse MeteorDecode in the frame list, and delete the unused /api/v1/eq/report client. --- api.md | 1 - lib/core/geo/location_api.dart | 3 +- lib/core/network/api_paths.dart | 28 +++++++++++++++++++ lib/core/network/etag_interceptor.dart | 17 +++++------ .../disaster_map/data/disaster_map_api.dart | 24 ++++++++-------- .../data/dpm_tile_prefetcher.dart | 3 +- .../earthquake/data/earthquake_api.dart | 25 +++++------------ .../notification/data/notify_api.dart | 5 ++-- lib/features/weather/data/frame_tile_api.dart | 19 +++++-------- .../weather/data/frame_tile_repository.dart | 3 +- lib/shared/map/map_style.dart | 4 ++- 11 files changed, 75 insertions(+), 57 deletions(-) create mode 100644 lib/core/network/api_paths.dart diff --git a/api.md b/api.md index c121b22bc..d6e127c5f 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` | 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/network/api_paths.dart b/lib/core/network/api_paths.dart new file mode 100644 index 000000000..1bfe42d01 --- /dev/null +++ b/lib/core/network/api_paths.dart @@ -0,0 +1,28 @@ +/// 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'; + + /// 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..91121e455 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,10 @@ 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.dpm}/', '/gh/exptechtw/map-assets/', // glyph PBFs (jsDelivr) ]; 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/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/weather/data/frame_tile_api.dart b/lib/features/weather/data/frame_tile_api.dart index db913b0ee..cc8f11615 100644 --- a/lib/features/weather/data/frame_tile_api.dart +++ b/lib/features/weather/data/frame_tile_api.dart @@ -3,7 +3,9 @@ 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/core/network/meteor_decode.dart'; /// Tile endpoints for one v2 raster overlay — radar / satellite / QPESUMS all /// share the same shape, differing only in the URL path segment ([path]). @@ -32,7 +34,7 @@ class FrameTileApi { /// /// `https://api.core-tnn1.exptech.dev/api/v2/tiles//list` Future> getFrames() async => framesFromList( - await _client.get(_listTier, '/api/v2/tiles/$path/list') as List, + await _client.get(_listTier, '${ApiPaths.tiles}/$path/list') as List, ); /// XYZ WebP raster tile URL template for a [frame] (a Unix timestamp). @@ -40,19 +42,12 @@ class FrameTileApi { /// `https://static.core-tnn1.exptech.dev/api/v2/tiles///{z}/{x}/{y}.webp` String tileUrl(String frame) => '${_client.hostsFor(_tileTier).first}' - '/api/v2/tiles/$path/$frame/{z}/{x}/{y}.webp'; + '${ApiPaths.tiles}/$path/$frame/{z}/{x}/{y}.webp'; /// 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) { - if (deltas.isEmpty) return const []; - var value = (deltas.first as num).toInt(); - final values = [value]; - for (var i = 1; i < deltas.length; i++) { - value += (deltas[i] as num).toInt(); - values.add(value); - } - return [for (final v in values.reversed) v.toString()]; - } + static List framesFromList(List deltas) => [ + for (final v in MeteorDecode.deltaSeconds(deltas).reversed) v.toString(), + ]; } diff --git a/lib/features/weather/data/frame_tile_repository.dart b/lib/features/weather/data/frame_tile_repository.dart index 1185652f5..c56d3ebea 100644 --- a/lib/features/weather/data/frame_tile_repository.dart +++ b/lib/features/weather/data/frame_tile_repository.dart @@ -4,6 +4,7 @@ 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'; @@ -107,7 +108,7 @@ final class FrameTileRepositoryImpl extends FrameTileRepository int get maxZoom => 11; @override - String get tilePathPrefix => '/api/v2/tiles/${_api.path}/'; + String get tilePathPrefix => '${ApiPaths.tiles}/${_api.path}/'; @override Future>> frames() => guardResult(_api.getFrames); diff --git a/lib/shared/map/map_style.dart b/lib/shared/map/map_style.dart index 1c54073df..53e6a519d 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]. @@ -101,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 = From 5fd2d0276570767d7ee9b90239c7854fcf1efad1 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 11:22:45 +0800 Subject: [PATCH 64/92] feat: more satellite channel --- api.md | 16 +- .../presentation/layers/satellite_layer.dart | 65 ++- .../map/presentation/pages/map_page.dart | 9 +- lib/features/weather/data/frame_tile_api.dart | 20 +- .../weather/domain/satellite_channel.dart | 54 +++ lib/features/weather/weather_providers.dart | 13 + lib/l10n/app_en.arb | 142 +++++- lib/l10n/app_fil.arb | 37 +- lib/l10n/app_id.arb | 37 +- lib/l10n/app_ja.arb | 37 +- lib/l10n/app_ko.arb | 37 +- lib/l10n/app_th.arb | 37 +- lib/l10n/app_vi.arb | 37 +- lib/l10n/app_zh.arb | 37 +- lib/l10n/app_zh_Hans.arb | 37 +- lib/l10n/app_zh_Hant_HK.arb | 37 +- lib/l10n/app_zh_TW.arb | 37 +- lib/l10n/gen/app_localizations.dart | 212 ++++++++- lib/l10n/gen/app_localizations_en.dart | 108 ++++- lib/l10n/gen/app_localizations_fil.dart | 108 ++++- lib/l10n/gen/app_localizations_id.dart | 108 ++++- lib/l10n/gen/app_localizations_ja.dart | 107 ++++- lib/l10n/gen/app_localizations_ko.dart | 107 ++++- lib/l10n/gen/app_localizations_th.dart | 108 ++++- lib/l10n/gen/app_localizations_vi.dart | 108 ++++- lib/l10n/gen/app_localizations_zh.dart | 428 +++++++++++++++++- lib/shared/map/map_layer_category.dart | 36 +- test/features/map/satellite_layer_test.dart | 19 +- 28 files changed, 2081 insertions(+), 57 deletions(-) create mode 100644 lib/features/weather/domain/satellite_channel.dart diff --git a/api.md b/api.md index d6e127c5f..30de24a39 100644 --- a/api.md +++ b/api.md @@ -62,14 +62,20 @@ 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` | `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/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` diff --git a/lib/features/map/presentation/layers/satellite_layer.dart b/lib/features/map/presentation/layers/satellite_layer.dart index 1bcd340d6..65e1bcf4a 100644 --- a/lib/features/map/presentation/layers/satellite_layer.dart +++ b/lib/features/map/presentation/layers/satellite_layer.dart @@ -1,3 +1,4 @@ +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'; @@ -5,21 +6,35 @@ import 'package:dpip/shared/map/raster_timeline_layer.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. 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; + + /// 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 IconData get icon => Icons.satellite_alt_outlined; @@ -68,3 +83,45 @@ class SatelliteMapLayer extends RasterTimelineLayer { @override Widget buildLegend(BuildContext context) => const SizedBox.shrink(); } + +/// 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/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index cc5b04007..b464184c6 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -24,6 +24,7 @@ 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/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_scaffold.dart'; @@ -50,7 +51,13 @@ class _MapPageState extends State { late final List _layers = [ RadarMapLayer(context.read()), QpesumsMapLayer(context.read()), - SatelliteMapLayer(context.read()), + // 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/weather/data/frame_tile_api.dart b/lib/features/weather/data/frame_tile_api.dart index cc8f11615..18cb5db1a 100644 --- a/lib/features/weather/data/frame_tile_api.dart +++ b/lib/features/weather/data/frame_tile_api.dart @@ -17,13 +17,18 @@ import 'package:dpip/core/network/meteor_decode.dart'; /// ([ApiTier.coreStaticExclusive]); [tileUrl] feeds MapLibre. Prefetch warms /// SQLite + ambient under the same origin URL. Caching is **ETag-only**. class FrameTileApi { - const FrameTileApi(this._client, this.path); + const FrameTileApi(this._client, this.path, {this.channel}); final ApiClient _client; /// The overlay's URL path segment: `radar`, `satellite`, or `qpesums`. 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; + /// The v2 frame list lives on `api.core-tnn1` (no region failover). static const ApiTier _listTier = ApiTier.coreExclusiveApi; @@ -32,17 +37,22 @@ class FrameTileApi { /// Available frame timestamps, **newest first**. /// - /// `https://api.core-tnn1.exptech.dev/api/v2/tiles//list` + /// `https://api.core-tnn1.exptech.dev/api/v2/tiles//list[?channel=…]` Future> getFrames() async => framesFromList( - await _client.get(_listTier, '${ApiPaths.tiles}/$path/list') as List, + 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` + /// `https://static.core-tnn1.exptech.dev/api/v2/tiles///{z}/{x}/{y}.webp[?channel=…]` String tileUrl(String frame) => '${_client.hostsFor(_tileTier).first}' - '${ApiPaths.tiles}/$path/$frame/{z}/{x}/{y}.webp'; + '${ApiPaths.tiles}/$path/$frame/{z}/{x}/{y}.webp$_query'; + + String get _query { + final channel = this.channel; + return channel == null ? '' : '?channel=$channel'; + } /// Restores the delta-encoded list `[base, Δ, Δ, …]` to absolute timestamps /// and returns them **newest first** as strings (the frame id used by diff --git a/lib/features/weather/domain/satellite_channel.dart b/lib/features/weather/domain/satellite_channel.dart new file mode 100644 index 000000000..35bb4c14e --- /dev/null +++ b/lib/features/weather/domain/satellite_channel.dart @@ -0,0 +1,54 @@ +/// 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. +/// +/// 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'), + visibleGreen('2'), + visibleRed('3'), + nir('4'), + nirPhase('5'), + nirCloud('6'), + swir('7'), + wvUpper('8'), + wvMid('9'), + wvLow('10'), + so2('11'), + ozone('12'), + irClean('13'), + irLong('14'), + irLong2('15'), + co2('16'), + truecolor('truecolor'), + naturalcolor('naturalcolor'), + ash('ash'), + dust('dust'), + airmass('airmass'), + nightmicrophysics('nightmicrophysics'), + watervapor('watervapor'), + btdSplit('btd_split'), + btdFog('btd_fog'), + btdWvirw('btd_wvirw'), + btdSo2('btd_so2'), + btdCo2('btd_co2'), + btdOzone('btd_ozone'), + cloudtop('cloudtop'), + cloudmask('cloudmask'), + sst('sst'), + ndvi('ndvi'), + ndwi('ndwi'), + mndwi('mndwi'); + + const SatelliteChannel(this.key); + + /// The `?channel=` value naming this view on the tile endpoints. + final String key; +} diff --git a/lib/features/weather/weather_providers.dart b/lib/features/weather/weather_providers.dart index cc90dac75..298376b82 100644 --- a/lib/features/weather/weather_providers.dart +++ b/lib/features/weather/weather_providers.dart @@ -14,6 +14,7 @@ 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:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; @@ -44,6 +45,18 @@ List weatherProviders(SharedDeps deps) { 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(), + ), + }, + ), Provider.value( value: MeteorWeatherRepositoryImpl(MeteorWeatherApi(deps.apiClient)), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1f7e539ce..7f18fbc8f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -782,10 +782,150 @@ "@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" + }, "mapLayerQpesums": "1h Precipitation Forecast", "@mapLayerQpesums": { "description": "Name of the QPESUMS next-1-hour precipitation forecast layer in the layer picker" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index a425ed783..f37490f4d 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -164,7 +164,42 @@ "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", "mapLayerQpesums": "Pagtaya ng ulan sa susunod na 1 oras", "mapLayerLightning": "Kidlat", "lightningLegendCg": "Ulap–lupa · {minutes} min", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 5647e974d..be1dc0a3a 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -164,7 +164,42 @@ "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", "mapLayerQpesums": "Prakiraan hujan 1 jam ke depan", "mapLayerLightning": "Petir", "lightningLegendCg": "Awan–tanah · {minutes} mnt", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 62b07827c..c73508834 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -164,7 +164,42 @@ "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", "mapLayerQpesums": "1時間降水量予報", "mapLayerLightning": "雷", "lightningLegendCg": "対地 · {minutes} 分以内", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index fdf07433a..6351b0ae5 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -164,7 +164,42 @@ "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", "mapLayerQpesums": "1시간 강수 예보", "mapLayerLightning": "번개", "lightningLegendCg": "대지로 · {minutes}분 이내", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 5bc2ec468..e34f58c3a 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -164,7 +164,42 @@ "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", "mapLayerQpesums": "พยากรณ์ฝน 1 ชั่วโมงข้างหน้า", "mapLayerLightning": "ฟ้าผ่า", "lightningLegendCg": "เมฆสู่พื้น · {minutes} นาที", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index c455b5eba..d07d999ae 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -164,7 +164,42 @@ "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", "mapLayerQpesums": "Dự báo mưa 1 giờ tới", "mapLayerLightning": "Sét", "lightningLegendCg": "Mây–đất · {minutes} phút", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bd296bff9..31bd4a738 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -164,7 +164,42 @@ "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": "ひまわり 水體指數", "mapLayerQpesums": "未來 1 小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 36ffbc009..7d47f94aa 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -164,7 +164,42 @@ "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": "ひまわり 水体指数", "mapLayerQpesums": "未来 1 小时降水预报", "mapLayerLightning": "闪电", "lightningLegendCg": "对地 · {minutes} 分钟内", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 2b2af3725..54cb64409 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -164,7 +164,42 @@ "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": "ひまわり 水體指數", "mapLayerQpesums": "未來 1 小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 08adf8478..d82e9c8b6 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -164,7 +164,42 @@ "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": "ひまわり 水體指數", "mapLayerQpesums": "未來 1 小時降水預報", "mapLayerLightning": "閃電", "lightningLegendCg": "對地 · {minutes} 分內", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 059dbbe3c..e987750bf 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1098,9 +1098,219 @@ 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; + /// Name of the QPESUMS next-1-hour precipitation forecast layer in the layer picker /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index eb274ead9..be78abae0 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -553,7 +553,113 @@ class AppLocalizationsEn extends AppLocalizations { 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 mapLayerQpesums => '1h Precipitation Forecast'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index e03682d02..16cecd29f 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -558,7 +558,113 @@ class AppLocalizationsFil extends AppLocalizations { 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 mapLayerQpesums => 'Pagtaya ng ulan sa susunod na 1 oras'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 1364e09b4..37e227f2f 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -554,7 +554,113 @@ class AppLocalizationsId extends AppLocalizations { 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 mapLayerQpesums => 'Prakiraan hujan 1 jam ke depan'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index e77c649ee..7510bae55 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -549,7 +549,112 @@ class AppLocalizationsJa extends AppLocalizations { 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 mapLayerQpesums => '1時間降水量予報'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 0ec3b59c2..9423cfda9 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -549,7 +549,112 @@ class AppLocalizationsKo extends AppLocalizations { 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 mapLayerQpesums => '1시간 강수 예보'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index e1f2a7555..b2399b058 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -551,7 +551,113 @@ class AppLocalizationsTh extends AppLocalizations { 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 mapLayerQpesums => 'พยากรณ์ฝน 1 ชั่วโมงข้างหน้า'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 036709b56..4c3736a9f 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -551,7 +551,113 @@ class AppLocalizationsVi extends AppLocalizations { 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 mapLayerQpesums => 'Dự báo mưa 1 giờ tới'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index ae7372dae..10e3fcab4 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -548,7 +548,112 @@ class AppLocalizationsZh extends AppLocalizations { 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 mapLayerQpesums => '未來 1 小時降水預報'; @@ -2074,7 +2179,112 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { 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 mapLayerQpesums => '未来 1 小时降水预报'; @@ -3600,7 +3810,112 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { 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 mapLayerQpesums => '未來 1 小時降水預報'; @@ -5126,7 +5441,112 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { 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 mapLayerQpesums => '未來 1 小時降水預報'; diff --git a/lib/shared/map/map_layer_category.dart b/lib/shared/map/map_layer_category.dart index 28a494980..0339b176d 100644 --- a/lib/shared/map/map_layer_category.dart +++ b/lib/shared/map/map_layer_category.dart @@ -30,21 +30,27 @@ enum MapLayerCategory { /// /// 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. -MapLayerCategory categoryOf(String layerId) => switch (layerId) { - 'monitor' => MapLayerCategory.earthquake, - 'typhoon' => MapLayerCategory.typhoon, - 'temperature' || - 'humidity' || - 'pressure' || - 'wind' || - 'rain' || - 'lightning' => MapLayerCategory.weather, - 'satellite' => MapLayerCategory.satellite, - 'radar' || 'qpesums' => MapLayerCategory.radar, - 'dpm' => MapLayerCategory.life, - _ => 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; + } + return switch (layerId) { + 'monitor' => MapLayerCategory.earthquake, + 'typhoon' => MapLayerCategory.typhoon, + 'temperature' || + 'humidity' || + 'pressure' || + 'wind' || + 'rain' || + 'lightning' => MapLayerCategory.weather, + 'radar' || 'qpesums' => MapLayerCategory.radar, + 'dpm' => MapLayerCategory.life, + _ => MapLayerCategory.weather, + }; +} /// Localised header for a group. String categoryLabel(MapLayerCategory category, AppLocalizations l10n) => diff --git a/test/features/map/satellite_layer_test.dart b/test/features/map/satellite_layer_test.dart index 5e70acbfa..b158fe813 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'; @@ -18,13 +19,17 @@ 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 +42,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(); @@ -64,7 +72,10 @@ void main() { ); test('dark boundary outlines are added once and removed on clear', () 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(); @@ -93,7 +104,7 @@ void main() { 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(); From 7459bc88246838ff130300bc0abd94b51c71a87f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 13:37:26 +0800 Subject: [PATCH 65/92] feat: expand satellite channels to all Himawari bands and products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the full AHI band catalogue (B01–B16) plus the named products the satellite-tiles-go backend serves (btd_*, cloudtop, truecolor, ash, …), each with its own id/label mapping. The frame API now takes a `channel` query; a band can carry a `style` for temperature-scaled renderings. --- lib/features/weather/data/frame_tile_api.dart | 18 ++- .../weather/data/frame_tile_repository.dart | 3 + .../weather/domain/satellite_channel.dart | 115 ++++++++++++------ .../weather/domain/satellite_repository.dart | 10 +- .../features/weather/frame_tile_api_test.dart | 72 +++++++++++ 5 files changed, 177 insertions(+), 41 deletions(-) diff --git a/lib/features/weather/data/frame_tile_api.dart b/lib/features/weather/data/frame_tile_api.dart index 18cb5db1a..c4c075a6f 100644 --- a/lib/features/weather/data/frame_tile_api.dart +++ b/lib/features/weather/data/frame_tile_api.dart @@ -17,7 +17,7 @@ import 'package:dpip/core/network/meteor_decode.dart'; /// ([ApiTier.coreStaticExclusive]); [tileUrl] feeds MapLibre. Prefetch warms /// SQLite + ambient under the same origin URL. Caching is **ETag-only**. class FrameTileApi { - const FrameTileApi(this._client, this.path, {this.channel}); + FrameTileApi(this._client, this.path, {this.channel, this.style}); final ApiClient _client; @@ -29,6 +29,12 @@ class FrameTileApi { /// 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; + /// The v2 frame list lives on `api.core-tnn1` (no region failover). static const ApiTier _listTier = ApiTier.coreExclusiveApi; @@ -44,14 +50,20 @@ class FrameTileApi { /// 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=…]` + /// `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; - return channel == null ? '' : '?channel=$channel'; + final style = this.style; + if (channel == null && style == null) return ''; + final parts = [ + if (channel != null) 'channel=$channel', + if (style != null) 'style=$style', + ]; + return '?${parts.join('&')}'; } /// Restores the delta-encoded list `[base, Δ, Δ, …]` to absolute timestamps diff --git a/lib/features/weather/data/frame_tile_repository.dart b/lib/features/weather/data/frame_tile_repository.dart index c56d3ebea..c622fe979 100644 --- a/lib/features/weather/data/frame_tile_repository.dart +++ b/lib/features/weather/data/frame_tile_repository.dart @@ -115,4 +115,7 @@ final class FrameTileRepositoryImpl extends FrameTileRepository @override String tileUrl(String frame) => _api.tileUrl(frame); + + @override + void setStyle(String? style) => _api.style = style; } diff --git a/lib/features/weather/domain/satellite_channel.dart b/lib/features/weather/domain/satellite_channel.dart index 35bb4c14e..888511918 100644 --- a/lib/features/weather/domain/satellite_channel.dart +++ b/lib/features/weather/domain/satellite_channel.dart @@ -7,48 +7,89 @@ /// 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'), - visibleGreen('2'), - visibleRed('3'), - nir('4'), - nirPhase('5'), - nirCloud('6'), - swir('7'), - wvUpper('8'), - wvMid('9'), - wvLow('10'), - so2('11'), - ozone('12'), - irClean('13'), - irLong('14'), - irLong2('15'), - co2('16'), - truecolor('truecolor'), - naturalcolor('naturalcolor'), - ash('ash'), - dust('dust'), - airmass('airmass'), - nightmicrophysics('nightmicrophysics'), - watervapor('watervapor'), - btdSplit('btd_split'), - btdFog('btd_fog'), - btdWvirw('btd_wvirw'), - btdSo2('btd_so2'), - btdCo2('btd_co2'), - btdOzone('btd_ozone'), - cloudtop('cloudtop'), - cloudmask('cloudmask'), - sst('sst'), - ndvi('ndvi'), - ndwi('ndwi'), - mndwi('mndwi'); - - const SatelliteChannel(this.key); + 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/test/features/weather/frame_tile_api_test.dart b/test/features/weather/frame_tile_api_test.dart index a8bcfc53d..ffcc8389b 100644 --- a/test/features/weather/frame_tile_api_test.dart +++ b/test/features/weather/frame_tile_api_test.dart @@ -1,5 +1,38 @@ +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', () { @@ -37,4 +70,43 @@ void main() { 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')); + }); + }); } From 3ec3845f54b3aea95f801cbcf23c6244b355fe06 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 13:37:35 +0800 Subject: [PATCH 66/92] feat: bright-yellow admin frame over satellite layers Satellite imagery is fully opaque and overall dark, so the white admin frame washes out on it. AdminOutline.add takes an optional lineColor and satellite draws bright yellow country/county + dark yellow township borders (typhoon's satellite underlay too); radar/QPESUMS keep the white core. Add an onReloadActive hook to MapLayer chrome so a style change can re-mount a layer, and stop moving the camera on a bare layer switch. --- .../layers/disaster_map_layer.dart | 1 + .../presentation/layers/qpesums_layer.dart | 1 + .../map/presentation/layers/radar_layer.dart | 1 + .../map/presentation/layers/rain_layer.dart | 1 + .../layers/scan_range_overlay_chrome.dart | 5 +- .../presentation/layers/typhoon_layer.dart | 100 +++++++++++++++--- lib/shared/map/admin_outline.dart | 12 ++- lib/shared/map/map_layer.dart | 13 +++ lib/shared/map/map_scaffold.dart | 56 ++++++++-- lib/shared/map/map_style.dart | 16 ++- lib/shared/map/raster_timeline_layer.dart | 4 + .../features/map/raster_timeline_harness.dart | 8 ++ .../map/typhoon_overlay_menu_test.dart | 65 +++++++++++- 13 files changed, 250 insertions(+), 33 deletions(-) diff --git a/lib/features/map/presentation/layers/disaster_map_layer.dart b/lib/features/map/presentation/layers/disaster_map_layer.dart index 1cc864ce6..6ff2ef4ee 100644 --- a/lib/features/map/presentation/layers/disaster_map_layer.dart +++ b/lib/features/map/presentation/layers/disaster_map_layer.dart @@ -611,6 +611,7 @@ class DisasterMapLayer with MapLayerDefaults implements MapLayer { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, }) => DisasterMapOverlayMenu( layer: this, showTownLabels: showTownLabels, diff --git a/lib/features/map/presentation/layers/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart index 89d7d4bb9..00e5255db 100644 --- a/lib/features/map/presentation/layers/qpesums_layer.dart +++ b/lib/features/map/presentation/layers/qpesums_layer.dart @@ -62,6 +62,7 @@ class QpesumsMapLayer extends RasterTimelineLayer with ScanRangeOverlayChrome { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, }) => ScanRangeOverlayMenu( layer: this, tooltip: AppLocalizations.of(context).qpesumsOverlayMenuTooltip, diff --git a/lib/features/map/presentation/layers/radar_layer.dart b/lib/features/map/presentation/layers/radar_layer.dart index 378ebfe93..acef14bc4 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -47,6 +47,7 @@ class RadarMapLayer extends RasterTimelineLayer with ScanRangeOverlayChrome { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, }) => RadarOverlayMenu( layer: this, showTownLabels: showTownLabels, diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index c47e22990..e9f7fe6f8 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -138,6 +138,7 @@ class RainMapLayer BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, }) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).colorScheme; diff --git a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart index af99e6c05..e232f7b38 100644 --- a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart +++ b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart @@ -8,6 +8,7 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/features/map/presentation/layers/radar_scan_range.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/map_style.dart'; import 'package:dpip/shared/map/raster_timeline_layer.dart'; @@ -202,7 +203,7 @@ mixin ScanRangeOverlayChrome on RasterTimelineLayer { if (showCountyOutline.value) SymbolLegendItem( swatch: LineSwatch( - color: const Color(0xFFFFFFFF), + color: colorFromHexRgb(AdminOutline.lineColor)!, width: AdminBoundary.county.lineWidth, opacity: AdminBoundary.county.lineOpacity, casingColor: const Color(0xFF000000), @@ -214,7 +215,7 @@ mixin ScanRangeOverlayChrome on RasterTimelineLayer { if (showTownOutline.value) SymbolLegendItem( swatch: LineSwatch( - color: const Color(0xFFFFFFFF), + color: colorFromHexRgb(AdminOutline.lineColor)!, width: AdminBoundary.town.lineWidth, opacity: AdminBoundary.town.lineOpacity, casingColor: const Color(0xFF000000), diff --git a/lib/features/map/presentation/layers/typhoon_layer.dart b/lib/features/map/presentation/layers/typhoon_layer.dart index 154e30a0d..8d1f98a76 100644 --- a/lib/features/map/presentation/layers/typhoon_layer.dart +++ b/lib/features/map/presentation/layers/typhoon_layer.dart @@ -29,6 +29,7 @@ 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'; @@ -113,6 +114,7 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { 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 @@ -793,7 +795,7 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { _queue(() => _syncRadarChrome(controller)); } - /// Turns the 縣市 borders on/off (radar underlay only). + /// Turns the 縣市 borders on/off (radar and satellite underlays). void setShowCountyOutline(bool value) { if (showCountyOutline.value == value) return; showCountyOutline.value = value; @@ -802,7 +804,7 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { _queue(() => _syncRadarChrome(controller)); } - /// Turns the 鄉鎮 borders on/off (radar underlay only). + /// Turns the 鄉鎮 borders on/off (radar and satellite underlays). void setShowTownOutline(bool value) { if (showTownOutline.value == value) return; showTownOutline.value = value; @@ -811,20 +813,28 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { _queue(() => _syncRadarChrome(controller)); } - /// Adds or removes the radar underlay's chrome — the coverage outline and the - /// county borders — to match the toggles. + /// 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 isRadar = weatherOverlay.value == TyphoonWeatherOverlay.radar; + 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 (isRadar && showTownOutline.value) AdminBoundary.town, - if (isRadar && showCountyOutline.value) AdminBoundary.county, + 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 { @@ -840,8 +850,30 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { await RadarScanRange.remove(controller); } } - // Borders after the range so they insert above it: the dashed range is a - // hint, the borders are the reference frame. + 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; @@ -850,15 +882,29 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { await AdminOutline.remove(controller, boundary); } else { _boundariesShown.add(boundary); - await AdminOutline.add(controller, boundary, belowLayerId: below); + 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 radar chrome'); + Log.handle( + error, + stackTrace, + 'Failed to sync the typhoon weather chrome', + ); } } @@ -940,6 +986,12 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { _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); @@ -1072,6 +1124,7 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, }) => TyphoonOverlayMenu( layer: this, showTownLabels: showTownLabels, @@ -1098,6 +1151,8 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { // 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: [ @@ -1243,10 +1298,20 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { ), label: l10n.radarScanRange, ), - if (isRadar && showCountyOutline.value) + 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: const Color(0xFFFFFFFF), + color: colorFromHexRgb( + isRadar ? AdminOutline.lineColor : satelliteOutlineColor, + )!, width: AdminBoundary.county.lineWidth, opacity: AdminBoundary.county.lineOpacity, casingColor: const Color(0xFF000000), @@ -1255,10 +1320,14 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { ), label: l10n.radarCountyOutline, ), - if (isRadar && showTownOutline.value) + if ((isRadar || isSatellite) && showTownOutline.value) SymbolLegendItem( swatch: LineSwatch( - color: const Color(0xFFFFFFFF), + color: colorFromHexRgb( + isRadar + ? AdminOutline.lineColor + : satelliteTownOutlineColor, + )!, width: AdminBoundary.town.lineWidth, opacity: AdminBoundary.town.lineOpacity, casingColor: const Color(0xFF000000), @@ -1287,6 +1356,7 @@ class TyphoonMapLayer with MapLayerDefaults implements MapLayer { // the next attach re-adds instead of no-oping on stale state. _added = false; _rangeShown = false; + _globalShown = false; _boundariesShown.clear(); } diff --git a/lib/shared/map/admin_outline.dart b/lib/shared/map/admin_outline.dart index 234bf484a..223c87e87 100644 --- a/lib/shared/map/admin_outline.dart +++ b/lib/shared/map/admin_outline.dart @@ -73,13 +73,17 @@ abstract final class AdminOutline { /// Adds [boundary]'s two strokes. Casing first so the core paints over it. /// - /// [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. + /// [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( diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart index 96def1416..713dc787e 100644 --- a/lib/shared/map/map_layer.dart +++ b/lib/shared/map/map_layer.dart @@ -47,6 +47,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; @@ -110,10 +114,15 @@ abstract interface class MapLayer { /// 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.). @@ -175,6 +184,9 @@ abstract interface class MapLayer { /// 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([]); @@ -214,6 +226,7 @@ mixin MapLayerDefaults implements MapLayer { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, }) => const SizedBox.shrink(); @override diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index e1a15c0ac..6d62270b4 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -131,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(); @@ -225,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); } @@ -282,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; @@ -291,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(); + } }); } @@ -546,13 +569,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) { @@ -662,6 +703,7 @@ class _MapScaffoldState extends State { context, showTownLabels: _showTownLabels, onShowTownLabelsChanged: _setShowTownLabels, + onReloadActive: _reloadActive, ); final hasChrome = chrome is! SizedBox; return Row( diff --git a/lib/shared/map/map_style.dart b/lib/shared/map/map_style.dart index 53e6a519d..36155bd9a 100644 --- a/lib/shared/map/map_style.dart +++ b/lib/shared/map/map_style.dart @@ -165,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/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index 87332b886..30f0062ca 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -123,6 +123,9 @@ abstract class RasterTimelineLayer implements MapLayer { @override bool get usesTimeline => true; + @override + String? subtitle(BuildContext context) => null; + @override double get bottomChromeFraction => 0; @@ -149,6 +152,7 @@ abstract class RasterTimelineLayer implements MapLayer { BuildContext context, { required ValueListenable showTownLabels, required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, }) => const SizedBox.shrink(); @override diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index 5fd2fd991..69cac0e2e 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -109,6 +109,7 @@ class RecordingMapController implements MapLibreMapController { bool enableInteraction = true, }) async { below[layerId] = belowLayerId; + lineColor[layerId] = properties.toJson()['line-color']?.toString(); calls.add('addLineLayer:$layerId'); } @@ -121,6 +122,13 @@ class RecordingMapController implements MapLibreMapController { /// 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, diff --git a/test/features/map/typhoon_overlay_menu_test.dart b/test/features/map/typhoon_overlay_menu_test.dart index 0f4907c8c..9c2604036 100644 --- a/test/features/map/typhoon_overlay_menu_test.dart +++ b/test/features/map/typhoon_overlay_menu_test.dart @@ -6,6 +6,7 @@ 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'; @@ -32,6 +33,9 @@ class _FakeSatelliteRepository extends FakeRasterFrameSource @override String tileUrl(String frame) => 'https://host/sat/$frame/{z}/{x}/{y}.png'; + + @override + void setStyle(String? style) {} } TyphoonMapLayer _layer() => TyphoonMapLayer( @@ -109,8 +113,9 @@ void main() { await _open(tester); final l10n = await _l10n(); - // A coverage boundary over Himawari IR would bound an instrument that is - // not on screen, and IR does not bury the base style's borders. + // 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); @@ -203,6 +208,35 @@ void main() { ); }); + 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( @@ -223,6 +257,33 @@ void main() { ); }); + 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); From eb3a22bd53d8b94797f39197a76758a0865887c5 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 13:37:45 +0800 Subject: [PATCH 67/92] feat: satellite layer styles, legends and switcher subtitles Thermal bands get a colour-style menu (grayscale/jma/bd) that re-mounts the layer through onReloadActive; every channel's picker tile shows a subtitle with the band or formula. Per-channel legends explain the colour scale and why parts are transparent (clear sky, night, no value), all localised across the supported locales. --- .../presentation/layers/satellite_layer.dart | 79 +++- .../widgets/satellite_legend.dart | 376 ++++++++++++++++++ .../widgets/satellite_style_menu.dart | 175 ++++++++ lib/l10n/app_en.arb | 88 ++++ lib/l10n/app_fil.arb | 22 + lib/l10n/app_id.arb | 22 + lib/l10n/app_ja.arb | 22 + lib/l10n/app_ko.arb | 22 + lib/l10n/app_th.arb | 22 + lib/l10n/app_vi.arb | 22 + lib/l10n/app_zh.arb | 30 +- lib/l10n/app_zh_Hans.arb | 30 +- lib/l10n/app_zh_Hant_HK.arb | 30 +- lib/l10n/app_zh_TW.arb | 30 +- lib/l10n/gen/app_localizations.dart | 132 ++++++ lib/l10n/gen/app_localizations_en.dart | 76 ++++ lib/l10n/gen/app_localizations_fil.dart | 76 ++++ lib/l10n/gen/app_localizations_id.dart | 76 ++++ lib/l10n/gen/app_localizations_ja.dart | 66 +++ lib/l10n/gen/app_localizations_ko.dart | 67 ++++ lib/l10n/gen/app_localizations_th.dart | 76 ++++ lib/l10n/gen/app_localizations_vi.dart | 76 ++++ lib/l10n/gen/app_localizations_zh.dart | 296 +++++++++++++- lib/shared/map/map_layer_switcher.dart | 30 +- test/features/map/satellite_layer_test.dart | 6 +- test/features/map/satellite_legend_test.dart | 175 ++++++++ .../map/satellite_style_menu_test.dart | 181 +++++++++ test/shared/map/map_layer_order_test.dart | 4 + test/shared/map/map_layer_switcher_test.dart | 19 +- test/shared/map/map_timeline_test.dart | 27 ++ 30 files changed, 2300 insertions(+), 53 deletions(-) create mode 100644 lib/features/map/presentation/widgets/satellite_legend.dart create mode 100644 lib/features/map/presentation/widgets/satellite_style_menu.dart create mode 100644 test/features/map/satellite_legend_test.dart create mode 100644 test/features/map/satellite_style_menu_test.dart diff --git a/lib/features/map/presentation/layers/satellite_layer.dart b/lib/features/map/presentation/layers/satellite_layer.dart index 65e1bcf4a..69335a0df 100644 --- a/lib/features/map/presentation/layers/satellite_layer.dart +++ b/lib/features/map/presentation/layers/satellite_layer.dart @@ -1,8 +1,13 @@ +import 'dart:async'; + +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'; @@ -10,9 +15,8 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// 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, { @@ -23,6 +27,32 @@ class SatelliteMapLayer extends RasterTimelineLayer { /// `?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, + ); + + /// 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 @@ -36,6 +66,9 @@ class SatelliteMapLayer extends RasterTimelineLayer { String label(BuildContext context) => satelliteChannelLabel(channel, AppLocalizations.of(context)); + @override + String? subtitle(BuildContext context) => channel.subtitle; + @override IconData get icon => Icons.satellite_alt_outlined; @@ -44,20 +77,42 @@ 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 scaffold's standalone labels menu instead. + @override + Widget buildTopTrailingChrome( + BuildContext context, { + required ValueListenable showTownLabels, + required ValueChanged onShowTownLabelsChanged, + required Future Function() onReloadActive, + }) { + if (!channel.isBand || !channel.isThermal) return const SizedBox.shrink(); + return SatelliteStyleMenu( + layer: this, + onReloadActive: onReloadActive, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); + } + @override Future onAttached(MapLibreMapController controller) async { try { - for (final (layerId, sourceLayer) in const [ - (satelliteGlobalOutlineLayerId, 'global'), - (satelliteCountyOutlineLayerId, 'city'), + // Country/global edges + county frame in bright yellow, township mesh in + // dark yellow — the hue set that stays legible on imagery which is + // overall dark. Country + county share the same colour because both are + // the "which administrative unit" frame. + for (final (layerId, sourceLayer, color, width) in const [ + (satelliteGlobalOutlineLayerId, 'global', satelliteOutlineColor, 1.0), + (satelliteCountyOutlineLayerId, 'city', satelliteOutlineColor, 1.0), + (satelliteTownOutlineLayerId, 'town', satelliteTownOutlineColor, 0.7), ]) { await controller.addLineLayer( 'exptech', layerId, - const LineLayerProperties( - lineColor: satelliteOutlineColor, - lineWidth: 1.0, - ), + LineLayerProperties(lineColor: color, lineWidth: width), sourceLayer: sourceLayer, enableInteraction: false, ); @@ -71,6 +126,7 @@ class SatelliteMapLayer extends RasterTimelineLayer { @override Future onDetached(MapLibreMapController controller) async { for (final layerId in const [ + satelliteTownOutlineLayerId, satelliteCountyOutlineLayerId, satelliteGlobalOutlineLayerId, ]) { @@ -81,7 +137,8 @@ class SatelliteMapLayer extends RasterTimelineLayer { } @override - Widget buildLegend(BuildContext context) => const SizedBox.shrink(); + Widget buildLegend(BuildContext context) => + SatelliteLegend(channel: channel, style: style); } /// Localised picker label for a satellite [channel] — the `ひまわり `(B##) 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..c0caa1ed9 --- /dev/null +++ b/lib/features/map/presentation/widgets/satellite_legend.dart @@ -0,0 +1,376 @@ +/// 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). +class SatelliteLegend extends StatelessWidget { + const SatelliteLegend({ + super.key, + required this.channel, + required this.style, + }); + + final SatelliteChannel channel; + final ValueListenable style; + + static const Color _county = Color(0xFFFFD400); + static const Color _town = Color(0xFFB79A00); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: style, + 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: '%', + appendUnit: true, + 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', + appendUnit: true, + 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', + appendUnit: true, + stops: [(190, '#FFFFFF'), (245, '#7F7F7F'), (300, '#000000')], + ), + SatelliteChannel.cloudmask => _cloudMask(l10n), + SatelliteChannel.sst => const ColorScaleLegend( + unit: '°C', + appendUnit: true, + stops: [ + (-2, '#3C1478'), + (3, '#2846C8'), + (8, '#28A0DC'), + (13, '#3CC8A0'), + (18, '#AADC50'), + (23, '#F5C832'), + (28, '#F07828'), + (32, '#C81E28'), + ], + ), + SatelliteChannel.ndvi => const ColorScaleLegend( + unit: 'NDVI', + appendUnit: true, + stops: [(0, '#C8AF6E'), (0.5, '#5F8A3A'), (1, '#196A23')], + ), + SatelliteChannel.ndwi => const ColorScaleLegend( + unit: 'NDWI', + appendUnit: true, + stops: [(0, '#78BEE8'), (0.5, '#3C7FD0'), (1, '#0A3CB8')], + ), + SatelliteChannel.mndwi => const ColorScaleLegend( + unit: 'MNDWI', + appendUnit: true, + 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', + appendUnit: true, + stops: [(190, '#FFFFFF'), (255, '#7F7F7F'), (320, '#000000')], + ), + SatelliteStyle.jma => const ColorScaleLegend( + unit: '°C', + appendUnit: true, + 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', + appendUnit: true, + 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. + Widget _boundaries(BuildContext context) { + final l10n = AppLocalizations.of(context); + return SymbolLegend( + items: [ + 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..41fcbbc36 --- /dev/null +++ b/lib/features/map/presentation/widgets/satellite_style_menu.dart @@ -0,0 +1,175 @@ +/// 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, showTownLabels]), + builder: (context, _) { + final style = layer.style.value; + final showLabels = showTownLabels.value; + final active = style != SatelliteStyle.gray || !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.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/l10n/app_en.arb b/lib/l10n/app_en.arb index 7f18fbc8f..92b6aca11 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -926,6 +926,94 @@ "@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" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index f37490f4d..0d24590c1 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -200,6 +200,28 @@ "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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index be1dc0a3a..aeaef7b89 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -200,6 +200,28 @@ "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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index c73508834..bccff574b 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -200,6 +200,28 @@ "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} 分以内", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6351b0ae5..26e014e74 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -200,6 +200,28 @@ "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}분 이내", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index e34f58c3a..8903cb965 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -200,6 +200,28 @@ "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} นาที", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index d07d999ae..f9a6f8ed2 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -200,6 +200,28 @@ "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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 31bd4a738..19d88c5f1 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -165,9 +165,9 @@ "mapLayerOrderReset": "回復預設順序", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線(B13)", - "mapLayerSatelliteB01": "ひまわり 可見光藍(B01)", - "mapLayerSatelliteB02": "ひまわり 可見光綠(B02)", - "mapLayerSatelliteB03": "ひまわり 可見光紅(B03)", + "mapLayerSatelliteB01": "ひまわり 可見光-藍(B01)", + "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", + "mapLayerSatelliteB03": "ひまわり 可見光-紅(B03)", "mapLayerSatelliteB04": "ひまわり 近紅外(B04)", "mapLayerSatelliteB05": "ひまわり 近紅外(B05)", "mapLayerSatelliteB06": "ひまわり 近紅外(B06)", @@ -199,7 +199,29 @@ "mapLayerSatelliteSst": "ひまわり 海表溫度", "mapLayerSatelliteNdvi": "ひまわり 植生指數", "mapLayerSatelliteNdwi": "ひまわり 水體指數", - "mapLayerSatelliteMndwi": "ひまわり 水體指數", + "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} 分內", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 7d47f94aa..a325d4340 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -165,9 +165,9 @@ "mapLayerOrderReset": "恢复默认顺序", "mapLayerRadar": "雷达合成回波图", "mapLayerSatellite": "ひまわり 红外线(B13)", - "mapLayerSatelliteB01": "ひまわり 可见光蓝(B01)", - "mapLayerSatelliteB02": "ひまわり 可见光绿(B02)", - "mapLayerSatelliteB03": "ひまわり 可见光红(B03)", + "mapLayerSatelliteB01": "ひまわり 可见光-蓝(B01)", + "mapLayerSatelliteB02": "ひまわり 可见光-绿(B02)", + "mapLayerSatelliteB03": "ひまわり 可见光-红(B03)", "mapLayerSatelliteB04": "ひまわり 近红外(B04)", "mapLayerSatelliteB05": "ひまわり 近红外(B05)", "mapLayerSatelliteB06": "ひまわり 近红外(B06)", @@ -199,7 +199,29 @@ "mapLayerSatelliteSst": "ひまわり 海表温度", "mapLayerSatelliteNdvi": "ひまわり 植被指数", "mapLayerSatelliteNdwi": "ひまわり 水体指数", - "mapLayerSatelliteMndwi": "ひまわり 水体指数", + "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} 分钟内", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 54cb64409..573713d94 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -165,9 +165,9 @@ "mapLayerOrderReset": "回復預設順序", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線(B13)", - "mapLayerSatelliteB01": "ひまわり 可見光藍(B01)", - "mapLayerSatelliteB02": "ひまわり 可見光綠(B02)", - "mapLayerSatelliteB03": "ひまわり 可見光紅(B03)", + "mapLayerSatelliteB01": "ひまわり 可見光-藍(B01)", + "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", + "mapLayerSatelliteB03": "ひまわり 可見光-紅(B03)", "mapLayerSatelliteB04": "ひまわり 近紅外(B04)", "mapLayerSatelliteB05": "ひまわり 近紅外(B05)", "mapLayerSatelliteB06": "ひまわり 近紅外(B06)", @@ -199,7 +199,29 @@ "mapLayerSatelliteSst": "ひまわり 海表溫度", "mapLayerSatelliteNdvi": "ひまわり 植生指數", "mapLayerSatelliteNdwi": "ひまわり 水體指數", - "mapLayerSatelliteMndwi": "ひまわり 水體指數", + "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} 分內", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index d82e9c8b6..931c0c477 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -165,9 +165,9 @@ "mapLayerOrderReset": "回復預設順序", "mapLayerRadar": "雷達合成回波圖", "mapLayerSatellite": "ひまわり 紅外線(B13)", - "mapLayerSatelliteB01": "ひまわり 可見光藍(B01)", - "mapLayerSatelliteB02": "ひまわり 可見光綠(B02)", - "mapLayerSatelliteB03": "ひまわり 可見光紅(B03)", + "mapLayerSatelliteB01": "ひまわり 可見光-藍(B01)", + "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", + "mapLayerSatelliteB03": "ひまわり 可見光-紅(B03)", "mapLayerSatelliteB04": "ひまわり 近紅外(B04)", "mapLayerSatelliteB05": "ひまわり 近紅外(B05)", "mapLayerSatelliteB06": "ひまわり 近紅外(B06)", @@ -199,7 +199,29 @@ "mapLayerSatelliteSst": "ひまわり 海表溫度", "mapLayerSatelliteNdvi": "ひまわり 植生指數", "mapLayerSatelliteNdwi": "ひまわり 水體指數", - "mapLayerSatelliteMndwi": "ひまわり 水體指數", + "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} 分內", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index e987750bf..cd39b8c7f 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1311,6 +1311,138 @@ abstract class AppLocalizations { /// **'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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index be78abae0..dfad3bf2c 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -661,6 +661,82 @@ class AppLocalizationsEn extends AppLocalizations { @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'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 16cecd29f..7dfca0b8a 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -666,6 +666,82 @@ class AppLocalizationsFil extends AppLocalizations { @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'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 37e227f2f..1f7e9dca4 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -662,6 +662,82 @@ class AppLocalizationsId extends AppLocalizations { @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'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 7510bae55..3556e693e 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -656,6 +656,72 @@ class AppLocalizationsJa extends AppLocalizations { @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時間降水量予報'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 9423cfda9..992d5b2f6 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -656,6 +656,73 @@ class AppLocalizationsKo extends AppLocalizations { @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시간 강수 예보'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index b2399b058..71707c9ac 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -659,6 +659,82 @@ class AppLocalizationsTh extends AppLocalizations { @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 ชั่วโมงข้างหน้า'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 4c3736a9f..bbc3f906f 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -659,6 +659,82 @@ class AppLocalizationsVi extends AppLocalizations { @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'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 10e3fcab4..a5a3028cb 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -551,13 +551,13 @@ class AppLocalizationsZh extends AppLocalizations { String get mapLayerSatellite => 'ひまわり 紅外線(B13)'; @override - String get mapLayerSatelliteB01 => 'ひまわり 可見光藍(B01)'; + String get mapLayerSatelliteB01 => 'ひまわり 可見光-藍(B01)'; @override - String get mapLayerSatelliteB02 => 'ひまわり 可見光綠(B02)'; + String get mapLayerSatelliteB02 => 'ひまわり 可見光-綠(B02)'; @override - String get mapLayerSatelliteB03 => 'ひまわり 可見光紅(B03)'; + String get mapLayerSatelliteB03 => 'ひまわり 可見光-紅(B03)'; @override String get mapLayerSatelliteB04 => 'ひまわり 近紅外(B04)'; @@ -653,7 +653,73 @@ class AppLocalizationsZh extends AppLocalizations { String get mapLayerSatelliteNdwi => 'ひまわり 水體指數'; @override - String get mapLayerSatelliteMndwi => 'ひまわり 水體指數'; + 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 小時降水預報'; @@ -2182,13 +2248,13 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get mapLayerSatellite => 'ひまわり 红外线(B13)'; @override - String get mapLayerSatelliteB01 => 'ひまわり 可见光蓝(B01)'; + String get mapLayerSatelliteB01 => 'ひまわり 可见光-蓝(B01)'; @override - String get mapLayerSatelliteB02 => 'ひまわり 可见光绿(B02)'; + String get mapLayerSatelliteB02 => 'ひまわり 可见光-绿(B02)'; @override - String get mapLayerSatelliteB03 => 'ひまわり 可见光红(B03)'; + String get mapLayerSatelliteB03 => 'ひまわり 可见光-红(B03)'; @override String get mapLayerSatelliteB04 => 'ひまわり 近红外(B04)'; @@ -2284,7 +2350,73 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get mapLayerSatelliteNdwi => 'ひまわり 水体指数'; @override - String get mapLayerSatelliteMndwi => 'ひまわり 水体指数'; + 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 小时降水预报'; @@ -3813,13 +3945,13 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get mapLayerSatellite => 'ひまわり 紅外線(B13)'; @override - String get mapLayerSatelliteB01 => 'ひまわり 可見光藍(B01)'; + String get mapLayerSatelliteB01 => 'ひまわり 可見光-藍(B01)'; @override - String get mapLayerSatelliteB02 => 'ひまわり 可見光綠(B02)'; + String get mapLayerSatelliteB02 => 'ひまわり 可見光-綠(B02)'; @override - String get mapLayerSatelliteB03 => 'ひまわり 可見光紅(B03)'; + String get mapLayerSatelliteB03 => 'ひまわり 可見光-紅(B03)'; @override String get mapLayerSatelliteB04 => 'ひまわり 近紅外(B04)'; @@ -3915,7 +4047,73 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get mapLayerSatelliteNdwi => 'ひまわり 水體指數'; @override - String get mapLayerSatelliteMndwi => 'ひまわり 水體指數'; + 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 小時降水預報'; @@ -5444,13 +5642,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get mapLayerSatellite => 'ひまわり 紅外線(B13)'; @override - String get mapLayerSatelliteB01 => 'ひまわり 可見光藍(B01)'; + String get mapLayerSatelliteB01 => 'ひまわり 可見光-藍(B01)'; @override - String get mapLayerSatelliteB02 => 'ひまわり 可見光綠(B02)'; + String get mapLayerSatelliteB02 => 'ひまわり 可見光-綠(B02)'; @override - String get mapLayerSatelliteB03 => 'ひまわり 可見光紅(B03)'; + String get mapLayerSatelliteB03 => 'ひまわり 可見光-紅(B03)'; @override String get mapLayerSatelliteB04 => 'ひまわり 近紅外(B04)'; @@ -5546,7 +5744,73 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get mapLayerSatelliteNdwi => 'ひまわり 水體指數'; @override - String get mapLayerSatelliteMndwi => 'ひまわり 水體指數'; + 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 小時降水預報'; diff --git a/lib/shared/map/map_layer_switcher.dart b/lib/shared/map/map_layer_switcher.dart index b8e043eeb..a303abdd2 100644 --- a/lib/shared/map/map_layer_switcher.dart +++ b/lib/shared/map/map_layer_switcher.dart @@ -254,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) diff --git a/test/features/map/satellite_layer_test.dart b/test/features/map/satellite_layer_test.dart index b158fe813..01e161890 100644 --- a/test/features/map/satellite_layer_test.dart +++ b/test/features/map/satellite_layer_test.dart @@ -13,6 +13,9 @@ 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() { @@ -71,7 +74,7 @@ void main() { }, ); - test('dark boundary outlines are added once and removed on clear', () async { + test('bright yellow outlines are added once and removed on clear', () async { final layer = SatelliteMapLayer( _FakeSatelliteRepository(_ids(5)), channel: SatelliteChannel.irClean, @@ -96,6 +99,7 @@ void main() { expect( controller.calls, containsAll([ + 'removeLayer:$satelliteTownOutlineLayerId', 'removeLayer:$satelliteCountyOutlineLayerId', 'removeLayer:$satelliteGlobalOutlineLayerId', ]), diff --git a/test/features/map/satellite_legend_test.dart b/test/features/map/satellite_legend_test.dart new file mode 100644 index 000000000..76514bd35 --- /dev/null +++ b/test/features/map/satellite_legend_test.dart @@ -0,0 +1,175 @@ +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). + expect(find.text('190 K'), findsOneWidget); + expect(find.text('320 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 K'), findsOneWidget); + + style.value = SatelliteStyle.jma; + await tester.pumpAndSettle(); + + // The cloud-top enhancement colours only the coldest tops (below −40 °C). + expect(find.text('190 K'), findsNothing); + expect(find.text('-45 °C'), findsOneWidget); + expect(find.text('-92 °C'), 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..de5d49c5a --- /dev/null +++ b/test/features/map/satellite_style_menu_test.dart @@ -0,0 +1,181 @@ +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 no style menu (scaffold falls back)', ( + 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 no style menu (grayscale only)', ( + 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/shared/map/map_layer_order_test.dart b/test/shared/map/map_layer_order_test.dart index 54a22da9d..6c17d888e 100644 --- a/test/shared/map/map_layer_order_test.dart +++ b/test/shared/map/map_layer_order_test.dart @@ -1,6 +1,7 @@ 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. @@ -10,6 +11,9 @@ class _FakeLayer implements MapLayer { @override final String id; + @override + String? subtitle(BuildContext context) => null; + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/test/shared/map/map_layer_switcher_test.dart b/test/shared/map/map_layer_switcher_test.dart index bcc5a924c..42251a566 100644 --- a/test/shared/map/map_layer_switcher_test.dart +++ b/test/shared/map/map_layer_switcher_test.dart @@ -8,17 +8,22 @@ 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 only. +/// Minimal [MapLayer] for switcher tests — identity + label (+ optional +/// subtitle) only. class _FakeLayer implements MapLayer { - _FakeLayer(this.id, this._label); + _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; @@ -31,7 +36,7 @@ void main() { final radar = _FakeLayer('radar', 'Radar echo'); final qpesums = _FakeLayer('qpesums', 'Precip'); - final satellite = _FakeLayer('satellite', 'Satellite IR'); + 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, satellite, rain, typhoon]; @@ -124,6 +129,14 @@ void main() { lessThan(topOf(tester, l10n.mapLayerCategorySatellite)), ); expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Precip'))); + // 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, + ); }); testWidgets( diff --git a/test/shared/map/map_timeline_test.dart b/test/shared/map/map_timeline_test.dart index 1c15e2280..d20e36022 100644 --- a/test/shared/map/map_timeline_test.dart +++ b/test/shared/map/map_timeline_test.dart @@ -100,4 +100,31 @@ void main() { 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); + }, + ); } From 165eb537f525bc89a30be8ea9fb5863c866afe57 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 13:37:53 +0800 Subject: [PATCH 68/92] =?UTF-8?q?fix:=20degrade=20=E6=89=80=E5=9C=A8?= =?UTF-8?q?=E5=9C=B0=20to=20nationwide=20when=20GPS=20is=20unavailable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lost GPS fix left RegionStore on the stale last-known township, so the home backdrop kept framing and outlining a place the user wasn't in. LocationMonitor now clears the code when location becomes unusable, and the backdrop frames on a cached fix (never a live read) so a simulator with no location falls back to the nationwide view instantly instead of waiting out the 10s GPS timeout. --- lib/core/geo/location_monitor.dart | 12 ++- lib/core/geo/location_service.dart | 56 ++++++++++--- .../widgets/home_map_backdrop.dart | 78 ++++++++++++------- test/core/geo/location_monitor_test.dart | 68 +++++++++++++++- test/core/geo/location_service_test.dart | 31 ++++++++ 5 files changed, 202 insertions(+), 43 deletions(-) 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/features/home/presentation/widgets/home_map_backdrop.dart b/lib/features/home/presentation/widgets/home_map_backdrop.dart index 1eb7fa4c7..ca3af8966 100644 --- a/lib/features/home/presentation/widgets/home_map_backdrop.dart +++ b/lib/features/home/presentation/widgets/home_map_backdrop.dart @@ -159,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 { @@ -179,38 +172,57 @@ 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. Falls back to the township - // box — or the whole island when GPS is unavailable — if no fix is had. - final gpsCentred = _regions?.selected is CurrentArea; + // 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 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) { - final fix = await location.currentFix(); - if (!mounted || gen != _selectionGen || styleEpoch != _styleEpoch) return; - frame = fix == null - ? _latLngBounds(bounds) - : _latLngBounds([ - fix.lng - _gpsSpan, - fix.lat - _gpsSpan, - fix.lng + _gpsSpan, - fix.lat + _gpsSpan, - ]); + if (gpsCentred && fix != null) { + frame = _latLngBounds([ + fix.lng - _gpsSpan, + fix.lat - _gpsSpan, + fix.lng + _gpsSpan, + fix.lat + _gpsSpan, + ]); } else { frame = _latLngBounds(bounds); } @@ -229,7 +241,7 @@ class _HomeMapBackdropState extends State final bottomInset = size.height * HomeSheetExtent.rest; _queue(() async { if (!mounted || gen != _selectionGen || styleEpoch != _styleEpoch) return; - await _restackOverlays(controller); + 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 @@ -245,7 +257,7 @@ class _HomeMapBackdropState extends State CameraUpdate.newLatLngZoom(fit.target, fit.zoom), ); } - _appliedCode = code; + _appliedCode = effective; _appliedCodeEpoch = styleEpoch; }); } @@ -261,9 +273,15 @@ class _HomeMapBackdropState extends State /// 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) async { - final code = _selectedCode; - final wanted = backdropBoundaries(code); + Future _restackOverlays( + MapLibreMapController controller, { + String? code, + }) async { + // The caller passes the *effective* code (GPS-aware). A radar refresh has no + // selection context, so it falls back to the last applied one — the outline + // must match what the camera is framing, never drift back to a stale code. + final subject = code ?? _appliedCode; + final wanted = backdropBoundaries(subject); // 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]) { @@ -273,7 +291,7 @@ class _HomeMapBackdropState extends State } } // The selection is the point of the backdrop — always last, always on top. - await _addSelectedLayers(controller, _filterFor(_townCode(code))); + await _addSelectedLayers(controller, _filterFor(_townCode(subject))); } /// (Re)adds the purple selection outline on the vector `town` layer (filtered 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); + }); } From 9650b6d28b9ad50a7671ed483f08e6c5759894eb Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 13:38:02 +0800 Subject: [PATCH 69/92] chore: pin maplibre_gl git dependency in pubspec.lock A `pub get` run while the local path override was active had rewritten the lock entry to the machine-local fork path. Restore the committed git source so CI resolves the pinned revision. --- pubspec.lock | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index c9c20a049..d78758930 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -648,9 +648,11 @@ packages: maplibre_gl: dependency: "direct main" description: - path: "/Users/yuyu1015/Documents/GitHub/flutter-maplibre-gl/maplibre_gl" - relative: false - source: path + path: maplibre_gl + ref: "7c2e249149ff3023c54c86a1f4c0dca5fb43d6a1" + resolved-ref: "7c2e249149ff3023c54c86a1f4c0dca5fb43d6a1" + url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" + source: git version: "0.26.2" maplibre_gl_platform_interface: dependency: transitive From bac1a7f365be055889f9ede5b0e02754958d4cd4 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 15:07:31 +0800 Subject: [PATCH 70/92] =?UTF-8?q?fix:=20clear=20the=20stale=20township=20f?= =?UTF-8?q?rame=20when=20=E6=89=80=E5=9C=A8=E5=9C=B0=20loses=20GPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _restackOverlays fell back to the last applied code, so an explicit null (GPS lost, or the nationwide view) re-drew the previous township's mesh and purple selection outline. The caller now owns the subject and a deliberate null reaches the outline layers unchanged, clearing the frame. --- .../widgets/home_map_backdrop.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/features/home/presentation/widgets/home_map_backdrop.dart b/lib/features/home/presentation/widgets/home_map_backdrop.dart index ca3af8966..fdcac1ff4 100644 --- a/lib/features/home/presentation/widgets/home_map_backdrop.dart +++ b/lib/features/home/presentation/widgets/home_map_backdrop.dart @@ -275,13 +275,14 @@ class _HomeMapBackdropState extends State /// carrying information. Future _restackOverlays( MapLibreMapController controller, { - String? code, + required String? code, }) async { - // The caller passes the *effective* code (GPS-aware). A radar refresh has no - // selection context, so it falls back to the last applied one — the outline - // must match what the camera is framing, never drift back to a stale code. - final subject = code ?? _appliedCode; - final wanted = backdropBoundaries(subject); + // 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]) { @@ -291,7 +292,7 @@ class _HomeMapBackdropState extends State } } // The selection is the point of the backdrop — always last, always on top. - await _addSelectedLayers(controller, _filterFor(_townCode(subject))); + await _addSelectedLayers(controller, _filterFor(_townCode(code))); } /// (Re)adds the purple selection outline on the vector `town` layer (filtered @@ -348,7 +349,9 @@ class _HomeMapBackdropState extends State // are hairlines tuned for a bare basemap and they wash out under the // echo. [_restackOverlays] puts a legible set back over it. ); - await _restackOverlays(controller); + // 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; }); From 2c8ee7accb7abd57dfd41be06062d5115c1a84ed Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 15:22:50 +0800 Subject: [PATCH 71/92] fix: weather icon --- lib/core/weather/weather_condition.dart | 9 +++++---- lib/core/weather/weather_icons.dart | 20 +++++++++++++++++++ .../presentation/pages/experimental_page.dart | 3 ++- lib/shared/widgets/event_icon.dart | 3 ++- test/core/weather/weather_condition_test.dart | 7 ++++--- 5 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 lib/core/weather/weather_icons.dart diff --git a/lib/core/weather/weather_condition.dart b/lib/core/weather/weather_condition.dart index 7259048ec..b1ac2b2c7 100644 --- a/lib/core/weather/weather_condition.dart +++ b/lib/core/weather/weather_condition.dart @@ -5,6 +5,7 @@ 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 @@ -51,12 +52,12 @@ const Map _phenomenonIcon = { 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: Icons.water_drop_outlined, // 有雨 + 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: Icons.grain_outlined, // 有陣雨 — a shower, distinct from steady 有雨 + 11: rainyLight, // 有陣雨 — a shower, lighter drops than steady 有雨 12: Icons.cloudy_snowing, // 陣雨雪 13: Icons.grain_outlined, // 有雹 14: Icons.thunderstorm_outlined, // 有雷雨 @@ -138,7 +139,7 @@ double? weatherSnowIntensity(int code) { WeatherMode.auto => _fallback(weather, colors), WeatherMode.thunderstorm => (Icons.thunderstorm_outlined, colors.tertiary), WeatherMode.snow => (Icons.ac_unit_outlined, colors.primary), - WeatherMode.rain => (Icons.water_drop_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), @@ -168,7 +169,7 @@ Color? _accent(WeatherMode mode, ColorScheme colors) => switch (mode) { return (Icons.ac_unit_outlined, colors.primary); } if (weather.contains('雨')) { - return (Icons.water_drop_outlined, colors.primary); + return (rainy, colors.primary); } if (weather.contains('晴')) { return (Icons.wb_sunny_outlined, colors.tertiary); 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/settings/presentation/pages/experimental_page.dart b/lib/features/settings/presentation/pages/experimental_page.dart index f2a61b96f..4335c09b5 100644 --- a/lib/features/settings/presentation/pages/experimental_page.dart +++ b/lib/features/settings/presentation/pages/experimental_page.dart @@ -1,4 +1,5 @@ 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'; @@ -56,7 +57,7 @@ class ExperimentalPage extends StatelessWidget { WeatherMode.clear => Icons.wb_sunny_outlined, WeatherMode.cloudy => Icons.filter_drama_outlined, WeatherMode.overcast => Icons.cloud_outlined, - WeatherMode.rain => Icons.water_drop_outlined, + WeatherMode.rain => rainy, WeatherMode.snow => Icons.ac_unit_outlined, WeatherMode.sand => Icons.blur_on_outlined, WeatherMode.fog => Icons.foggy, diff --git a/lib/shared/widgets/event_icon.dart b/lib/shared/widgets/event_icon.dart index f39e8377d..faace1ea7 100644 --- a/lib/shared/widgets/event_icon.dart +++ b/lib/shared/widgets/event_icon.dart @@ -2,6 +2,7 @@ /// 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 @@ -12,7 +13,7 @@ IconData eventTypeIcon(String key) => switch (key) { 'report' => Icons.description_outlined, 'intensity' => Icons.graphic_eq, 'thunderstorm' => Icons.thunderstorm_outlined, - 'heavy_rain' => Icons.water_drop_outlined, + 'heavy_rain' => rainyHeavy, 'weather_warning' => Icons.warning_amber_rounded, 'tsunami' => Icons.tsunami_outlined, _ => Icons.notifications_outlined, diff --git a/test/core/weather/weather_condition_test.dart b/test/core/weather/weather_condition_test.dart index 3e03c4ec1..889083429 100644 --- a/test/core/weather/weather_condition_test.dart +++ b/test/core/weather/weather_condition_test.dart @@ -1,5 +1,6 @@ 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'; @@ -52,7 +53,7 @@ void main() { 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, Icons.water_drop_outlined); + expect(weatherVisual('晴', 106, colors).$1, rainy); expect(weatherVisual('陰', 208, colors).$1, Icons.snowing); expect(weatherVisual('晴', 105, colors).$1, Icons.foggy); }); @@ -61,7 +62,7 @@ void main() { // 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, Icons.grain_outlined); // 有陣雨 + 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); // 陣雨雪 @@ -87,7 +88,7 @@ void main() { test('a missing code falls back to substring matching', () { expect(weatherVisual('午後雷陣雨', 0, colors).$1, Icons.thunderstorm_outlined); - expect(weatherVisual('短暫雨', 0, colors).$1, Icons.water_drop_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); From c109471570492becfaf7b8405192e9ec227c8c44 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 19:35:00 +0800 Subject: [PATCH 72/92] feat: ECMWF/GFS wind-forecast overlays with particle animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the 數值預報 group to the map: a QPESUMS sibling that fetches each model's WND1 grid (/api/v1/wind//.bin) and rides a CPU particle field over the wind tiles, ported from the web renderer's trail-buffer pass. A forecast opens on the frame nearest the present instead of its furthest step (nowFrameIndex), the 0.25° grids cap at zoom 7, and the layer draws its own admin frame via the shared AdminOutlineChrome. --- lib/core/network/api_paths.dart | 4 + .../layers/admin_outline_chrome.dart | 201 ++++++++ .../layers/wind_forecast_layer.dart | 187 +++++++ .../layers/wind_particle_sim.dart | 422 +++++++++++++++ .../map/presentation/pages/map_page.dart | 11 + .../widgets/forecast_overlay_menu.dart | 104 ++++ .../widgets/wind_particle_overlay.dart | 264 ++++++++++ lib/features/weather/data/frame_tile_api.dart | 31 +- .../weather/data/frame_tile_repository.dart | 24 +- lib/features/weather/domain/wind_field.dart | 129 +++++ .../weather/domain/wind_forecast_model.dart | 24 + .../domain/wind_forecast_repository.dart | 17 + lib/features/weather/weather_providers.dart | 17 +- lib/l10n/app_en.arb | 37 ++ lib/l10n/app_fil.arb | 29 +- lib/l10n/app_id.arb | 29 +- lib/l10n/app_ja.arb | 29 +- lib/l10n/app_ko.arb | 29 +- lib/l10n/app_th.arb | 29 +- lib/l10n/app_vi.arb | 29 +- lib/l10n/app_zh.arb | 29 +- lib/l10n/app_zh_Hans.arb | 29 +- lib/l10n/app_zh_Hant_HK.arb | 29 +- lib/l10n/app_zh_TW.arb | 29 +- lib/l10n/gen/app_localizations.dart | 54 ++ lib/l10n/gen/app_localizations_en.dart | 27 + lib/l10n/gen/app_localizations_fil.dart | 30 ++ lib/l10n/gen/app_localizations_id.dart | 27 + lib/l10n/gen/app_localizations_ja.dart | 27 + lib/l10n/gen/app_localizations_ko.dart | 27 + lib/l10n/gen/app_localizations_th.dart | 27 + lib/l10n/gen/app_localizations_vi.dart | 27 + lib/l10n/gen/app_localizations_zh.dart | 108 ++++ lib/shared/map/admin_outline.dart | 13 + lib/shared/map/map_layer.dart | 28 + lib/shared/map/map_layer_category.dart | 21 +- lib/shared/map/map_scaffold.dart | 6 +- lib/shared/map/map_timeline.dart | 7 +- .../map/wind_forecast_layer_test.dart | 483 ++++++++++++++++++ test/features/map/wind_particle_sim_test.dart | 255 +++++++++ test/features/map/wind_web_parity_test.dart | 102 ++++ .../features/weather/frame_tile_api_test.dart | 30 ++ test/features/weather/wind_field_test.dart | 98 ++++ test/shared/map/map_layer_category_test.dart | 9 +- test/shared/map/map_layer_order_test.dart | 30 +- test/shared/map/map_layer_switcher_test.dart | 85 +-- test/shared/map/map_timeline_test.dart | 39 ++ test/shared/map/now_frame_index_test.dart | 50 ++ 48 files changed, 3309 insertions(+), 63 deletions(-) create mode 100644 lib/features/map/presentation/layers/admin_outline_chrome.dart create mode 100644 lib/features/map/presentation/layers/wind_forecast_layer.dart create mode 100644 lib/features/map/presentation/layers/wind_particle_sim.dart create mode 100644 lib/features/map/presentation/widgets/forecast_overlay_menu.dart create mode 100644 lib/features/map/presentation/widgets/wind_particle_overlay.dart create mode 100644 lib/features/weather/domain/wind_field.dart create mode 100644 lib/features/weather/domain/wind_forecast_model.dart create mode 100644 lib/features/weather/domain/wind_forecast_repository.dart create mode 100644 test/features/map/wind_forecast_layer_test.dart create mode 100644 test/features/map/wind_particle_sim_test.dart create mode 100644 test/features/map/wind_web_parity_test.dart create mode 100644 test/features/weather/wind_field_test.dart create mode 100644 test/shared/map/now_frame_index_test.dart diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index 1bfe42d01..a37a836f9 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -8,6 +8,10 @@ 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'; 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/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart new file mode 100644 index 000000000..8157f962c --- /dev/null +++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart @@ -0,0 +1,187 @@ +/// 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); + } + + /// 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 (source as WindForecastRepository).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; + + /// 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_particle_sim.dart b/lib/features/map/presentation/layers/wind_particle_sim.dart new file mode 100644 index 000000000..b813d32b1 --- /dev/null +++ b/lib/features/map/presentation/layers/wind_particle_sim.dart @@ -0,0 +1,422 @@ +/// 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: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) / (0.6 * kWindSpeedScale - 0)).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; +} + +/// 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. + 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); + + for (final p in particles) { + final u = _windU(p.x, p.y); + final v = _windV(p.x, p.y); + p.speed = math.sqrt(u * u + v * v); + final lat = field.lat0 + p.y * field.dLat * field.height; + final distortion = math.cos(lat * math.pi / 180); + p.x = (p.x + u / distortion * fieldStep) % 1.0; + p.y = p.y - v * fieldStep; + + final screen = projectLatLng( + cam, + field.lat0 + p.y * field.dLat * field.height, + field.lon0 + p.x * 360, + size, + ); + 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 = _windU(x, y); + final v = _windV(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 wind lookup at field-space [x], [y] — the hand-rolled texture + /// sample the web shader does (`lookup_wind`), reading the two quantised + /// planes directly. + double _windU(double x, double y) => _sample(0, x, y); + double _windV(double x, double y) => _sample(1, x, y); + + double _sample(int component, 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 plane = component == 0 ? field.u : field.v; + final min = component == 0 ? field.uMin : field.vMin; + final max = component == 0 ? field.uMax : field.vMax; + final a = plane[j0 * field.width + i0]; + final b = plane[j0 * field.width + i1]; + final c = plane[j1 * field.width + i0]; + final d = plane[j1 * field.width + i1]; + final top = a + (b - a) * tx; + final bottom = c + (d - c) * tx; + return min + ((top + (bottom - top) * ty) / 255) * (max - min); + } + + 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 b464184c6..157f75647 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -17,6 +17,7 @@ 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'; @@ -26,6 +27,8 @@ 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'; @@ -50,7 +53,15 @@ class _MapPageState extends State { // Built once so each layer keeps its own MapLibre state across rebuilds. late final List _layers = [ RadarMapLayer(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) 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/wind_particle_overlay.dart b/lib/features/map/presentation/widgets/wind_particle_overlay.dart new file mode 100644 index 000000000..b6c4438ca --- /dev/null +++ b/lib/features/map/presentation/widgets/wind_particle_overlay.dart @@ -0,0 +1,264 @@ +/// 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.centerLat != camera.centerLat || + previous.centerLng != camera.centerLng || + previous.zoom != camera.zoom || + previous.bearing != camera.bearing)) { + _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; + + /// 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 = List.generate(buckets, (_) => [], growable: false); + 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/weather/data/frame_tile_api.dart b/lib/features/weather/data/frame_tile_api.dart index c4c075a6f..f6c7bc140 100644 --- a/lib/features/weather/data/frame_tile_api.dart +++ b/lib/features/weather/data/frame_tile_api.dart @@ -1,14 +1,17 @@ /// Shared frame-keyed tile API for the v2 raster overlays (radar, satellite, -/// QPESUMS). +/// 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 all -/// share the same shape, differing only in the URL path segment ([path]). +/// 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 @@ -17,11 +20,12 @@ import 'package:dpip/core/network/meteor_decode.dart'; /// ([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}); + FrameTileApi(this._client, this.path, {this.channel, this.style, this.model}); final ApiClient _client; - /// The overlay's URL path segment: `radar`, `satellite`, or `qpesums`. + /// The overlay's URL path segment: `radar`, `satellite`, `qpesums`, or + /// `wind`. final String path; /// Optional `?channel=` for the satellite overlay — selects which band or @@ -35,6 +39,11 @@ class FrameTileApi { /// 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; @@ -58,10 +67,12 @@ class FrameTileApi { String get _query { final channel = this.channel; final style = this.style; - if (channel == null && style == null) return ''; + 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('&')}'; } @@ -72,4 +83,12 @@ class FrameTileApi { 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 c622fe979..96964c815 100644 --- a/lib/features/weather/data/frame_tile_repository.dart +++ b/lib/features/weather/data/frame_tile_repository.dart @@ -9,6 +9,8 @@ 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'; @@ -94,18 +96,25 @@ abstract base class FrameTileRepository implements RasterFrameSource { /// [RasterFrameSource] for one v2 tile overlay, backed by [FrameTileApi]. /// -/// The concrete overlay is selected by the API's [FrameTileApi.path]. The three +/// 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 { - FrameTileRepositoryImpl(this._api, super.warmer); + 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 - int get maxZoom => 11; + final int maxZoom; @override String get tilePathPrefix => '${ApiPaths.tiles}/${_api.path}/'; @@ -116,6 +125,13 @@ final class FrameTileRepositoryImpl extends FrameTileRepository @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/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/weather_providers.dart b/lib/features/weather/weather_providers.dart index 298376b82..f23ffd92d 100644 --- a/lib/features/weather/weather_providers.dart +++ b/lib/features/weather/weather_providers.dart @@ -16,11 +16,13 @@ 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 / QPESUMS overlays, meteor weather / -/// rain / lightning, and the next-hour rain trend. +/// rain / lightning, the next-hour rain trend, and the wind forecast models. /// /// 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 @@ -57,6 +59,19 @@ List weatherProviders(SharedDeps deps) { ), }, ), + // 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)), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 92b6aca11..08f10424d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1635,6 +1635,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)" @@ -1779,6 +1787,11 @@ "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" @@ -2304,6 +2317,14 @@ "@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." @@ -2327,5 +2348,21 @@ "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." } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 0d24590c1..8eaab99f3 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -339,6 +339,8 @@ "mapLayerHumidity": "Halumigmig", "mapLayerPressure": "Presyon", "mapLayerWind": "Hangin", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "Ulan", "rainIntervalMenu": "Bintana ng akumulasyon", "rainIntervalNow": "Ngayon", @@ -405,7 +407,8 @@ "mapLayerCategoryWeather": "Obserbasyon sa panahon", "mapLayerCategorySatellite": "Satellite", "mapLayerCategoryRadar": "Radar", - "mapLayerCategoryLife": "Pang-araw-araw na buhay", "mapOverlaySectionMap": "Mapa", + "mapLayerCategoryLife": "Pang-araw-araw na buhay", + "mapLayerCategoryForecast": "Numerical forecast", "mapOverlaySectionMap": "Mapa", "rainIntervalSection": "Window ng oras", "mapTownLabels": "Mga pangalan ng bayan", @@ -604,6 +607,14 @@ "@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." @@ -627,5 +638,21 @@ "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 aeaef7b89..33e9e5d43 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -339,6 +339,8 @@ "mapLayerHumidity": "Kelembapan", "mapLayerPressure": "Tekanan", "mapLayerWind": "Angin", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "Curah hujan", "rainIntervalMenu": "Jendela akumulasi", "rainIntervalNow": "Hari ini", @@ -405,7 +407,8 @@ "mapLayerCategoryWeather": "Pengamatan cuaca", "mapLayerCategorySatellite": "Satelit", "mapLayerCategoryRadar": "Radar", - "mapLayerCategoryLife": "Kehidupan sehari-hari", "mapOverlaySectionMap": "Peta", + "mapLayerCategoryLife": "Kehidupan sehari-hari", + "mapLayerCategoryForecast": "Prakiraan numerik", "mapOverlaySectionMap": "Peta", "rainIntervalSection": "Jendela waktu", "mapTownLabels": "Nama kecamatan", @@ -604,6 +607,14 @@ "@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." @@ -627,5 +638,21 @@ "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 bccff574b..3f9716690 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -339,6 +339,8 @@ "mapLayerHumidity": "湿度", "mapLayerPressure": "気圧", "mapLayerWind": "風向", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "降水量", "rainIntervalMenu": "累積期間", "rainIntervalNow": "今日", @@ -405,7 +407,8 @@ "mapLayerCategoryWeather": "気象観測", "mapLayerCategorySatellite": "衛星", "mapLayerCategoryRadar": "レーダー", - "mapLayerCategoryLife": "生活", "mapOverlaySectionMap": "地図", + "mapLayerCategoryLife": "生活", + "mapLayerCategoryForecast": "数値予報", "mapOverlaySectionMap": "地図", "rainIntervalSection": "集計時間", "mapTownLabels": "郷鎮名", @@ -604,6 +607,14 @@ "@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." @@ -627,5 +638,21 @@ "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 26e014e74..dec8a7e61 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -339,6 +339,8 @@ "mapLayerHumidity": "습도", "mapLayerPressure": "기압", "mapLayerWind": "바람", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "강수량", "rainIntervalMenu": "누적 구간", "rainIntervalNow": "오늘", @@ -405,7 +407,8 @@ "mapLayerCategoryWeather": "기상 관측", "mapLayerCategorySatellite": "위성", "mapLayerCategoryRadar": "레이더", - "mapLayerCategoryLife": "생활", "mapOverlaySectionMap": "지도", + "mapLayerCategoryLife": "생활", + "mapLayerCategoryForecast": "수치 예보", "mapOverlaySectionMap": "지도", "rainIntervalSection": "집계 시간", "mapTownLabels": "읍면동 이름", @@ -604,6 +607,14 @@ "@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." @@ -627,5 +638,21 @@ "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 8903cb965..90feaa53b 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -339,6 +339,8 @@ "mapLayerHumidity": "ความชื้น", "mapLayerPressure": "ความกดอากาศ", "mapLayerWind": "ลม", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "ปริมาณฝน", "rainIntervalMenu": "ช่วงสะสม", "rainIntervalNow": "วันนี้", @@ -405,7 +407,8 @@ "mapLayerCategoryWeather": "การสังเกตสภาพอากาศ", "mapLayerCategorySatellite": "ดาวเทียม", "mapLayerCategoryRadar": "เรดาร์", - "mapLayerCategoryLife": "ชีวิตประจำวัน", "mapOverlaySectionMap": "แผนที่", + "mapLayerCategoryLife": "ชีวิตประจำวัน", + "mapLayerCategoryForecast": "การพยากรณ์เชิงตัวเลข", "mapOverlaySectionMap": "แผนที่", "rainIntervalSection": "ช่วงเวลา", "mapTownLabels": "ชื่อตำบล", @@ -604,6 +607,14 @@ "@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." @@ -627,5 +638,21 @@ "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 f9a6f8ed2..fa18ed176 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -339,6 +339,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", @@ -405,7 +407,8 @@ "mapLayerCategoryWeather": "Quan sát thời tiết", "mapLayerCategorySatellite": "Vệ tinh", "mapLayerCategoryRadar": "Ra đa", - "mapLayerCategoryLife": "Đời sống", "mapOverlaySectionMap": "Bản đồ", + "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", @@ -604,6 +607,14 @@ "@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." @@ -627,5 +638,21 @@ "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 19d88c5f1..f34970c75 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -339,6 +339,8 @@ "mapLayerHumidity": "濕度", "mapLayerPressure": "氣壓", "mapLayerWind": "風向", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "雨量", "rainIntervalMenu": "累積時段", "rainIntervalNow": "今日", @@ -425,7 +427,8 @@ "mapLayerCategoryWeather": "氣象觀測", "mapLayerCategorySatellite": "衛星", "mapLayerCategoryRadar": "雷達", - "mapLayerCategoryLife": "生活", "mapOverlaySectionMap": "地圖", + "mapLayerCategoryLife": "生活", + "mapLayerCategoryForecast": "數值預報", "mapOverlaySectionMap": "地圖", "rainIntervalSection": "統計時間", "mapTownLabels": "鄉鎮名稱", @@ -604,6 +607,14 @@ "@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." @@ -627,5 +638,21 @@ "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 a325d4340..2ea929650 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -339,6 +339,8 @@ "mapLayerHumidity": "湿度", "mapLayerPressure": "气压", "mapLayerWind": "风向", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "雨量", "rainIntervalMenu": "累积时段", "rainIntervalNow": "今日", @@ -405,7 +407,8 @@ "mapLayerCategoryWeather": "气象观测", "mapLayerCategorySatellite": "卫星", "mapLayerCategoryRadar": "雷达", - "mapLayerCategoryLife": "生活", "mapOverlaySectionMap": "地图", + "mapLayerCategoryLife": "生活", + "mapLayerCategoryForecast": "数值预报", "mapOverlaySectionMap": "地图", "rainIntervalSection": "统计时间", "mapTownLabels": "乡镇名称", @@ -604,6 +607,14 @@ "@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." @@ -627,5 +638,21 @@ "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 573713d94..6a51d63a4 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -339,6 +339,8 @@ "mapLayerHumidity": "濕度", "mapLayerPressure": "氣壓", "mapLayerWind": "風向", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "雨量", "rainIntervalMenu": "累積時段", "rainIntervalNow": "今日", @@ -405,7 +407,8 @@ "mapLayerCategoryWeather": "氣象觀測", "mapLayerCategorySatellite": "衛星", "mapLayerCategoryRadar": "雷達", - "mapLayerCategoryLife": "生活", "mapOverlaySectionMap": "地圖", + "mapLayerCategoryLife": "生活", + "mapLayerCategoryForecast": "數值預報", "mapOverlaySectionMap": "地圖", "rainIntervalSection": "統計時間", "mapTownLabels": "鄉鎮名稱", @@ -604,6 +607,14 @@ "@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." @@ -627,5 +638,21 @@ "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 931c0c477..6ba1c4531 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -339,6 +339,8 @@ "mapLayerHumidity": "濕度", "mapLayerPressure": "氣壓", "mapLayerWind": "風向", + "mapLayerWindForecastEcmwf": "ECMWF", + "mapLayerWindForecastGfs": "GFS", "mapLayerRain": "雨量", "rainIntervalMenu": "累積時段", "rainIntervalNow": "今日", @@ -425,7 +427,8 @@ "mapLayerCategoryWeather": "氣象觀測", "mapLayerCategorySatellite": "衛星", "mapLayerCategoryRadar": "雷達", - "mapLayerCategoryLife": "生活", "mapOverlaySectionMap": "地圖", + "mapLayerCategoryLife": "生活", + "mapLayerCategoryForecast": "數值預報", "mapOverlaySectionMap": "地圖", "rainIntervalSection": "統計時間", "mapTownLabels": "鄉鎮名稱", @@ -604,6 +607,14 @@ "@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." @@ -627,5 +638,21 @@ "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 cd39b8c7f..28d2b4306 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2367,6 +2367,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: @@ -2571,6 +2583,12 @@ abstract class AppLocalizations { /// **'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: @@ -3273,6 +3291,18 @@ abstract class AppLocalizations { /// **'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: @@ -3308,6 +3338,30 @@ abstract class AppLocalizations { /// 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 dfad3bf2c..34f6adcc2 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1239,6 +1239,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'; @@ -1343,6 +1349,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapLayerCategoryLife => 'Daily life'; + @override + String get mapLayerCategoryForecast => 'Numerical forecast'; + @override String get mapOverlaySectionMap => 'Map'; @@ -1719,6 +1728,12 @@ class AppLocalizationsEn extends AppLocalizations { @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'; @@ -1738,4 +1753,16 @@ class AppLocalizationsEn extends AppLocalizations { @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 7dfca0b8a..82bfabf67 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1245,6 +1245,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'; @@ -1350,6 +1356,9 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapLayerCategoryLife => 'Pang-araw-araw na buhay'; + @override + String get mapLayerCategoryForecast => 'Numerical forecast'; + @override String get mapOverlaySectionMap => 'Mapa'; @@ -1727,6 +1736,12 @@ class AppLocalizationsFil extends AppLocalizations { @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'; @@ -1747,4 +1762,19 @@ class AppLocalizationsFil extends AppLocalizations { @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 1f7e9dca4..51ca7bd6f 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1242,6 +1242,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'; @@ -1346,6 +1352,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapLayerCategoryLife => 'Kehidupan sehari-hari'; + @override + String get mapLayerCategoryForecast => 'Prakiraan numerik'; + @override String get mapOverlaySectionMap => 'Peta'; @@ -1722,6 +1731,12 @@ class AppLocalizationsId extends AppLocalizations { @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'; @@ -1741,4 +1756,16 @@ class AppLocalizationsId extends AppLocalizations { @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 3556e693e..fff6b3c8e 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1217,6 +1217,12 @@ class AppLocalizationsJa extends AppLocalizations { return '資料時刻\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '強震モニタ'; @@ -1321,6 +1327,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapLayerCategoryLife => '生活'; + @override + String get mapLayerCategoryForecast => '数値予報'; + @override String get mapOverlaySectionMap => '地図'; @@ -1688,6 +1697,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get radarCountyOutline => '県市境界'; + @override + String get radarGlobalOutline => '国境線'; + + @override + String get radarGlobalOutlineHint => '各国の国境外枠'; + @override String get radarCountyOutlineHint => 'エコーの上に描画'; @@ -1705,4 +1720,16 @@ class AppLocalizationsJa extends AppLocalizations { @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 992d5b2f6..0865f6793 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1218,6 +1218,12 @@ class AppLocalizationsKo extends AppLocalizations { return 'Data time\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '실시간 지진 모니터'; @@ -1322,6 +1328,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapLayerCategoryLife => '생활'; + @override + String get mapLayerCategoryForecast => '수치 예보'; + @override String get mapOverlaySectionMap => '지도'; @@ -1697,6 +1706,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get radarCountyOutline => '시·군 경계'; + @override + String get radarGlobalOutline => '국경'; + + @override + String get radarGlobalOutlineHint => '각국 국경선'; + @override String get radarCountyOutlineHint => '에코 위에 표시'; @@ -1714,4 +1729,16 @@ class AppLocalizationsKo extends AppLocalizations { @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 71707c9ac..402f48e3f 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1237,6 +1237,12 @@ class AppLocalizationsTh extends AppLocalizations { return 'Data time\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => 'เครื่องตรวจแผ่นดินไหว'; @@ -1341,6 +1347,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapLayerCategoryLife => 'ชีวิตประจำวัน'; + @override + String get mapLayerCategoryForecast => 'การพยากรณ์เชิงตัวเลข'; + @override String get mapOverlaySectionMap => 'แผนที่'; @@ -1717,6 +1726,12 @@ class AppLocalizationsTh extends AppLocalizations { @override String get radarCountyOutline => 'เส้นแบ่งเขตจังหวัด'; + @override + String get radarGlobalOutline => 'เส้นแบ่งเขตประเทศ'; + + @override + String get radarGlobalOutlineHint => 'กรอบนอกของทุกประเทศ'; + @override String get radarCountyOutlineHint => 'วาดทับภาพเอคโค'; @@ -1736,4 +1751,16 @@ class AppLocalizationsTh extends AppLocalizations { @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 bbc3f906f..2d0a98470 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1238,6 +1238,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'; @@ -1343,6 +1349,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapLayerCategoryLife => 'Đời sống'; + @override + String get mapLayerCategoryForecast => 'Dự báo số'; + @override String get mapOverlaySectionMap => 'Bản đồ'; @@ -1720,6 +1729,12 @@ class AppLocalizationsVi extends AppLocalizations { @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'; @@ -1739,4 +1754,16 @@ class AppLocalizationsVi extends AppLocalizations { @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 a5a3028cb..ac21081b7 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1211,6 +1211,12 @@ class AppLocalizationsZh extends AppLocalizations { return '資料時間\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '強震監視器'; @@ -1315,6 +1321,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapLayerCategoryLife => '生活'; + @override + String get mapLayerCategoryForecast => '數值預報'; + @override String get mapOverlaySectionMap => '地圖'; @@ -1682,6 +1691,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get radarCountyOutline => '縣市界線'; + @override + String get radarGlobalOutline => '國界'; + + @override + String get radarGlobalOutlineHint => '各國國界外框'; + @override String get radarCountyOutlineHint => '畫在回波之上'; @@ -1699,6 +1714,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get qpesumsOverlayMenuTooltip => '定量降水預報圖層選項'; + + @override + String get windForecastOverlayMenuTooltip => '風場預報圖層選項'; + + @override + String get windForecastCountyOutlineHint => '繪製於風場之上'; + + @override + String get windForecastGlobalOutlineHint => '各國國界外框'; + + @override + String get windForecastTownOutlineHint => '更細的網格'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -2908,6 +2935,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { return '资料时间\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '强震监视器'; @@ -3012,6 +3045,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapLayerCategoryLife => '生活'; + @override + String get mapLayerCategoryForecast => '数值预报'; + @override String get mapOverlaySectionMap => '地图'; @@ -3379,6 +3415,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get radarCountyOutline => '县市界线'; + @override + String get radarGlobalOutline => '国界'; + + @override + String get radarGlobalOutlineHint => '各国国界外框'; + @override String get radarCountyOutlineHint => '画在回波之上'; @@ -3396,6 +3438,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get qpesumsOverlayMenuTooltip => '定量降水预报图层选项'; + + @override + String get windForecastOverlayMenuTooltip => '风场预报图层选项'; + + @override + String get windForecastCountyOutlineHint => '绘制于风场之上'; + + @override + String get windForecastGlobalOutlineHint => '各国国界外框'; + + @override + String get windForecastTownOutlineHint => '更细的网格'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -4605,6 +4659,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { return '資料時間\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '強震監視器'; @@ -4709,6 +4769,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapLayerCategoryLife => '生活'; + @override + String get mapLayerCategoryForecast => '數值預報'; + @override String get mapOverlaySectionMap => '地圖'; @@ -5076,6 +5139,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get radarCountyOutline => '縣市界線'; + @override + String get radarGlobalOutline => '國界'; + + @override + String get radarGlobalOutlineHint => '各國國界外框'; + @override String get radarCountyOutlineHint => '畫在回波之上'; @@ -5093,6 +5162,18 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get qpesumsOverlayMenuTooltip => '定量降水預報圖層選項'; + + @override + String get windForecastOverlayMenuTooltip => '風場預報圖層選項'; + + @override + String get windForecastCountyOutlineHint => '繪製於風場之上'; + + @override + String get windForecastGlobalOutlineHint => '各國國界外框'; + + @override + String get windForecastTownOutlineHint => '更細的網格'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -6302,6 +6383,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '資料時間\n$time'; } + @override + String get mapLayerWindForecastEcmwf => 'ECMWF'; + + @override + String get mapLayerWindForecastGfs => 'GFS'; + @override String get mapLayerMonitor => '強震監視器'; @@ -6406,6 +6493,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapLayerCategoryLife => '生活'; + @override + String get mapLayerCategoryForecast => '數值預報'; + @override String get mapOverlaySectionMap => '地圖'; @@ -6773,6 +6863,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get radarCountyOutline => '縣市界線'; + @override + String get radarGlobalOutline => '國界'; + + @override + String get radarGlobalOutlineHint => '各國國界外框'; + @override String get radarCountyOutlineHint => '畫在回波之上'; @@ -6790,4 +6886,16 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @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/map/admin_outline.dart b/lib/shared/map/admin_outline.dart index 223c87e87..ba12db3a1 100644 --- a/lib/shared/map/admin_outline.dart +++ b/lib/shared/map/admin_outline.dart @@ -2,6 +2,19 @@ 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', diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart index 713dc787e..a1c937f5b 100644 --- a/lib/shared/map/map_layer.dart +++ b/lib/shared/map/map_layer.dart @@ -26,6 +26,34 @@ class MapFrame { int get hashCode => Object.hash(id, time); } +/// Index of the frame nearest the present moment in a chronological [frames] +/// list, or 0 when it is empty. +/// +/// 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. For observations the answer is unchanged. +int nowFrameIndex(List frames, {DateTime? now}) { + if (frames.isEmpty) return 0; + final at = now ?? DateTime.now(); + var best = 0; + var bestGap = Duration.zero; + for (var i = 0; i < frames.length; i++) { + final gap = frames[i].time.difference(at).abs(); + if (i == 0 || gap < bestGap) { + best = i; + bestGap = gap; + } + } + return best; +} + /// A pluggable overlay on the shared map — radar today, rain / lightning / /// typhoon / … as they land. /// diff --git a/lib/shared/map/map_layer_category.dart b/lib/shared/map/map_layer_category.dart index 0339b176d..5ec7705d1 100644 --- a/lib/shared/map/map_layer_category.dart +++ b/lib/shared/map/map_layer_category.dart @@ -4,14 +4,18 @@ library; import 'package:dpip/l10n/gen/app_localizations.dart'; -/// The six groups the overlays fall into, in display order. +/// The seven groups the overlays fall into, in display order. enum MapLayerCategory { /// Seismic-monitor events (RTS). earthquake, - /// Ground-radar imagery + short-term precipitation estimate. + /// Ground-radar imagery. radar, + /// Numerical weather prediction: the short-term precipitation forecast + /// (QPESUMS) and the ECMWF / GFS wind fields. + forecast, + /// Typhoon tracks. typhoon, @@ -37,6 +41,12 @@ 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, @@ -46,7 +56,11 @@ MapLayerCategory categoryOf(String layerId) { 'wind' || 'rain' || 'lightning' => MapLayerCategory.weather, - 'radar' || 'qpesums' => MapLayerCategory.radar, + '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, }; @@ -59,6 +73,7 @@ String categoryLabel(MapLayerCategory category, AppLocalizations l10n) => 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_scaffold.dart b/lib/shared/map/map_scaffold.dart index 6d62270b4..cc6769b72 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -460,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 in the middle of its own range, with the future to the right. final controller = _controller!; final layer = _active; _queue(() => layer.prepare(controller, frames)); diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index 5398f03a4..5c0a46b96 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -12,7 +12,8 @@ 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 nearest the present is labelled "now" — the newest one for +/// observed data, and somewhere in the middle for a forecast. /// /// Stateless about the map — it only turns [frames] + [selectedIndex] into a /// scrub gesture, so `MapScaffold` can drive any layer's frames through it. @@ -168,7 +169,9 @@ 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 isNow = _liveIndex == nowFrameIndex(widget.frames); final labelStep = (48 / _slotWidth).ceil(); return Column( 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..63fcdd179 --- /dev/null +++ b/test/features/map/wind_particle_sim_test.dart @@ -0,0 +1,255 @@ +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/weather/frame_tile_api_test.dart b/test/features/weather/frame_tile_api_test.dart index ffcc8389b..8b26fdb96 100644 --- a/test/features/weather/frame_tile_api_test.dart +++ b/test/features/weather/frame_tile_api_test.dart @@ -108,5 +108,35 @@ void main() { 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/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/shared/map/map_layer_category_test.dart b/test/shared/map/map_layer_category_test.dart index b4ece9fac..c9bc50b0c 100644 --- a/test/shared/map/map_layer_category_test.dart +++ b/test/shared/map/map_layer_category_test.dart @@ -31,9 +31,14 @@ void main() { expect(categoryOf('satellite'), MapLayerCategory.satellite); }); - test('radar and the precipitation forecast share a group', () { + 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); - expect(categoryOf('qpesums'), MapLayerCategory.radar); }); test('the disaster-prevention map is the daily-life group', () { diff --git a/test/shared/map/map_layer_order_test.dart b/test/shared/map/map_layer_order_test.dart index 6c17d888e..63a0a2616 100644 --- a/test/shared/map/map_layer_order_test.dart +++ b/test/shared/map/map_layer_order_test.dart @@ -101,7 +101,15 @@ void main() { names( orderedCategories(MapLayerCategory.values, ['radar', 'typhoon']), ), - ['radar', 'typhoon', 'earthquake', 'weather', 'satellite', 'life'], + [ + 'radar', + 'typhoon', + 'earthquake', + 'forecast', + 'weather', + 'satellite', + 'life', + ], ); }, ); @@ -109,7 +117,15 @@ void main() { test('a stale name is ignored without dropping any category', () { expect( names(orderedCategories(MapLayerCategory.values, ['gone', 'life'])), - ['life', 'earthquake', 'radar', 'typhoon', 'weather', 'satellite'], + [ + 'life', + 'earthquake', + 'radar', + 'forecast', + 'typhoon', + 'weather', + 'satellite', + ], ); }); @@ -122,7 +138,15 @@ void main() { 'radar', ]), ), - ['typhoon', 'radar', 'earthquake', 'weather', 'satellite', 'life'], + [ + '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 index 42251a566..882d873ca 100644 --- a/test/shared/map/map_layer_switcher_test.dart +++ b/test/shared/map/map_layer_switcher_test.dart @@ -36,10 +36,12 @@ void main() { 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, satellite, rain, typhoon]; + final layers = [radar, qpesums, ecmwf, gfs, satellite, rain, typhoon]; Future pumpSwitcher( WidgetTester tester, @@ -114,10 +116,15 @@ void main() { centreX(tester, find.text(l10n.mapLayers)), closeTo(screenWidth(tester) / 2, 1), ); - // Group order: radar → typhoon → weather → satellite; the two radar-group - // layers keep their saved relative order (radar echo before precip). + // 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( @@ -128,7 +135,10 @@ void main() { topOf(tester, l10n.mapLayerCategoryWeather), lessThan(topOf(tester, l10n.mapLayerCategorySatellite)), ); - expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Precip'))); + // 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( @@ -137,6 +147,13 @@ void main() { ), findsOneWidget, ); + expect( + find.descendant( + of: find.byType(ListView), + matching: find.text('0.25° · 3 h'), + ), + findsOneWidget, + ); }); testWidgets( @@ -150,11 +167,11 @@ void main() { await tester.pumpAndSettle(); // Unsaved layers keep the declared order within their groups: Radar - // group (radar echo, precip) at the top, then weather (rain) above - // satellite (satellite IR). + // 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, 'Radar echo'), lessThan(topOf(tester, 'Precip'))); + expect(topOf(tester, 'Precip'), lessThan(topOf(tester, 'GFS'))); }, ); @@ -175,19 +192,20 @@ void main() { 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 (typhoon). - expect(find.byIcon(Icons.drag_handle), findsNWidgets(4)); + // 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); - expect(inEditor(l10n.mapLayerCategoryRadar), findsOneWidget); // The picker behind still shows the active layer's tile — the editor's - // level-1 list must not. Only radar holds more than one layer, so just one - // category is drill-in-able. + // 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); @@ -209,25 +227,26 @@ void main() { of: find.byType(ReorderableListView), matching: find.text(text), ); - final radarCategory = find.descendant( + final forecastCategory = find.descendant( of: find.byType(ReorderableListView), - matching: find.text(l10n.mapLayerCategoryRadar), + matching: find.text(l10n.mapLayerCategoryForecast), ); - await tester.tap(radarCategory); + await tester.tap(forecastCategory); await tester.pumpAndSettle(); - // Level 2 shows just the radar-group layers, each with a drag handle. - expect(editorText('Radar echo'), findsOneWidget); + // 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(2)); + 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('Radar echo'), findsNothing); - expect(editorText(l10n.mapLayerCategoryTyphoon), findsOneWidget); + expect(editorText('Precip'), findsNothing); + expect(editorText(l10n.mapLayerCategoryRadar), findsOneWidget); }); testWidgets('dragging a category reorders the categories', (tester) async { @@ -250,6 +269,7 @@ void main() { expect(controller.categoryOrder, [ 'satellite', 'radar', + 'forecast', 'typhoon', 'weather', ]); @@ -257,6 +277,8 @@ void main() { 'satellite', 'radar', 'qpesums', + 'wind-ecmwf', + 'wind-gfs', 'typhoon', 'rain', ]); @@ -275,12 +297,12 @@ void main() { await tester.tap( find.descendant( of: find.byType(ReorderableListView), - matching: find.text(l10n.mapLayerCategoryRadar), + matching: find.text(l10n.mapLayerCategoryForecast), ), ); await tester.pumpAndSettle(); - // Precip sits below radar echo. Drag its handle far up to flip them. + // 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), @@ -288,12 +310,14 @@ void main() { await tester.drag(handles.last, const Offset(0, -300)); await tester.pumpAndSettle(); - // Only the layer ids changed — precip now before radar echo within the - // radar group; the category order is untouched (declared order, so the - // flattened snapshot leads with the radar block). + // 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, [ - 'qpesums', 'radar', + 'wind-gfs', + 'qpesums', + 'wind-ecmwf', 'typhoon', 'rain', 'satellite', @@ -304,7 +328,7 @@ void main() { testWidgets('reset restores the grouped default and clears both preferences', ( tester, ) async { - // A saved order that flips the radar pair and moves a category — a + // 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'], @@ -324,12 +348,13 @@ void main() { // The category list falls back to the grouped default… final inEditor = find.descendant( of: find.byType(ReorderableListView), - matching: find.text(l10n.mapLayerCategoryRadar), + matching: find.text(l10n.mapLayerCategoryForecast), ); await tester.tap(inEditor); await tester.pumpAndSettle(); - // …and radar echo is back before precip within the radar group. - expect(topOf(tester, 'Radar echo'), lessThan(topOf(tester, 'Precip'))); + // …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_timeline_test.dart b/test/shared/map/map_timeline_test.dart index d20e36022..c51524dbf 100644 --- a/test/shared/map/map_timeline_test.dart +++ b/test/shared/map/map_timeline_test.dart @@ -34,7 +34,46 @@ Widget _wrap({ ), ); +/// 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( 'labels the newest selected frame as "now" without layout error', (tester) async { 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..63e6b7c80 --- /dev/null +++ b/test/shared/map/now_frame_index_test.dart @@ -0,0 +1,50 @@ +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 in the middle of its own range', () { + // 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('picks the nearest step when none lands exactly on now', () { + // Three-hourly ECMWF steps straddling a present that falls between two. + 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), 1); + }); + + 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); + }); +} From 04536783ac5624ca5445ec9271342fa775444ec6 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 19:35:13 +0800 Subject: [PATCH 73/92] =?UTF-8?q?feat:=20=E5=9C=8B=E7=95=8C=20borders=20de?= =?UTF-8?q?fault-off=20on=20every=20reference=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The world-country frame is noise on a Taiwan-focused surface, so it now ships off everywhere and is an explicit toggle: radar/QPESUMS fold their admin chrome onto the shared AdminOutlineChrome (which gains the global toggle and its legend row), and satellite gets the same toggle in both its style menu (thermal bands) and a new reference menu (products and reflectance bands), with the legend row following the switch. --- .../presentation/layers/qpesums_layer.dart | 4 +- .../map/presentation/layers/radar_layer.dart | 4 +- .../presentation/layers/satellite_layer.dart | 114 ++++++++++++++---- .../layers/scan_range_overlay_chrome.dart | 114 ++---------------- .../widgets/satellite_legend.dart | 33 +++-- .../widgets/satellite_style_menu.dart | 84 ++++++++++++- .../widgets/scan_range_overlay_menu.dart | 21 +++- .../features/map/radar_overlay_menu_test.dart | 26 +++- test/features/map/satellite_layer_test.dart | 66 ++++++++-- test/features/map/satellite_legend_test.dart | 16 +-- .../map/satellite_style_menu_test.dart | 39 +++--- 11 files changed, 328 insertions(+), 193 deletions(-) diff --git a/lib/features/map/presentation/layers/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart index 00e5255db..588c6c59a 100644 --- a/lib/features/map/presentation/layers/qpesums_layer.dart +++ b/lib/features/map/presentation/layers/qpesums_layer.dart @@ -1,3 +1,4 @@ +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'; @@ -18,7 +19,8 @@ import 'package:flutter/material.dart'; /// 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 ScanRangeOverlayChrome { +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 diff --git a/lib/features/map/presentation/layers/radar_layer.dart b/lib/features/map/presentation/layers/radar_layer.dart index acef14bc4..62c9c6db2 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -1,3 +1,4 @@ +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'; @@ -19,7 +20,8 @@ import 'package:flutter/material.dart'; /// 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 ScanRangeOverlayChrome { +class RadarMapLayer extends RasterTimelineLayer + with AdminOutlineChrome, ScanRangeOverlayChrome { RadarMapLayer(RadarRepository super.repository); /// The radar composite's own ids — the default geometry and layer naming. diff --git a/lib/features/map/presentation/layers/satellite_layer.dart b/lib/features/map/presentation/layers/satellite_layer.dart index 69335a0df..2670317e6 100644 --- a/lib/features/map/presentation/layers/satellite_layer.dart +++ b/lib/features/map/presentation/layers/satellite_layer.dart @@ -1,5 +1,6 @@ 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'; @@ -34,6 +35,52 @@ class SatelliteMapLayer extends RasterTimelineLayer { 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 @@ -80,7 +127,7 @@ class SatelliteMapLayer extends RasterTimelineLayer { /// 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 scaffold's standalone labels menu instead. + /// gets the reference menu (國界 toggle + township labels) instead. @override Widget buildTopTrailingChrome( BuildContext context, { @@ -88,10 +135,16 @@ class SatelliteMapLayer extends RasterTimelineLayer { required ValueChanged onShowTownLabelsChanged, required Future Function() onReloadActive, }) { - if (!channel.isBand || !channel.isThermal) return const SizedBox.shrink(); - return SatelliteStyleMenu( + if (channel.isBand && channel.isThermal) { + return SatelliteStyleMenu( + layer: this, + onReloadActive: onReloadActive, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + ); + } + return SatelliteReferenceMenu( layer: this, - onReloadActive: onReloadActive, showTownLabels: showTownLabels, onShowTownLabelsChanged: onShowTownLabelsChanged, ); @@ -99,24 +152,30 @@ class SatelliteMapLayer extends RasterTimelineLayer { @override Future onAttached(MapLibreMapController controller) async { + this.controller = controller; try { - // Country/global edges + county frame in bright yellow, township mesh in - // dark yellow — the hue set that stays legible on imagery which is - // overall dark. Country + county share the same colour because both are - // the "which administrative unit" frame. - for (final (layerId, sourceLayer, color, width) in const [ - (satelliteGlobalOutlineLayerId, 'global', satelliteOutlineColor, 1.0), - (satelliteCountyOutlineLayerId, 'city', satelliteOutlineColor, 1.0), - (satelliteTownOutlineLayerId, 'town', satelliteTownOutlineColor, 0.7), - ]) { - await controller.addLineLayer( - 'exptech', - layerId, - LineLayerProperties(lineColor: color, lineWidth: width), - 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); @@ -125,10 +184,12 @@ class SatelliteMapLayer extends RasterTimelineLayer { @override Future onDetached(MapLibreMapController controller) async { + this.controller = null; + _globalShown = false; for (final layerId in const [ - satelliteTownOutlineLayerId, - satelliteCountyOutlineLayerId, satelliteGlobalOutlineLayerId, + satelliteCountyOutlineLayerId, + satelliteTownOutlineLayerId, ]) { try { await controller.removeLayer(layerId); @@ -137,8 +198,11 @@ class SatelliteMapLayer extends RasterTimelineLayer { } @override - Widget buildLegend(BuildContext context) => - SatelliteLegend(channel: channel, style: style); + Widget buildLegend(BuildContext context) => SatelliteLegend( + channel: channel, + style: style, + showGlobal: showGlobalOutline, + ); } /// Localised picker label for a satellite [channel] — the `ひまわり `(B##) diff --git a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart index e232f7b38..73e5ef8be 100644 --- a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart +++ b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart @@ -6,12 +6,10 @@ 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/color_hex.dart'; -import 'package:dpip/shared/map/admin_outline.dart'; import 'package:dpip/shared/map/map_style.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'; @@ -26,26 +24,18 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// 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 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 RasterTimelineLayer { +/// 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); - /// 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); - - MapLibreMapController? _controller; bool _rangeShown = false; - final Set _boundariesShown = {}; /// Source id this layer's scan-range outline is drawn under — distinct per /// layer, so two rasters sharing the composite geometry never clash. @@ -60,15 +50,7 @@ mixin ScanRangeOverlayChrome on RasterTimelineLayer { /// All chrome listenables, for a legend that follows the toggles. Listenable get chromeListenable => - Listenable.merge([showScanRange, showCountyOutline, showTownOutline]); - - /// Which boundary sets should currently be on the map. - Set get _wantedBoundaries => { - // Town first so a later insert of county lands above it: the coarse frame - // should win where the two run together along a coastline. - if (showTownOutline.value) AdminBoundary.town, - if (showCountyOutline.value) AdminBoundary.county, - }; + Listenable.merge([showScanRange, adminChromeListenable]); /// Turns the coverage outline on/off, applying it to a live map immediately. void setShowScanRange(bool value) { @@ -77,40 +59,15 @@ mixin ScanRangeOverlayChrome on RasterTimelineLayer { unawaited(_syncRange()); } - /// 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); - _controller = controller; await _syncRange(); - // Borders last, so they end up above the scan range as well as the raster — - // the range is a dashed hint, the borders are the reference frame. - await _syncBoundaries(); } @override Future onDetached(MapLibreMapController controller) async { super.onDetached(controller); - _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); - } if (_rangeShown) { _rangeShown = false; await RadarScanRange.remove( @@ -126,13 +83,12 @@ mixin ScanRangeOverlayChrome on RasterTimelineLayer { // 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; - _boundariesShown.clear(); super.onStyleReset(); } /// Adds or removes the coverage outline to match [showScanRange]. Future _syncRange() async { - final controller = _controller; + final controller = this.controller; if (controller == null) return; final wanted = showScanRange.value; @@ -162,29 +118,6 @@ mixin ScanRangeOverlayChrome on RasterTimelineLayer { } } - /// Brings the drawn boundary sets in line with the toggles. - Future _syncBoundaries() async { - final controller = _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 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) { @@ -200,30 +133,7 @@ mixin ScanRangeOverlayChrome on RasterTimelineLayer { ), label: l10n.radarScanRange, ), - 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, - ), + ...adminLegendItems(context), ]; } diff --git a/lib/features/map/presentation/widgets/satellite_legend.dart b/lib/features/map/presentation/widgets/satellite_legend.dart index c0caa1ed9..c6d41914b 100644 --- a/lib/features/map/presentation/widgets/satellite_legend.dart +++ b/lib/features/map/presentation/widgets/satellite_legend.dart @@ -19,24 +19,31 @@ 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). +/// (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: style, + listenable: Listenable.merge([style, ?showGlobal]), builder: (context, _) { final transparency = _transparencyNote(context); return MapLegendCard( @@ -140,7 +147,6 @@ class SatelliteLegend extends StatelessWidget { SatelliteChannel.nirPhase || SatelliteChannel.nirCloud => const ColorScaleLegend( unit: '%', - appendUnit: true, stops: [(0, '#000000'), (50, '#808080'), (100, '#FFFFFF')], ), // Thermal bands (B07–B16) take the selected [style]. @@ -170,7 +176,6 @@ class SatelliteLegend extends StatelessWidget { ), SatelliteChannel.watervapor => const ColorScaleLegend( unit: 'K', - appendUnit: true, stops: [(190, '#FFFFFF'), (235, '#7F7F7F'), (280, '#000000')], ), // Brightness-temperature differences: negative blue, zero transparent @@ -183,13 +188,11 @@ class SatelliteLegend extends StatelessWidget { SatelliteChannel.btdOzone => _diverging(-41.5, 4.3), SatelliteChannel.cloudtop => const ColorScaleLegend( unit: 'K', - appendUnit: true, stops: [(190, '#FFFFFF'), (245, '#7F7F7F'), (300, '#000000')], ), SatelliteChannel.cloudmask => _cloudMask(l10n), SatelliteChannel.sst => const ColorScaleLegend( unit: '°C', - appendUnit: true, stops: [ (-2, '#3C1478'), (3, '#2846C8'), @@ -203,17 +206,14 @@ class SatelliteLegend extends StatelessWidget { ), SatelliteChannel.ndvi => const ColorScaleLegend( unit: 'NDVI', - appendUnit: true, stops: [(0, '#C8AF6E'), (0.5, '#5F8A3A'), (1, '#196A23')], ), SatelliteChannel.ndwi => const ColorScaleLegend( unit: 'NDWI', - appendUnit: true, stops: [(0, '#78BEE8'), (0.5, '#3C7FD0'), (1, '#0A3CB8')], ), SatelliteChannel.mndwi => const ColorScaleLegend( unit: 'MNDWI', - appendUnit: true, stops: [(0, '#78BEE8'), (0.5, '#3C7FD0'), (1, '#0A3CB8')], ), }; @@ -225,12 +225,10 @@ class SatelliteLegend extends StatelessWidget { return switch (style.value) { SatelliteStyle.gray => const ColorScaleLegend( unit: 'K', - appendUnit: true, stops: [(190, '#FFFFFF'), (255, '#7F7F7F'), (320, '#000000')], ), SatelliteStyle.jma => const ColorScaleLegend( unit: '°C', - appendUnit: true, stops: [ (-45, '#7891D2'), (-52, '#2D55CD'), @@ -272,7 +270,6 @@ class SatelliteLegend extends StatelessWidget { /// negative, near-zero the basemap, red positive. ColorScaleLegend _diverging(double lo, double hi) => ColorScaleLegend( unit: 'K', - appendUnit: true, stops: [(lo, '#1E5AEB'), (0, '#E8ECF2'), (hi, '#E61E14')], ); @@ -300,15 +297,17 @@ class SatelliteLegend extends StatelessWidget { /// The border reference every channel shares — bright-yellow country and /// county frame, dark-yellow township mesh, matching [SatelliteMapLayer]'s - /// runtime line layers exactly. + /// 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: [ - SymbolLegendItem( - swatch: const LineSwatch(color: _county, width: 1.0), - label: l10n.mapLayerSatelliteGlobalOutline, - ), + 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, diff --git a/lib/features/map/presentation/widgets/satellite_style_menu.dart b/lib/features/map/presentation/widgets/satellite_style_menu.dart index 41fcbbc36..93b15fdff 100644 --- a/lib/features/map/presentation/widgets/satellite_style_menu.dart +++ b/lib/features/map/presentation/widgets/satellite_style_menu.dart @@ -36,11 +36,17 @@ class SatelliteStyleMenu extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return ListenableBuilder( - listenable: Listenable.merge([layer.style, showTownLabels]), + 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 || !showLabels; + final active = + style != SatelliteStyle.gray || showGlobal || !showLabels; return MenuAnchor( alignmentOffset: const Offset(0, 4), style: MapChipButton.menuStyle(context), @@ -93,6 +99,80 @@ class SatelliteStyleMenu extends StatelessWidget { ), ), 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, diff --git a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart index 84d2e5607..dd2dcf59d 100644 --- a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart @@ -43,6 +43,7 @@ class ScanRangeOverlayMenu extends StatelessWidget { 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; @@ -52,9 +53,15 @@ class ScanRangeOverlayMenu extends StatelessWidget { builder: (context, controller, _) => MapChipButton( icon: Icons.tune, tooltip: tooltip, - // The dot marks "not the defaults". All four ship on, so it - // lights up when one has been switched *off*. - active: !showRange || !showCounty || !showTown || !showLabels, + // 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(), ), @@ -62,6 +69,14 @@ class ScanRangeOverlayMenu extends StatelessWidget { 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, diff --git a/test/features/map/radar_overlay_menu_test.dart b/test/features/map/radar_overlay_menu_test.dart index 5c21d4a40..7d3890f37 100644 --- a/test/features/map/radar_overlay_menu_test.dart +++ b/test/features/map/radar_overlay_menu_test.dart @@ -52,7 +52,7 @@ void _useTallSurface(WidgetTester tester) { } void main() { - testWidgets('the chip opens a menu carrying all four overlay toggles', ( + testWidgets('the chip opens a menu carrying all five overlay toggles', ( tester, ) async { _useTallSurface(tester); @@ -66,6 +66,7 @@ void main() { 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); @@ -74,9 +75,28 @@ void main() { // chrome first, then the base-map settings. expect(find.text(l10n.mapOverlaySectionReference), findsOneWidget); expect(find.text(l10n.mapOverlaySectionMap), findsOneWidget); - // All four ship on, so every box starts ticked. + // 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), findsNothing); + 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 { diff --git a/test/features/map/satellite_layer_test.dart b/test/features/map/satellite_layer_test.dart index 01e161890..8f85618a2 100644 --- a/test/features/map/satellite_layer_test.dart +++ b/test/features/map/satellite_layer_test.dart @@ -74,7 +74,47 @@ void main() { }, ); - test('bright yellow outlines are added once and removed on clear', () async { + 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, @@ -84,25 +124,25 @@ void main() { 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:$satelliteTownOutlineLayerId', - 'removeLayer:$satelliteCountyOutlineLayerId', - 'removeLayer:$satelliteGlobalOutlineLayerId', - ]), + contains('removeLayer:$satelliteGlobalOutlineLayerId'), ); }); diff --git a/test/features/map/satellite_legend_test.dart b/test/features/map/satellite_legend_test.dart index 76514bd35..384201fe8 100644 --- a/test/features/map/satellite_legend_test.dart +++ b/test/features/map/satellite_legend_test.dart @@ -28,9 +28,11 @@ void main() { ); final l10n = await _l10n(); - // JMA convention: 190 K cold (white) → 320 K warm (black). - expect(find.text('190 K'), findsOneWidget); - expect(find.text('320 K'), findsOneWidget); + // 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); @@ -44,15 +46,15 @@ void main() { await tester.pumpWidget( _wrap(SatelliteLegend(channel: SatelliteChannel.irClean, style: style)), ); - expect(find.text('190 K'), findsOneWidget); + 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 K'), findsNothing); - expect(find.text('-45 °C'), findsOneWidget); - expect(find.text('-92 °C'), findsOneWidget); + 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 { diff --git a/test/features/map/satellite_style_menu_test.dart b/test/features/map/satellite_style_menu_test.dart index de5d49c5a..d9bc2ad35 100644 --- a/test/features/map/satellite_style_menu_test.dart +++ b/test/features/map/satellite_style_menu_test.dart @@ -112,7 +112,7 @@ void main() { }, ); - testWidgets('a named product offers no style menu (scaffold falls back)', ( + testWidgets('a named product offers the reference menu, not the style menu', ( tester, ) async { useTallSurface(tester); @@ -127,26 +127,27 @@ void main() { onShowTownLabelsChanged: (_) {}, onReloadActive: () async {}, ); - expect(chrome, isA()); + expect(chrome, isA()); }); - testWidgets('a reflectance band offers no style menu (grayscale only)', ( - 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 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); From 1c5d122387dc17988c1c930d38ac8d7eb9a24421 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 19:35:25 +0800 Subject: [PATCH 74/92] refactor: legend units always shown under the scale Drop the appendUnit mode so every value column stays numbers-only and the unit lives on its own line below the ramp. --- .../layers/weather_station_layer.dart | 6 +--- lib/shared/widgets/map_color_legend.dart | 31 ++++++------------- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/lib/features/map/presentation/layers/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart index 8ee5f971a..895f1c0ad 100644 --- a/lib/features/map/presentation/layers/weather_station_layer.dart +++ b/lib/features/map/presentation/layers/weather_station_layer.dart @@ -211,11 +211,7 @@ abstract class WeatherStationLayer< @override Widget buildLegend(BuildContext context) { final header = legendHeader(context); - final scale = ColorScaleLegend( - stops: colorStops, - unit: unit, - appendUnit: true, - ); + final scale = ColorScaleLegend(stops: colorStops, unit: unit); final child = header == null ? scale : Column( diff --git a/lib/shared/widgets/map_color_legend.dart b/lib/shared/widgets/map_color_legend.dart index 4a90c0faa..ad6ec2558 100644 --- a/lib/shared/widgets/map_color_legend.dart +++ b/lib/shared/widgets/map_color_legend.dart @@ -39,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; @@ -104,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), ], @@ -123,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; } } From 19de96db98b9900f24d1072f8b02d3c49d1d5d51 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 20:46:09 +0800 Subject: [PATCH 75/92] feat: cache wind-forecast tiles in the immutable ETag store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ECMWF/GFS tile URLs are content-addressed like radar/satellite, but the marker list never covered the wind path — every revisit re-downloaded the frame. One marker brings them into SQLite, the native intercept, and the warm path, with a parity test alongside the radar one. --- lib/core/network/etag_interceptor.dart | 1 + test/core/network/etag_binary_test.dart | 32 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/lib/core/network/etag_interceptor.dart b/lib/core/network/etag_interceptor.dart index 91121e455..49e098ecd 100644 --- a/lib/core/network/etag_interceptor.dart +++ b/lib/core/network/etag_interceptor.dart @@ -80,6 +80,7 @@ class EtagInterceptor extends Interceptor { 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/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 From 0e307e549aaafcf789f7663d001f4784fcd07afe Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 20:46:20 +0800 Subject: [PATCH 76/92] perf: equivalent simplification of the wind particle hot path Cut the per-frame CPU cost without changing the picture: the view rotation is computed once and inlined instead of a sin/cos per particle, u/v bilinear samples share one cell-geometry pass, the speed buckets are reused instead of allocated every frame, and the layer stops re-casting the repository. --- .../layers/wind_forecast_layer.dart | 9 +- .../layers/wind_particle_sim.dart | 111 ++++++++++++------ .../widgets/wind_particle_overlay.dart | 32 +++-- 3 files changed, 104 insertions(+), 48 deletions(-) diff --git a/lib/features/map/presentation/layers/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart index 8157f962c..a80a03d28 100644 --- a/lib/features/map/presentation/layers/wind_forecast_layer.dart +++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart @@ -61,6 +61,11 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { 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 @@ -68,9 +73,7 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { Future _loadField(String frameId) async { if (frameId == _loadedFieldFrame) return; _loadedFieldFrame = frameId; - final result = await (source as WindForecastRepository).fetchWindField( - frameId, - ); + final result = await _wind.fetchWindField(frameId); result.when( ok: (windField) => field.value = windField, err: (failure) { diff --git a/lib/features/map/presentation/layers/wind_particle_sim.dart b/lib/features/map/presentation/layers/wind_particle_sim.dart index b813d32b1..e407b7495 100644 --- a/lib/features/map/presentation/layers/wind_particle_sim.dart +++ b/lib/features/map/presentation/layers/wind_particle_sim.dart @@ -9,6 +9,7 @@ library; import 'dart:math' as math; +import 'dart:typed_data'; import 'dart:ui'; import 'package:dpip/features/weather/domain/wind_field.dart'; @@ -93,13 +94,12 @@ double fadeOpacityFor(double zoom) => _lerpStops(_kFadeOpacity, 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); +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) / (0.6 * kWindSpeedScale - 0)).clamp(0.0, 1.0); + final t = (speed / (0.6 * kWindSpeedScale)).clamp(0.0, 1.0); final s = t * t * (3 - 2 * t); // smoothstep return _kDensityCalm + (_kDensityStrong - _kDensityCalm) * s; } @@ -120,6 +120,17 @@ class WindCamera { /// 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. @@ -255,6 +266,11 @@ class WindParticleSim { /// 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); @@ -265,20 +281,31 @@ class WindParticleSim { } _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 = _windU(p.x, p.y); - final v = _windV(p.x, p.y); + final (u, v) = _sampleUV(p.x, p.y); p.speed = math.sqrt(u * u + v * v); - final lat = field.lat0 + p.y * field.dLat * field.height; - final distortion = math.cos(lat * math.pi / 180); - p.x = (p.x + u / distortion * fieldStep) % 1.0; - p.y = p.y - v * fieldStep; - - final screen = projectLatLng( - cam, - field.lat0 + p.y * field.dLat * field.height, - field.lon0 + p.x * 360, - size, + 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); @@ -303,7 +330,10 @@ class WindParticleSim { } while (particles.length < count) { particles.add( - WindParticle(_lerpX(r, _random.nextDouble()), _lerp(r.y0, r.y1, _random.nextDouble())), + WindParticle( + _lerpX(r, _random.nextDouble()), + _lerp(r.y0, r.y1, _random.nextDouble()), + ), ); } } @@ -362,8 +392,7 @@ class WindParticleSim { void _respawn(WindParticle p, _FieldRect r) { final x = _lerpX(r, _random.nextDouble()); final y = _lerp(r.y0, r.y1, _random.nextDouble()); - final u = _windU(x, y); - final v = _windV(x, y); + final (u, v) = _sampleUV(x, y); final weight = densityWeight(math.sqrt(u * u + v * v)); if (_random.nextDouble() < weight / math.max(_kDensityCalm, _kDensityStrong)) { @@ -372,13 +401,12 @@ class WindParticleSim { } } - /// Bilinear wind lookup at field-space [x], [y] — the hand-rolled texture - /// sample the web shader does (`lookup_wind`), reading the two quantised - /// planes directly. - double _windU(double x, double y) => _sample(0, x, y); - double _windV(double x, double y) => _sample(1, x, y); - - double _sample(int component, double x, double 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 @@ -392,16 +420,33 @@ class WindParticleSim { final j1 = j0 + 1 < field.height ? j0 + 1 : j0; final tx = fx - fx.floorToDouble(); final ty = (fy - j0).clamp(0.0, 1.0); - final plane = component == 0 ? field.u : field.v; - final min = component == 0 ? field.uMin : field.vMin; - final max = component == 0 ? field.uMax : field.vMax; - final a = plane[j0 * field.width + i0]; - final b = plane[j0 * field.width + i1]; - final c = plane[j1 * field.width + i0]; - final d = plane[j1 * field.width + i1]; + 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 min + ((top + (bottom - top) * ty) / 255) * (max - min); + return lo + (top + (bottom - top) * ty) / 255 * (hi - lo); } double _lerp(double a, double b, double t) => a + (b - a) * t; diff --git a/lib/features/map/presentation/widgets/wind_particle_overlay.dart b/lib/features/map/presentation/widgets/wind_particle_overlay.dart index b6c4438ca..609dfe66d 100644 --- a/lib/features/map/presentation/widgets/wind_particle_overlay.dart +++ b/lib/features/map/presentation/widgets/wind_particle_overlay.dart @@ -98,11 +98,7 @@ class _WindParticleOverlayState extends State bearing: position.bearing, ); final previous = _lastCamera; - if (previous != null && - (previous.centerLat != camera.centerLat || - previous.centerLng != camera.centerLng || - previous.zoom != camera.zoom || - previous.bearing != camera.bearing)) { + if (previous != null && previous != camera) { _trails.clear(); } _lastCamera = camera; @@ -153,10 +149,20 @@ class _WindParticleOverlayState extends State 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 zoom = + 3; // the wind layer's own floor, until the ticker says otherwise double devicePixelRatio = 1; void clear() { @@ -172,8 +178,7 @@ class _TrailBuffer { 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)) { + if (previous != null && (previous.width != w || previous.height != h)) { clear(); } @@ -185,9 +190,9 @@ class _TrailBuffer { old, Offset.zero, Paint() - ..color = const Color(0xFFFFFFFF).withValues( - alpha: fadeOpacityFor(zoom), - ), + ..color = const Color( + 0xFFFFFFFF, + ).withValues(alpha: fadeOpacityFor(zoom)), ); } _stamp(canvas, particles, dpr); @@ -207,7 +212,10 @@ class _TrailBuffer { /// rounding does to it anyway. void _stamp(Canvas canvas, Iterable particles, double dpr) { const buckets = 16; - final points = List.generate(buckets, (_) => [], growable: false); + final points = _buckets; + for (final bucket in points) { + bucket.clear(); + } for (final p in particles) { final screen = p.screen; if (screen == null) continue; From b08405dd90db2ad4efb771dbf3c9ea18a8c8ac9b Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 20:46:32 +0800 Subject: [PATCH 77/92] feat: per-hour usage history chart on the Debug page NetworkUsageStore gains a zero-filled per-hour history query, and the Developer page plots the trailing 24 hours under the network section. --- lib/core/network/network_usage_store.dart | 54 ++++ .../presentation/pages/developer_page.dart | 233 +++++++++++++++++- .../network/network_usage_store_test.dart | 24 ++ 3 files changed, 307 insertions(+), 4 deletions(-) diff --git a/lib/core/network/network_usage_store.dart b/lib/core/network/network_usage_store.dart index 276787798..87c0d1d2b 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,41 @@ class NetworkUsageStore { } } + /// Per-hour usage over the trailing [hours] buckets, oldest first — the + /// trend series for the Debug page's chart. + /// + /// Every slot in the window is present: a silent hour is a zero bar, not a + /// gap, so the chart stays continuous. Best-effort: empty on error. + Future> history({int hours = 24}) async { + await flush(); + try { + final hour = _now().millisecondsSinceEpoch ~/ _hourMs; + final rows = await _db.query( + _buckets, + where: 'hour >= ?', + whereArgs: [hour - hours + 1], + orderBy: 'hour', + ); + final byHour = >{ + for (final row in rows) row['hour'] as int: row, + }; + return [ + for (var h = hour - hours + 1; h <= hour; h++) + HourUsage( + hour: h, + down: _counter(byHour[h]?['down']), + saved: _counter(byHour[h]?['saved']), + hits: _counter(byHour[h]?['hits']), + misses: _counter(byHour[h]?['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/features/settings/presentation/pages/developer_page.dart b/lib/features/settings/presentation/pages/developer_page.dart index 3ccadc39d..05be00366 100644 --- a/lib/features/settings/presentation/pages/developer_page.dart +++ b/lib/features/settings/presentation/pages/developer_page.dart @@ -9,6 +9,7 @@ library; import 'dart:io'; +import 'dart:math' as math; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/build_info.g.dart'; @@ -17,11 +18,13 @@ 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/realtime/app_time.dart'; import 'package:dpip/core/settings/experimental_settings.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'; import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -62,6 +65,7 @@ class DeveloperPage extends StatefulWidget { class _DeveloperPageState extends State { List<({String title, List<_Field> fields})>? _sections; + List? _usageHistory; bool _clearing = false; /// Version-row taps toward the experimental unlock. Deliberately not @@ -84,6 +88,7 @@ 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 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 @@ -178,7 +183,12 @@ class _DeveloperPageState extends State { ], ), ]; - if (mounted) setState(() => _sections = sections); + if (mounted) { + setState(() { + _sections = sections; + _usageHistory = usageHistory; + }); + } } String get _osName => Platform.isIOS @@ -352,9 +362,9 @@ 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, @@ -362,6 +372,9 @@ class _DeveloperPageState extends State { ? _copy : null, ), + if (sections[i].title == 'Network usage' && + _usageHistory != null) + _NetworkUsageChart(history: _usageHistory!), ], const SectionHeader('Maintenance'), ListTile( @@ -426,3 +439,215 @@ class _DiagRow extends StatelessWidget { ); } } + +/// Hourly downloaded-vs-saved trend for the Network usage section. +/// +/// One stacked bar per hour over the trailing 24 h (Taipei wall-clock on the +/// x-axis): the blue segment is what the app actually downloaded, the green +/// segment what the cache saved on top — so the shape of the day and the cache +/// payoff read together. Deliberately matches the section's English-only +/// convention. +class _NetworkUsageChart extends StatelessWidget { + const _NetworkUsageChart({required this.history}); + + final List history; + + static const Color _savedColor = Color(0xFF66BB6A); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final maxBytes = history.fold(0, (m, h) => math.max(m, h.down + h.saved)); + 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 hour, 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 < history.length; i++) + BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: history[i].down / divisor + history[i].saved / divisor, + width: 6, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(2), + ), + rodStackItems: [ + BarChartRodStackItem( + 0, + history[i].down / divisor, + colors.primary, + ), + BarChartRodStackItem( + history[i].down / divisor, + history[i].down / divisor + history[i].saved / divisor, + _savedColor, + ), + ], + ), + ], + ), + ]; + + 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: [ + _legendDot(colors.primary), + const SizedBox(width: AppSpacing.sm), + const Text('Downloaded'), + const SizedBox(width: AppSpacing.md), + _legendDot(_savedColor), + const SizedBox(width: AppSpacing.sm), + const Text('Saved'), + ], + ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: 150, + child: BarChart( + BarChartData( + maxY: maxY, + alignment: BarChartAlignment.spaceBetween, + groupsSpace: 2, + 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, + interval: 4, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= history.length) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + meta: meta, + child: Text( + _taipeiHour(history[index].hour).toString(), + 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, + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final hour = history[group.x]; + final down = hour.down / divisor; + final saved = hour.saved / divisor; + return BarTooltipItem( + '${_taipeiHour(hour.hour).toString().padLeft(2, '0')}:00\n' + '↓ ${_formatNumber(down)} $unit\n' + 'Saved ${_formatNumber(saved)} $unit', + theme.textTheme.labelSmall!.copyWith( + color: colors.onSurface, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + }, + ), + ), + ), + ), + ), + ], + ), + ); + } + + 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 hour of a UTC epoch hour — what the "now" column reads. + static int _taipeiHour(int utcHour) => AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch(utcHour * 3600000, isUtc: true), + ).hour; +} diff --git a/test/core/network/network_usage_store_test.dart b/test/core/network/network_usage_store_test.dart index 706c56450..7265a9d3f 100644 --- a/test/core/network/network_usage_store_test.dart +++ b/test/core/network/network_usage_store_test.dart @@ -163,4 +163,28 @@ 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), + ); + }); } From ddcaf5617aecd1702cbc4ba8696b55b97565b6fb Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 20:46:51 +0800 Subject: [PATCH 78/92] =?UTF-8?q?feat:=20colour-coded=20=E7=8F=BE=E5=9C=A8?= =?UTF-8?q?/=E6=AD=B7=E5=8F=B2/=E6=9C=AA=E4=BE=86=20eras=20on=20the=20map?= =?UTF-8?q?=20timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frames either side of the present now carry an era label in a fixed hue (history blue, now green, future purple, light/dark shaded per theme) on both the headline time and the ruler ticks. --- lib/l10n/app_en.arb | 8 +++ lib/l10n/app_fil.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_th.arb | 2 + lib/l10n/app_vi.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_Hans.arb | 2 + lib/l10n/app_zh_Hant_HK.arb | 2 + lib/l10n/app_zh_TW.arb | 2 + lib/l10n/gen/app_localizations.dart | 12 +++++ lib/l10n/gen/app_localizations_en.dart | 6 +++ lib/l10n/gen/app_localizations_fil.dart | 6 +++ lib/l10n/gen/app_localizations_id.dart | 6 +++ lib/l10n/gen/app_localizations_ja.dart | 6 +++ lib/l10n/gen/app_localizations_ko.dart | 6 +++ lib/l10n/gen/app_localizations_th.dart | 6 +++ lib/l10n/gen/app_localizations_vi.dart | 6 +++ lib/l10n/gen/app_localizations_zh.dart | 24 +++++++++ lib/shared/map/map_timeline.dart | 68 +++++++++++++++++++------ test/shared/map/map_timeline_test.dart | 19 +++++++ 22 files changed, 177 insertions(+), 16 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 08f10424d..164d97c8d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1044,6 +1044,14 @@ "@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" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 8eaab99f3..46fb26d63 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -227,6 +227,8 @@ "lightningLegendCg": "Ulap–lupa · {minutes} min", "lightningLegendCc": "Ulap–ulap · {minutes} min", "mapTimelineNow": "Ngayon", + "mapTimelinePast": "Nakaraan", + "mapTimelineFuture": "Hinaharap", "mapTimelineObserved": "Naobserbahan", "mapTimelineForecast": "Pagtaya", "notifySettingsMenu": "Mga setting ng notipikasyon", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 33e9e5d43..d4422fabe 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -227,6 +227,8 @@ "lightningLegendCg": "Awan–tanah · {minutes} mnt", "lightningLegendCc": "Awan–awan · {minutes} mnt", "mapTimelineNow": "Sekarang", + "mapTimelinePast": "Lampau", + "mapTimelineFuture": "Mendatang", "mapTimelineObserved": "Diamati", "mapTimelineForecast": "Prakiraan", "notifySettingsMenu": "Pengaturan notifikasi", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 3f9716690..ed99eb58c 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -227,6 +227,8 @@ "lightningLegendCg": "対地 · {minutes} 分以内", "lightningLegendCc": "雲間 · {minutes} 分以内", "mapTimelineNow": "現在", + "mapTimelinePast": "過去", + "mapTimelineFuture": "未来", "mapTimelineObserved": "観測", "mapTimelineForecast": "予報", "notifySettingsMenu": "通知設定", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index dec8a7e61..41c0dc21f 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -227,6 +227,8 @@ "lightningLegendCg": "대지로 · {minutes}분 이내", "lightningLegendCc": "구름 사이 · {minutes}분 이내", "mapTimelineNow": "현재", + "mapTimelinePast": "과거", + "mapTimelineFuture": "미래", "mapTimelineObserved": "관측", "mapTimelineForecast": "예보", "notifySettingsMenu": "알림 설정", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 90feaa53b..bd12078eb 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -227,6 +227,8 @@ "lightningLegendCg": "เมฆสู่พื้น · {minutes} นาที", "lightningLegendCc": "เมฆสู่เมฆ · {minutes} นาที", "mapTimelineNow": "ตอนนี้", + "mapTimelinePast": "อดีต", + "mapTimelineFuture": "อนาคต", "mapTimelineObserved": "เวลาตรวจวัด", "mapTimelineForecast": "พยากรณ์", "notifySettingsMenu": "การตั้งค่าการแจ้งเตือน", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index fa18ed176..ca18ac7e4 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -227,6 +227,8 @@ "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", "notifySettingsMenu": "Cài đặt thông báo", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f34970c75..2bcdee962 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -227,6 +227,8 @@ "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", "mapTimelineNow": "現在", + "mapTimelinePast": "歷史", + "mapTimelineFuture": "未來", "mapTimelineObserved": "觀測", "mapTimelineForecast": "預報", "notifySettingsMenu": "通知設定", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 2ea929650..6d0024ef2 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -227,6 +227,8 @@ "lightningLegendCg": "对地 · {minutes} 分钟内", "lightningLegendCc": "云间 · {minutes} 分钟内", "mapTimelineNow": "现在", + "mapTimelinePast": "历史", + "mapTimelineFuture": "未来", "mapTimelineObserved": "观测", "mapTimelineForecast": "预报", "notifySettingsMenu": "通知设置", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 6a51d63a4..48b017954 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -227,6 +227,8 @@ "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", "mapTimelineNow": "現在", + "mapTimelinePast": "歷史", + "mapTimelineFuture": "未來", "mapTimelineObserved": "觀測", "mapTimelineForecast": "預報", "notifySettingsMenu": "通知設定", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 6ba1c4531..7711cb229 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -227,6 +227,8 @@ "lightningLegendCg": "對地 · {minutes} 分內", "lightningLegendCc": "雲間 · {minutes} 分內", "mapTimelineNow": "現在", + "mapTimelinePast": "歷史", + "mapTimelineFuture": "未來", "mapTimelineObserved": "觀測", "mapTimelineForecast": "預報", "notifySettingsMenu": "通知設定", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 28d2b4306..4615087db 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1473,6 +1473,18 @@ 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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 34f6adcc2..c6f71640c 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -756,6 +756,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapTimelineNow => 'Now'; + @override + String get mapTimelinePast => 'Past'; + + @override + String get mapTimelineFuture => 'Future'; + @override String get mapTimelineObserved => 'Observed'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 82bfabf67..b3e6053c8 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -761,6 +761,12 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapTimelineNow => 'Ngayon'; + @override + String get mapTimelinePast => 'Nakaraan'; + + @override + String get mapTimelineFuture => 'Hinaharap'; + @override String get mapTimelineObserved => 'Naobserbahan'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 51ca7bd6f..b0d1ad5b2 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -757,6 +757,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapTimelineNow => 'Sekarang'; + @override + String get mapTimelinePast => 'Lampau'; + + @override + String get mapTimelineFuture => 'Mendatang'; + @override String get mapTimelineObserved => 'Diamati'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index fff6b3c8e..e63e112de 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -741,6 +741,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapTimelineNow => '現在'; + @override + String get mapTimelinePast => '過去'; + + @override + String get mapTimelineFuture => '未来'; + @override String get mapTimelineObserved => '観測'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 0865f6793..4b5aecf2c 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -742,6 +742,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapTimelineNow => '현재'; + @override + String get mapTimelinePast => '과거'; + + @override + String get mapTimelineFuture => '미래'; + @override String get mapTimelineObserved => '관측'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 402f48e3f..da39b9273 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -754,6 +754,12 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapTimelineNow => 'ตอนนี้'; + @override + String get mapTimelinePast => 'อดีต'; + + @override + String get mapTimelineFuture => 'อนาคต'; + @override String get mapTimelineObserved => 'เวลาตรวจวัด'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 2d0a98470..ce4f8afc6 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -754,6 +754,12 @@ 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'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index ac21081b7..318857272 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -740,6 +740,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapTimelineNow => '現在'; + @override + String get mapTimelinePast => '歷史'; + + @override + String get mapTimelineFuture => '未來'; + @override String get mapTimelineObserved => '觀測'; @@ -2464,6 +2470,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapTimelineNow => '现在'; + @override + String get mapTimelinePast => '历史'; + + @override + String get mapTimelineFuture => '未来'; + @override String get mapTimelineObserved => '观测'; @@ -4188,6 +4200,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapTimelineNow => '現在'; + @override + String get mapTimelinePast => '歷史'; + + @override + String get mapTimelineFuture => '未來'; + @override String get mapTimelineObserved => '觀測'; @@ -5912,6 +5930,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapTimelineNow => '現在'; + @override + String get mapTimelinePast => '歷史'; + + @override + String get mapTimelineFuture => '未來'; + @override String get mapTimelineObserved => '觀測'; diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index 5c0a46b96..85c7ffcc3 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -171,15 +171,16 @@ class _MapTimelineState extends State { final colors = theme.colorScheme; // Asked of the clock rather than of the list, so a forecast's "現在" lands // on the present rather than on its furthest step. - final isNow = _liveIndex == nowFrameIndex(widget.frames); + 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( @@ -211,19 +212,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], style: theme.textTheme.headlineSmall?.copyWith( - color: colors.primary, + color: _eraColor(era, theme.brightness), fontFeatures: const [FontFeature.tabularFigures()], ), ), @@ -256,6 +259,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, @@ -283,11 +290,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, @@ -295,6 +332,7 @@ class _Tick extends StatelessWidget { final double width; final String? label; + final Color labelColor; final bool emphasised; final ColorScheme colors; final TextStyle? textStyle; @@ -325,9 +363,7 @@ class _Tick extends StatelessWidget { label!, maxLines: 1, softWrap: false, - style: textStyle?.copyWith( - color: colors.onSurfaceVariant, - ), + style: textStyle?.copyWith(color: labelColor), ), ), ), diff --git a/test/shared/map/map_timeline_test.dart b/test/shared/map/map_timeline_test.dart index c51524dbf..76f8f6455 100644 --- a/test/shared/map/map_timeline_test.dart +++ b/test/shared/map/map_timeline_test.dart @@ -74,6 +74,25 @@ void main() { 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 { From a26bb822d36044a75723c84cc3656d21f191f0cb Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 20:58:21 +0800 Subject: [PATCH 79/92] refactor: extract Debug network chart into a tested public widget The per-hour usage chart leaves DeveloperPage as a public NetworkUsageChart widget so the stacked downloaded/saved bars and the empty placeholder are covered by widget tests instead of living inline in the page. --- .../presentation/pages/developer_page.dart | 218 +---------------- .../widgets/network_usage_chart.dart | 222 ++++++++++++++++++ .../network/network_usage_store_test.dart | 7 +- .../widgets/network_usage_chart_test.dart | 49 ++++ 4 files changed, 276 insertions(+), 220 deletions(-) create mode 100644 lib/features/settings/presentation/widgets/network_usage_chart.dart create mode 100644 test/features/settings/presentation/widgets/network_usage_chart_test.dart diff --git a/lib/features/settings/presentation/pages/developer_page.dart b/lib/features/settings/presentation/pages/developer_page.dart index 05be00366..a53db5b00 100644 --- a/lib/features/settings/presentation/pages/developer_page.dart +++ b/lib/features/settings/presentation/pages/developer_page.dart @@ -9,7 +9,6 @@ library; import 'dart:io'; -import 'dart:math' as math; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/build_info.g.dart'; @@ -18,13 +17,12 @@ 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/realtime/app_time.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'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -374,7 +372,7 @@ class _DeveloperPageState extends State { ), if (sections[i].title == 'Network usage' && _usageHistory != null) - _NetworkUsageChart(history: _usageHistory!), + NetworkUsageChart(history: _usageHistory!), ], const SectionHeader('Maintenance'), ListTile( @@ -439,215 +437,3 @@ class _DiagRow extends StatelessWidget { ); } } - -/// Hourly downloaded-vs-saved trend for the Network usage section. -/// -/// One stacked bar per hour over the trailing 24 h (Taipei wall-clock on the -/// x-axis): the blue segment is what the app actually downloaded, the green -/// segment what the cache saved on top — so the shape of the day and the cache -/// payoff read together. Deliberately matches the section's English-only -/// convention. -class _NetworkUsageChart extends StatelessWidget { - const _NetworkUsageChart({required this.history}); - - final List history; - - static const Color _savedColor = Color(0xFF66BB6A); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - final maxBytes = history.fold(0, (m, h) => math.max(m, h.down + h.saved)); - 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 hour, 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 < history.length; i++) - BarChartGroupData( - x: i, - barRods: [ - BarChartRodData( - toY: history[i].down / divisor + history[i].saved / divisor, - width: 6, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(2), - ), - rodStackItems: [ - BarChartRodStackItem( - 0, - history[i].down / divisor, - colors.primary, - ), - BarChartRodStackItem( - history[i].down / divisor, - history[i].down / divisor + history[i].saved / divisor, - _savedColor, - ), - ], - ), - ], - ), - ]; - - 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: [ - _legendDot(colors.primary), - const SizedBox(width: AppSpacing.sm), - const Text('Downloaded'), - const SizedBox(width: AppSpacing.md), - _legendDot(_savedColor), - const SizedBox(width: AppSpacing.sm), - const Text('Saved'), - ], - ), - const SizedBox(height: AppSpacing.sm), - SizedBox( - height: 150, - child: BarChart( - BarChartData( - maxY: maxY, - alignment: BarChartAlignment.spaceBetween, - groupsSpace: 2, - 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, - interval: 4, - getTitlesWidget: (value, meta) { - final index = value.toInt(); - if (index < 0 || index >= history.length) { - return const SizedBox.shrink(); - } - return SideTitleWidget( - meta: meta, - child: Text( - _taipeiHour(history[index].hour).toString(), - 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, - getTooltipItem: (group, groupIndex, rod, rodIndex) { - final hour = history[group.x]; - final down = hour.down / divisor; - final saved = hour.saved / divisor; - return BarTooltipItem( - '${_taipeiHour(hour.hour).toString().padLeft(2, '0')}:00\n' - '↓ ${_formatNumber(down)} $unit\n' - 'Saved ${_formatNumber(saved)} $unit', - theme.textTheme.labelSmall!.copyWith( - color: colors.onSurface, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ); - }, - ), - ), - ), - ), - ), - ], - ), - ); - } - - 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 hour of a UTC epoch hour — what the "now" column reads. - static int _taipeiHour(int utcHour) => AppTime.taipei( - DateTime.fromMillisecondsSinceEpoch(utcHour * 3600000, isUtc: true), - ).hour; -} 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..9c87540dd --- /dev/null +++ b/lib/features/settings/presentation/widgets/network_usage_chart.dart @@ -0,0 +1,222 @@ +/// 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'; + +/// One stacked bar per hour over the trailing [history] (typically 24 h, Taipei +/// wall-clock on the x-axis): the blue segment is what the app actually +/// downloaded, the green segment what the cache saved on top — so the shape of +/// the day and the cache payoff read together. +/// +/// Deliberately English-only, matching the Debug page's convention. +class NetworkUsageChart extends StatelessWidget { + const NetworkUsageChart({super.key, required this.history}); + + final List history; + + static const Color _savedColor = Color(0xFF66BB6A); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final maxBytes = history.fold( + 0, + (m, h) => math.max(m, h.down + h.saved), + ); + 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 hour, 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 < history.length; i++) + BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: history[i].down / divisor + history[i].saved / divisor, + width: 6, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(2), + ), + rodStackItems: [ + BarChartRodStackItem( + 0, + history[i].down / divisor, + colors.primary, + ), + BarChartRodStackItem( + history[i].down / divisor, + history[i].down / divisor + history[i].saved / divisor, + _savedColor, + ), + ], + ), + ], + ), + ]; + + 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: [ + _legendDot(colors.primary), + const SizedBox(width: AppSpacing.sm), + const Text('Downloaded'), + const SizedBox(width: AppSpacing.md), + _legendDot(_savedColor), + const SizedBox(width: AppSpacing.sm), + const Text('Saved'), + ], + ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: 150, + child: BarChart( + BarChartData( + maxY: maxY, + alignment: BarChartAlignment.spaceBetween, + groupsSpace: 2, + 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, + interval: 4, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= history.length) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + meta: meta, + child: Text( + _taipeiHour(history[index].hour).toString(), + 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, + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final hour = history[group.x]; + final down = hour.down / divisor; + final saved = hour.saved / divisor; + return BarTooltipItem( + '${_taipeiHour(hour.hour).toString().padLeft(2, '0')}:00\n' + '↓ ${_formatNumber(down)} $unit\n' + 'Saved ${_formatNumber(saved)} $unit', + theme.textTheme.labelSmall!.copyWith( + color: colors.onSurface, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + }, + ), + ), + ), + ), + ), + ], + ), + ); + } + + 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 hour of a UTC epoch hour — what the "now" column reads. + static int _taipeiHour(int utcHour) => AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch(utcHour * 3600000, isUtc: true), + ).hour; +} diff --git a/test/core/network/network_usage_store_test.dart b/test/core/network/network_usage_store_test.dart index 7265a9d3f..b413daf16 100644 --- a/test/core/network/network_usage_store_test.dart +++ b/test/core/network/network_usage_store_test.dart @@ -182,9 +182,8 @@ void main() { 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), - ); + expect([ + for (final h in history) h.hour, + ], List.generate(4, (i) => history.first.hour + i)); }); } 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..ff0f413ca --- /dev/null +++ b/test/features/settings/presentation/widgets/network_usage_chart_test.dart @@ -0,0 +1,49 @@ +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, + ); + + 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)])), + ); + + expect(find.byType(BarChart), findsNothing); + expect(find.textContaining('No traffic recorded yet'), findsOneWidget); + }); + + testWidgets('renders a stacked bar per hour with a legend', (tester) async { + await tester.pumpWidget( + wrap( + NetworkUsageChart( + history: [ + for (var h = 0; h < 24; h++) usage(h, down: 2048, saved: 1024), + ], + ), + ), + ); + + expect(find.byType(BarChart), findsOneWidget); + expect(find.text('Downloaded'), findsOneWidget); + expect(find.text('Saved'), findsOneWidget); + + // The chart is the data, not a blank stub: 24 groups with one rod each. + final chart = tester.widget(find.byType(BarChart)); + final data = chart.data; + expect(data.barGroups, hasLength(24)); + expect(data.barGroups.first.barRods, hasLength(1)); + expect(data.barGroups.first.barRods.first.rodStackItems, hasLength(2)); + }); +} From c3a94ab6bba739530f0870e38b175921b8298c57 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 20:58:32 +0800 Subject: [PATCH 80/92] style: reformat drifted map timeline and wind files Brings files that had drifted from dart format back in line (pure layout changes; no behaviour touched). --- lib/shared/map/map_timeline.dart | 14 ++++---- test/features/map/wind_particle_sim_test.dart | 36 +++++++++---------- test/shared/map/map_timeline_test.dart | 5 ++- test/shared/map/now_frame_index_test.dart | 5 ++- 4 files changed, 31 insertions(+), 29 deletions(-) diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index 85c7ffcc3..ff6cb3abf 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -293,11 +293,12 @@ 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, -}; +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. /// @@ -312,8 +313,7 @@ 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.past => brightness == Brightness.dark ? _pastDark : _pastLight, TimelineEra.now => brightness == Brightness.dark ? _nowDark : _nowLight, TimelineEra.future => brightness == Brightness.dark ? _futureDark : _futureLight, diff --git a/test/features/map/wind_particle_sim_test.dart b/test/features/map/wind_particle_sim_test.dart index 63fcdd179..4e1cf7414 100644 --- a/test/features/map/wind_particle_sim_test.dart +++ b/test/features/map/wind_particle_sim_test.dart @@ -10,21 +10,21 @@ import 'package:flutter_test/flutter_test.dart'; /// (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]), -); + 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); @@ -211,10 +211,7 @@ void main() { // 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 sim = WindParticleSim(_eastField(), random: math.Random(5)); final cam = WindCamera( centerLat: 25, centerLng: 121, @@ -250,6 +247,5 @@ void main() { // clamp is not mistaken for a bug next time someone measures z10. expect(pixelsPerFrame(10), greaterThan(slow * 4)); }); - }); } diff --git a/test/shared/map/map_timeline_test.dart b/test/shared/map/map_timeline_test.dart index 76f8f6455..d450e8067 100644 --- a/test/shared/map/map_timeline_test.dart +++ b/test/shared/map/map_timeline_test.dart @@ -41,7 +41,10 @@ List _forecastFrames() { final now = DateTime.now(); return [ for (var h = -6; h <= 16; h++) - MapFrame(id: '$h', time: now.add(Duration(hours: h))), + MapFrame( + id: '$h', + time: now.add(Duration(hours: h)), + ), ]; } diff --git a/test/shared/map/now_frame_index_test.dart b/test/shared/map/now_frame_index_test.dart index 63e6b7c80..a44a77b31 100644 --- a/test/shared/map/now_frame_index_test.dart +++ b/test/shared/map/now_frame_index_test.dart @@ -7,7 +7,10 @@ final _now = DateTime(2026, 8, 11, 11, 0); /// 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))), + MapFrame( + id: '$h', + time: _now.add(Duration(hours: h)), + ), ]; void main() { From 2996ce641fbc9288a069ab556e5bf52c80fb6ad1 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 21:02:48 +0800 Subject: [PATCH 81/92] feat: red/green network chart with mixed or single-series toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Download plots red and Saved green for contrast, and a segmented switch chooses stacked-both, download-only, or saved-only — the legend and tooltip follow the selection. --- .../widgets/network_usage_chart.dart | 161 +++++++++++++----- .../widgets/network_usage_chart_test.dart | 88 ++++++++-- 2 files changed, 188 insertions(+), 61 deletions(-) diff --git a/lib/features/settings/presentation/widgets/network_usage_chart.dart b/lib/features/settings/presentation/widgets/network_usage_chart.dart index 9c87540dd..cf5612ec4 100644 --- a/lib/features/settings/presentation/widgets/network_usage_chart.dart +++ b/lib/features/settings/presentation/widgets/network_usage_chart.dart @@ -9,26 +9,56 @@ import 'package:dpip/core/realtime/app_time.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; -/// One stacked bar per hour over the trailing [history] (typically 24 h, Taipei -/// wall-clock on the x-axis): the blue segment is what the app actually -/// downloaded, the green segment what the cache saved on top — so the shape of -/// the day and the cache payoff read together. +/// 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, +} + +/// Hourly trend for the Network usage section. +/// +/// One bar per hour over the trailing [history] (typically 24 h, Taipei +/// wall-clock on the x-axis): **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 between stacked +/// and single-series views. /// /// Deliberately English-only, matching the Debug page's convention. -class NetworkUsageChart extends StatelessWidget { +class NetworkUsageChart extends StatefulWidget { const NetworkUsageChart({super.key, required this.history}); final List history; - static const Color _savedColor = Color(0xFF66BB6A); + @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; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - final maxBytes = history.fold( + 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 = widget.history.fold( 0, - (m, h) => math.max(m, h.down + h.saved), + (m, h) => + math.max(m, (showDownload ? h.down : 0) + (showSaved ? h.saved : 0)), ); if (maxBytes == 0) { return Padding( @@ -54,30 +84,10 @@ class NetworkUsageChart extends StatelessWidget { final yInterval = maxY / 4; final groups = [ - for (var i = 0; i < history.length; i++) + for (var i = 0; i < widget.history.length; i++) BarChartGroupData( x: i, - barRods: [ - BarChartRodData( - toY: history[i].down / divisor + history[i].saved / divisor, - width: 6, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(2), - ), - rodStackItems: [ - BarChartRodStackItem( - 0, - history[i].down / divisor, - colors.primary, - ), - BarChartRodStackItem( - history[i].down / divisor, - history[i].down / divisor + history[i].saved / divisor, - _savedColor, - ), - ], - ), - ], + barRods: [_rod(widget.history[i], divisor, showDownload, showSaved)], ), ]; @@ -94,14 +104,42 @@ class NetworkUsageChart extends StatelessWidget { Row( mainAxisSize: MainAxisSize.min, children: [ - _legendDot(colors.primary), - const SizedBox(width: AppSpacing.sm), - const Text('Downloaded'), - const SizedBox(width: AppSpacing.md), - _legendDot(_savedColor), - const SizedBox(width: AppSpacing.sm), - const Text('Saved'), + 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), + 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) => + setState(() => _mode = selection.first), + showSelectedIcon: false, + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), ), const SizedBox(height: AppSpacing.sm), SizedBox( @@ -152,13 +190,13 @@ class NetworkUsageChart extends StatelessWidget { interval: 4, getTitlesWidget: (value, meta) { final index = value.toInt(); - if (index < 0 || index >= history.length) { + if (index < 0 || index >= widget.history.length) { return const SizedBox.shrink(); } return SideTitleWidget( meta: meta, child: Text( - _taipeiHour(history[index].hour).toString(), + _taipeiHour(widget.history[index].hour).toString(), style: theme.textTheme.labelSmall?.copyWith( color: colors.onSurfaceVariant, fontFeatures: const [ @@ -181,13 +219,22 @@ class NetworkUsageChart extends StatelessWidget { ), tooltipMargin: AppSpacing.sm, getTooltipItem: (group, groupIndex, rod, rodIndex) { - final hour = history[group.x]; - final down = hour.down / divisor; - final saved = hour.saved / divisor; + final hour = widget.history[group.x]; + final lines = StringBuffer( + '${_taipeiHour(hour.hour).toString().padLeft(2, '0')}:00', + ); + if (showDownload) { + lines.writeln( + '↓ ${_formatNumber(hour.down / divisor)} $unit', + ); + } + if (showSaved) { + lines.writeln( + 'Saved ${_formatNumber(hour.saved / divisor)} $unit', + ); + } return BarTooltipItem( - '${_taipeiHour(hour.hour).toString().padLeft(2, '0')}:00\n' - '↓ ${_formatNumber(down)} $unit\n' - 'Saved ${_formatNumber(saved)} $unit', + lines.toString().trimRight(), theme.textTheme.labelSmall!.copyWith( color: colors.onSurface, fontFeatures: const [FontFeature.tabularFigures()], @@ -204,6 +251,30 @@ class NetworkUsageChart extends StatelessWidget { ); } + BarChartRodData _rod( + HourUsage hour, + double divisor, + bool showDownload, + bool showSaved, + ) { + final down = hour.down / divisor; + final saved = hour.saved / divisor; + return BarChartRodData( + toY: (showDownload ? down : 0) + (showSaved ? saved : 0), + width: 6, + 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, diff --git a/test/features/settings/presentation/widgets/network_usage_chart_test.dart b/test/features/settings/presentation/widgets/network_usage_chart_test.dart index ff0f413ca..39e72287d 100644 --- a/test/features/settings/presentation/widgets/network_usage_chart_test.dart +++ b/test/features/settings/presentation/widgets/network_usage_chart_test.dart @@ -15,16 +15,8 @@ void main() { misses: down > 0 ? 1 : 0, ); - 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)])), - ); - - expect(find.byType(BarChart), findsNothing); - expect(find.textContaining('No traffic recorded yet'), findsOneWidget); - }); - - testWidgets('renders a stacked bar per hour with a legend', (tester) async { + /// The chart built for [usage] bars with real traffic. + Future pumpChart(WidgetTester tester) async { await tester.pumpWidget( wrap( NetworkUsageChart( @@ -34,16 +26,80 @@ void main() { ), ), ); + return tester.widget(find.byType(BarChart)).data; + } + + /// Taps one [SegmentedButton] segment by its 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)])), + ); + + expect(find.byType(BarChart), findsNothing); + expect(find.textContaining('No traffic recorded yet'), findsOneWidget); + }); + + testWidgets('defaults to stacked red download over green saved', ( + tester, + ) async { + final data = await pumpChart(tester); - expect(find.byType(BarChart), findsOneWidget); expect(find.text('Downloaded'), findsOneWidget); - expect(find.text('Saved'), findsOneWidget); + // "Saved" appears twice: the legend dot row and the switch segment. + expect(find.text('Saved'), findsNWidgets(2)); // The chart is the data, not a blank stub: 24 groups with one rod each. - final chart = tester.widget(find.byType(BarChart)); - final data = chart.data; expect(data.barGroups, hasLength(24)); - expect(data.barGroups.first.barRods, hasLength(1)); - expect(data.barGroups.first.barRods.first.rodStackItems, hasLength(2)); + 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)); + }); + + 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); }); } From a83a1d111ad7a562640a5a8971d1d8247952ec7f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 21:21:30 +0800 Subject: [PATCH 82/92] feat: 24h/7d window toggle on the network usage chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store history() aggregates into arbitrary bucket widths, so the chart can switch between 24 hourly points and 28 six-hour points for the trailing week. The x-axis is labelled in Taipei wall-clock (11日13時). --- lib/core/network/network_usage_store.dart | 46 +++--- .../presentation/pages/developer_page.dart | 14 +- .../widgets/network_usage_chart.dart | 135 ++++++++++++------ .../network/network_usage_store_test.dart | 35 +++++ .../widgets/network_usage_chart_test.dart | 49 ++++--- 5 files changed, 203 insertions(+), 76 deletions(-) diff --git a/lib/core/network/network_usage_store.dart b/lib/core/network/network_usage_store.dart index 87c0d1d2b..17bd6b145 100644 --- a/lib/core/network/network_usage_store.dart +++ b/lib/core/network/network_usage_store.dart @@ -279,32 +279,42 @@ class NetworkUsageStore { } } - /// Per-hour usage over the trailing [hours] buckets, oldest first — the - /// trend series for the Debug page's chart. + /// Usage over the trailing window, oldest first — the trend series for the + /// Debug page's chart. /// - /// Every slot in the window is present: a silent hour is a zero bar, not a - /// gap, so the chart stays continuous. Best-effort: empty on error. - Future> history({int hours = 24}) async { + /// [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 rows = await _db.query( - _buckets, - where: 'hour >= ?', - whereArgs: [hour - hours + 1], - orderBy: 'hour', + 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 byHour = >{ - for (final row in rows) row['hour'] as int: row, + final byBucket = >{ + for (final row in rows) row['bucket'] as int: row, }; return [ - for (var h = hour - hours + 1; h <= hour; h++) + for (var b = bucket - count + 1; b <= bucket; b++) HourUsage( - hour: h, - down: _counter(byHour[h]?['down']), - saved: _counter(byHour[h]?['saved']), - hits: _counter(byHour[h]?['hits']), - misses: _counter(byHour[h]?['misses']), + hour: b * bucketHours, + down: _counter(byBucket[b]?['down']), + saved: _counter(byBucket[b]?['saved']), + hits: _counter(byBucket[b]?['hits']), + misses: _counter(byBucket[b]?['misses']), ), ]; } catch (_) { diff --git a/lib/features/settings/presentation/pages/developer_page.dart b/lib/features/settings/presentation/pages/developer_page.dart index a53db5b00..d0290fc42 100644 --- a/lib/features/settings/presentation/pages/developer_page.dart +++ b/lib/features/settings/presentation/pages/developer_page.dart @@ -64,6 +64,7 @@ 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 @@ -87,6 +88,10 @@ class _DeveloperPageState extends State { 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 @@ -185,6 +190,7 @@ class _DeveloperPageState extends State { setState(() { _sections = sections; _usageHistory = usageHistory; + _usageWeek = usageWeek; }); } } @@ -371,8 +377,12 @@ class _DeveloperPageState extends State { : null, ), if (sections[i].title == 'Network usage' && - _usageHistory != null) - NetworkUsageChart(history: _usageHistory!), + _usageHistory != null && + _usageWeek != null) + NetworkUsageChart( + history: _usageHistory!, + week: _usageWeek!, + ), ], const SectionHeader('Maintenance'), ListTile( diff --git a/lib/features/settings/presentation/widgets/network_usage_chart.dart b/lib/features/settings/presentation/widgets/network_usage_chart.dart index cf5612ec4..78f36545b 100644 --- a/lib/features/settings/presentation/widgets/network_usage_chart.dart +++ b/lib/features/settings/presentation/widgets/network_usage_chart.dart @@ -21,20 +21,38 @@ enum NetworkChartMode { saved, } -/// Hourly trend for the Network usage section. +/// 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 hour over the trailing [history] (typically 24 h, Taipei -/// wall-clock on the x-axis): **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 between stacked -/// and single-series views. +/// 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}); + 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(); } @@ -44,18 +62,24 @@ class _NetworkUsageChartState extends State { 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 = widget.history.fold( + final maxBytes = samples.fold( 0, (m, h) => math.max(m, (showDownload ? h.down : 0) + (showSaved ? h.saved : 0)), @@ -75,7 +99,7 @@ class _NetworkUsageChartState extends State { ); } - // Plot in the smaller unit that still fits the largest hour, so a light + // 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) @@ -84,10 +108,10 @@ class _NetworkUsageChartState extends State { final yInterval = maxY / 4; final groups = [ - for (var i = 0; i < widget.history.length; i++) + for (var i = 0; i < samples.length; i++) BarChartGroupData( x: i, - barRods: [_rod(widget.history[i], divisor, showDownload, showSaved)], + barRods: [_rod(samples[i], divisor, showDownload, showSaved)], ), ]; @@ -120,21 +144,19 @@ class _NetworkUsageChartState extends State { ], ), const SizedBox(height: AppSpacing.sm), - SegmentedButton( + _ModeToggle( + mode: _mode, + onChanged: (mode) => setState(() => _mode = mode), + ), + const SizedBox(height: AppSpacing.sm), + SegmentedButton( segments: const [ - ButtonSegment(value: NetworkChartMode.both, label: Text('Both')), - ButtonSegment( - value: NetworkChartMode.download, - label: Text('Download'), - ), - ButtonSegment( - value: NetworkChartMode.saved, - label: Text('Saved'), - ), + ButtonSegment(value: NetworkChartWindow.day, label: Text('24h')), + ButtonSegment(value: NetworkChartWindow.week, label: Text('7d')), ], - selected: {_mode}, + selected: {_window}, onSelectionChanged: (selection) => - setState(() => _mode = selection.first), + setState(() => _window = selection.first), showSelectedIcon: false, style: const ButtonStyle( visualDensity: VisualDensity.compact, @@ -148,7 +170,7 @@ class _NetworkUsageChartState extends State { BarChartData( maxY: maxY, alignment: BarChartAlignment.spaceBetween, - groupsSpace: 2, + groupsSpace: _window == NetworkChartWindow.day ? 2 : 6, barGroups: groups, gridData: FlGridData( drawVerticalLine: false, @@ -187,16 +209,18 @@ class _NetworkUsageChartState extends State { sideTitles: SideTitles( showTitles: true, reservedSize: 22, + // 24h: every 4th hour. 7d: every 4th six-hour bucket, + // i.e. one label a day. interval: 4, getTitlesWidget: (value, meta) { final index = value.toInt(); - if (index < 0 || index >= widget.history.length) { + if (index < 0 || index >= samples.length) { return const SizedBox.shrink(); } return SideTitleWidget( meta: meta, child: Text( - _taipeiHour(widget.history[index].hour).toString(), + _taipeiLabel(samples[index].hour), style: theme.textTheme.labelSmall?.copyWith( color: colors.onSurfaceVariant, fontFeatures: const [ @@ -219,18 +243,16 @@ class _NetworkUsageChartState extends State { ), tooltipMargin: AppSpacing.sm, getTooltipItem: (group, groupIndex, rod, rodIndex) { - final hour = widget.history[group.x]; - final lines = StringBuffer( - '${_taipeiHour(hour.hour).toString().padLeft(2, '0')}:00', - ); + final sample = samples[group.x]; + final lines = StringBuffer(_taipeiLabel(sample.hour)); if (showDownload) { lines.writeln( - '↓ ${_formatNumber(hour.down / divisor)} $unit', + '↓ ${_formatNumber(sample.down / divisor)} $unit', ); } if (showSaved) { lines.writeln( - 'Saved ${_formatNumber(hour.saved / divisor)} $unit', + 'Saved ${_formatNumber(sample.saved / divisor)} $unit', ); } return BarTooltipItem( @@ -252,16 +274,16 @@ class _NetworkUsageChartState extends State { } BarChartRodData _rod( - HourUsage hour, + HourUsage sample, double divisor, bool showDownload, bool showSaved, ) { - final down = hour.down / divisor; - final saved = hour.saved / divisor; + final down = sample.down / divisor; + final saved = sample.saved / divisor; return BarChartRodData( toY: (showDownload ? down : 0) + (showSaved ? saved : 0), - width: 6, + width: _window == NetworkChartWindow.day ? 6 : 12, borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), rodStackItems: switch ((showDownload, showSaved)) { (true, true) => [ @@ -286,8 +308,41 @@ class _NetworkUsageChartState extends State { return value.toStringAsFixed(1); } - /// Taipei wall-clock hour of a UTC epoch hour — what the "now" column reads. - static int _taipeiHour(int utcHour) => AppTime.taipei( - DateTime.fromMillisecondsSinceEpoch(utcHour * 3600000, isUtc: true), - ).hour; + /// 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/test/core/network/network_usage_store_test.dart b/test/core/network/network_usage_store_test.dart index b413daf16..c955b80ad 100644 --- a/test/core/network/network_usage_store_test.dart +++ b/test/core/network/network_usage_store_test.dart @@ -186,4 +186,39 @@ void main() { 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/features/settings/presentation/widgets/network_usage_chart_test.dart b/test/features/settings/presentation/widgets/network_usage_chart_test.dart index 39e72287d..e7114b98c 100644 --- a/test/features/settings/presentation/widgets/network_usage_chart_test.dart +++ b/test/features/settings/presentation/widgets/network_usage_chart_test.dart @@ -16,12 +16,20 @@ void main() { ); /// The chart built for [usage] bars with real traffic. - Future pumpChart(WidgetTester tester) async { + Future pumpChart( + WidgetTester tester, { + int hours = 24, + int weekPoints = 28, + }) async { await tester.pumpWidget( wrap( NetworkUsageChart( history: [ - for (var h = 0; h < 24; h++) usage(h, down: 2048, saved: 1024), + 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), ], ), ), @@ -29,11 +37,11 @@ void main() { return tester.widget(find.byType(BarChart)).data; } - /// Taps one [SegmentedButton] segment by its label. - Future tapSegment(WidgetTester tester, String label) async { + /// 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), + of: find.byType(SegmentedButton), matching: find.text(label), ), ); @@ -42,35 +50,44 @@ void main() { 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)])), + 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 stacked red download over green saved', ( - tester, - ) async { + testWidgets('defaults to the 24h window with hourly points', (tester) async { final data = await pumpChart(tester); - expect(find.text('Downloaded'), findsOneWidget); - // "Saved" appears twice: the legend dot row and the switch segment. - expect(find.text('Saved'), findsNWidgets(2)); - - // The chart is the data, not a blank stub: 24 groups with one rod each. 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 16 → 1970-01-02 00:00 +08 → 2日0時. + expect(find.text('2日0時'), findsWidgets); + }); + + 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, 12); }); testWidgets('switching to Download plots only the red series', ( tester, ) async { await pumpChart(tester); - await tapSegment(tester, 'Download'); + await tapSegment(tester, 'Download'); final stacks = tester .widget(find.byType(BarChart)) @@ -88,7 +105,7 @@ void main() { testWidgets('switching to Saved plots only the green series', (tester) async { await pumpChart(tester); - await tapSegment(tester, 'Saved'); + await tapSegment(tester, 'Saved'); final stacks = tester .widget(find.byType(BarChart)) From eed7c4d22c2de7dbe84d2bfd31d27995287da6b0 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 21:33:57 +0800 Subject: [PATCH 83/92] fix: space out x-axis labels on the network usage chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fl_chart's BarChart ignores SideTitles.interval (discrete x slots), so every hour was labelled and the full-width 11日13時 text collided. The step is now applied inside getTitlesWidget — every 6 h on the 24h window, every 7 buckets on 7d — and the 7d bars are slimmed so 28 fit the width. --- .../presentation/widgets/network_usage_chart.dart | 15 ++++++++++----- .../widgets/network_usage_chart_test.dart | 10 +++++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/features/settings/presentation/widgets/network_usage_chart.dart b/lib/features/settings/presentation/widgets/network_usage_chart.dart index 78f36545b..b3bb5d252 100644 --- a/lib/features/settings/presentation/widgets/network_usage_chart.dart +++ b/lib/features/settings/presentation/widgets/network_usage_chart.dart @@ -209,12 +209,17 @@ class _NetworkUsageChartState extends State { sideTitles: SideTitles( showTitles: true, reservedSize: 22, - // 24h: every 4th hour. 7d: every 4th six-hour bucket, - // i.e. one label a day. - interval: 4, + // 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(); - if (index < 0 || index >= samples.length) { + final step = _window == NetworkChartWindow.day ? 6 : 7; + if (index < 0 || + index >= samples.length || + index % step != 0) { return const SizedBox.shrink(); } return SideTitleWidget( @@ -283,7 +288,7 @@ class _NetworkUsageChartState extends State { final saved = sample.saved / divisor; return BarChartRodData( toY: (showDownload ? down : 0) + (showSaved ? saved : 0), - width: _window == NetworkChartWindow.day ? 6 : 12, + width: _window == NetworkChartWindow.day ? 6 : 8, borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), rodStackItems: switch ((showDownload, showSaved)) { (true, true) => [ diff --git a/test/features/settings/presentation/widgets/network_usage_chart_test.dart b/test/features/settings/presentation/widgets/network_usage_chart_test.dart index e7114b98c..78ca10a4e 100644 --- a/test/features/settings/presentation/widgets/network_usage_chart_test.dart +++ b/test/features/settings/presentation/widgets/network_usage_chart_test.dart @@ -70,8 +70,10 @@ void main() { expect(stacks, hasLength(2)); expect(stacks[0].color, const Color(0xFFE53935)); expect(stacks[1].color, const Color(0xFF66BB6A)); - // Taipei labels: epoch hour 16 → 1970-01-02 00:00 +08 → 2日0時. - expect(find.text('2日0時'), findsWidgets); + // 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)); }); testWidgets('switching to 7d shows 28 six-hour buckets', (tester) async { @@ -80,7 +82,9 @@ void main() { final data = tester.widget(find.byType(BarChart)).data; expect(data.barGroups, hasLength(28)); - expect(data.barGroups.first.barRods.first.width, 12); + 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', ( From 358382cf5777ef7584efadea8008f8821d8a3441 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 21:38:01 +0800 Subject: [PATCH 84/92] fix: keep the network chart tooltip inside the screen The tooltip for edge bars rendered past the phone bounds. Clamp the bubble with fitInsideHorizontally/Vertically and cap its width so it always stays within the chart. --- .../settings/presentation/widgets/network_usage_chart.dart | 5 +++++ .../presentation/widgets/network_usage_chart_test.dart | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/lib/features/settings/presentation/widgets/network_usage_chart.dart b/lib/features/settings/presentation/widgets/network_usage_chart.dart index b3bb5d252..6882e8d31 100644 --- a/lib/features/settings/presentation/widgets/network_usage_chart.dart +++ b/lib/features/settings/presentation/widgets/network_usage_chart.dart @@ -247,6 +247,11 @@ class _NetworkUsageChartState extends State { 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)); diff --git a/test/features/settings/presentation/widgets/network_usage_chart_test.dart b/test/features/settings/presentation/widgets/network_usage_chart_test.dart index 78ca10a4e..51192c48a 100644 --- a/test/features/settings/presentation/widgets/network_usage_chart_test.dart +++ b/test/features/settings/presentation/widgets/network_usage_chart_test.dart @@ -74,6 +74,12 @@ void main() { 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 { From fa2474ce8907ee2169ec3855a504416bfeae8f19 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 21:52:40 +0800 Subject: [PATCH 85/92] =?UTF-8?q?fix:=20a=20future=20forecast=20step=20is?= =?UTF-8?q?=20never=20labelled=20=E7=8F=BE=E5=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nowFrameIndex used absolute distance, so at 21:43 a 3-hourly ECMWF set labelled the 23:00 prediction as the present (77 min away) over the already-due 20:00 (103 min). The present is now the newest frame at or before now — observed data is unchanged, and a forecast never presents a prediction as current before it has happened. --- lib/shared/map/map_layer.dart | 24 +++++++++++------------ lib/shared/map/map_scaffold.dart | 2 +- lib/shared/map/map_timeline.dart | 5 +++-- test/shared/map/now_frame_index_test.dart | 21 ++++++++++++++++---- 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart index a1c937f5b..48fcdda40 100644 --- a/lib/shared/map/map_layer.dart +++ b/lib/shared/map/map_layer.dart @@ -26,8 +26,8 @@ class MapFrame { int get hashCode => Object.hash(id, time); } -/// Index of the frame nearest the present moment in a chronological [frames] -/// list, or 0 when it is empty. +/// 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 @@ -38,20 +38,20 @@ class MapFrame { /// 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. For observations the answer is unchanged. +/// 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(); - var best = 0; - var bestGap = Duration.zero; - for (var i = 0; i < frames.length; i++) { - final gap = frames[i].time.difference(at).abs(); - if (i == 0 || gap < bestGap) { - best = i; - bestGap = gap; - } + // 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; } - return best; + // 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 / diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index cc6769b72..671488c33 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -465,7 +465,7 @@ class _MapScaffoldState extends State { if (frames.isNotEmpty) { // Register the set, then reveal the present (a layer adds tiles // lazily). For observed data that is the newest frame; a forecast - // opens in the middle of its own range, with the future to the right. + // opens on the newest already-due step, future steps to the right. final controller = _controller!; final layer = _active; _queue(() => layer.prepare(controller, frames)); diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index ff6cb3abf..e469e53cb 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -12,8 +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 frame nearest the present is labelled "now" — the newest one for -/// observed data, and somewhere in the middle for a forecast. +/// 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. diff --git a/test/shared/map/now_frame_index_test.dart b/test/shared/map/now_frame_index_test.dart index a44a77b31..bfdfcd1c7 100644 --- a/test/shared/map/now_frame_index_test.dart +++ b/test/shared/map/now_frame_index_test.dart @@ -20,7 +20,7 @@ void main() { expect(nowFrameIndex(frames, now: _now), frames.length - 1); }); - test('a forecast puts the present in the middle of its own range', () { + 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); @@ -33,14 +33,27 @@ void main() { ); }); - test('picks the nearest step when none lands exactly on now', () { - // Three-hourly ECMWF steps straddling a present that falls between two. + 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), 1); + expect(nowFrameIndex(frames, now: _now), 0); }); test('an entirely future set answers with its first step', () { From d7b535b201e136fa3244f2e58bed0f062c801b89 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 21:52:40 +0800 Subject: [PATCH 86/92] feat: surface a snackbar when the chosen map app cannot open Picking Google/Apple Maps now awaits the launch result and, when the app is missing, shows an error snackbar instead of silently doing nothing. The mapAppOpenFailed string is added across every ARB and the l10n generated code is refreshed to match. --- .../map/presentation/widgets/dpm_sheet.dart | 2 +- lib/l10n/app_en.arb | 7 +++++++ lib/l10n/app_fil.arb | 3 +++ lib/l10n/app_id.arb | 3 +++ lib/l10n/app_ja.arb | 3 +++ lib/l10n/app_ko.arb | 3 +++ lib/l10n/app_th.arb | 3 +++ lib/l10n/app_vi.arb | 3 +++ lib/l10n/app_zh.arb | 3 +++ lib/l10n/app_zh_Hans.arb | 3 +++ lib/l10n/app_zh_Hant_HK.arb | 3 +++ lib/l10n/app_zh_TW.arb | 3 +++ lib/l10n/gen/app_localizations.dart | 6 ++++++ lib/l10n/gen/app_localizations_en.dart | 5 +++++ lib/l10n/gen/app_localizations_fil.dart | 5 +++++ lib/l10n/gen/app_localizations_id.dart | 5 +++++ lib/l10n/gen/app_localizations_ja.dart | 5 +++++ lib/l10n/gen/app_localizations_ko.dart | 5 +++++ lib/l10n/gen/app_localizations_th.dart | 5 +++++ lib/l10n/gen/app_localizations_vi.dart | 5 +++++ lib/l10n/gen/app_localizations_zh.dart | 20 +++++++++++++++++++ 21 files changed, 99 insertions(+), 1 deletion(-) diff --git a/lib/features/map/presentation/widgets/dpm_sheet.dart b/lib/features/map/presentation/widgets/dpm_sheet.dart index bfeafa597..1de486278 100644 --- a/lib/features/map/presentation/widgets/dpm_sheet.dart +++ b/lib/features/map/presentation/widgets/dpm_sheet.dart @@ -567,7 +567,7 @@ class _Header extends StatelessWidget { if (mapsTarget != null) IconButton( tooltip: l10n.dpmOpenInMaps, - onPressed: () => showMapAppPicker(context, mapsTarget!), + onPressed: () => pickAndOpenMapApp(context, mapsTarget!), icon: const Icon(Icons.directions_outlined), ), IconButton( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 164d97c8d..9d52e7796 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1762,6 +1762,13 @@ "@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" + }, "mapOverlaySectionReference": "Reference layers", "@mapOverlaySectionReference": { diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 46fb26d63..17e5f0576 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -402,6 +402,9 @@ "mapAppCoordinatesCopied": "Na-kopya ang coordinates", "@mapAppCoordinatesCopied": { }, + "mapAppOpenFailed": "Hindi mabuksan ang {app}", + "@mapAppOpenFailed": { + }, "mapOverlaySectionReference": "Layer ng sanggunian", "mapLayerCategoryEarthquake": "Lindol", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d4422fabe..b8c780138 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -402,6 +402,9 @@ "mapAppCoordinatesCopied": "Koordinat disalin", "@mapAppCoordinatesCopied": { }, + "mapAppOpenFailed": "Tidak dapat membuka {app}", + "@mapAppOpenFailed": { + }, "mapOverlaySectionReference": "Lapisan referensi", "mapLayerCategoryEarthquake": "Gempa", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ed99eb58c..a7f3259a8 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -402,6 +402,9 @@ "mapAppCoordinatesCopied": "座標をコピーしました", "@mapAppCoordinatesCopied": { }, + "mapAppOpenFailed": "{app} を開けませんでした", + "@mapAppOpenFailed": { + }, "mapOverlaySectionReference": "参照レイヤー", "mapLayerCategoryEarthquake": "地震", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 41c0dc21f..a89a0e161 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -402,6 +402,9 @@ "mapAppCoordinatesCopied": "좌표가 복사되었습니다", "@mapAppCoordinatesCopied": { }, + "mapAppOpenFailed": "{app}을(를) 열 수 없습니다", + "@mapAppOpenFailed": { + }, "mapOverlaySectionReference": "참조 레이어", "mapLayerCategoryEarthquake": "지진", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index bd12078eb..63ba3bb61 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -402,6 +402,9 @@ "mapAppCoordinatesCopied": "คัดลอกพิกัดแล้ว", "@mapAppCoordinatesCopied": { }, + "mapAppOpenFailed": "ไม่สามารถเปิด {app} ได้", + "@mapAppOpenFailed": { + }, "mapOverlaySectionReference": "เลเยอร์อ้างอิง", "mapLayerCategoryEarthquake": "แผ่นดินไหว", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index ca18ac7e4..6277dfb60 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -402,6 +402,9 @@ "mapAppCoordinatesCopied": "Đã sao chép tọa độ", "@mapAppCoordinatesCopied": { }, + "mapAppOpenFailed": "Không thể mở {app}", + "@mapAppOpenFailed": { + }, "mapOverlaySectionReference": "Lớp tham chiếu", "mapLayerCategoryEarthquake": "Động đất", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2bcdee962..d733e10bc 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -422,6 +422,9 @@ "mapAppCoordinatesCopied": "已複製座標", "@mapAppCoordinatesCopied": { }, + "mapAppOpenFailed": "無法開啟 {app}", + "@mapAppOpenFailed": { + }, "mapOverlaySectionReference": "參考圖層", "mapLayerCategoryEarthquake": "地震", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 6d0024ef2..6a8832c05 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -402,6 +402,9 @@ "mapAppCoordinatesCopied": "已复制坐标", "@mapAppCoordinatesCopied": { }, + "mapAppOpenFailed": "无法打开 {app}", + "@mapAppOpenFailed": { + }, "mapOverlaySectionReference": "参考图层", "mapLayerCategoryEarthquake": "地震", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 48b017954..0812e8c81 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -402,6 +402,9 @@ "mapAppCoordinatesCopied": "已複製座標", "@mapAppCoordinatesCopied": { }, + "mapAppOpenFailed": "無法開啟 {app}", + "@mapAppOpenFailed": { + }, "mapOverlaySectionReference": "參考圖層", "mapLayerCategoryEarthquake": "地震", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7711cb229..64212c05e 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -422,6 +422,9 @@ "mapAppCoordinatesCopied": "已複製座標", "@mapAppCoordinatesCopied": { }, + "mapAppOpenFailed": "無法開啟 {app}", + "@mapAppOpenFailed": { + }, "mapOverlaySectionReference": "參考圖層", "mapLayerCategoryEarthquake": "地震", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 4615087db..26bb5d606 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2553,6 +2553,12 @@ abstract class AppLocalizations { /// **'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); + /// Section title in map overlay settings menus: the reference overlays /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index c6f71640c..6bb8a1c0e 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1334,6 +1334,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapAppCoordinatesCopied => 'Coordinates copied'; + @override + String mapAppOpenFailed(String app) { + return 'Could not open $app'; + } + @override String get mapOverlaySectionReference => 'Reference layers'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index b3e6053c8..ca4c3084e 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1341,6 +1341,11 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapAppCoordinatesCopied => 'Na-kopya ang coordinates'; + @override + String mapAppOpenFailed(String app) { + return 'Hindi mabuksan ang $app'; + } + @override String get mapOverlaySectionReference => 'Layer ng sanggunian'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index b0d1ad5b2..072723e27 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1337,6 +1337,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapAppCoordinatesCopied => 'Koordinat disalin'; + @override + String mapAppOpenFailed(String app) { + return 'Tidak dapat membuka $app'; + } + @override String get mapOverlaySectionReference => 'Lapisan referensi'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index e63e112de..775172349 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1312,6 +1312,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapAppCoordinatesCopied => '座標をコピーしました'; + @override + String mapAppOpenFailed(String app) { + return '$app を開けませんでした'; + } + @override String get mapOverlaySectionReference => '参照レイヤー'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 4b5aecf2c..e210c7692 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1313,6 +1313,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapAppCoordinatesCopied => '좌표가 복사되었습니다'; + @override + String mapAppOpenFailed(String app) { + return '$app을(를) 열 수 없습니다'; + } + @override String get mapOverlaySectionReference => '참조 레이어'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index da39b9273..74d427b36 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1332,6 +1332,11 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapAppCoordinatesCopied => 'คัดลอกพิกัดแล้ว'; + @override + String mapAppOpenFailed(String app) { + return 'ไม่สามารถเปิด $app ได้'; + } + @override String get mapOverlaySectionReference => 'เลเยอร์อ้างอิง'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index ce4f8afc6..552ef9c28 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1334,6 +1334,11 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapAppCoordinatesCopied => 'Đã sao chép tọa độ'; + @override + String mapAppOpenFailed(String app) { + return 'Không thể mở $app'; + } + @override String get mapOverlaySectionReference => 'Lớp tham chiếu'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 318857272..537b8e0cb 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1306,6 +1306,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapAppCoordinatesCopied => '已複製座標'; + @override + String mapAppOpenFailed(String app) { + return '無法開啟 $app'; + } + @override String get mapOverlaySectionReference => '參考圖層'; @@ -3036,6 +3041,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapAppCoordinatesCopied => '已复制坐标'; + @override + String mapAppOpenFailed(String app) { + return '无法打开 $app'; + } + @override String get mapOverlaySectionReference => '参考图层'; @@ -4766,6 +4776,11 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapAppCoordinatesCopied => '已複製座標'; + @override + String mapAppOpenFailed(String app) { + return '無法開啟 $app'; + } + @override String get mapOverlaySectionReference => '參考圖層'; @@ -6496,6 +6511,11 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapAppCoordinatesCopied => '已複製座標'; + @override + String mapAppOpenFailed(String app) { + return '無法開啟 $app'; + } + @override String get mapOverlaySectionReference => '參考圖層'; From 55bda875507f30bf816b4f899d599e94cf511a3d Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 22:47:00 +0800 Subject: [PATCH 87/92] feat: map timeline shows a forecast's issue time and valid span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A next-hour forecast frame at 21:00 estimates the 21:00-22:00 window, so QPESUMS now renders the selected frame as a range ("21:50 – 22:50") instead of a bare instant read as a point measurement. Wind layers (ECMWF/GFS) additionally name the model-run issue time (資料時間) under the caption: the API serves only the latest run, so the oldest frame is the run start. --- .../presentation/layers/qpesums_layer.dart | 6 +++ .../layers/wind_forecast_layer.dart | 6 +++ lib/l10n/app_en.arb | 13 ++++++ lib/l10n/app_fil.arb | 4 ++ lib/l10n/app_id.arb | 4 ++ lib/l10n/app_ja.arb | 4 ++ lib/l10n/app_ko.arb | 4 ++ lib/l10n/app_th.arb | 4 ++ lib/l10n/app_vi.arb | 4 ++ lib/l10n/app_zh.arb | 3 ++ lib/l10n/app_zh_Hans.arb | 4 ++ lib/l10n/app_zh_Hant_HK.arb | 4 ++ lib/l10n/app_zh_TW.arb | 3 ++ lib/l10n/gen/app_localizations.dart | 12 +++++ lib/l10n/gen/app_localizations_en.dart | 8 ++++ lib/l10n/gen/app_localizations_fil.dart | 8 ++++ lib/l10n/gen/app_localizations_id.dart | 9 ++++ lib/l10n/gen/app_localizations_ja.dart | 8 ++++ lib/l10n/gen/app_localizations_ko.dart | 8 ++++ lib/l10n/gen/app_localizations_th.dart | 8 ++++ lib/l10n/gen/app_localizations_vi.dart | 8 ++++ lib/l10n/gen/app_localizations_zh.dart | 32 +++++++++++++ lib/shared/map/map_scaffold.dart | 7 ++- lib/shared/map/map_timeline.dart | 36 ++++++++++++++- lib/shared/map/raster_timeline_layer.dart | 24 ++++++++++ test/shared/map/map_timeline_test.dart | 45 +++++++++++++++++++ 26 files changed, 274 insertions(+), 2 deletions(-) diff --git a/lib/features/map/presentation/layers/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart index 588c6c59a..4ed332edb 100644 --- a/lib/features/map/presentation/layers/qpesums_layer.dart +++ b/lib/features/map/presentation/layers/qpesums_layer.dart @@ -59,6 +59,12 @@ class QpesumsMapLayer extends RasterTimelineLayer 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, { diff --git a/lib/features/map/presentation/layers/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart index a80a03d28..3cd6576df 100644 --- a/lib/features/map/presentation/layers/wind_forecast_layer.dart +++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart @@ -130,6 +130,12 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { 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 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9d52e7796..c0115c47a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1060,6 +1060,15 @@ "@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" @@ -1769,6 +1778,10 @@ }, "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": { diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 17e5f0576..8b1096ce8 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -231,6 +231,8 @@ "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.", @@ -406,6 +408,8 @@ "@mapAppOpenFailed": { }, + "mapAppCallFailed": "Hindi makatawag ang device na ito", + "mapOverlaySectionReference": "Layer ng sanggunian", "mapLayerCategoryEarthquake": "Lindol", "mapLayerCategoryTyphoon": "Bagyo", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index b8c780138..1226901b8 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -231,6 +231,8 @@ "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.", @@ -406,6 +408,8 @@ "@mapAppOpenFailed": { }, + "mapAppCallFailed": "Perangkat ini tidak dapat melakukan panggilan telepon", + "mapOverlaySectionReference": "Lapisan referensi", "mapLayerCategoryEarthquake": "Gempa", "mapLayerCategoryTyphoon": "Topan", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index a7f3259a8..6f1850bea 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -231,6 +231,8 @@ "mapTimelineFuture": "未来", "mapTimelineObserved": "観測", "mapTimelineForecast": "予報", + "mapTimelineDataTime": "データ時刻 {time}", + "mapTimelineDataTime": "データ時刻 {time}", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "プッシュ通知はまだ準備できていません。しばらくしてから再度お試しください。", @@ -406,6 +408,8 @@ "@mapAppOpenFailed": { }, + "mapAppCallFailed": "この端末では通話できません", + "mapOverlaySectionReference": "参照レイヤー", "mapLayerCategoryEarthquake": "地震", "mapLayerCategoryTyphoon": "台風", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index a89a0e161..c9da0203e 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -231,6 +231,8 @@ "mapTimelineFuture": "미래", "mapTimelineObserved": "관측", "mapTimelineForecast": "예보", + "mapTimelineDataTime": "데이터 시간 {time}", + "mapTimelineDataTime": "자료 시간 {time}", "notifySettingsMenu": "알림 설정", "notifyTitle": "알림", "notifyUnavailable": "푸시 알림이 아직 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.", @@ -406,6 +408,8 @@ "@mapAppOpenFailed": { }, + "mapAppCallFailed": "이 기기에서는 전화를 걸 수 없습니다", + "mapOverlaySectionReference": "참조 레이어", "mapLayerCategoryEarthquake": "지진", "mapLayerCategoryTyphoon": "태풍", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 63ba3bb61..60c6f68ee 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -231,6 +231,8 @@ "mapTimelineFuture": "อนาคต", "mapTimelineObserved": "เวลาตรวจวัด", "mapTimelineForecast": "พยากรณ์", + "mapTimelineDataTime": "เวลาข้อมูล {time}", + "mapTimelineDataTime": "เวลาข้อมูล {time}", "notifySettingsMenu": "การตั้งค่าการแจ้งเตือน", "notifyTitle": "การแจ้งเตือน", "notifyUnavailable": "การแจ้งเตือนแบบพุชยังไม่พร้อม — โปรดลองอีกครั้งในภายหลัง", @@ -406,6 +408,8 @@ "@mapAppOpenFailed": { }, + "mapAppCallFailed": "อุปกรณ์นี้ไม่สามารถโทรออกได้", + "mapOverlaySectionReference": "เลเยอร์อ้างอิง", "mapLayerCategoryEarthquake": "แผ่นดินไหว", "mapLayerCategoryTyphoon": "พายุไต้ฝุ่น", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 6277dfb60..1b84445e4 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -231,6 +231,8 @@ "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.", @@ -406,6 +408,8 @@ "@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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d733e10bc..95072fe11 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -231,6 +231,7 @@ "mapTimelineFuture": "未來", "mapTimelineObserved": "觀測", "mapTimelineForecast": "預報", + "mapTimelineDataTime": "資料時間 {time}", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "推播尚未就緒,請稍後再試。", @@ -426,6 +427,8 @@ "@mapAppOpenFailed": { }, + "mapAppCallFailed": "此裝置無法撥打電話", + "mapOverlaySectionReference": "參考圖層", "mapLayerCategoryEarthquake": "地震", "mapLayerCategoryTyphoon": "颱風", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 6a8832c05..cf7519714 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -231,6 +231,8 @@ "mapTimelineFuture": "未来", "mapTimelineObserved": "观测", "mapTimelineForecast": "预报", + "mapTimelineDataTime": "资料时间 {time}", + "mapTimelineDataTime": "资料时间 {time}", "notifySettingsMenu": "通知设置", "notifyTitle": "通知", "notifyUnavailable": "推送通知尚未就绪,请稍后再试。", @@ -406,6 +408,8 @@ "@mapAppOpenFailed": { }, + "mapAppCallFailed": "此设备无法拨打电话", + "mapOverlaySectionReference": "参考图层", "mapLayerCategoryEarthquake": "地震", "mapLayerCategoryTyphoon": "台风", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 0812e8c81..a9a584f8c 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -231,6 +231,8 @@ "mapTimelineFuture": "未來", "mapTimelineObserved": "觀測", "mapTimelineForecast": "預報", + "mapTimelineDataTime": "資料時間 {time}", + "mapTimelineDataTime": "資料時間 {time}", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "推送尚未就緒,請稍後再試。", @@ -406,6 +408,8 @@ "@mapAppOpenFailed": { }, + "mapAppCallFailed": "此裝置無法撥打電話", + "mapOverlaySectionReference": "參考圖層", "mapLayerCategoryEarthquake": "地震", "mapLayerCategoryTyphoon": "颱風", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 64212c05e..4bf69549c 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -231,6 +231,7 @@ "mapTimelineFuture": "未來", "mapTimelineObserved": "觀測", "mapTimelineForecast": "預報", + "mapTimelineDataTime": "資料時間 {time}", "notifySettingsMenu": "通知設定", "notifyTitle": "通知", "notifyUnavailable": "推播尚未就緒,請稍後再試。", @@ -426,6 +427,8 @@ "@mapAppOpenFailed": { }, + "mapAppCallFailed": "此裝置無法撥打電話", + "mapOverlaySectionReference": "參考圖層", "mapLayerCategoryEarthquake": "地震", "mapLayerCategoryTyphoon": "颱風", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 26bb5d606..5e2fefb8c 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1497,6 +1497,12 @@ abstract class AppLocalizations { /// **'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: @@ -2559,6 +2565,12 @@ abstract class AppLocalizations { /// **'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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 6bb8a1c0e..62cfcb37c 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -768,6 +768,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapTimelineForecast => 'Forecast'; + @override + String mapTimelineDataTime(String time) { + return 'Data $time'; + } + @override String get notifySettingsMenu => 'Notification settings'; @@ -1339,6 +1344,9 @@ class AppLocalizationsEn extends AppLocalizations { return 'Could not open $app'; } + @override + String get mapAppCallFailed => 'This device cannot make phone calls'; + @override String get mapOverlaySectionReference => 'Reference layers'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index ca4c3084e..98f680930 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -773,6 +773,11 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapTimelineForecast => 'Pagtaya'; + @override + String mapTimelineDataTime(String time) { + return 'Oras ng data $time'; + } + @override String get notifySettingsMenu => 'Mga setting ng notipikasyon'; @@ -1346,6 +1351,9 @@ class AppLocalizationsFil extends AppLocalizations { return 'Hindi mabuksan ang $app'; } + @override + String get mapAppCallFailed => 'Hindi makatawag ang device na ito'; + @override String get mapOverlaySectionReference => 'Layer ng sanggunian'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 072723e27..d3233875d 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -769,6 +769,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapTimelineForecast => 'Prakiraan'; + @override + String mapTimelineDataTime(String time) { + return 'Waktu data $time'; + } + @override String get notifySettingsMenu => 'Pengaturan notifikasi'; @@ -1342,6 +1347,10 @@ class AppLocalizationsId extends AppLocalizations { return 'Tidak dapat membuka $app'; } + @override + String get mapAppCallFailed => + 'Perangkat ini tidak dapat melakukan panggilan telepon'; + @override String get mapOverlaySectionReference => 'Lapisan referensi'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 775172349..2b05611a7 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -753,6 +753,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapTimelineForecast => '予報'; + @override + String mapTimelineDataTime(String time) { + return 'データ時刻 $time'; + } + @override String get notifySettingsMenu => '通知設定'; @@ -1317,6 +1322,9 @@ class AppLocalizationsJa extends AppLocalizations { return '$app を開けませんでした'; } + @override + String get mapAppCallFailed => 'この端末では通話できません'; + @override String get mapOverlaySectionReference => '参照レイヤー'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index e210c7692..028c06e27 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -754,6 +754,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapTimelineForecast => '예보'; + @override + String mapTimelineDataTime(String time) { + return '자료 시간 $time'; + } + @override String get notifySettingsMenu => '알림 설정'; @@ -1318,6 +1323,9 @@ class AppLocalizationsKo extends AppLocalizations { return '$app을(를) 열 수 없습니다'; } + @override + String get mapAppCallFailed => '이 기기에서는 전화를 걸 수 없습니다'; + @override String get mapOverlaySectionReference => '참조 레이어'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 74d427b36..fae47c8aa 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -766,6 +766,11 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapTimelineForecast => 'พยากรณ์'; + @override + String mapTimelineDataTime(String time) { + return 'เวลาข้อมูล $time'; + } + @override String get notifySettingsMenu => 'การตั้งค่าการแจ้งเตือน'; @@ -1337,6 +1342,9 @@ class AppLocalizationsTh extends AppLocalizations { return 'ไม่สามารถเปิด $app ได้'; } + @override + String get mapAppCallFailed => 'อุปกรณ์นี้ไม่สามารถโทรออกได้'; + @override String get mapOverlaySectionReference => 'เลเยอร์อ้างอิง'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 552ef9c28..c4d9c541b 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -766,6 +766,11 @@ class AppLocalizationsVi extends AppLocalizations { @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'; @@ -1339,6 +1344,9 @@ class AppLocalizationsVi extends AppLocalizations { 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'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 537b8e0cb..6a5e6def1 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -752,6 +752,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapTimelineForecast => '預報'; + @override + String mapTimelineDataTime(String time) { + return '資料時間 $time'; + } + @override String get notifySettingsMenu => '通知設定'; @@ -1311,6 +1316,9 @@ class AppLocalizationsZh extends AppLocalizations { return '無法開啟 $app'; } + @override + String get mapAppCallFailed => '此裝置無法撥打電話'; + @override String get mapOverlaySectionReference => '參考圖層'; @@ -2487,6 +2495,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapTimelineForecast => '预报'; + @override + String mapTimelineDataTime(String time) { + return '资料时间 $time'; + } + @override String get notifySettingsMenu => '通知设置'; @@ -3046,6 +3059,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { return '无法打开 $app'; } + @override + String get mapAppCallFailed => '此设备无法拨打电话'; + @override String get mapOverlaySectionReference => '参考图层'; @@ -4222,6 +4238,11 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapTimelineForecast => '預報'; + @override + String mapTimelineDataTime(String time) { + return '資料時間 $time'; + } + @override String get notifySettingsMenu => '通知設定'; @@ -4781,6 +4802,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { return '無法開啟 $app'; } + @override + String get mapAppCallFailed => '此裝置無法撥打電話'; + @override String get mapOverlaySectionReference => '參考圖層'; @@ -5957,6 +5981,11 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapTimelineForecast => '預報'; + @override + String mapTimelineDataTime(String time) { + return '資料時間 $time'; + } + @override String get notifySettingsMenu => '通知設定'; @@ -6516,6 +6545,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '無法開啟 $app'; } + @override + String get mapAppCallFailed => '此裝置無法撥打電話'; + @override String get mapOverlaySectionReference => '參考圖層'; diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 671488c33..a4c309cb7 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -817,9 +817,12 @@ class _MapScaffoldState extends State { // 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 caption = active is RasterTimelineLayer + 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( @@ -832,6 +835,8 @@ class _MapScaffoldState extends State { onSelected: _onFrameSelected, onScrubbing: _onScrubbing, caption: caption, + framePeriod: framePeriod, + dataTime: dataTime, ), ), ); diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index e469e53cb..4268152f8 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -26,6 +26,8 @@ class MapTimeline extends StatefulWidget { required this.onSelected, this.onScrubbing, this.caption, + this.framePeriod, + this.dataTime, }); /// Frames in chronological order (oldest first); must be non-empty. @@ -44,6 +46,17 @@ class MapTimeline extends StatefulWidget { /// "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(); } @@ -52,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). @@ -72,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(); @@ -198,6 +224,14 @@ class _MapTimelineState extends State { 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( @@ -225,7 +259,7 @@ class _MapTimelineState extends State { ), const SizedBox(width: AppSpacing.sm), Text( - _times[_liveIndex], + _bigLabel, style: theme.textTheme.headlineSmall?.copyWith( color: _eraColor(era, theme.brightness), fontFeatures: const [FontFeature.tabularFigures()], diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index 30f0062ca..a12173ea5 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -112,6 +112,30 @@ abstract class RasterTimelineLayer implements MapLayer { 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]. diff --git a/test/shared/map/map_timeline_test.dart b/test/shared/map/map_timeline_test.dart index d450e8067..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,6 +30,8 @@ Widget _wrap({ frames: frames, selectedIndex: selectedIndex, onSelected: onSelected, + framePeriod: framePeriod, + dataTime: dataTime, ), ), ), @@ -162,6 +166,47 @@ void main() { }, ); + 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 { From 36f36ad23ed9d78e0b2b4e606e53df169f523442 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 22:47:14 +0800 Subject: [PATCH 88/92] refactor: DPM sheet accents per sub-layer, drop category filters Restroom/shelter chips are gone from the peek; each body now takes its sub-layer's colour accent. Map-app flow is folded into one pickAndOpenMapApp helper with a shared failure snackbar, and AED pages can dial the emergency phone via a tel: helper (mapAppCallFailed when no handler). --- .../map/presentation/widgets/dpm_sheet.dart | 402 +++++++++--------- lib/shared/map/maps_launcher.dart | 40 ++ pubspec.lock | 10 +- pubspec.yaml | 1 + test/shared/map/maps_launcher_test.dart | 157 ++++++- 5 files changed, 407 insertions(+), 203 deletions(-) diff --git a/lib/features/map/presentation/widgets/dpm_sheet.dart b/lib/features/map/presentation/widgets/dpm_sheet.dart index 1de486278..c8522618f 100644 --- a/lib/features/map/presentation/widgets/dpm_sheet.dart +++ b/lib/features/map/presentation/widgets/dpm_sheet.dart @@ -5,11 +5,11 @@ library; 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'; @@ -106,11 +106,7 @@ class _DpmSheetState extends State { ), children: [ SheetGrip(extent: _extent), - if (!selected) ...[ - _Hint(), - _Filters(restroom: widget.restroom, shelter: widget.shelter), - ] else - _dispatchBody(active!), + if (!selected) _Hint() else _dispatchBody(context, active!), ], ), ); @@ -118,9 +114,12 @@ class _DpmSheetState extends State { ); } - 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, @@ -129,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, @@ -136,6 +136,7 @@ class _DpmSheetState extends State { ); } return _ShelterBody( + accent: accent, detail: widget.shelter.detail, previewName: widget.shelter.previewName, previewPlace: widget.shelter.previewPlace, @@ -166,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) { @@ -315,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; @@ -349,6 +207,7 @@ 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, @@ -359,38 +218,86 @@ class _AedBody extends StatelessWidget { 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; @@ -401,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; @@ -421,6 +330,7 @@ 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, @@ -435,18 +345,22 @@ class _RestroomBody extends StatelessWidget { 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, ), ], ); @@ -466,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; @@ -486,6 +402,7 @@ 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, @@ -496,24 +413,30 @@ class _ShelterBody extends StatelessWidget { 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, ), @@ -525,8 +448,13 @@ 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, @@ -535,6 +463,7 @@ class _Header extends StatelessWidget { required this.body, }); + final Color accent; final IconData icon; final String name; final String place; @@ -553,19 +482,46 @@ class _Header extends StatelessWidget { 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( + IconButton.filledTonal( tooltip: l10n.dpmOpenInMaps, onPressed: () => pickAndOpenMapApp(context, mapsTarget!), icon: const Icon(Icons.directions_outlined), @@ -577,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, ], @@ -594,6 +541,26 @@ 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) { @@ -605,37 +572,80 @@ class _Loading extends StatelessWidget { } 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, - ), - ), - const SizedBox(height: 2), - Text( - value, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w600, + child: onTap == null + ? row + : InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppSpacing.xs), + child: row, ), - ), - ], - ), ); } } diff --git a/lib/shared/map/maps_launcher.dart b/lib/shared/map/maps_launcher.dart index 6e7f49042..552d71cfe 100644 --- a/lib/shared/map/maps_launcher.dart +++ b/lib/shared/map/maps_launcher.dart @@ -71,6 +71,24 @@ Future openInMapApp(MapApp app, MapLaunchTarget target) => launchUrl( 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); @@ -140,4 +158,26 @@ Future showMapAppPicker(BuildContext context, MapLaunchTarget target) { ); } +/// 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/pubspec.lock b/pubspec.lock index d78758930..054ece321 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -648,11 +648,9 @@ packages: maplibre_gl: dependency: "direct main" description: - path: maplibre_gl - ref: "7c2e249149ff3023c54c86a1f4c0dca5fb43d6a1" - resolved-ref: "7c2e249149ff3023c54c86a1f4c0dca5fb43d6a1" - url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" - source: git + path: "/Users/yuyu1015/Documents/GitHub/flutter-maplibre-gl/maplibre_gl" + relative: false + source: path version: "0.26.2" maplibre_gl_platform_interface: dependency: transitive @@ -1180,7 +1178,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 f06e39b3e..b5aa9db13 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/shared/map/maps_launcher_test.dart b/test/shared/map/maps_launcher_test.dart index ee1a84077..c595ff301 100644 --- a/test/shared/map/maps_launcher_test.dart +++ b/test/shared/map/maps_launcher_test.dart @@ -1,6 +1,36 @@ +import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/maps_launcher.dart'; -import 'package:flutter/foundation.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: '台北市'); @@ -44,4 +74,129 @@ void main() { } }); }); + + 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; } From 4c473ef8b3c63509cf2e50ab949931dbc54304e2 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 22:47:25 +0800 Subject: [PATCH 89/92] style: polish typhoon warning and waypoint cards Warning blocks gain an icon and a border; tapped waypoints become filled circle markers with a forecast-lead line and a clearer two-line layout. --- .../presentation/widgets/typhoon_panel.dart | 369 ++++++++++++++---- 1 file changed, 294 insertions(+), 75 deletions(-) diff --git a/lib/features/map/presentation/widgets/typhoon_panel.dart b/lib/features/map/presentation/widgets/typhoon_panel.dart index d37eb53d4..c73432575 100644 --- a/lib/features/map/presentation/widgets/typhoon_panel.dart +++ b/lib/features/map/presentation/widgets/typhoon_panel.dart @@ -284,6 +284,20 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + width: atTop ? 56 : 46, + height: atTop ? 56 : 46, + decoration: BoxDecoration( + color: colors.primaryContainer.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: Icon( + Icons.cyclone, + color: colors.primary, + size: atTop ? 32 : 26, + ), + ), + const SizedBox(width: AppSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -391,6 +405,7 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { children: [ if (lat != null && lon != null) _TableRow( + icon: Icons.place_outlined, label: l10n.typhoonLabelPosition, value: Column( crossAxisAlignment: CrossAxisAlignment.end, @@ -408,11 +423,13 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_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)), @@ -421,6 +438,7 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { ), if (pressure != null) _TableRow( + icon: Icons.compress_outlined, label: l10n.typhoonLabelPressure, value: Text( l10n.typhoonValueHpa(pressure.toStringAsFixed(0)), @@ -429,6 +447,7 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { ), if (wind != null) _TableRow( + icon: Icons.air_outlined, label: l10n.typhoonLabelWind, value: Text( l10n.typhoonValueMs(wind.toStringAsFixed(0)), @@ -437,6 +456,7 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { ), if (gust != null) _TableRow( + icon: Icons.air, label: l10n.typhoonLabelGust, value: Text( l10n.typhoonValueMs(gust.toStringAsFixed(0)), @@ -481,17 +501,24 @@ 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 @@ -508,8 +535,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( @@ -759,18 +790,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( @@ -851,102 +897,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.wind != null) + _WaypointRow( + icon: Icons.air_outlined, + label: l10n.typhoonLabelWind, + value: l10n.typhoonValueMs( + forecast.wind!.toStringAsFixed(0), ), - if (forecast.gust != null) - Text( - '${l10n.typhoonLabelGust} ' - '${l10n.typhoonValueMs(forecast.gust!.toStringAsFixed(0))}', - style: theme.textTheme.bodySmall, - ), - 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, - ), + ], ], ), ), @@ -955,3 +1057,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, + ), + ), + ), + ], + ), + ); + } +} From 8c072aedf4469172548a6050c4aed910030319a0 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 23:05:05 +0800 Subject: [PATCH 90/92] fix: restore maplibre_gl git dependency in pubspec.lock A local pubspec_overrides.yaml path fork leaked into the lockfile and failed the check_pubspec_lock.sh CI gate. Regenerated the lock with the override aside so maplibre_gl resolves back to the pinned git ref. --- pubspec.lock | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 054ece321..a53ef4d30 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -648,9 +648,11 @@ packages: maplibre_gl: dependency: "direct main" description: - path: "/Users/yuyu1015/Documents/GitHub/flutter-maplibre-gl/maplibre_gl" - relative: false - source: path + path: maplibre_gl + ref: "7c2e249149ff3023c54c86a1f4c0dca5fb43d6a1" + resolved-ref: "7c2e249149ff3023c54c86a1f4c0dca5fb43d6a1" + url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" + source: git version: "0.26.2" maplibre_gl_platform_interface: dependency: transitive From 4b6e76a0ecb8028e065f04c2128a68ef085e81fb Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 23:25:55 +0800 Subject: [PATCH 91/92] fix: typhoon cards --- .../presentation/widgets/typhoon_panel.dart | 320 ++++++++++++------ lib/l10n/app_en.arb | 18 + lib/l10n/app_fil.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_th.arb | 2 + lib/l10n/app_vi.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_Hans.arb | 2 + lib/l10n/app_zh_Hant_HK.arb | 2 + lib/l10n/app_zh_TW.arb | 2 + lib/l10n/gen/app_localizations.dart | 12 + lib/l10n/gen/app_localizations_en.dart | 10 + lib/l10n/gen/app_localizations_fil.dart | 10 + lib/l10n/gen/app_localizations_id.dart | 10 + lib/l10n/gen/app_localizations_ja.dart | 10 + lib/l10n/gen/app_localizations_ko.dart | 10 + lib/l10n/gen/app_localizations_th.dart | 10 + lib/l10n/gen/app_localizations_vi.dart | 10 + lib/l10n/gen/app_localizations_zh.dart | 40 +++ 21 files changed, 370 insertions(+), 110 deletions(-) diff --git a/lib/features/map/presentation/widgets/typhoon_panel.dart b/lib/features/map/presentation/widgets/typhoon_panel.dart index c73432575..e1a0230e0 100644 --- a/lib/features/map/presentation/widgets/typhoon_panel.dart +++ b/lib/features/map/presentation/widgets/typhoon_panel.dart @@ -190,24 +190,37 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_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; @@ -221,16 +234,13 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_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 @@ -241,33 +251,46 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { ), ); - final options = <({String key, String title})>[ + ({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 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; @@ -285,86 +308,54 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: atTop ? 56 : 46, - height: atTop ? 56 : 46, + width: 44, + height: 44, decoration: BoxDecoration( color: colors.primaryContainer.withValues(alpha: 0.6), shape: BoxShape.circle, ), - child: Icon( - Icons.cyclone, - color: colors.primary, - size: atTop ? 32 : 26, - ), + 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 || dataTime != null) + const SizedBox(height: AppSpacing.xs), + ], if (intensity != null) _IntensityChip(intensity: intensity), if (dataTime != null) @@ -392,6 +383,21 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { ), ], ), + if (multi) ...[ + const SizedBox(height: AppSpacing.sm), + 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(height: AppSpacing.md), DecoratedBox( decoration: BoxDecoration( @@ -496,6 +502,100 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_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({ diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c0115c47a..fd92bf70d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2038,6 +2038,24 @@ } } }, + "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", "@typhoonIntensityMild": { "description": "CWA class: mild typhoon (past-track colour)" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 8b1096ce8..af8a747a5 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -489,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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 1226901b8..0184ae547 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -489,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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 6f1850bea..c0b869e12 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -489,6 +489,8 @@ "typhoonIntensityTd": "熱帯低気圧", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "熱帯低気圧 TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "これまでの進行方向", "typhoonLabelGaleAvg": "強風域の平均半径", "typhoonLabelGust": "最大瞬間風速", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c9da0203e..d093ffbdc 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -489,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", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 60c6f68ee..08c59c276 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -489,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", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 1b84445e4..e7a2538b9 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -489,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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 95072fe11..ab9566b17 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -485,6 +485,8 @@ "typhoonIntensityTd": "熱帶性低氣壓", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "熱帶性低氣壓 TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonIntensityMild": "輕度颱風", "typhoonIntensityModerate": "中度颱風", "typhoonIntensityIntense": "強烈颱風", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index cf7519714..3b5661bb4 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -489,6 +489,8 @@ "typhoonIntensityTd": "热带性低气压", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "热带性低气压 TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "过去移动方向", "typhoonLabelGaleAvg": "七级风平均暴风半径", "typhoonLabelGust": "瞬间最大阵风", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index a9a584f8c..f522ea7c8 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -489,6 +489,8 @@ "typhoonIntensityTd": "熱帶性低氣壓", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "熱帶性低氣壓 TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonLabelDirection": "過去移動方向", "typhoonLabelGaleAvg": "七級風平均暴風半徑", "typhoonLabelGust": "瞬間最大陣風", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 4bf69549c..7c2d7cc3e 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -485,6 +485,8 @@ "typhoonIntensityTd": "熱帶性低氣壓", "typhoonPickerNamed": "{name} TY {no}", "typhoonPickerTd": "熱帶性低氣壓 TD {no}", + "typhoonTyNo": "TY {no}", + "typhoonTdNo": "TD {no}", "typhoonIntensityMild": "輕度颱風", "typhoonIntensityModerate": "中度颱風", "typhoonIntensityIntense": "強烈颱風", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 5e2fefb8c..51afffd59 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2901,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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 62cfcb37c..93e9b7ff4 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1522,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'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 98f680930..5597c6670 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1530,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'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index d3233875d..f7f66edec 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1526,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'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 2b05611a7..1613102fd 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1500,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 => '弱い台風'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 028c06e27..92da0d8a1 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1501,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'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index fae47c8aa..ba21b5d13 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1520,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'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index c4d9c541b..b0273d988 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1523,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'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 6a5e6def1..d05c52ec0 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1494,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 => '輕度颱風'; @@ -3237,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 => '轻度台风'; @@ -4980,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 => '輕度颱風'; @@ -6723,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 => '輕度颱風'; From 37220f1646b8365f3799b663b1cb1fe7ab99b92f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 11 Aug 2026 23:31:13 +0800 Subject: [PATCH 92/92] fix: typhoon cards --- .../presentation/widgets/typhoon_panel.dart | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/lib/features/map/presentation/widgets/typhoon_panel.dart b/lib/features/map/presentation/widgets/typhoon_panel.dart index e1a0230e0..ff6815bdd 100644 --- a/lib/features/map/presentation/widgets/typhoon_panel.dart +++ b/lib/features/map/presentation/widgets/typhoon_panel.dart @@ -353,47 +353,49 @@ class _BulletinState extends State<_Bulletin> with SheetExtentFlag<_Bulletin> { children: [ if (serialBadge != null) ...[ _SerialBadge(label: serialBadge), - if (intensity != null || dataTime != null) + 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 (multi) ...[ + if (dataTime != null || multi) ...[ const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.xs, + Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - for (final o in options) - _CyclonePill( - label: o.label, - selected: o.key == selectedKey, - onTap: () => layer.selectCyclone(o.key), + 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, ), ], ),

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