diff --git a/lib/api/model/weather/typhoon.dart b/lib/api/model/weather/typhoon.dart new file mode 100644 index 000000000..f90c5483f --- /dev/null +++ b/lib/api/model/weather/typhoon.dart @@ -0,0 +1,30 @@ +import 'package:dpip/utils/geojson.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'typhoon.g.dart'; + +@JsonSerializable() +class Typhoon { + final int time; + final int type; + final Location loc; + + const Typhoon({required this.time, required this.type, required this.loc}); + + factory Typhoon.fromJson(Map json) => _$TyphoonFromJson(json); + + Map toJson() => _$TyphoonToJson(this); + +} + +@JsonSerializable() +class Location { + final double lat; + final double lng; + + const Location({required this.lat, required this.lng}); + + factory Location.fromJson(Map json) => _$LocationFromJson(json); + + Map toJson() => _$LocationToJson(this); +} diff --git a/lib/app/map/_lib/managers/typhoon.dart b/lib/app/map/_lib/managers/typhoon.dart new file mode 100644 index 000000000..383736709 --- /dev/null +++ b/lib/app/map/_lib/managers/typhoon.dart @@ -0,0 +1,272 @@ +import 'package:collection/collection.dart'; +import 'package:dpip/api/exptech.dart'; +import 'package:dpip/app/map/_lib/manager.dart'; +import 'package:dpip/app/map/_lib/utils.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/core/providers.dart'; +import 'package:dpip/models/data.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/latlng.dart'; +import 'package:dpip/utils/log.dart'; +import 'package:dpip/widgets/map/map.dart'; +import 'package:dpip/widgets/sheet/morphing_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +class TyphoonMapLayerManager extends MapLayerManager { + TyphoonMapLayerManager(super.context, super.controller); + + final currentTyphoonTime = ValueNotifier(GlobalProviders.data.typhoon.firstOrNull); + final isLoading = ValueNotifier(false); + + Map typhoonData = {}; + List typhoonList = []; + int selectedTyphoonId = -1; + List sourceList = []; + List layerList = []; + List typhoon_name_list = []; + List typhoon_id_list = []; + String selectedTimestamp = ''; + bool isUserLocationValid = false; + + DateTime? _lastFetchTime; + + Function(String)? onTimeChanged; + + Future setTyphoonTime(String time) async { + if (currentTyphoonTime.value == time || isLoading.value) return; + + isLoading.value = true; + + try { + await remove(); + currentTyphoonTime.value = time; + await setup(); + + onTimeChanged?.call(time); + } catch (e, s) { + TalkerManager.instance.error('TyphoonMapLayerManager.setTyphoonTime', e, s); + } finally { + isLoading.value = false; + } + } + + Future _focus() async { + try { + final location = GlobalProviders.location.coordinates; + + if (location != null && location.isValid) { + await controller.animateCamera(CameraUpdate.newLatLngZoom(location, 7.4)); + } + } catch (e, s) { + TalkerManager.instance.error('TyphoonMapLayerManager._focus', e, s); + } + } + + Future _fetchData() async { + try { + typhoonData = await ExpTech().getTyphoonGeojson(); + + if (!context.mounted) return; + + GlobalProviders.data.setTyphoon(typhoonList); + currentTyphoonTime.value ??= typhoonList.first; + _lastFetchTime = DateTime.now(); + } catch (e, s) { + TalkerManager.instance.error('TyphoonMapLayerManager._fetchData', e, s); + } + } + + @override + Future setup() async { + if (didSetup) return; + + try { + if (typhoonData.isEmpty) { + typhoonData = await ExpTech().getTyphoonGeojson(); + } + + const sourceId = 'typhoon-geojson'; + final sources = await controller.getSourceIds(); + if (!sources.contains(sourceId)) { + await controller.addSource( + sourceId, + GeojsonSourceProperties(data: typhoonData), + ); + } + + if (!(await controller.getLayerIds()).contains('typhoon-path')) { + await controller.addLayer( + sourceId, + 'typhoon-path', + const LineLayerProperties( + lineColor: [ + 'match', ['get', 'color'], + 0, '#1565C0', // 藍色 + 1, '#4CAF50', // 綠色 + 2, '#FFC107', // 黃色 + 3, '#FF5722', // 橙色 + '#757575', // 默認灰色 + ], + lineWidth: 2, + ), + ); + } + + if (!(await controller.getLayerIds()).contains('typhoon-points')) { + await controller.addLayer( + sourceId, + 'typhoon-points', + const CircleLayerProperties( + circleRadius: 3, + circleColor: [ + 'match', ['get', 'color'], + 0, '#1565C0', + 1, '#4CAF50', + 2, '#FFC107', + 3, '#FF5722', + '#757575', + ], + circleStrokeWidth: 2, + circleStrokeColor: '#FFFFFF', + ), + filter: [ + 'all', + ['!=', ['get', 'forecast'], true], + ], + ); + } + + if (!(await controller.getLayerIds()).contains('typhoon-wind-circle')) { + await controller.addLayer( + sourceId, + 'typhoon-wind-circle', + const FillLayerProperties( + fillColor: 'rgba(255, 0, 0, 0.1)', + fillOutlineColor: 'rgba(255, 0, 0, 0.6)', + ), + filter: [ + 'all', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['get', 'type'], 'wind-circle'], + ['==', ['get', 'forecast'], true], + ['==', ['get', 'tau'], 0], + ], + belowLayerId: BaseMapLayerIds.userLocation, + ); + } + + didSetup = true; + } catch (e, s) { + TalkerManager.instance.error('TyphoonMapLayerManager.setup', e, s); + } + } + + @override + Future hide() async { + if (!visible) return; + + final time = currentTyphoonTime.value; + if (time == null) return; + + final layerId = MapLayerIds.typhoon(time); + + try { + await controller.setLayerVisibility(layerId, false); + + visible = false; + } catch (e, s) { + TalkerManager.instance.error('TyphoonMapLayerManager.hide', e, s); + } + } + + @override + Future show() async { + if (visible) return; + + final time = currentTyphoonTime.value; + if (time == null) return; + + final layerId = MapLayerIds.typhoon(time); + + try { + await controller.setLayerVisibility(layerId, true); + + await _focus(); + + visible = true; + + if (_lastFetchTime == null || DateTime.now().difference(_lastFetchTime!).inMinutes > 5) await _fetchData(); + } catch (e, s) { + TalkerManager.instance.error('TyphoonMapLayerManager.show', e, s); + } + } + + @override + Future remove() async { + try { + final time = currentTyphoonTime.value; + if (time == null) return; + + final layerId = MapLayerIds.typhoon(time); + final sourceId = MapSourceIds.typhoon(time); + + await controller.removeLayer(layerId); + + await controller.removeSource(sourceId); + } catch (e, s) { + TalkerManager.instance.error('TyphoonMapLayerManager.remove', e, s); + } + + didSetup = false; + } + + @override + Widget build(BuildContext context) => TyphoonMapLayerSheet(manager: this); +} + +class TyphoonMapLayerSheet extends StatelessWidget { + final TyphoonMapLayerManager manager; + + const TyphoonMapLayerSheet({super.key, required this.manager}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + MorphingSheet( + title: '颱風'.i18n, + borderRadius: BorderRadius.circular(16), + elevation: 4, + partialBuilder: (context, controller, sheetController) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Selector>( + selector: (context, model) => model.typhoon, + builder: (context, typhoon, child) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + spacing: 8, + children: [ + const Icon(Symbols.bolt, size: 24), + Text('颱風'.i18n, style: context.textTheme.titleMedium), + ], + ), + ), + ], + ); + }, + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/app/map/_lib/utils.dart b/lib/app/map/_lib/utils.dart index 33378365d..333b0e1fd 100644 --- a/lib/app/map/_lib/utils.dart +++ b/lib/app/map/_lib/utils.dart @@ -1,17 +1,11 @@ import 'package:dpip/widgets/map/map.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -enum MapLayer { monitor, report, tsunami, radar, temperature, precipitation, wind, lightning } +enum MapLayer { monitor, report, tsunami, radar, temperature, precipitation, wind, lightning, typhoon} const Set kEarthquakeLayers = {MapLayer.monitor, MapLayer.report, MapLayer.tsunami}; -const Set kWeatherLayers = { - MapLayer.radar, - MapLayer.temperature, - MapLayer.precipitation, - MapLayer.wind, - MapLayer.lightning, -}; +const Set kWeatherLayers = {MapLayer.radar, MapLayer.temperature, MapLayer.precipitation, MapLayer.wind, MapLayer.lightning, MapLayer.typhoon}; const Map> kAllowedLayerCombinations = { MapLayer.monitor: {MapLayer.monitor}, @@ -22,6 +16,7 @@ const Map> kAllowedLayerCombinations = { MapLayer.precipitation: {MapLayer.radar, MapLayer.precipitation}, MapLayer.wind: {MapLayer.radar, MapLayer.wind}, MapLayer.lightning: {MapLayer.radar, MapLayer.lightning}, + MapLayer.typhoon: {MapLayer.typhoon}, }; /// Validates if a combination of map layers follows the defined rules. @@ -66,6 +61,7 @@ class MapSourceIds { static String precipitation([String? time]) => time == null ? 'precipitation' : 'precipitation-$time'; static String wind([String? time]) => time == null ? 'wind' : 'wind-$time'; static String lightning([String? time]) => time == null ? 'lightning' : 'lightning-$time'; + static String typhoon([String? time]) => time == null ? 'typhoon' : 'typhoon-$time'; static String intensity() => 'intensity'; static String intensity0() => 'intensity0'; static String box() => 'box'; @@ -83,6 +79,7 @@ class MapLayerIds { static String precipitation([String? time]) => time == null ? 'precipitation' : 'precipitation-$time'; static String wind([String? time]) => time == null ? 'wind' : 'wind-$time'; static String lightning([String? time]) => time == null ? 'lightning' : 'lightning-$time'; + static String typhoon([String? time]) => time == null ? 'typhoon' : 'typhoon-$time'; static String intensity() => 'intensity'; static String intensity0() => 'intensity0'; static String box() => 'box'; diff --git a/lib/app/map/_widgets/layer_toggle_sheet.dart b/lib/app/map/_widgets/layer_toggle_sheet.dart index 51ae3283f..e6f20d33e 100644 --- a/lib/app/map/_widgets/layer_toggle_sheet.dart +++ b/lib/app/map/_widgets/layer_toggle_sheet.dart @@ -164,6 +164,12 @@ class _LayerToggleSheetState extends State { onChanged: (_) => _toggleLayer(MapLayer.lightning), onLongPress: (_) => _toggleLayer(MapLayer.lightning, overlay: true), ), + LayerToggle( + label: '颱風'.i18n, + checked: _activeLayers.contains(MapLayer.typhoon), + onChanged: (checked) => _toggleLayer(MapLayer.typhoon), + onLongPress: (_) => _toggleLayer(MapLayer.typhoon, overlay: true), + ), ], ), ], diff --git a/lib/app/map/page.dart b/lib/app/map/page.dart index 7f0f8a4ff..b99281aab 100644 --- a/lib/app/map/page.dart +++ b/lib/app/map/page.dart @@ -8,6 +8,7 @@ import 'package:dpip/app/map/_lib/managers/radar.dart'; import 'package:dpip/app/map/_lib/managers/report.dart'; import 'package:dpip/app/map/_lib/managers/temperature.dart'; import 'package:dpip/app/map/_lib/managers/tsunami.dart'; +import 'package:dpip/app/map/_lib/managers/typhoon.dart'; import 'package:dpip/app/map/_lib/managers/wind.dart'; import 'package:dpip/app/map/_lib/utils.dart'; import 'package:dpip/app/map/_widgets/ui/positioned_back_button.dart'; @@ -79,7 +80,7 @@ class _MapPageState extends State with TickerProviderStateMixin { Set get activeLayers => _activeLayers; MapLayer? get primaryLayer { - for (final layer in [MapLayer.temperature, MapLayer.precipitation, MapLayer.wind, MapLayer.lightning]) { + for (final layer in [MapLayer.temperature, MapLayer.precipitation, MapLayer.wind, MapLayer.lightning, MapLayer.typhoon]) { if (_activeLayers.contains(layer)) { return layer; } @@ -120,6 +121,11 @@ class _MapPageState extends State with TickerProviderStateMixin { lightningManager?.onTimeChanged = (time) { syncTimeToRadar(time); }; + + final typhoonManager = getLayerManager(MapLayer.typhoon); + typhoonManager?.onTimeChanged = (time) { + syncTimeToRadar(time); + }; } Future _syncRadarTimeOnCombination(MapLayer newLayer) async { @@ -141,6 +147,9 @@ class _MapPageState extends State with TickerProviderStateMixin { case MapLayer.lightning: final manager = getLayerManager(MapLayer.lightning); newTime = manager?.currentLightningTime.value; + case MapLayer.typhoon: + final manager = getLayerManager(MapLayer.typhoon); + newTime = manager?.currentTyphoonTime.value; default: } @@ -240,6 +249,7 @@ class _MapPageState extends State with TickerProviderStateMixin { _managers[MapLayer.precipitation] = PrecipitationMapLayerManager(context, controller); _managers[MapLayer.wind] = WindMapLayerManager(context, controller); _managers[MapLayer.lightning] = LightningMapLayerManager(context, controller); + _managers[MapLayer.typhoon] = TyphoonMapLayerManager(context, controller); _setupWeatherLayerTimeSync(); diff --git a/lib/models/data.dart b/lib/models/data.dart index acac13e5a..81ef0768d 100644 --- a/lib/models/data.dart +++ b/lib/models/data.dart @@ -13,8 +13,9 @@ import 'package:dpip/api/model/report/earthquake_report.dart'; import 'package:dpip/api/model/report/partial_earthquake_report.dart'; import 'package:dpip/api/model/rts/rts.dart'; import 'package:dpip/api/model/station.dart'; -import 'package:dpip/api/model/weather/rain.dart'; import 'package:dpip/api/model/weather/lightning.dart'; +import 'package:dpip/api/model/weather/rain.dart'; +import 'package:dpip/api/model/weather/typhoon.dart'; import 'package:dpip/api/model/weather/weather.dart'; import 'package:dpip/core/eew.dart'; import 'package:dpip/global.dart'; @@ -170,6 +171,24 @@ class _DpipDataModel extends ChangeNotifier { notifyListeners(); } + List _typhoon = []; + + UnmodifiableListView get typhoon => UnmodifiableListView(_typhoon); + + void setTyphoon(List typhoon) { + _typhoon = typhoon; + notifyListeners(); + } + + final Map> _typhoonData = {}; + + UnmodifiableMapView> get typhoonData => UnmodifiableMapView(_typhoonData); + + void setTyphoonData(String time, List typhoon) { + _typhoonData[time] = typhoon; + notifyListeners(); + } + int _timeOffset = 0; int get timeOffset => _timeOffset;