From c070bceba4df09c2f45abdd459b24248133ca605 Mon Sep 17 00:00:00 2001 From: Justin Johnson Date: Tue, 26 Mar 2024 19:15:05 -0700 Subject: [PATCH] DBCC22-1485 Regional weather panel --- .env.example | 7 +- compose/backend/start.sh | 2 +- src/backend/apps/feed/client.py | 267 +++++++++--------- src/backend/apps/feed/serializers.py | 10 +- ...gionalweather_observation_name_and_more.py | 59 ++++ src/backend/apps/weather/models.py | 24 +- src/backend/apps/weather/serializers.py | 18 +- src/backend/apps/weather/tasks.py | 28 +- src/frontend/src/Components/Filters.js | 7 +- src/frontend/src/Components/Map.js | 99 ++++++- src/frontend/src/Components/WeatherIcon.js | 60 ++++ .../data/featureStyleDefinitions.js | 27 ++ src/frontend/src/Components/data/weather.js | 10 + .../Components/map/layers/regionalLayer.js | 51 ++++ src/frontend/src/Components/map/mapPopup.js | 164 ++++++++--- src/frontend/src/Components/map/mapPopup.scss | 22 +- .../mapIcons/regional-weather-active.png | Bin 0 -> 2980 bytes .../mapIcons/regional-weather-hover.png | Bin 0 -> 2489 bytes .../mapIcons/regional-weather-static.png | Bin 0 -> 2431 bytes src/frontend/src/slices/feedsSlice.js | 13 +- 20 files changed, 654 insertions(+), 214 deletions(-) create mode 100644 src/backend/apps/weather/migrations/0012_remove_regionalweather_observation_name_and_more.py create mode 100644 src/frontend/src/Components/WeatherIcon.js create mode 100644 src/frontend/src/Components/map/layers/regionalLayer.js create mode 100644 src/frontend/src/images/mapIcons/regional-weather-active.png create mode 100644 src/frontend/src/images/mapIcons/regional-weather-hover.png create mode 100644 src/frontend/src/images/mapIcons/regional-weather-static.png diff --git a/.env.example b/.env.example index b8d39192..bbacf448 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,7 @@ REACT_APP_RECAPTCHA_SITE_KEY= # API DRIVEBC_INLAND_FERRY_API_BASE_URL= DRIVEBC_IMAGE_API_BASE_URL= +DRIVEBC_IMAGE_BASE_URL= DRIVEBC_IMAGE_PROXY_URL= DRIVEBC_WEBCAM_API_BASE_URL= DRIVEBC_OPEN_511_API_BASE_URL= @@ -73,4 +74,8 @@ WEATHER_CLIENT_SECRET= # TESTS # optional: set to config.test.DbcRunner to user test runner allowing for # skipping db creation entirely -TEST_RUNNER=config.test.DbcRunner \ No newline at end of file +TEST_RUNNER=config.test.DbcRunner + +# OPTIONAL: include, set to true, to trigger optional backend code supporting +# dev in a docker environment (e.g., URLs for local image serving) +DEV_ENVIRONMENT=true diff --git a/compose/backend/start.sh b/compose/backend/start.sh index 5cf5df3c..c60f2dfa 100644 --- a/compose/backend/start.sh +++ b/compose/backend/start.sh @@ -20,4 +20,4 @@ echo 'creating superuser done; starting service' # python manage.py runserver 0.0.0.0:8000 #trap : TERM INT; sleep 9999999999d & wait export DJANGO_SETTINGS_MODULE=config.settings -gunicorn -b 0.0.0.0 config.wsgi 2> /tmp/gunicorn.log +gunicorn -b 0.0.0.0 --reload config.wsgi 2> /tmp/gunicorn.log diff --git a/src/backend/apps/feed/client.py b/src/backend/apps/feed/client.py index a4dcf291..fe6348cc 100644 --- a/src/backend/apps/feed/client.py +++ b/src/backend/apps/feed/client.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone import logging from typing import Dict from urllib.parse import urljoin @@ -32,28 +33,33 @@ # Maps the key for our client API's serializer fields to the matching pair of # the source API's DataSetName and DisplayName fields +# serializer DataSetName DisplayName value field SERIALIZER_TO_DATASET_MAPPING = { - "air_temperature": ("air_temp", "Air Temp"), - "road_temperature": ("sfc_temp", "Pavement Temp"), - "road_surface": ("sfc_stat_derived_state", "Pavement Status (State)", "MeaningfulValue"), - "precipitation": ("pcpn_amt_pst1hr", "Precip Hourly"), - "snow": ("snwfl_amt_pst1hr", "Snowfall (hourly)"), - "wind_direction": ("wnd_dir", "Wind Direction (current)"), - "average_wind": ("wnd_spd", "Wind Speed (current)"), - "maximum_wind": ("max_wnd_spd_pst1hr", "Wind Speed (max)") + "air_temperature": ("air_temp", "Air Temp"), + "road_temperature": ("sfc_temp", "Pavement Temp"), + "road_surface": ("sfc_stat_derived_state", "Pavement Status (State)", "MeaningfulValue"), + "precipitation": ("pcpn_amt_pst1hr", "Precip Hourly"), + "snow": ("snwfl_amt_pst1hr", "Snowfall (hourly)"), + "wind_direction": ("wnd_dir", "Wind Direction (current)"), + "average_wind": ("wnd_spd", "Wind Speed (current)"), + "maximum_wind": ("max_wnd_spd_pst1hr", "Wind Speed (max)") } # Generated list of DataSetName values for filtering excluded dataset entries +# ['air_temp', 'sfc_temp', ...] DATASETNAMES = [value[0] for value in SERIALIZER_TO_DATASET_MAPPING.values()] # Generated mapping of DataSetName to DisplayName +# { "air_temp": "Air Temp", ... } DISPLAYNAME_MAPPING = {value[0]: value[1] for value in SERIALIZER_TO_DATASET_MAPPING.values()} # Generated mapping of DataSetName to Serializer field +# { "air_temp": "air_temperature", ... } SERIALIZER_MAPPING = {value[0]: key for key, value in SERIALIZER_TO_DATASET_MAPPING.items()} # Generated mapping of DataSetName to the name of the value field in the tuple; # defaults to "Value" +# { "air_temp": "Value", "road_surface": "MeanginfulValue", ...} VALUE_FIELD_MAPPING = {value[0]: (value[2] if len(value) > 2 else "Value") for value in SERIALIZER_TO_DATASET_MAPPING.values()} @@ -61,7 +67,7 @@ class FeedClient: - """Feed client for external DriveBC APIs.""" + """ Feed client for external DriveBC APIs. """ def __init__(self): self.resource_map: Dict[str, dict] = { @@ -106,7 +112,9 @@ def _get_endpoint(self, resource_type, resource_name): @staticmethod def _get_response_data_or_raise(response): - """Checks and returns the response if it has usable content. + """ + Checks and returns the response if it has usable content. + All responses with status 401 and up will be raised as an HTTP error. """ if response and response.status_code <= httpx.codes.BAD_REQUEST: @@ -126,6 +134,30 @@ def _process_get_request(self, endpoint, params, resource_type, timeout=5.0): ) return self._get_response_data_or_raise(response) + # TODO: make client manage token by expiry so that repeated calls to this + # method either return a currently valid token or fetch a fresh one + def get_access_token(self): + """ + Return a bearer token + + The URL and credentials aren't weather specific; they're for the shared + services API gateway. + """ + + token_url = settings.DRIVEBC_WEATHER_API_TOKEN_URL + client_id = settings.WEATHER_CLIENT_ID + client_secret = settings.WEATHER_CLIENT_SECRET + + token_params = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + } + + response = requests.post(token_url, data=token_params) + response.raise_for_status() + return response.json().get("access_token") + def get_single_feed(self, dbo, resource_type, resource_name, serializer_cls): """Get data feed for a single object.""" endpoint = self._get_endpoint( @@ -220,128 +252,114 @@ def get_ferries_list(self): # Regional Weather def get_regional_weather_list_feed(self, resource_type, resource_name, serializer_cls, params=None): """Get data feed for list of objects.""" - area_code_endpoint = settings.DRIVEBC_WEATHER_AREAS_API_BASE_URL - # Obtain Access Token - token_url = settings.DRIVEBC_WEATHER_API_TOKEN_URL - client_id = settings.WEATHER_CLIENT_ID - client_secret = settings.WEATHER_CLIENT_SECRET - token_params = { - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - } + area_code_endpoint = settings.DRIVEBC_WEATHER_AREAS_API_BASE_URL try: - response = requests.post(token_url, data=token_params) - response.raise_for_status() - token_data = response.json() - access_token = token_data.get("access_token") + access_token = self.get_access_token() + headers = {"Authorization": f"Bearer {access_token}"} except requests.RequestException as e: return Response({"error": f"Error obtaining access token: {str(e)}"}, status=500) + external_api_url = area_code_endpoint - headers = {"Authorization": f"Bearer {access_token}"} + try: response = requests.get(external_api_url, headers=headers) response.raise_for_status() - data = response.json() - json_response = data + json_response = response.json() json_objects = [] + for entry in json_response: - area_code = entry["AreaCode"] + area_code = entry.get("AreaCode") api_endpoint = settings.DRIVEBC_WEATHER_API_BASE_URL + f"/{area_code}" - # Reget access token in case the previous token expired + + # Get fresh token in case earlier token has expired try: - response = requests.post(token_url, data=token_params) - response.raise_for_status() - token_data = response.json() - access_token = token_data.get("access_token") + access_token = self.get_access_token() + headers = {"Authorization": f"Bearer {access_token}"} except requests.RequestException as e: return Response({"error": f"Error obtaining access token: {str(e)}"}, status=500) - headers = {"Authorization": f"Bearer {access_token}"} try: response = requests.get(api_endpoint, headers=headers) + if response.status_code == 204: + continue # empty response, continue with next entry data = response.json() - name_data = data.get("Location", {}).get("Name", {}) - code = name_data.get("Code") if name_data else None - location_latitude = name_data.get("Latitude") if name_data else None - location_longitude = name_data.get("Longitude") if name_data else None - name = name_data.get("Value") if name_data else None - - observation_data = data.get("CurrentConditions", {}).get("ObservationDateTimeUTC", {}) - observation_name = observation_data.get("Name") if observation_data else None - observation_zone = observation_data.get("Zone") if observation_data else None - observation_utc_offset = observation_data.get("UTCOffset") if observation_data else None - observation_text_summary = observation_data.get("TextSummary") if observation_data else None - - condition_data = data.get("CurrentConditions", {}) - condition = condition_data.get("Condition") if condition_data else None - - temperature_data = data.get("CurrentConditions", {}).get("Temperature", {}) - temperature_units = temperature_data.get("Units") if temperature_data else None - temperature_value = temperature_data.get("Value") if temperature_data else None - visibility_data = data.get("CurrentConditions", {}).get("Visibility", {}) - visibility_units = visibility_data.get("Units") if visibility_data else None - visibility_value = visibility_data.get("Value") if visibility_data else None - - wind_data = data.get("CurrentConditions", {}).get("Wind", {}) - wind_speed = wind_data.get("Speed") if wind_data else None - wind_gust = wind_data.get("Gust") if wind_data else None - wind_direction = wind_data.get("Direction") if wind_data else None - - wind_speed_units = wind_speed.get("Units") if wind_speed else None - wind_speed_value = wind_speed.get("Value") if wind_speed else None - - wind_gust_units = wind_gust.get("Units") if wind_gust else None - wind_gust_value = wind_gust.get("Value") if wind_gust else None + location_data = data.get("Location") or {} + name_data = location_data.get("Name") or {} + condition_data = data.get("CurrentConditions") or {} + temperature_data = condition_data.get("Temperature") or {} + visibility_data = condition_data.get("Visibility") or {} + wind_data = condition_data.get("Wind") or {} + wind_speed = wind_data.get("Speed") or {} + wind_gust = wind_data.get("Gust") or {} + icon = condition_data.get("IconCode") or {} conditions = { - 'condition': condition, - 'temperature_units': temperature_units, - 'temperature_value': temperature_value, - 'visibility_units': visibility_units, - 'visibility_value': visibility_value, - 'wind_speed_units': wind_speed_units, - 'wind_speed_value': wind_speed_value, - 'wind_gust_units': wind_gust_units, - 'wind_gust_value': wind_gust_value, - 'wind_direction': wind_direction, + 'condition': condition_data.get("Condition"), + 'temperature_units': temperature_data.get("Units"), + 'temperature_value': temperature_data.get("Value"), + 'visibility_units': visibility_data.get("Units"), + 'visibility_value': visibility_data.get("Value"), + 'wind_speed_units': wind_speed.get("Units"), + 'wind_speed_value': wind_speed.get("Value"), + 'wind_gust_units': wind_gust.get("Units"), + 'wind_gust_value': wind_gust.get("Value"), + 'wind_direction': wind_data.get("Direction"), + 'icon_code': icon.get("Code") } - name_data = data.get("Location", {}).get("Name", {}) - code = name_data.get("Code") if name_data else None - location_latitude = name_data.get("Latitude") if name_data else None - location_longitude = name_data.get("Longitude") if name_data else None - name = name_data.get("Value") if name_data else None - - observation_data = data.get("CurrentConditions", {}).get("ObservationDateTimeUTC", {}) - observation_name = observation_data.get("Name") if observation_data else None - observation_zone = observation_data.get("Zone") if observation_data else None - observation_utc_offset = observation_data.get("UTCOffset") if observation_data else None - observation_text_summary = observation_data.get("TextSummary") if observation_data else None - - forecast_group_data = data.get("ForecastGroup", {}) - forecast_group = forecast_group_data.get("Forecasts") if forecast_group_data else None - - hourly_forecast_group_data = data.get("HourlyForecastGroup", {}) - hourly_forecast_group = hourly_forecast_group_data.get( - "HourlyForecasts") if hourly_forecast_group_data else None + code = name_data.get("Code") + station_data = condition_data.get('Station') or {} + + observed_data = condition_data.get("ObservationDateTimeUTC") or {} + observed = observed_data.get("TextSummary") + if observed is not None: + observed = datetime.strptime(observed, '%A %B %d, %Y at %H:%M %Z') + observed = observed.replace(tzinfo=timezone.utc) + + forecast_issued = data.get("ForecastIssuedUtc") + if forecast_issued is not None: + try: + # Env Canada sends this field as ISO time without + # offset, needed for python to parse correctly + forecast_issued = datetime.fromisoformat(f"{forecast_issued}+00:00") + except: # date parsing error + logger.error(f"Issued UTC sent by {code} as {forecast_issued}") + + riseset_data = data.get("RiseSet") or {} + sunrise = riseset_data.get('SunriseUtc') + if sunrise is not None: + sunrise = datetime.strptime(sunrise, '%A %B %d, %Y at %H:%M %Z') + sunrise = sunrise.replace(tzinfo=timezone.utc) + sunset = riseset_data.get('SunsetUtc') + if sunset is not None: + sunset = datetime.strptime(sunset, '%A %B %d, %Y at %H:%M %Z') + sunset = sunset.replace(tzinfo=timezone.utc) + + forecast_data = data.get("ForecastGroup") or {} + hourly_data = data.get("HourlyForecastGroup") or {} + + warnings = data.get("Warnings") or {} + if warnings.get("Url") is None: + warnings = None regional_weather_data = { 'code': code, - 'location_latitude': location_latitude, - 'location_longitude': location_longitude, - 'name': name, - 'region': data.get("Location", {}).get("Region"), - 'observation_name': observation_name, - 'observation_zone': observation_zone, - 'observation_utc_offset': observation_utc_offset, - 'observation_text_summary': observation_text_summary, + 'station': station_data.get("Code"), + 'location_latitude': name_data.get("Latitude"), + 'location_longitude': name_data.get("Longitude"), + 'name': name_data.get("Value"), + 'region': location_data.get("Region"), 'conditions': conditions, - 'forecast_group': forecast_group, - 'hourly_forecast_group': hourly_forecast_group, + 'forecast_group': forecast_data.get("Forecasts"), + 'hourly_forecast_group': hourly_data.get("HourlyForecasts"), + 'observed': observed, + 'forecast_issued': forecast_issued, + 'sunrise': sunrise, + 'sunset': sunset, + 'warnings': warnings, } serializer = serializer_cls(data=regional_weather_data, @@ -349,7 +367,7 @@ def get_regional_weather_list_feed(self, resource_type, resource_name, serialize json_objects.append(regional_weather_data) except requests.RequestException as e: - print(f"Error making API call for Area Code {area_code}: {e}") + logger.error(f"Error making API call for Area Code {area_code}: {e}") except requests.RequestException: return Response("Error fetching data from weather API", status=500) @@ -361,7 +379,7 @@ def get_regional_weather_list_feed(self, resource_type, resource_name, serialize except (KeyError, ValidationError): field_errors = serializer.errors for field, errors in field_errors.items(): - print(f"Field: {field}, Errors: {errors}") + logger.error(f"Field: {field}, Errors: {errors}") def get_regional_weather_list(self): return self.get_regional_weather_list_feed( @@ -373,45 +391,32 @@ def get_regional_weather_list(self): def get_current_weather_list_feed(self, resource_type, resource_name, serializer_cls, params=None): """Get data feed for list of objects.""" area_code_endpoint = settings.DRIVEBC_WEATHER_CURRENT_STATIONS_API_BASE_URL - # Obtain Access Token - token_url = settings.DRIVEBC_WEATHER_API_TOKEN_URL - client_id = settings.WEATHER_CLIENT_ID - client_secret = settings.WEATHER_CLIENT_SECRET - - token_params = { - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - } try: - response = requests.post(token_url, data=token_params) - response.raise_for_status() - token_data = response.json() - access_token = token_data.get("access_token") + access_token = self.get_access_token() + headers = {"Authorization": f"Bearer {access_token}"} except requests.RequestException as e: return Response({"error": f"Error obtaining access token: {str(e)}"}, status=500) + external_api_url = area_code_endpoint headers = {"Authorization": f"Bearer {access_token}"} + try: response = requests.get(external_api_url, headers=headers) response.raise_for_status() - data = response.json() - json_response = data + json_response = response.json() json_objects = [] for station in json_response: - station_number = station["WeatherStationNumber"] + station_number = station.get("WeatherStationNumber") api_endpoint = settings.DRIVEBC_WEATHER_CURRENT_API_BASE_URL + f"{station_number}" - # Reget access token in case the previous token expired + + # get fresh token to avoid previous token expiring try: - response = requests.post(token_url, data=token_params) - response.raise_for_status() - token_data = response.json() - access_token = token_data.get("access_token") + access_token = self.get_access_token() + headers = {"Authorization": f"Bearer {access_token}"} except requests.RequestException as e: return Response({"error": f"Error obtaining access token: {str(e)}"}, status=500) - headers = {"Authorization": f"Bearer {access_token}"} try: response = requests.get(api_endpoint, headers=headers) @@ -456,7 +461,7 @@ def get_current_weather_list_feed(self, resource_type, resource_name, serializer json_objects.append(current_weather_data) except requests.RequestException as e: - print(f"Error making API call for Area Code {station_number}: {e}") + logger.error(f"Error making API call for Area Code {station_number}: {e}") try: serializer.is_valid(raise_exception=True) return json_objects @@ -464,7 +469,7 @@ def get_current_weather_list_feed(self, resource_type, resource_name, serializer except (KeyError, ValidationError): field_errors = serializer.errors for field, errors in field_errors.items(): - print(f"Field: {field}, Errors: {errors}") + logger.error(f"Field: {field}, Errors: {errors}") except requests.RequestException: return Response("Error fetching data from weather API", status=500) diff --git a/src/backend/apps/feed/serializers.py b/src/backend/apps/feed/serializers.py index 876f736d..faea9e15 100644 --- a/src/backend/apps/feed/serializers.py +++ b/src/backend/apps/feed/serializers.py @@ -233,17 +233,19 @@ class Meta: fields = ( 'id', 'code', + 'station', 'location_latitude', 'location_longitude', 'name', 'region', - 'observation_name', - 'observation_zone', - 'observation_utc_offset', - 'observation_text_summary', 'conditions', 'forecast_group', 'hourly_forecast_group', + 'observed', + 'forecast_issued', + 'sunrise', + 'sunset', + 'warnings', ) diff --git a/src/backend/apps/weather/migrations/0012_remove_regionalweather_observation_name_and_more.py b/src/backend/apps/weather/migrations/0012_remove_regionalweather_observation_name_and_more.py new file mode 100644 index 00000000..b2adf345 --- /dev/null +++ b/src/backend/apps/weather/migrations/0012_remove_regionalweather_observation_name_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.3 on 2024-03-26 22:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('weather', '0011_alter_currentweather_datasets'), + ] + + operations = [ + migrations.RemoveField( + model_name='regionalweather', + name='observation_name', + ), + migrations.RemoveField( + model_name='regionalweather', + name='observation_text_summary', + ), + migrations.RemoveField( + model_name='regionalweather', + name='observation_utc_offset', + ), + migrations.RemoveField( + model_name='regionalweather', + name='observation_zone', + ), + migrations.AddField( + model_name='regionalweather', + name='forecast_issued', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='regionalweather', + name='observed', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='regionalweather', + name='station', + field=models.CharField(max_length=3, null=True), + ), + migrations.AddField( + model_name='regionalweather', + name='sunrise', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='regionalweather', + name='sunset', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='regionalweather', + name='warnings', + field=models.JSONField(null=True), + ), + ] diff --git a/src/backend/apps/weather/models.py b/src/backend/apps/weather/models.py index 8e63f98e..dfe4d988 100644 --- a/src/backend/apps/weather/models.py +++ b/src/backend/apps/weather/models.py @@ -4,27 +4,33 @@ class RegionalWeather(BaseModel): - location = models.PointField(null=True) + """ Weather reports and forecasts from Environment Canada """ + code = models.CharField(max_length=10, null=True) - location_latitude = models.CharField(max_length=10, null=True) - location_longitude = models.CharField(max_length=10, null=True) + station = models.CharField(max_length=3, null=True) name = models.CharField(max_length=100, null=True) region = models.CharField(max_length=255, null=True) - observation_name = models.CharField(max_length=50, null=True) - observation_zone = models.CharField(max_length=10, null=True) - observation_utc_offset = models.IntegerField(null=True) - observation_text_summary = models.CharField(max_length=255, null=True) + location_latitude = models.CharField(max_length=10, null=True) + location_longitude = models.CharField(max_length=10, null=True) + location = models.PointField(null=True) + + observed = models.DateTimeField(null=True) # current conditions + forecast_issued = models.DateTimeField(null=True) + sunrise = models.DateTimeField(null=True) + sunset = models.DateTimeField(null=True) conditions = models.JSONField(null=True) forecast_group = models.JSONField(null=True) hourly_forecast_group = models.JSONField(null=True) + warnings = models.JSONField(null=True) + def get_forecasts(self): return self.forecast_group.get('Forecasts', []) def __str__(self): - return f"Regional Forecast for {self.pk}" + return f"Regional Forecast for {self.code} ({self.station})" def save(self, *args, **kwargs): latitude, longitude = self.convert_coordinates(str(self.location_latitude), str(self.location_longitude)) @@ -44,6 +50,8 @@ def convert_coordinates(self, latitude_str, longitude_str): class CurrentWeather(BaseModel): + """ Weather reports from MOTI sites """ + location = models.PointField(null=True) weather_station_name = models.CharField(max_length=100) elevation = models.IntegerField(null=True) diff --git a/src/backend/apps/weather/serializers.py b/src/backend/apps/weather/serializers.py index e9ea19a2..ce4283b3 100644 --- a/src/backend/apps/weather/serializers.py +++ b/src/backend/apps/weather/serializers.py @@ -1,11 +1,27 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + from apps.weather.models import CurrentWeather, RegionalWeather from rest_framework import serializers +tz = ZoneInfo("America/Vancouver") + + class RegionalWeatherSerializer(serializers.ModelSerializer): + class Meta: model = RegionalWeather - exclude = ['location_latitude', 'location_longitude'] + fields = ['location', + 'conditions', + 'name', + 'station', + 'observed', + 'forecast_issued', + 'sunrise', + 'sunset', + 'warnings', + ] # Current Weather serializer diff --git a/src/backend/apps/weather/tasks.py b/src/backend/apps/weather/tasks.py index db49de5c..c21058c1 100644 --- a/src/backend/apps/weather/tasks.py +++ b/src/backend/apps/weather/tasks.py @@ -9,22 +9,24 @@ logger = logging.getLogger(__name__) -def populate_regional_weather_from_data(new_regional_weather_data): - code = new_regional_weather_data.get('code') +def populate_regional_weather_from_data(new_data): + code = new_data.get('code') existing_record = RegionalWeather.objects.filter(code=code).first() data = { 'code': code, - 'location_latitude': new_regional_weather_data.get('location_latitude'), - 'location_longitude': new_regional_weather_data.get('location_longitude'), - 'name': new_regional_weather_data.get('name'), - 'region': new_regional_weather_data.get('region'), - 'observation_name': new_regional_weather_data.get('observation_name'), - 'observation_zone': new_regional_weather_data.get('observation_zone'), - 'observation_utc_offset': new_regional_weather_data.get('observation_utc_offset'), - 'observation_text_summary': new_regional_weather_data.get('observation_text_summary'), - 'conditions': new_regional_weather_data.get('conditions'), - 'forecast_group': new_regional_weather_data.get('forecast_group'), - 'hourly_forecast_group': new_regional_weather_data.get('hourly_forecast_group'), + 'location_latitude': new_data.get('location_latitude'), + 'location_longitude': new_data.get('location_longitude'), + 'name': new_data.get('name'), + 'region': new_data.get('region'), + 'conditions': new_data.get('conditions'), + 'forecast_group': new_data.get('forecast_group'), + 'hourly_forecast_group': new_data.get('hourly_forecast_group'), + 'station': new_data.get('station'), + 'observed': new_data.get('observed'), + 'forecast_issued': new_data.get('forecast_issued'), + 'sunrise': new_data.get('sunrise'), + 'sunset': new_data.get('sunset'), + 'warnings': new_data.get('warnings'), } if existing_record: diff --git a/src/frontend/src/Components/Filters.js b/src/frontend/src/Components/Filters.js index 9c9677e8..f8eea796 100644 --- a/src/frontend/src/Components/Filters.js +++ b/src/frontend/src/Components/Filters.js @@ -320,12 +320,17 @@ export default function Filters(props) { ? +
{toggleHandler('weather', e.target.checked); setWeather(!weather)}} + onChange={e => { + toggleHandler('weather', e.target.checked); + toggleHandler('regional', e.target.checked); + setWeather(!weather)} + } defaultChecked={mapContext.visible_layers.weather} disabled={disableFeatures} /> diff --git a/src/frontend/src/Components/Map.js b/src/frontend/src/Components/Map.js index 7423355f..8606916b 100644 --- a/src/frontend/src/Components/Map.js +++ b/src/frontend/src/Components/Map.js @@ -15,6 +15,7 @@ import { updateEvents, updateFerries, updateWeather, + updateRegional, updateRestStops, } from '../slices/feedsSlice'; import { updateMapState } from '../slices/mapSlice'; @@ -37,14 +38,16 @@ import { getEventPopup, getFerryPopup, getWeatherPopup, + getRegionalPopup, getRestStopPopup, } from './map/mapPopup.js'; import { getEvents } from './data/events.js'; -import { getWeather } from './data/weather.js'; +import { getWeather, getRegional } from './data/weather.js'; import { getRestStops } from './data/restStops.js'; import { getRestStopsLayer } from './map/layers/restStopsLayer.js'; import { loadEventsLayers } from './map/layers/eventsLayer.js'; import { loadWeatherLayers } from './map/layers/weatherLayer.js'; +import { loadRegionalLayers } from './map/layers/regionalLayer.js'; import { fitMap, blueLocationMarkup, @@ -82,6 +85,7 @@ import { cameraStyles, ferryStyles, roadWeatherStyles, + regionalStyles, restStopStyles, } from './data/featureStyleDefinitions.js'; import './Map.scss'; @@ -102,7 +106,9 @@ export default function MapWrapper({ ferries, ferriesTimeStamp, // CMS weather, - weatherTimeStamp, // Weather + weatherTimeStamp, // Current Weather + regional, + regionalTimeStamp, // Regional Weather restStops, restStopsTimeStamp, // Rest Stops searchLocationFrom, @@ -121,9 +127,12 @@ export default function MapWrapper({ // CMS ferries: state.feeds.ferries.list, ferriesTimeStamp: state.feeds.ferries.routeTimeStamp, - // Weather + // Current Weather weather: state.feeds.weather.list, weatherTimeStamp: state.feeds.weather.routeTimeStamp, + // Regional Weather + regional: state.feeds.regional.list, + regionalTimeStamp: state.feeds.regional.routeTimeStamp, // Rest Stops restStops: state.feeds.restStops.list, restStopsTimeStamp: state.feeds.restStops.routeTimeStamp, @@ -184,6 +193,13 @@ export default function MapWrapper({ setClickedWeather(feature); }; + const [clickedRegional, setClickedRegional] = useState(); + const clickedRegionalRef = useRef(); + const updateClickedRegional = feature => { + clickedRegionalRef.current = feature; + setClickedRegional(feature); + }; + const [clickedRestStop, setClickedRestStop] = useState(); const clickedRestStopRef = useRef(); const updateClickedRestStop = (feature) => { @@ -334,6 +350,7 @@ export default function MapWrapper({ clickedFerryRef.current.setStyle(ferryStyles['static']); updateClickedFerry(null); } + if ( clickedWeatherRef.current && targetFeature != clickedWeatherRef.current @@ -341,6 +358,15 @@ export default function MapWrapper({ clickedWeatherRef.current.setStyle(roadWeatherStyles['static']); updateClickedWeather(null); } + + if ( + clickedRegionalRef.current && + targetFeature != clickedRegionalRef.current + ) { + clickedRegionalRef.current.setStyle(regionalStyles['static']); + updateClickedRegional(null); + } + if (clickedRestStopRef.current && targetFeature != clickedRestStopRef.current) { clickedRestStopRef.current.setStyle(restStopStyles['static']); updateClickedRestStop(null); @@ -411,6 +437,19 @@ export default function MapWrapper({ popup.current.getElement().style.top = '40px'; }; + const regionalClickHandler = feature => { + // reset previous clicked feature + resetClickedStates(feature); + + // set new clicked ferry feature + feature.setStyle(regionalStyles['active']); + feature.setProperties({ clicked: true }, true); + updateClickedRegional(feature); + + popup.current.setPosition(feature.getGeometry().getCoordinates()); + popup.current.getElement().style.top = '40px'; + }; + const restStopClickHandler = (feature) => { // reset previous clicked feature resetClickedStates(feature); @@ -446,6 +485,9 @@ export default function MapWrapper({ case 'weather': weatherClickHandler(clickedFeature); return; + case 'regional': + regionalClickHandler(clickedFeature); + return; case 'rest': restStopClickHandler(clickedFeature); return; @@ -483,6 +525,9 @@ export default function MapWrapper({ case 'weather': hoveredFeature.current.setStyle(roadWeatherStyles['static']); break; + case 'regional': + hoveredFeature.current.setStyle(regionalStyles['static']); + break; case 'rest': hoveredFeature.current.setStyle(restStopStyles['static']); break; @@ -534,6 +579,11 @@ export default function MapWrapper({ targetFeature.setStyle(restStopStyles['hover']); } return; + case 'regional': + if (!targetFeature.getProperties().clicked) { + targetFeature.setStyle(regionalStyles['hover']); + } + return; } } @@ -747,6 +797,37 @@ export default function MapWrapper({ } }; + useEffect(() => { + if (mapLayers.current['regional']) { + mapRef.current.removeLayer(mapLayers.current['regional']); + } + if (regional) { + mapLayers.current['regional'] = loadRegionalLayers( + regional, + mapContext, + mapRef.current.getView().getProjection().getCode(), + ); + mapRef.current.addLayer(mapLayers.current['regional']); + mapLayers.current['regional'].setZIndex(67); + } + }, [regional]); + window.mapLayers = mapLayers; // TOFO: remove debugging + + const loadRegional = async route => { + const newRouteTimestamp = route ? route.searchTimestamp : null; + + // Fetch data if it doesn't already exist or route was updated + if (!regional || regionalTimeStamp != newRouteTimestamp) { + dispatch( + updateRegional({ + list: await getRegional(route ? route.points : null), + routeTimeStamp: route ? route.searchTimestamp : null, + timeStamp: new Date().getTime(), + }), + ); + } + }; + const loadData = isInitialMount => { if (selectedRoute && selectedRoute.routeFound) { const routeLayer = getRouteLayer( @@ -761,6 +842,7 @@ export default function MapWrapper({ loadEvents(selectedRoute); loadFerries(); loadWeather(); + loadRegional(); loadRestStops(); // Zoom/pan to route on route updates @@ -773,6 +855,7 @@ export default function MapWrapper({ loadEvents(null); loadFerries(); loadWeather(); + loadRegional(); loadRestStops(); } }; @@ -836,6 +919,13 @@ export default function MapWrapper({ updateClickedWeather(null); } + // check for active weather icons + if (clickedRegionalRef.current) { + clickedRegionalRef.current.setStyle(regionalStyles['static']); + clickedRegionalRef.current.set('clicked', false); + updateClickedRegional(null); + } + // check for active rest stop icons if (clickedRestStopRef.current) { clickedRestStopRef.current.setStyle(restStopStyles['static']); @@ -941,6 +1031,7 @@ export default function MapWrapper({ clickedEvent || clickedFerry || clickedWeather || + clickedRegional || clickedRestStop ); @@ -975,6 +1066,8 @@ export default function MapWrapper({ {clickedWeather && getWeatherPopup(clickedWeather)} + {clickedRegional && getRegionalPopup(clickedRegional)} + {clickedRestStop && getRestStopPopup(clickedRestStop)}
diff --git a/src/frontend/src/Components/WeatherIcon.js b/src/frontend/src/Components/WeatherIcon.js new file mode 100644 index 00000000..3b34ca94 --- /dev/null +++ b/src/frontend/src/Components/WeatherIcon.js @@ -0,0 +1,60 @@ +import React from 'react'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import * as FA from '@fortawesome/pro-solid-svg-icons'; + +// Environment Canada icon code to weather condition icon mapping +const ICONS = { + "00": FA.faSun, + "01": FA.faSun, + "02": FA.faSunCloud, + "03": FA.faCloudsSun, + "06": FA.faCloudShowers, + "07": FA.faCloudSleet, + "08": FA.faCloudSnow, + "10": FA.faClouds, + "11": FA.faCloudRain, + "12": FA.faCloudShowersHeavy, + "13": FA.faCloudShowersHeavy, + "14": FA.faCloudHailMixed, + "15": FA.faCloudSleet, + "16": FA.faSnowflake, + "17": FA.faSnowflake, + "18": FA.faSnowflake, + "19": FA.faCloudBolt, + "23": FA.faSunHaze, + "24": FA.faCloudFog, + "25": FA.faSnowBlowing, + "26": FA.faIcicles, + "27": FA.faCloudHail, + "28": FA.faCloudHailMixed, + "30": FA.faMoonStars, + "31": FA.faMoon, + "32": FA.faMoonCloud, + "33": FA.faCloudsMoon, + "36": FA.faCloudMoonRain, + "37": FA.faCloudMoonRain, + "38": FA.faMoonCloud, + "39": FA.faCloudBolt, + "40": FA.faSnowBlowing, + "41": FA.faTornado, + "42": FA.faTornado, + "43": FA.faWind, + "44": FA.faSmoke, + "45": FA.faSun, // custom + "46": FA.faCloudBolt, + "47": FA.faSun, // custom + "48": FA.faSun, // custom +} + +export default function WeatherIcon({code, className}) { + if (['45', '47', '48'].includes(code)) { + // FIXME: replace with custom SVGs from design + return ; + } else if (ICONS[code]) { + return ; + } + + // default to generic sun cloud icon + +} diff --git a/src/frontend/src/Components/data/featureStyleDefinitions.js b/src/frontend/src/Components/data/featureStyleDefinitions.js index 7417041a..7606e51a 100644 --- a/src/frontend/src/Components/data/featureStyleDefinitions.js +++ b/src/frontend/src/Components/data/featureStyleDefinitions.js @@ -16,6 +16,11 @@ import roadWeatherIconActive from '../../images/mapIcons/road-weather-active.png import roadWeatherIconHover from '../../images/mapIcons/road-weather-hover.png'; import roadWeatherIconStatic from '../../images/mapIcons/road-weather-static.png'; +// Regional Weather +import regionalWeatherIconActive from '../../images/mapIcons/regional-weather-active.png'; +import regionalWeatherIconHover from '../../images/mapIcons/regional-weather-hover.png'; +import regionalWeatherIconStatic from '../../images/mapIcons/regional-weather-static.png'; + // Rest Stops import restStopIconActive from '../../images/mapIcons/rest-active.png'; import restStopIconHover from '../../images/mapIcons/rest-hover.png'; @@ -122,6 +127,28 @@ export const roadWeatherStyles = { }), }; +// Regional weather icon styles +export const regionalStyles = { + static: new Style({ + image: new Icon({ + scale: 0.25, + src: regionalWeatherIconStatic, + }), + }), + hover: new Style({ + image: new Icon({ + scale: 0.25, + src: regionalWeatherIconHover, + }), + }), + active: new Style({ + image: new Icon({ + scale: 0.25, + src: regionalWeatherIconActive, + }), + }), +}; + // Rest Stop icon styles export const restStopStyles = { static: new Style({ diff --git a/src/frontend/src/Components/data/weather.js b/src/frontend/src/Components/data/weather.js index 256d9ae2..b7d4b204 100644 --- a/src/frontend/src/Components/data/weather.js +++ b/src/frontend/src/Components/data/weather.js @@ -9,3 +9,13 @@ export function getWeather(routePoints) { console.log(error); }); } + +export function getRegional(routePoints) { + const payload = routePoints ? { route: routePoints } : {}; + + return get(`${window.API_HOST}/api/weather/regional`, payload) + .then((data) => data) + .catch((error) => { + console.log(error); + }); +} diff --git a/src/frontend/src/Components/map/layers/regionalLayer.js b/src/frontend/src/Components/map/layers/regionalLayer.js new file mode 100644 index 00000000..af3a73d2 --- /dev/null +++ b/src/frontend/src/Components/map/layers/regionalLayer.js @@ -0,0 +1,51 @@ +// Components and functions +import { transformFeature } from '../helper.js'; + +// OpenLayers +import { Point } from 'ol/geom'; +import * as ol from 'ol'; +import GeoJSON from 'ol/format/GeoJSON.js'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; + +// Styling +import { regionalStyles } from '../../data/featureStyleDefinitions.js'; + +export function loadRegionalLayers(weatherData, mapContext, projectionCode) { + return new VectorLayer({ + classname: 'regional', + visible: mapContext.visible_layers.weather, + source: new VectorSource({ + format: new GeoJSON(), + loader: function (extent, resolution, projection) { + const vectorSource = this; + vectorSource.clear(); + + weatherData.forEach(weather => { + // Build a new OpenLayers feature + if(!weather.location){ + return + } + const lat = weather.location.coordinates[1]; + const lng = weather.location.coordinates[0] + const olGeometry = new Point([lng, lat]); + const olFeature = new ol.Feature({ geometry: olGeometry }); + + // Transfer properties + olFeature.setProperties(weather) + olFeature.set('type', 'regional'); + + // Transform the projection + const olFeatureForMap = transformFeature( + olFeature, + 'EPSG:4326', + projectionCode, + ); + + vectorSource.addFeature(olFeatureForMap); + }); + }, + }), + style: regionalStyles['static'], + }); +} diff --git a/src/frontend/src/Components/map/mapPopup.js b/src/frontend/src/Components/map/mapPopup.js index db0fdcb1..290e5c0d 100644 --- a/src/frontend/src/Components/map/mapPopup.js +++ b/src/frontend/src/Components/map/mapPopup.js @@ -36,6 +36,8 @@ import { import './mapPopup.scss'; +import WeatherIcon from '../WeatherIcon'; + function convertCategory(event) { switch (event.display_category) { case 'closures': @@ -127,8 +129,6 @@ export function getEventPopup(eventFeature) { } - - ); @@ -166,6 +166,8 @@ export function getFerryPopup(ferryFeature) { ); } + + export function getWeatherPopup(weatherFeature) { const weatherData = weatherFeature.getProperties(); @@ -262,52 +264,120 @@ export function getWeatherPopup(weatherFeature) { + ); +} - // Regional weather html structure - //
- //
- //
- // - //
- //

Regional Weather

- //
- //
- // - //

Arctic outflow warning

- //

Details

- //
- //
- //
- //

Cummins Lakes Park

- // - //
- //
- // - //

Partly cloudy

- //

24℃

- //
- //
- //
- // - //
- //

Visibility

- //

42km

- //
- //
- //
- // - //
- //

Wind

- //

SW 27 gusts 44 km/h

- //
- //
- //
- //
- //
- //

Past 24 hours

- //

Courtesy of Environment Canada

- //
- //
+ +export function getRegionalPopup(weatherFeature) { + const weather = weatherFeature.getProperties(); + const conditions = weather.conditions; + + return ( +
+
+
+ +
+

Regional Weather

+
+ { weather.warnings && +
+ { weather.warnings.Events.map(event => { + return
+ +

{ event.Description }

+
; + })} + +

+ Details

+
+ } +
+
+

{weather.name}

+ { weather.observed && } +
+
+ +

{ conditions.condition }

+ + { conditions.temperature_units && +

+ { Math.round(conditions.temperature_value) } + { conditions.temperature_units } +

+ } + + { (conditions.visibility_value || conditions.wind_speed_value) && ( +
+ { conditions.visibility_value && ( +
+
+ +
+

Visibility

+

+ {Math.round(conditions.visibility_value)} + {conditions.visibility_units} +

+
+ )} + + { conditions.wind_speed_value && +
+
+ +
+

Wind

+

  + { conditions.wind_speed_value === "calm" ? + calm : + + { Math.round(conditions.wind_speed_value) } + { conditions.wind_speed_units } + { conditions.wind_gust_value && ( +  gusts + {Math.round(conditions.wind_gust_value)} +  {conditions.wind_gust_units} + + )} + + } +

+
+ } +
+ )} + +
+
+
+ { weather.station && +

+ Past 24 hours +

+ } +

+ Courtesy of   + Environment Canada

+
+
); } diff --git a/src/frontend/src/Components/map/mapPopup.scss b/src/frontend/src/Components/map/mapPopup.scss index 81ce56ee..2b1c8288 100644 --- a/src/frontend/src/Components/map/mapPopup.scss +++ b/src/frontend/src/Components/map/mapPopup.scss @@ -72,7 +72,7 @@ } .formatted-date { - font-size: 0.75rem; + font-size: 0.625rem; } } @@ -405,15 +405,31 @@ .popup__advisory { display: flex; + flex-direction: column; align-items: center; background-color: #FDD47B; color: #584215; padding: 0.75rem 1rem; + .event { + display: flex; + width: 100%; + align-items: center; + margin-bottom: 0.5rem; + + p { + line-height: 1.3; + } + + svg { + margin-right: 0.75rem; + } + } + svg { margin-right: 4px; } - + p { margin: 0; } @@ -425,7 +441,7 @@ } .link { - margin-left: auto; + min-width: max-content; } } diff --git a/src/frontend/src/images/mapIcons/regional-weather-active.png b/src/frontend/src/images/mapIcons/regional-weather-active.png new file mode 100644 index 0000000000000000000000000000000000000000..331ae67565c0438c6eb62410732ca2da02792dc0 GIT binary patch literal 2980 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 literal 0 HcmV?d00001 diff --git a/src/frontend/src/images/mapIcons/regional-weather-hover.png b/src/frontend/src/images/mapIcons/regional-weather-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..e6a2e7a420126139341eb7327f6700dd5a229098 GIT binary patch literal 2489 zcmV;q2}bsbP)@~0drDELIAGL9O(c600d`2O+f$vv5yP`b!1Sb*|#SG05aH>QKW=J9x)Dfpkt3uMPK!;YsWN4}aT`Dz1N`?lq zrGcSS%vun@It^+>Cn-(AG*Jh8yOk7Ro5^K^G>S2~+<9w{7H`L5+jcJ0%AIf` z80>hi(^q?G`ka)&ZKG*){q;SXz88yalswPsKz77WveEuEDJn+S=<;)>h?vEUFjW9X z{WFbjQO3+Ds%N7)O{dLfLy-cG2ZO;E8bzs$@O>>U)Z+7zf@pVVhjw;%%bm8-l&0`+ zY%1ixS)hP&BfqD+n|edN)E5d-TU#3`_SxFnqWIbxMWc(9NF=OIE1C&81#p|M(E~CO zzMlzuJTlG|peWDM@-i*VFHnjp#e@wO&>R&$p-H^^&LRH~?Au4ivmx}EnTyok-cAa8 z1OfpHF)M*tvAOw&@o;OCPH+DCSL#>%z-K_#^gK468S3q&si{d)RL;ct^AwM-6}`_) z(R(mOI)3;M;idtYozc+|QdGy}cavO}MxN=J@Lp#@Pis1pWEC(pJfs|L4eu%%7TYQb%FU_^t{! zYY%cKBN~&vo;Jk&K9=1o+h2PiQ&bP~f$R|l6;wD|3@0gShe_$CRVRg1lmaB96d)O; z0Ldr?NJc3@GD-oGQ3{ZZQh;QX0wkjpAQ?|6U}EAt4WB(nOH0e-l$+5gE1!Q%v$J#5 zh)7pJBAMjv99Y1fxpawUW-js9<=EZb<%*k|yT)Hf7Z>gH=6DY23P`02g^iYu$b|Vq zwgAV5hR<@v<&#*q?8ose(iM<2X=1_*KsSNWtYD-yC$POnQS}-yhf5qG81G4 z%*x8@D$QK{k^$)*2YzR!v*Q47lJz>SDgdG4`g(#3 zJoz}ug>J`0pnW6M7oy(Y5OoDQb8RxXHnsJHR##SdzF-E-TE1z6640A@4WgQM8+xDWo>`5Qk|wIHKis$hhpBYR>ZQdUTaj^^hVD*BA@;GmSoV?=YtFxs^l zu);>EQ$|O=pm2D6?%kE#AB__O#|X}a5@u(wa;J^Y%FmeLc>4$X zxli%?{oJSQdazpRY97E%1T#gYz=#x44$O$1QnUc)MsG%QpM?%&CwH+%%P#r3gUm1u3^?N`tzN* zSEx%O78#GwSYphy@Fw<8-K`*M0=)ddat-)^zR>X=BZ zoN|VRlOxRG<^!SHjdm~-;FM7&*-B;pj_&Sm8W^bG2F1E4pj33T&PJk$V6eb$7eqKB zdvsnj>LYXKv!_=&^MI6yP#GQ5D{)T5aM}NI=O#xVWD%IU)29$tRGf`?5 zU-=nsKJXWAwjta82lzdA;_mj|ze-s3Eivn}sPpIAYE-laiC&vVyc4b0 zu-vAl(RIk4hoNj1TsFIKdTN>!)nTkuFoz3Snh76h<*vtUV9t4pooMeC<_f@bD}?t}f)|ukfPg=M zo@kKOFHW)VJ=q6Aw~_*E^P(oJ?W9?G0U0h&aBc8ECcFqPZE|1O00000NkvXXu0mjf Dcove6 literal 0 HcmV?d00001 diff --git a/src/frontend/src/images/mapIcons/regional-weather-static.png b/src/frontend/src/images/mapIcons/regional-weather-static.png new file mode 100644 index 0000000000000000000000000000000000000000..c67a7f25fd5514bf34c29b8c10487407b97ee780 GIT binary patch literal 2431 zcmV-_34r#AP)@~0drDELIAGL9O(c600d`2O+f$vv5yPh=JN-{K3L^7b#+T8c-bI+IOKhO5_*~)#SD9OF|;`aBuyWgMp zyE}`TBoYb@{bSQPi)ez16eMx=`-%^Q^ajPKK7TExP)gDI!;6L^vt{z5ba+ zD^!|h4DGY=T9GE5M#BLGoC<|PFD$xE4#FQC9HeM8O5yMr`2zt8urCAA-rgSN*Vkz! zvqGzx3~g?1I^DK$q)6v~XG1}5tWrR&o)1CC;|mlX8zVzw(s%DtGMR8v#DS4eQ2?Lw zEqY9f@X_Nh#y;h}-_Sf570JZ+luD)4OB>9g85K6+h-iH?;QNMs`#`=k7K_oP%U5V% zV1NwnfXv3n-{Uon!_t|K8^5jXS zjl~M5-}3ox{=L1uYop;mxSJA~^D*rhIs;^>IxNrZl6!A6r)Q{G3bLdJVrI_oAdn@T zm@0~N0kXGkmR^wCp{c2wC zhOuyU9Az=t+eO9i4~~qG!5jRFA%^VX1yyA@jq&vkht$oGO$MzP1!%@7Kr==GnlTE{ zj8TAQi~=-c6rdTS0L>T$XvQc&Gd-bzix)qksWTr^I(?Vif|uTriT7!7agq8F>*OfP zJYSOi<1uxUe14r~rl%{vPn|u>6cTG#bA+mc+1W2ByS7$o%dGy|^U1{U$T0yTz8x(|}5B#mXQGjdm`Fzc5 z%c-AuPlrO#>!UN%yseeL)vLThn%Q!$qpSeoLkZ2WZl)mjbW&)Il-Y}DRlr&uL)!|l z3;i|BiG+C2ufZLzV}+X+VeO7!+kBA<;K*BNO?U1gxQKmv&>$|MA>IRuv2HI*a(VM zRd^%Xh8v9DFB|b*p(}wH+{$R#hgb3YOTvI}-wI-pG;9DyfB=GDV20f3Gj+P5DqTrM zIIfW@Bcj#KHWF#VO%-4y)w3l?Y0u0|Q!mi9Tu_m`fyV^?ms1LazO?vl!{;z+63pi4 zrYlnrgo5dy*0n&3`%b{0pPl=%stty`k=G@F{lMr%nne6Nkh19JBE_yKpq_UF1IaxY z<}NO|4BX_k%);0ztJ3QNMaiFuClO;{3d5Ay4P)*36eRvbwGi(Fc<*Z=aD!qopJUF4 zFx#;u0tA7mxabC+Du5@I7{yqfnMKwgXY=ox@SXye_BO!c| z$H6E_CV!|q^f5|W4cy$)NYfWJTp6)!QzodP5It^UiPQO=9D(@|{6z5`Opy#HRQ?5q| zlz~jANI|QJgChXUJZeDg1(jd;U{v+vwwhWiFot4IMr={97^@$* zQr513fP)7RlW!-?NfYEmou1xQEZ9(8-Qe%x+N zy8_UwMd=7$lqZ|?>v7LAQ-bh5EmJ-07zJp?C_pnt0h%!i(2P-l zW{d(fV-%nnqX5kq1!%@7Kr==GnlTE{3=~jx%&M2gu7@3mD!yHtp9C$@kDDI#3u@s2 zWN#OU{*)Wrt*tTCMqLS7v?ym=7G>nd<%6ex8al+Py568bt9GG4cSy1Wn5W#-;?7?d|Pdf57(!`|-56p;UKo(U>{Lye%O7n>T)}ZY-F%LCjd_#aPS2mS%o!9V~2002ovPDHLkV1gOqf;s>I literal 0 HcmV?d00001 diff --git a/src/frontend/src/slices/feedsSlice.js b/src/frontend/src/slices/feedsSlice.js index 3401d974..9922980f 100644 --- a/src/frontend/src/slices/feedsSlice.js +++ b/src/frontend/src/slices/feedsSlice.js @@ -13,6 +13,7 @@ export const feedsSlice = createSlice({ events: feedsInitialState, ferries: feedsInitialState, weather: feedsInitialState, + regional: feedsInitialState, restStops: feedsInitialState, }, reducers: { @@ -28,12 +29,22 @@ export const feedsSlice = createSlice({ updateWeather: (state, action) => { state.weather = action.payload; }, + updateRegional: (state, action) => { + state.regional = action.payload; + }, updateRestStops: (state, action) => { state.restStops = action.payload; }, }, }); -export const { updateCameras, updateEvents, updateFerries, updateWeather, updateRestStops } = feedsSlice.actions; +export const { + updateCameras, + updateEvents, + updateFerries, + updateWeather, + updateRegional, + updateRestStops, +} = feedsSlice.actions; export default feedsSlice.reducer;