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 146087dc6..9770bd695 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 @@ -30,28 +31,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()} @@ -59,7 +65,7 @@ class FeedClient: - """Feed client for external DriveBC APIs.""" + """ Feed client for external DriveBC APIs. """ def __init__(self): self.resource_map: Dict[str, dict] = { @@ -101,7 +107,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: @@ -121,6 +129,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( @@ -215,128 +247,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, @@ -344,7 +362,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) @@ -356,7 +374,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( @@ -368,45 +386,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) @@ -451,7 +456,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 @@ -459,7 +464,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 e495eb714..88721b983 100644 --- a/src/backend/apps/feed/serializers.py +++ b/src/backend/apps/feed/serializers.py @@ -232,17 +232,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 10049e8f8..e6a40cb6f 100644 --- a/src/frontend/src/Components/Filters.js +++ b/src/frontend/src/Components/Filters.js @@ -312,12 +312,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 f9309f58b..c7ff914ba 100644 --- a/src/frontend/src/Components/Map.js +++ b/src/frontend/src/Components/Map.js @@ -15,6 +15,7 @@ import { updateEvents, updateFerries, updateWeather, + updateRegional, } from '../slices/feedsSlice'; import { updateMapState } from '../slices/mapSlice'; @@ -36,11 +37,13 @@ import { getEventPopup, getFerryPopup, getWeatherPopup, + getRegionalPopup, } from './map/mapPopup.js'; import { getEvents } from './data/events.js'; -import { getWeather } from './data/weather.js'; +import { getWeather, getRegional } from './data/weather.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, @@ -78,6 +81,7 @@ import { cameraStyles, ferryStyles, roadWeatherStyles, + regionalStyles, } from './data/featureStyleDefinitions.js'; import './Map.scss'; @@ -97,7 +101,9 @@ export default function MapWrapper({ ferries, ferriesTimeStamp, // CMS weather, - weatherTimeStamp, // Weather + weatherTimeStamp, // Current Weather + regional, + regionalTimeStamp, // Regional Weather searchLocationFrom, selectedRoute, // Routing zoom, @@ -114,9 +120,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, // Routing searchLocationFrom: state.routes.searchLocationFrom, selectedRoute: state.routes.selectedRoute, @@ -174,6 +183,13 @@ export default function MapWrapper({ setClickedWeather(feature); }; + const [clickedRegional, setClickedRegional] = useState(); + const clickedRegionalRef = useRef(); + const updateClickedRegional = feature => { + clickedRegionalRef.current = feature; + setClickedRegional(feature); + }; + // Define the function to be executed after the delay function resetCameraPopupRef() { cameraPopupRef.current = null; @@ -317,6 +333,7 @@ export default function MapWrapper({ clickedFerryRef.current.setStyle(ferryStyles['static']); updateClickedFerry(null); } + if ( clickedWeatherRef.current && targetFeature != clickedWeatherRef.current @@ -324,6 +341,14 @@ export default function MapWrapper({ clickedWeatherRef.current.setStyle(roadWeatherStyles['static']); updateClickedWeather(null); } + + if ( + clickedRegionalRef.current && + targetFeature != clickedRegionalRef.current + ) { + clickedRegionalRef.current.setStyle(regionalStyles['static']); + updateClickedRegional(null); + } }; const camClickHandler = feature => { @@ -390,6 +415,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'; + }; + mapRef.current.on('click', async e => { const features = mapRef.current.getFeaturesAtPixel(e.pixel, { hitTolerance: 20, @@ -410,7 +448,10 @@ export default function MapWrapper({ case 'weather': weatherClickHandler(clickedFeature); return; - } + case 'regional': + regionalClickHandler(clickedFeature); + return; + } } // Close popups if clicked on blank space @@ -441,10 +482,13 @@ export default function MapWrapper({ case 'ferry': hoveredFeature.current.setStyle(ferryStyles['static']); break; - case 'weather': - hoveredFeature.current.setStyle(roadWeatherStyles['static']); - break; - } + case 'weather': + hoveredFeature.current.setStyle(roadWeatherStyles['static']); + break; + case 'regional': + hoveredFeature.current.setStyle(regionalStyles['static']); + break; + } } hoveredFeature.current = null; @@ -487,6 +531,11 @@ export default function MapWrapper({ targetFeature.setStyle(roadWeatherStyles['hover']); } return; + case 'regional': + if (!targetFeature.getProperties().clicked) { + targetFeature.setStyle(regionalStyles['hover']); + } + return; } } @@ -674,6 +723,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( @@ -688,6 +768,7 @@ export default function MapWrapper({ loadEvents(selectedRoute); loadFerries(); loadWeather(); + loadRegional(); // Zoom/pan to route on route updates if (!isInitialMount) { @@ -699,6 +780,7 @@ export default function MapWrapper({ loadEvents(null); loadFerries(); loadWeather(); + loadRegional(); } }; @@ -761,6 +843,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); + } + // Reset cam popup handler lock timer cameraPopupRef.current = null; } @@ -858,7 +947,8 @@ export default function MapWrapper({ clickedCamera || clickedEvent || clickedFerry || - clickedWeather + clickedWeather || + clickedRegional ); return ( @@ -891,6 +981,8 @@ export default function MapWrapper({ {clickedFerry && getFerryPopup(clickedFerry)} {clickedWeather && getWeatherPopup(clickedWeather)} + + {clickedRegional && getRegionalPopup(clickedRegional)}
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 69ba302b8..f60c512c8 100644 --- a/src/frontend/src/Components/data/featureStyleDefinitions.js +++ b/src/frontend/src/Components/data/featureStyleDefinitions.js @@ -16,6 +16,10 @@ 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'; // Events // Closures @@ -118,6 +122,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, + }), + }), +}; + // Event icon styles export const eventStyles = { // Line Segments 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 8733e3a01..618111794 100644 --- a/src/frontend/src/Components/map/mapPopup.js +++ b/src/frontend/src/Components/map/mapPopup.js @@ -22,6 +22,8 @@ import { import './mapPopup.scss'; +import WeatherIcon from '../WeatherIcon'; + function convertCategory(event) { switch (event.display_category) { case 'closures': @@ -113,8 +115,6 @@ export function getEventPopup(eventFeature) { } - - ); @@ -152,6 +152,8 @@ export function getFerryPopup(ferryFeature) { ); } + + export function getWeatherPopup(weatherFeature) { const weatherData = weatherFeature.getProperties(); @@ -248,51 +250,119 @@ 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 fbef9943f..08464c4ee 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; } } @@ -393,15 +393,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; } @@ -413,7 +429,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 75e095cbf..4fc4ff1ea 100644 --- a/src/frontend/src/slices/feedsSlice.js +++ b/src/frontend/src/slices/feedsSlice.js @@ -12,7 +12,8 @@ export const feedsSlice = createSlice({ cameras: feedsInitialState, events: feedsInitialState, ferries: feedsInitialState, - weather: feedsInitialState + weather: feedsInitialState, + regional: feedsInitialState, }, reducers: { updateCameras: (state, action) => { @@ -27,9 +28,18 @@ export const feedsSlice = createSlice({ updateWeather: (state, action) => { state.weather = action.payload; }, + updateRegional: (state, action) => { + state.regional = action.payload; + }, }, }); -export const { updateCameras, updateEvents, updateFerries, updateWeather } = feedsSlice.actions; +export const { + updateCameras, + updateEvents, + updateFerries, + updateWeather, + updateRegional, +} = feedsSlice.actions; export default feedsSlice.reducer;