diff --git a/.env.example b/.env.example index b8d39192c..bbacf4484 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 5cf5df3cc..c60f2dfa5 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 a4dcf291b..fe6348cca 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 876f736de..faea9e153 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 000000000..b2adf345d --- /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 8e63f98eb..dfe4d9885 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 e9ea19a20..ce4283b3a 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 db49de5c3..c21058c11 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 9c9677e86..f8eea7961 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 7423355f6..8606916b2 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 000000000..3b34ca943 --- /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 7417041ab..7606e51a6 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 256d9ae2f..b7d4b204e 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 000000000..af3a73d2b --- /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 db0fdcb13..290e5c0db 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 81ce56ee3..2b1c8288a 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 000000000..331ae6756 Binary files /dev/null and b/src/frontend/src/images/mapIcons/regional-weather-active.png differ 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 000000000..e6a2e7a42 Binary files /dev/null and b/src/frontend/src/images/mapIcons/regional-weather-hover.png differ 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 000000000..c67a7f25f Binary files /dev/null and b/src/frontend/src/images/mapIcons/regional-weather-static.png differ diff --git a/src/frontend/src/slices/feedsSlice.js b/src/frontend/src/slices/feedsSlice.js index 3401d9749..9922980fa 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;