diff --git a/src/backend/apps/feed/client.py b/src/backend/apps/feed/client.py index 9b4aa04b0..146087dc6 100644 --- a/src/backend/apps/feed/client.py +++ b/src/backend/apps/feed/client.py @@ -5,6 +5,8 @@ import httpx import requests from apps.feed.constants import ( + CURRENT_WEATHER, + CURRENT_WEATHER_STATIONS, DIT, INLAND_FERRY, OPEN511, @@ -14,6 +16,7 @@ ) from apps.feed.serializers import ( CarsEventSerializer, + CurrentWeatherSerializer, EventAPISerializer, EventFeedSerializer, FerryAPISerializer, @@ -25,6 +28,33 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response +# Maps the key for our client API's serializer fields to the matching pair of +# the source API's DataSetName and DisplayName fields +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)") + } + +# Generated list of DataSetName values for filtering excluded dataset entries +DATASETNAMES = [value[0] for value in SERIALIZER_TO_DATASET_MAPPING.values()] + +# Generated mapping of DataSetName to DisplayName +DISPLAYNAME_MAPPING = {value[0]: value[1] for value in SERIALIZER_TO_DATASET_MAPPING.values()} + +# Generated mapping of DataSetName to Serializer field +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" +VALUE_FIELD_MAPPING = {value[0]: (value[2] if len(value) > 2 else "Value") + for value in SERIALIZER_TO_DATASET_MAPPING.values()} + logger = logging.getLogger(__name__) @@ -51,6 +81,12 @@ def __init__(self): REGIONAL_WEATHER_AREAS: { "base_url": settings.DRIVEBC_WEATHER_AREAS_API_BASE_URL, }, + CURRENT_WEATHER: { + "base_url": settings.DRIVEBC_WEATHER_CURRENT_API_BASE_URL, + }, + CURRENT_WEATHER_STATIONS: { + "base_url": settings.DRIVEBC_WEATHER_CURRENT_STATIONS_API_BASE_URL + } } def _get_auth_headers(self, resource_type): @@ -310,8 +346,8 @@ def get_regional_weather_list_feed(self, resource_type, resource_name, serialize except requests.RequestException as e: print(f"Error making API call for Area Code {area_code}: {e}") - except requests.RequestException as e: - return Response({"error": f"Error fetching data from weather API: {str(e)}"}, status=500) + except requests.RequestException: + return Response("Error fetching data from weather API", status=500) try: serializer.is_valid(raise_exception=True) @@ -327,3 +363,108 @@ def get_regional_weather_list(self): REGIONAL_WEATHER, 'regionalweather', RegionalWeatherSerializer, {"format": "json", "limit": 500} ) + + # Current Weather + 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") + 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_objects = [] + + for station in json_response: + station_number = station["WeatherStationNumber"] + api_endpoint = settings.DRIVEBC_WEATHER_CURRENT_API_BASE_URL + f"{station_number}" + # Reget access token in case the previous token 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") + 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) + data = response.json() + datasets = data.get("Datasets") if data else None + issuedUtc = data.get("IssuedUtc") + elevation = data.get('WeatherStation').get("Elevation") + Longitude = data.get('WeatherStation').get("Longitude") + Latitude = data.get('WeatherStation').get("Latitude") + weather_station_name = data.get('WeatherStation').get("WeatherStationName") + location_description = data.get('WeatherStation').get("LocationDescription") + # filtering down dataset to just SensorTypeName and DataSetName + filtered_dataset = {} + + if datasets is None: + continue + + for dataset in datasets: + dataset_name = dataset["DataSetName"] + if dataset_name not in DATASETNAMES: + continue + display_name = DISPLAYNAME_MAPPING[dataset_name] + serializer_name = SERIALIZER_MAPPING[dataset_name] + value_field = VALUE_FIELD_MAPPING[dataset_name] + + if display_name == dataset["DisplayName"]: + filtered_dataset[serializer_name] = { + "value": dataset[value_field], "unit": dataset["Unit"], + } + + current_weather_data = { + 'weather_station_name': weather_station_name, + 'elevation': elevation, + 'location_description': location_description, + 'datasets': filtered_dataset, + 'location_longitude': Longitude, + 'location_latitude': Latitude, + 'issuedUtc': issuedUtc, + } + serializer = serializer_cls(data=current_weather_data, + many=isinstance(current_weather_data, list)) + json_objects.append(current_weather_data) + + except requests.RequestException as e: + print(f"Error making API call for Area Code {station_number}: {e}") + try: + serializer.is_valid(raise_exception=True) + return json_objects + + except (KeyError, ValidationError): + field_errors = serializer.errors + for field, errors in field_errors.items(): + print(f"Field: {field}, Errors: {errors}") + except requests.RequestException: + return Response("Error fetching data from weather API", status=500) + + def get_current_weather_list(self): + return self.get_current_weather_list_feed( + CURRENT_WEATHER, 'currentweather', CurrentWeatherSerializer, + {"format": "json", "limit": 500} + ) diff --git a/src/backend/apps/feed/constants.py b/src/backend/apps/feed/constants.py index 1a897e520..7c2c58cc9 100644 --- a/src/backend/apps/feed/constants.py +++ b/src/backend/apps/feed/constants.py @@ -5,6 +5,8 @@ INLAND_FERRY = "inland_ferry" REGIONAL_WEATHER = "regional_weather" REGIONAL_WEATHER_AREAS = "regional_weather_areas" +CURRENT_WEATHER = "current_weather" +CURRENT_WEATHER_STATIONS = "current_weather_stations" DIRECTIONS = { 'in both directions': 'BOTH', @@ -12,4 +14,4 @@ 'southbound': 'S', 'eastbound': 'E', 'westbound': 'W', -} \ No newline at end of file +} diff --git a/src/backend/apps/feed/serializers.py b/src/backend/apps/feed/serializers.py index 87d41aa81..6b859de06 100644 --- a/src/backend/apps/feed/serializers.py +++ b/src/backend/apps/feed/serializers.py @@ -16,7 +16,7 @@ WebcamRegionField, WebcamRegionGroupField, ) -from apps.weather.models import RegionalWeather +from apps.weather.models import CurrentWeather, RegionalWeather from rest_framework import serializers @@ -239,3 +239,19 @@ class Meta: 'forecast_group', 'hourly_forecast_group', ) + + +# Current Weather serializer +class CurrentWeatherSerializer(serializers.Serializer): + class Meta: + model = CurrentWeather + fields = ( + 'id', + 'location_latitude', + 'location_longitude', + 'weather_station_name', + 'elevation', + 'location_description', + 'datasets', + 'issuedUtc', + ) diff --git a/src/backend/apps/weather/admin.py b/src/backend/apps/weather/admin.py index 77979826f..06e912d55 100644 --- a/src/backend/apps/weather/admin.py +++ b/src/backend/apps/weather/admin.py @@ -1,4 +1,4 @@ -from apps.weather.models import RegionalWeather +from apps.weather.models import CurrentWeather, RegionalWeather from django.contrib import admin from django.contrib.admin import ModelAdmin @@ -8,3 +8,4 @@ class WeatherAdmin(ModelAdmin): admin.site.register(RegionalWeather, WeatherAdmin) +admin.site.register(CurrentWeather, WeatherAdmin) diff --git a/src/backend/apps/weather/management/commands/populate_current.py b/src/backend/apps/weather/management/commands/populate_current.py new file mode 100644 index 000000000..edc3c6242 --- /dev/null +++ b/src/backend/apps/weather/management/commands/populate_current.py @@ -0,0 +1,7 @@ +from apps.weather.tasks import populate_all_current_weather_data +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + def handle(self, *args, **options): + populate_all_current_weather_data() diff --git a/src/backend/apps/weather/migrations/0001_squashed_0005_alter_currentweather_datasets.py b/src/backend/apps/weather/migrations/0001_squashed_0005_alter_currentweather_datasets.py new file mode 100644 index 000000000..55f895d12 --- /dev/null +++ b/src/backend/apps/weather/migrations/0001_squashed_0005_alter_currentweather_datasets.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.3 on 2024-02-29 18:55 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('weather', '0001_initial'), ('weather', '0002_rename_location_code_regionalweather_code_and_more'), ('weather', '0003_rename_location_name_regionalweather_name'), ('weather', '0004_currentweather'), ('weather', '0005_alter_currentweather_datasets')] + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='RegionalWeather', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('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)), + ('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)), + ('conditions', models.JSONField(null=True)), + ('forecast_group', models.JSONField(null=True)), + ('hourly_forecast_group', models.JSONField(null=True)), + ('location', django.contrib.gis.db.models.fields.PointField(null=True, srid=4326)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CurrentWeather', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('weather_station_name', models.CharField(max_length=100)), + ('elevation', models.IntegerField(null=True)), + ('location_description', models.TextField(null=True)), + ('datasets', models.JSONField(default=[], null=True)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/backend/apps/weather/migrations/0004_currentweather.py b/src/backend/apps/weather/migrations/0004_currentweather.py new file mode 100644 index 000000000..085d75d26 --- /dev/null +++ b/src/backend/apps/weather/migrations/0004_currentweather.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.3 on 2024-02-22 23:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('weather', '0003_rename_location_name_regionalweather_name'), + ] + + operations = [ + migrations.CreateModel( + name='CurrentWeather', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ('weather_station_name', models.CharField(max_length=100)), + ('elevation', models.IntegerField(null=True)), + ('location_description', models.TextField(null=True)), + ('datasets', models.JSONField(default=[])), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/backend/apps/weather/migrations/0005_alter_currentweather_datasets.py b/src/backend/apps/weather/migrations/0005_alter_currentweather_datasets.py new file mode 100644 index 000000000..bd8d859f6 --- /dev/null +++ b/src/backend/apps/weather/migrations/0005_alter_currentweather_datasets.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2024-02-26 17:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('weather', '0004_currentweather'), + ] + + operations = [ + migrations.AlterField( + model_name='currentweather', + name='datasets', + field=models.JSONField(default=[], null=True), + ), + ] diff --git a/src/backend/apps/weather/migrations/0006_currentweather_location_latitude_and_more.py b/src/backend/apps/weather/migrations/0006_currentweather_location_latitude_and_more.py new file mode 100644 index 000000000..b4346c450 --- /dev/null +++ b/src/backend/apps/weather/migrations/0006_currentweather_location_latitude_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.3 on 2024-02-29 23:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('weather', '0001_squashed_0005_alter_currentweather_datasets'), + ] + + operations = [ + migrations.AddField( + model_name='currentweather', + name='location_latitude', + field=models.CharField(max_length=10, null=True), + ), + migrations.AddField( + model_name='currentweather', + name='location_longitude', + field=models.CharField(max_length=10, null=True), + ), + ] diff --git a/src/backend/apps/weather/migrations/0007_alter_currentweather_location_latitude_and_more.py b/src/backend/apps/weather/migrations/0007_alter_currentweather_location_latitude_and_more.py new file mode 100644 index 000000000..23cf7616d --- /dev/null +++ b/src/backend/apps/weather/migrations/0007_alter_currentweather_location_latitude_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.3 on 2024-03-01 23:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('weather', '0006_currentweather_location_latitude_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='currentweather', + name='location_latitude', + field=models.CharField(max_length=20, null=True), + ), + migrations.AlterField( + model_name='currentweather', + name='location_longitude', + field=models.CharField(max_length=20, null=True), + ), + ] diff --git a/src/backend/apps/weather/migrations/0008_currentweather_location.py b/src/backend/apps/weather/migrations/0008_currentweather_location.py new file mode 100644 index 000000000..b5a700062 --- /dev/null +++ b/src/backend/apps/weather/migrations/0008_currentweather_location.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.3 on 2024-03-04 23:40 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('weather', '0007_alter_currentweather_location_latitude_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='currentweather', + name='location', + field=django.contrib.gis.db.models.fields.PointField(null=True, srid=4326), + ), + ] diff --git a/src/backend/apps/weather/migrations/0009_currentweather_issuedutc.py b/src/backend/apps/weather/migrations/0009_currentweather_issuedutc.py new file mode 100644 index 000000000..09ad88cc9 --- /dev/null +++ b/src/backend/apps/weather/migrations/0009_currentweather_issuedutc.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2024-03-07 17:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('weather', '0008_currentweather_location'), + ] + + operations = [ + migrations.AddField( + model_name='currentweather', + name='issuedUtc', + field=models.DateField(null=True), + ), + ] diff --git a/src/backend/apps/weather/migrations/0010_alter_currentweather_issuedutc.py b/src/backend/apps/weather/migrations/0010_alter_currentweather_issuedutc.py new file mode 100644 index 000000000..90bd68b78 --- /dev/null +++ b/src/backend/apps/weather/migrations/0010_alter_currentweather_issuedutc.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2024-03-08 19:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('weather', '0009_currentweather_issuedutc'), + ] + + operations = [ + migrations.AlterField( + model_name='currentweather', + name='issuedUtc', + field=models.DateTimeField(null=True), + ), + ] diff --git a/src/backend/apps/weather/models.py b/src/backend/apps/weather/models.py index 8a0487fbf..437a003fd 100644 --- a/src/backend/apps/weather/models.py +++ b/src/backend/apps/weather/models.py @@ -41,3 +41,21 @@ def convert_coordinates(self, latitude_str, longitude_str): longitude = -longitude return latitude, longitude + + +class CurrentWeather(BaseModel): + location = models.PointField(null=True) + weather_station_name = models.CharField(max_length=100) + elevation = models.IntegerField(null=True) + location_latitude = models.CharField(max_length=20, null=True) + location_longitude = models.CharField(max_length=20, null=True) + location_description = models.TextField(null=True) + datasets = models.JSONField(default=[], null=True) + issuedUtc = models.DateTimeField(null=True) + + def __str__(self): + return f"Current weather for {self.pk}" + + def save(self, *args, **kwargs): + self.location = Point(self.location_latitude, self.location_longitude) + super().save(*args, **kwargs) diff --git a/src/backend/apps/weather/serializers.py b/src/backend/apps/weather/serializers.py index b89173894..e9ea19a20 100644 --- a/src/backend/apps/weather/serializers.py +++ b/src/backend/apps/weather/serializers.py @@ -1,4 +1,4 @@ -from apps.weather.models import RegionalWeather +from apps.weather.models import CurrentWeather, RegionalWeather from rest_framework import serializers @@ -6,3 +6,73 @@ class RegionalWeatherSerializer(serializers.ModelSerializer): class Meta: model = RegionalWeather exclude = ['location_latitude', 'location_longitude'] + + +# Current Weather serializer +class CurrentWeatherSerializer(serializers.ModelSerializer): + air_temperature = serializers.SerializerMethodField() + road_temperature = serializers.SerializerMethodField() + precipitation = serializers.SerializerMethodField() + snow = serializers.SerializerMethodField() + average_wind = serializers.SerializerMethodField() + maximum_wind = serializers.SerializerMethodField() + road_condition = serializers.SerializerMethodField() + + class Meta: + model = CurrentWeather + fields = ['id', + 'weather_station_name', + 'air_temperature', + 'average_wind', + 'precipitation', + 'snow', + 'road_temperature', + 'maximum_wind', + 'road_condition', + 'location', + 'location_description', + 'issuedUtc', + 'elevation'] + + def get_air_temperature(self, obj): + if "air_temperature" in obj.datasets: + data = obj.datasets["air_temperature"] + return f'{data["value"]} {data["unit"]}' + return None + + def get_road_temperature(self, obj): + if "road_temperature" in obj.datasets: + data = obj.datasets["road_temperature"] + return f'{data["value"]} {data["unit"]}' + return None + + def get_precipitation(self, obj): + if "precipitation" in obj.datasets: + data = obj.datasets["precipitation"] + return f'{data["value"]} {data["unit"]}' + return None + + def get_snow(self, obj): + if "snow" in obj.datasets: + data = obj.datasets["snow"] + return f'{data["value"]} {data["unit"]}' + return + + def get_average_wind(self, obj): + if "average_wind" in obj.datasets: + data = obj.datasets["average_wind"] + return f'{data["value"]} {data["unit"]}' + return + + def get_maximum_wind(self, obj): + if "maximum_wind" in obj.datasets: + data = obj.datasets["maximum_wind"] + return f'{data["value"]} {data["unit"]}' + return None + + def get_road_condition(self, obj): + if "road_surface" in obj.datasets: + data = obj.datasets["road_surface"] + return data["value"] + return None + diff --git a/src/backend/apps/weather/tasks.py b/src/backend/apps/weather/tasks.py index 5521fdebc..db49de5c3 100644 --- a/src/backend/apps/weather/tasks.py +++ b/src/backend/apps/weather/tasks.py @@ -1,8 +1,9 @@ +import datetime import logging from apps.feed.client import FeedClient from apps.shared.enums import CacheKey -from apps.weather.models import RegionalWeather +from apps.weather.models import CurrentWeather, RegionalWeather from django.core.cache import cache logger = logging.getLogger(__name__) @@ -41,4 +42,35 @@ def populate_all_regional_weather_data(): populate_regional_weather_from_data(regional_weather_data) # Rebuild cache - cache.delete(CacheKey.REGIONAL_WEATHER_LIST) \ No newline at end of file + cache.delete(CacheKey.REGIONAL_WEATHER_LIST) + + +def populate_current_weather_from_data(new_current_weather_data): + weather_station_name = new_current_weather_data.get('weather_station_name') + existing_record = CurrentWeather.objects.filter(weather_station_name=weather_station_name).first() + issued_utc = new_current_weather_data.get('issuedUtc') + if issued_utc is not None: + issued_utc = datetime.datetime.strptime(issued_utc, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc) + + data = { + 'weather_station_name': weather_station_name, + 'elevation': new_current_weather_data.get('elevation'), + 'location_description': new_current_weather_data.get('location_description'), + 'datasets': new_current_weather_data.get('datasets'), + 'location_latitude': new_current_weather_data.get('location_latitude'), + 'location_longitude': new_current_weather_data.get('location_longitude'), + 'issuedUtc': issued_utc + } + if existing_record: + existing_record.__dict__.update(data) + existing_record.save() + else: + CurrentWeather.objects.create(**data) + + +def populate_all_current_weather_data(): + client = FeedClient() + feed_data = client.get_current_weather_list() + + for current_weather_data in feed_data: + populate_current_weather_from_data(current_weather_data) diff --git a/src/backend/apps/weather/tests/test_current_weather_populate.py b/src/backend/apps/weather/tests/test_current_weather_populate.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/apps/weather/urls.py b/src/backend/apps/weather/urls.py index 3c05c86ec..d522bcbbd 100644 --- a/src/backend/apps/weather/urls.py +++ b/src/backend/apps/weather/urls.py @@ -7,5 +7,6 @@ urlpatterns = [ path('regional', weather_views.WeatherViewSet.as_view({'get': 'regional'}), name='regional'), + path('current', weather_views.WeatherViewSet.as_view({'get': 'current'}), name='current'), path('', include(weather_router.urls)), ] diff --git a/src/backend/apps/weather/views.py b/src/backend/apps/weather/views.py index f15abd2a3..dd0baf3d3 100644 --- a/src/backend/apps/weather/views.py +++ b/src/backend/apps/weather/views.py @@ -1,7 +1,7 @@ from apps.shared.enums import CacheKey, CacheTimeout from apps.shared.views import CachedListModelMixin -from apps.weather.models import RegionalWeather -from apps.weather.serializers import RegionalWeatherSerializer +from apps.weather.models import CurrentWeather, RegionalWeather +from apps.weather.serializers import CurrentWeatherSerializer, RegionalWeatherSerializer from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.response import Response @@ -20,3 +20,9 @@ def regional(self, request, pk=None): regional_weather_objects = RegionalWeather.objects.all() serializer = RegionalWeatherSerializer(regional_weather_objects, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=True, methods=['get']) + def current(self, request, pk=None): + current_weather_objects = CurrentWeather.objects.all() + serializer = CurrentWeatherSerializer(current_weather_objects, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/src/backend/config/settings/drivebc.py b/src/backend/config/settings/drivebc.py index be81428dd..e32cc24f5 100644 --- a/src/backend/config/settings/drivebc.py +++ b/src/backend/config/settings/drivebc.py @@ -13,8 +13,10 @@ DRIVEBC_INLAND_FERRY_API_BASE_URL = env("DRIVEBC_INLAND_FERRY_API_BASE_URL") DRIVEBC_DIT_API_BASE_URL = env("DRIVEBC_DIT_API_BASE_URL") # Weather API Settings -WEATHER_CLIENT_ID=env("WEATHER_CLIENT_ID") -WEATHER_CLIENT_SECRET=env("WEATHER_CLIENT_SECRET") -DRIVEBC_WEATHER_API_BASE_URL=env("DRIVEBC_WEATHER_API_BASE_URL") -DRIVEBC_WEATHER_AREAS_API_BASE_URL=env("DRIVEBC_WEATHER_AREAS_API_BASE_URL") -DRIVEBC_WEATHER_API_TOKEN_URL=env("DRIVEBC_WEATHER_API_TOKEN_URL") \ No newline at end of file +WEATHER_CLIENT_ID = env("WEATHER_CLIENT_ID") +WEATHER_CLIENT_SECRET = env("WEATHER_CLIENT_SECRET") +DRIVEBC_WEATHER_API_BASE_URL = env("DRIVEBC_WEATHER_API_BASE_URL") +DRIVEBC_WEATHER_AREAS_API_BASE_URL = env("DRIVEBC_WEATHER_AREAS_API_BASE_URL") +DRIVEBC_WEATHER_API_TOKEN_URL = env("DRIVEBC_WEATHER_API_TOKEN_URL") +DRIVEBC_WEATHER_CURRENT_API_BASE_URL = env("DRIVEBC_WEATHER_CURRENT_API_BASE_URL") +DRIVEBC_WEATHER_CURRENT_STATIONS_API_BASE_URL = env("DRIVEBC_WEATHER_CURRENT_STATIONS_API_BASE_URL") diff --git a/src/frontend/src/App.js b/src/frontend/src/App.js index 4c434783b..1b9dcf0f7 100644 --- a/src/frontend/src/App.js +++ b/src/frontend/src/App.js @@ -37,6 +37,7 @@ function App() { roadConditions: true, highwayCams: false, inlandFerries: true, + weather: true, }, }; } diff --git a/src/frontend/src/Components/Filters.js b/src/frontend/src/Components/Filters.js index d01cdf9c8..6c3256d4a 100644 --- a/src/frontend/src/Components/Filters.js +++ b/src/frontend/src/Components/Filters.js @@ -11,6 +11,7 @@ import { faVideo, faSnowflake, faFerry, + faTemperatureHalf, // faRestroom, // faCloudSun } from '@fortawesome/free-solid-svg-icons'; @@ -77,6 +78,11 @@ export default function Filters(props) {

Travel requires the use of an inland ferry.

); + const tooltipWeather = ( + +

Weather updates for roads.

+
+ ); // const tooltipReststops = ( // @@ -103,6 +109,8 @@ export default function Filters(props) { const [roadConditions, setRoadConditions] = useState(mapContext.visible_layers.roadConditions); const [highwayCams, setHighwayCams] = useState(mapContext.visible_layers.highwayCams); const [inlandFerries, setInlandFerries] = useState(mapContext.visible_layers.inlandFerries); + const [weather, setWeather] = useState(mapContext.visible_layers.weather); + const largeScreen = useMediaQuery('only screen and (min-width : 768px)'); @@ -296,12 +304,31 @@ export default function Filters(props) { - Inland ferries + Inland Ferries ? +
+ {toggleHandler('weather', 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 43ca73fb1..f9309f58b 100644 --- a/src/frontend/src/Components/Map.js +++ b/src/frontend/src/Components/Map.js @@ -1,10 +1,21 @@ // React -import React, { useContext, useRef, useEffect, useState, useCallback } from 'react'; +import React, { + useContext, + useRef, + useEffect, + useState, + useCallback, +} from 'react'; // Redux -import { memoize } from 'proxy-memoize' -import { useSelector, useDispatch } from 'react-redux' -import { updateCameras, updateEvents, updateFerries } from '../slices/feedsSlice'; +import { memoize } from 'proxy-memoize'; +import { useSelector, useDispatch } from 'react-redux'; +import { + updateCameras, + updateEvents, + updateFerries, + updateWeather, +} from '../slices/feedsSlice'; import { updateMapState } from '../slices/mapSlice'; // External Components @@ -19,11 +30,17 @@ import { } from '@fortawesome/free-solid-svg-icons'; // Components and functions -import CamPopup from './map/camPopup.js' +import CamPopup from './map/camPopup.js'; import { getCamerasLayer } from './map/layers/camerasLayer.js'; -import { getEventPopup, getFerryPopup } from './map/mapPopup.js' +import { + getEventPopup, + getFerryPopup, + getWeatherPopup, +} from './map/mapPopup.js'; import { getEvents } from './data/events.js'; +import { getWeather } from './data/weather.js'; import { loadEventsLayers } from './map/layers/eventsLayer.js'; +import { loadWeatherLayers } from './map/layers/weatherLayer.js'; import { fitMap, blueLocationMarkup, @@ -32,7 +49,7 @@ import { getEventIcon, setZoomPan, zoomIn, - zoomOut + zoomOut, } from './map/helper.js'; import { getFerries } from './data/ferries.js'; import { getFerriesLayer } from './map/layers/ferriesLayer.js'; @@ -57,41 +74,58 @@ import VectorTileSource from 'ol/source/VectorTile.js'; import View from 'ol/View'; // Styling -import { cameraStyles, ferryStyles } from './data/featureStyleDefinitions.js'; +import { + cameraStyles, + ferryStyles, + roadWeatherStyles, +} from './data/featureStyleDefinitions.js'; import './Map.scss'; export default function MapWrapper({ camera, isPreview, cameraHandler, - mapViewRoute + mapViewRoute, }) { // Redux const dispatch = useDispatch(); const { - cameras, camTimeStamp, // Cameras - events, eventTimeStamp, // Events - ferries, ferriesTimeStamp, // CMS - searchLocationFrom, selectedRoute, // Routing - zoom, pan, // Map - - } = useSelector(useCallback(memoize(state => ({ - // Cameras - cameras: state.feeds.cameras.list, - camTimeStamp: state.feeds.cameras.routeTimeStamp, - // Events - events: state.feeds.events.list, - eventTimeStamp: state.feeds.events.routeTimeStamp, - // CMS - ferries: state.feeds.ferries.list, - ferriesTimeStamp: state.feeds.ferries.routeTimeStamp, - // Routing - searchLocationFrom: state.routes.searchLocationFrom, - selectedRoute: state.routes.selectedRoute, - // Map - zoom: state.map.zoom, - pan: state.map.pan - })))); + cameras, + camTimeStamp, // Cameras + events, + eventTimeStamp, // Events + ferries, + ferriesTimeStamp, // CMS + weather, + weatherTimeStamp, // Weather + searchLocationFrom, + selectedRoute, // Routing + zoom, + pan, // Map + } = useSelector( + useCallback( + memoize(state => ({ + // Cameras + cameras: state.feeds.cameras.list, + camTimeStamp: state.feeds.cameras.routeTimeStamp, + // Events + events: state.feeds.events.list, + eventTimeStamp: state.feeds.events.routeTimeStamp, + // CMS + ferries: state.feeds.ferries.list, + ferriesTimeStamp: state.feeds.ferries.routeTimeStamp, + // Weather + weather: state.feeds.weather.list, + weatherTimeStamp: state.feeds.weather.routeTimeStamp, + // Routing + searchLocationFrom: state.routes.searchLocationFrom, + selectedRoute: state.routes.selectedRoute, + // Map + zoom: state.map.zoom, + pan: state.map.pan, + })), + ), + ); // Context const { mapContext, setMapContext } = useContext(MapContext); @@ -114,29 +148,36 @@ export default function MapWrapper({ // Workaround for OL handlers not being able to read states const [clickedCamera, setClickedCamera] = useState(); const clickedCameraRef = useRef(); - const updateClickedCamera = (feature) => { + const updateClickedCamera = feature => { clickedCameraRef.current = feature; setClickedCamera(feature); - } + }; const [clickedEvent, setClickedEvent] = useState(); const clickedEventRef = useRef(); - const updateClickedEvent = (feature) => { + const updateClickedEvent = feature => { clickedEventRef.current = feature; setClickedEvent(feature); - } + }; const [clickedFerry, setClickedFerry] = useState(); const clickedFerryRef = useRef(); - const updateClickedFerry = (feature) => { + const updateClickedFerry = feature => { clickedFerryRef.current = feature; setClickedFerry(feature); - } + }; + + const [clickedWeather, setClickedWeather] = useState(); + const clickedWeatherRef = useRef(); + const updateClickedWeather = feature => { + clickedWeatherRef.current = feature; + setClickedWeather(feature); + }; // Define the function to be executed after the delay function resetCameraPopupRef() { - cameraPopupRef.current = null; - } + cameraPopupRef.current = null; + } useEffect(() => { // initialization hook for the OpenLayers map logic @@ -150,7 +191,7 @@ export default function MapWrapper({ animation: { duration: 250, }, - margin: 90, + margin: 90, }, }); @@ -167,8 +208,11 @@ export default function MapWrapper({ }; // Set map extent - const extent = [-143.23013896362576, 65.59132385849652, -109.97743701256154, 46.18015377362468]; - const transformedExtent = transformExtent(extent,'EPSG:4326','EPSG:3857'); + const extent = [ + -143.23013896362576, 65.59132385849652, -109.97743701256154, + 46.18015377362468, + ]; + const transformedExtent = transformExtent(extent, 'EPSG:4326', 'EPSG:3857'); mapView.current = new View({ projection: 'EPSG:3857', @@ -176,7 +220,7 @@ export default function MapWrapper({ center: camera ? handleCenter() : fromLonLat(pan), zoom: handleZoom(), maxZoom: 15, - extent: transformedExtent + extent: transformedExtent, }); // Apply the basemap style from the arcgis resource @@ -218,25 +262,40 @@ export default function MapWrapper({ }); mapRef.current.on('moveend', function () { - dispatch(updateMapState({pan: toLonLat(mapView.current.getCenter()), zoom: mapView.current.getZoom()})) + dispatch( + updateMapState({ + pan: toLonLat(mapView.current.getCenter()), + zoom: mapView.current.getZoom(), + }), + ); }); // Click states - const resetClickedStates = (targetFeature) => { + const resetClickedStates = targetFeature => { // camera is set to data structure rather than map feature if (clickedCameraRef.current && !clickedCameraRef.current.setStyle) { - clickedCameraRef.current = mapLayers.current['highwayCams'].getSource().getFeatureById(clickedCameraRef.current.id); + clickedCameraRef.current = mapLayers.current['highwayCams'] + .getSource() + .getFeatureById(clickedCameraRef.current.id); } - if (clickedCameraRef.current && targetFeature != clickedCameraRef.current) { + if ( + clickedCameraRef.current && + targetFeature != clickedCameraRef.current + ) { clickedCameraRef.current.setStyle(cameraStyles['static']); updateClickedCamera(null); } // event is set to data structure rather than map feature if (clickedEventRef.current && !clickedEventRef.current.ol_uid) { - const features = mapLayers.current[clickedEventRef.current.display_category].getSource(); - clickedEventRef.current = features.getFeatureById(clickedEventRef.current.id); + const features = + mapLayers.current[ + clickedEventRef.current.display_category + ].getSource(); + clickedEventRef.current = features.getFeatureById( + clickedEventRef.current.id, + ); } if (clickedEventRef.current && targetFeature != clickedEventRef.current) { @@ -245,7 +304,8 @@ export default function MapWrapper({ ); // Set associated line/point feature - const altFeature = clickedEventRef.current.getProperties()['altFeature']; + const altFeature = + clickedEventRef.current.getProperties()['altFeature']; if (altFeature) { altFeature.setStyle(getEventIcon(altFeature, 'static')); } @@ -257,18 +317,23 @@ export default function MapWrapper({ clickedFerryRef.current.setStyle(ferryStyles['static']); updateClickedFerry(null); } - } + if ( + clickedWeatherRef.current && + targetFeature != clickedWeatherRef.current + ) { + clickedWeatherRef.current.setStyle(roadWeatherStyles['static']); + updateClickedWeather(null); + } + }; - const camClickHandler = (feature) => { + const camClickHandler = feature => { resetClickedStates(feature); // set new clicked camera feature feature.setStyle(cameraStyles['active']); feature.setProperties({ clicked: true }, true); - popup.current.setPosition( - feature.getGeometry().getCoordinates(), - ); + popup.current.setPosition(feature.getGeometry().getCoordinates()); popup.current.getElement().style.top = '40px'; updateClickedCamera(feature); @@ -276,9 +341,9 @@ export default function MapWrapper({ cameraPopupRef.current = popup; setTimeout(resetCameraPopupRef, 500); - } + }; - const eventClickHandler = (feature) => { + const eventClickHandler = feature => { // reset previous clicked feature resetClickedStates(feature); @@ -295,13 +360,11 @@ export default function MapWrapper({ updateClickedEvent(feature); - popup.current.setPosition( - feature.getGeometry().getCoordinates(), - ); + popup.current.setPosition(feature.getGeometry().getCoordinates()); popup.current.getElement().style.top = '40px'; - } + }; - const ferryClickHandler = (feature) => { + const ferryClickHandler = feature => { // reset previous clicked feature resetClickedStates(feature); @@ -310,20 +373,31 @@ export default function MapWrapper({ feature.setProperties({ clicked: true }, true); updateClickedFerry(feature); - popup.current.setPosition( - feature.getGeometry().getCoordinates(), - ); + popup.current.setPosition(feature.getGeometry().getCoordinates()); popup.current.getElement().style.top = '40px'; - } + }; + + const weatherClickHandler = feature => { + // reset previous clicked feature + resetClickedStates(feature); + + // set new clicked ferry feature + feature.setStyle(roadWeatherStyles['active']); + feature.setProperties({ clicked: true }, true); + updateClickedWeather(feature); + + popup.current.setPosition(feature.getGeometry().getCoordinates()); + popup.current.getElement().style.top = '40px'; + }; - mapRef.current.on('click', async (e) => { + mapRef.current.on('click', async e => { const features = mapRef.current.getFeaturesAtPixel(e.pixel, { hitTolerance: 20, }); if (features.length) { const clickedFeature = features[0]; - switch(clickedFeature.getProperties()['type']) { + switch (clickedFeature.getProperties()['type']) { case 'camera': camClickHandler(clickedFeature); return; @@ -333,6 +407,9 @@ export default function MapWrapper({ case 'ferry': ferryClickHandler(clickedFeature); return; + case 'weather': + weatherClickHandler(clickedFeature); + return; } } @@ -341,7 +418,7 @@ export default function MapWrapper({ }); // Hover states - const resetHoveredStates = (targetFeature) => { + const resetHoveredStates = targetFeature => { if (hoveredFeature.current && targetFeature != hoveredFeature.current) { if (!hoveredFeature.current.getProperties().clicked) { switch (hoveredFeature.current.getProperties()['type']) { @@ -349,10 +426,13 @@ export default function MapWrapper({ hoveredFeature.current.setStyle(cameraStyles['static']); break; case 'event': { - hoveredFeature.current.setStyle(getEventIcon(hoveredFeature.current, 'static')); + hoveredFeature.current.setStyle( + getEventIcon(hoveredFeature.current, 'static'), + ); // Set associated line/point feature - const altFeature = hoveredFeature.current.getProperties()['altFeature']; + const altFeature = + hoveredFeature.current.getProperties()['altFeature']; if (altFeature) { altFeature.setStyle(getEventIcon(altFeature, 'static')); } @@ -361,14 +441,17 @@ export default function MapWrapper({ case 'ferry': hoveredFeature.current.setStyle(ferryStyles['static']); break; + case 'weather': + hoveredFeature.current.setStyle(roadWeatherStyles['static']); + break; } } hoveredFeature.current = null; } - } + }; - mapRef.current.on('pointermove', async (e) => { + mapRef.current.on('pointermove', async e => { const features = mapRef.current.getFeaturesAtPixel(e.pixel, { hitTolerance: 20, }); @@ -399,6 +482,11 @@ export default function MapWrapper({ targetFeature.setStyle(ferryStyles['hover']); } return; + case 'weather': + if (!targetFeature.getProperties().clicked) { + targetFeature.setStyle(roadWeatherStyles['hover']); + } + return; } } @@ -420,18 +508,25 @@ export default function MapWrapper({ searchLocationFrom[0].geometry.coordinates, blueLocationMarkup, mapRef, - locationPinRef + locationPinRef, ); - if (isInitialMountLocation.current === 'not set') { // first run of this effector + if (isInitialMountLocation.current === 'not set') { + // first run of this effector // store the initial searchLocationFrom.[0].label so that subsequent // runs can be evaluated to detect change in the search from isInitialMountLocation.current = searchLocationFrom[0].label; - } else if (isInitialMountLocation.current !== searchLocationFrom[0].label) { + } else if ( + isInitialMountLocation.current !== searchLocationFrom[0].label + ) { // only zoomPan on a real change in the search location from; this makes // this effector idempotent wrt state isInitialMountLocation.current = false; - setZoomPan(mapView, 9, fromLonLat(searchLocationFrom[0].geometry.coordinates)); + setZoomPan( + mapView, + 9, + fromLonLat(searchLocationFrom[0].geometry.coordinates), + ); } } else { // initial location was set, so no need to prevent pan/zoom @@ -440,7 +535,8 @@ export default function MapWrapper({ }, [searchLocationFrom]); useEffect(() => { - if (isInitialMountRoute.current) { // Do nothing on first load + if (isInitialMountRoute.current) { + // Do nothing on first load isInitialMountRoute.current = false; return; } @@ -472,42 +568,46 @@ export default function MapWrapper({ mapContext, camera, updateClickedCamera, - ) + ); mapRef.current.addLayer(mapLayers.current['highwayCams']); mapLayers.current['highwayCams'].setZIndex(78); } }, [cameras]); - const loadCameras = async (route) => { + const loadCameras = async route => { const newRouteTimestamp = route ? route.searchTimestamp : null; // Fetch data if it doesn't already exist or route was updated - if (!cameras || (camTimeStamp != newRouteTimestamp)) { - dispatch(updateCameras({ - list: await getCameras(route ? route.points : null), - routeTimeStamp: route ? route.searchTimestamp : null, - timeStamp: new Date().getTime() - })); + if (!cameras || camTimeStamp != newRouteTimestamp) { + dispatch( + updateCameras({ + list: await getCameras(route ? route.points : null), + routeTimeStamp: route ? route.searchTimestamp : null, + timeStamp: new Date().getTime(), + }), + ); } - } + }; useEffect(() => { loadEventsLayers(events, mapContext, mapLayers, mapRef); }, [events]); - const loadEvents = async (route) => { + const loadEvents = async route => { const newRouteTimestamp = route ? route.searchTimestamp : null; // Fetch data if it doesn't already exist or route was updated - if (!events || (eventTimeStamp != newRouteTimestamp)) { - dispatch(updateEvents({ - list: await getEvents(route ? route.points : null), - routeTimeStamp: route ? route.searchTimestamp : null, - timeStamp: new Date().getTime() - })); + if (!events || eventTimeStamp != newRouteTimestamp) { + dispatch( + updateEvents({ + list: await getEvents(route ? route.points : null), + routeTimeStamp: route ? route.searchTimestamp : null, + timeStamp: new Date().getTime(), + }), + ); } - } + }; useEffect(() => { // Remove layer if it already exists @@ -521,30 +621,65 @@ export default function MapWrapper({ mapLayers.current['inlandFerries'] = getFerriesLayer( ferries, mapRef.current.getView().getProjection().getCode(), - mapContext - ) + mapContext, + ); mapRef.current.addLayer(mapLayers.current['inlandFerries']); mapLayers.current['inlandFerries'].setZIndex(68); } }, [ferries]); - const loadFerries = async (route) => { + const loadFerries = async route => { const newRouteTimestamp = route ? route.searchTimestamp : null; // Fetch data if it doesn't already exist or route was updated - if (!ferries || (ferriesTimeStamp != newRouteTimestamp)) { - dispatch(updateFerries({ - list: await getFerries(route ? route.points : null), - routeTimeStamp: route ? route.searchTimestamp : null, - timeStamp: new Date().getTime() - })); + if (!ferries || ferriesTimeStamp != newRouteTimestamp) { + dispatch( + updateFerries({ + list: await getFerries(route ? route.points : null), + routeTimeStamp: route ? route.searchTimestamp : null, + timeStamp: new Date().getTime(), + }), + ); } - } + }; + + useEffect(() => { + if (mapLayers.current['weather']) { + mapRef.current.removeLayer(mapLayers.current['weather']); + } + if (weather) { + mapLayers.current['weather'] = loadWeatherLayers( + weather, + mapContext, + mapRef.current.getView().getProjection().getCode(), + ); + mapRef.current.addLayer(mapLayers.current['weather']); + mapLayers.current['weather'].setZIndex(66); + } + }, [weather]); + + const loadWeather = async route => { + const newRouteTimestamp = route ? route.searchTimestamp : null; - const loadData = (isInitialMount) => { + // Fetch data if it doesn't already exist or route was updated + if (!weather || weatherTimeStamp != newRouteTimestamp) { + dispatch( + updateWeather({ + list: await getWeather(route ? route.points : null), + routeTimeStamp: route ? route.searchTimestamp : null, + timeStamp: new Date().getTime(), + }), + ); + } + }; + + const loadData = isInitialMount => { if (selectedRoute && selectedRoute.routeFound) { - const routeLayer = getRouteLayer(selectedRoute, mapRef.current.getView().getProjection().getCode()); + const routeLayer = getRouteLayer( + selectedRoute, + mapRef.current.getView().getProjection().getCode(), + ); mapLayers.current['routeLayer'] = routeLayer; mapRef.current.addLayer(routeLayer); @@ -552,6 +687,7 @@ export default function MapWrapper({ loadCameras(selectedRoute); loadEvents(selectedRoute); loadFerries(); + loadWeather(); // Zoom/pan to route on route updates if (!isInitialMount) { @@ -562,15 +698,18 @@ export default function MapWrapper({ loadCameras(); loadEvents(null); loadFerries(); + loadWeather(); } - } + }; function closePopup() { popup.current.setPosition(undefined); // camera is set to data structure rather than map feature if (clickedCameraRef.current && !clickedCameraRef.current.setStyle) { - clickedCameraRef.current = mapLayers.current['highwayCams'].getSource().getFeatureById(clickedCameraRef.current.id); + clickedCameraRef.current = mapLayers.current['highwayCams'] + .getSource() + .getFeatureById(clickedCameraRef.current.id); } // check for active camera icons @@ -584,8 +723,11 @@ export default function MapWrapper({ // event is set to data structure rather than map feature if (clickedEventRef.current && !clickedEventRef.current.ol_uid) { - const features = mapLayers.current[clickedEventRef.current.display_category].getSource(); - clickedEventRef.current = features.getFeatureById(clickedEventRef.current.id); + const features = + mapLayers.current[clickedEventRef.current.display_category].getSource(); + clickedEventRef.current = features.getFeatureById( + clickedEventRef.current.id, + ); } if (clickedEventRef.current) { @@ -597,9 +739,7 @@ export default function MapWrapper({ // Set associated line/point feature const altFeature = clickedEventRef.current.getProperties()['altFeature']; if (altFeature) { - altFeature.setStyle( - getEventIcon(altFeature, 'static'), - ); + altFeature.setStyle(getEventIcon(altFeature, 'static')); altFeature.set('clicked', false); } @@ -614,6 +754,13 @@ export default function MapWrapper({ updateClickedFerry(null); } + // check for active weather icons + if (clickedWeatherRef.current) { + clickedWeatherRef.current.setStyle(roadWeatherStyles['static']); + clickedWeatherRef.current.set('clicked', false); + updateClickedWeather(null); + } + // Reset cam popup handler lock timer cameraPopupRef.current = null; } @@ -631,7 +778,6 @@ export default function MapWrapper({ ) { setZoomPan(mapView, 9, fromLonLat([longitude, latitude])); setLocationPin([longitude, latitude], redLocationMarkup, mapRef); - } else { // set my location to the center of BC for users outside of BC setZoomPan(mapView, 9, fromLonLat([-126.5, 54.2])); @@ -641,13 +787,11 @@ export default function MapWrapper({ error => { if (error.code === error.PERMISSION_DENIED) { // The user has blocked location access - console.error("Location access denied by user.", error); - } - else { + console.error('Location access denied by user.', error); + } else { // Zoom out and center to BC if location not available setZoomPan(mapView, 9, fromLonLat([-126.5, 54.2])); } - }, ); } @@ -665,8 +809,7 @@ export default function MapWrapper({ if (panel.current.classList.contains('open')) { if (!panel.current.classList.contains('maximized')) { panel.current.classList.add('maximized'); - } - else { + } else { panel.current.classList.remove('maximized'); } } @@ -689,10 +832,9 @@ export default function MapWrapper({ if (typeof camera === 'string') { camera = JSON.parse(camera); } - if(isPreview || camera){ - return 12 - } - else{ + if (isPreview || camera) { + return 12; + } else { return zoom; } } @@ -707,18 +849,23 @@ export default function MapWrapper({ } // Force camera and inland ferries filters to be checked on preview mode - if(isPreview) { + if (isPreview) { mapContext.visible_layers['highwayCams'] = true; mapContext.visible_layers['inlandFerries'] = true; } - const openPanel = !!(clickedCamera || clickedEvent || clickedFerry); + const openPanel = !!( + clickedCamera || + clickedEvent || + clickedFerry || + clickedWeather + ); return (
-
{ @@ -730,29 +877,29 @@ export default function MapWrapper({
- {clickedCamera && - - } + {clickedCamera && ( + + )} {clickedEvent && getEventPopup(clickedEvent)} {clickedFerry && getFerryPopup(clickedFerry)} -
+ {clickedWeather && getWeatherPopup(clickedWeather)} +
-
- @@ -778,7 +925,7 @@ export default function MapWrapper({ )} {!isPreview && ( -
+
@@ -794,8 +941,7 @@ export default function MapWrapper({
{isPreview && ( @@ -829,7 +975,6 @@ export default function MapWrapper({ enableRoadConditions={true} /> )} -
); } diff --git a/src/frontend/src/Components/data/featureStyleDefinitions.js b/src/frontend/src/Components/data/featureStyleDefinitions.js index 6fc2b6e43..69ba302b8 100644 --- a/src/frontend/src/Components/data/featureStyleDefinitions.js +++ b/src/frontend/src/Components/data/featureStyleDefinitions.js @@ -11,6 +11,12 @@ import ferryIconActive from '../../images/mapIcons/ferry-active.png'; import ferryIconHover from '../../images/mapIcons/ferry-hover.png'; import ferryIconStatic from '../../images/mapIcons/ferry-static.png'; +// Road Weather +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'; + + // Events // Closures import closuresActiveIcon from '../../images/mapIcons/closure-active.png'; @@ -90,6 +96,28 @@ export const ferryStyles = { }), }; +// Weather icon styles +export const roadWeatherStyles = { + static: new Style({ + image: new Icon({ + scale: 0.25, + src: roadWeatherIconStatic, + }), + }), + hover: new Style({ + image: new Icon({ + scale: 0.25, + src: roadWeatherIconHover, + }), + }), + active: new Style({ + image: new Icon({ + scale: 0.25, + src: roadWeatherIconActive, + }), + }), +}; + // 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 new file mode 100644 index 000000000..256d9ae2f --- /dev/null +++ b/src/frontend/src/Components/data/weather.js @@ -0,0 +1,11 @@ +import { get } from "./helper.js"; + +export function getWeather(routePoints) { + const payload = routePoints ? { route: routePoints } : {}; + + return get(`${window.API_HOST}/api/weather/current`, payload) + .then((data) => data) + .catch((error) => { + console.log(error); + }); +} diff --git a/src/frontend/src/Components/map/layers/weatherLayer.js b/src/frontend/src/Components/map/layers/weatherLayer.js new file mode 100644 index 000000000..fcc81ae5b --- /dev/null +++ b/src/frontend/src/Components/map/layers/weatherLayer.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 { roadWeatherStyles } from '../../data/featureStyleDefinitions.js'; + +export function loadWeatherLayers(weatherData, mapContext, projectionCode) { + return new VectorLayer({ + classname: 'weather', + 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[0]; + const lng = weather.location.coordinates[1] + const olGeometry = new Point([lng, lat]); + const olFeature = new ol.Feature({ geometry: olGeometry }); + + // Transfer properties + olFeature.setProperties(weather) + olFeature.set('type', 'weather'); + + // Transform the projection + const olFeatureForMap = transformFeature( + olFeature, + 'EPSG:4326', + projectionCode, + ); + + vectorSource.addFeature(olFeatureForMap); + }); + }, + }), + style: roadWeatherStyles['static'], + }); +} diff --git a/src/frontend/src/Components/map/mapPopup.js b/src/frontend/src/Components/map/mapPopup.js index abbcb69fb..90e916717 100644 --- a/src/frontend/src/Components/map/mapPopup.js +++ b/src/frontend/src/Components/map/mapPopup.js @@ -23,17 +23,23 @@ import { import './mapPopup.scss'; function convertCategory(event) { - switch(event.display_category) { + switch (event.display_category) { case 'closures': return 'Closure'; case 'majorEvents': - return event.event_type === 'INCIDENT' ? 'Major incident ' : 'Major delay'; + return event.event_type === 'INCIDENT' + ? 'Major incident ' + : 'Major delay'; case 'minorEvents': - return event.event_type === 'INCIDENT' ? 'Minor incident ' : 'Minor delay'; + return event.event_type === 'INCIDENT' + ? 'Minor incident ' + : 'Minor delay'; case 'futureEvents': - return event.severity === 'MAJOR' ? 'Major future event' : 'Minor future event'; - case 'roadConditions': - return 'Road condition' + return event.severity === 'MAJOR' + ? 'Major future event' + : 'Minor future event'; + case 'roadConditions': + return 'Road condition'; default: return ''; } @@ -41,32 +47,35 @@ function convertCategory(event) { function convertDirection(direction) { switch (direction) { - case "N": - return "Northbound"; - case "W": - return "Westbound"; - case "E": - return "Eastbound"; - case "S": - return "Southbound"; - case "BOTH": - return "Both Directions"; - case "NONE": - return " "; - default: - return " "; + case 'N': + return 'Northbound'; + case 'W': + return 'Westbound'; + case 'E': + return 'Eastbound'; + case 'S': + return 'Southbound'; + case 'BOTH': + return 'Both Directions'; + case 'NONE': + return ' '; + default: + return ' '; } } export function getEventPopup(eventFeature) { - const eventData = eventFeature.ol_uid ? eventFeature.getProperties() : eventFeature; + const eventData = eventFeature.ol_uid + ? eventFeature.getProperties() + : eventFeature; const severity = eventData.severity.toLowerCase(); return ( -
+
- +

{convertCategory(eventData)}

@@ -116,15 +125,18 @@ export function getFerryPopup(ferryFeature) {

- {`${ferryData.title}`} + {`${ferryData.title}`}

- {ferryData.image_url && + {ferryData.image_url && (
{ferryData.title}
- } + )}

{parse(ferryData.description)}

@@ -249,3 +261,98 @@ export function getFerryPopup(ferryFeature) { //
); } +export function getWeatherPopup(weatherFeature) { + const weatherData = weatherFeature.getProperties(); + + return ( +
+
+
+ +
+

Local Weather

+ Weather Stations +
+
+
+

{weatherData.weather_station_name}

+ +

{weatherData.location_description}

+
+
+
+

{weatherData.road_condition}

+

Road Condition

+
+ {(weatherData.air_temperature || weatherData.road_temperature) && ( +
+ {weatherData.air_temperature && ( +
+

{weatherData.air_temperature}

+

Air

+
+ )} + {weatherData.road_temperature && ( +
+

{weatherData.road_temperature}

+

Road

+
+ )} +
+ )} +
+ {weatherData.elevation && ( +
+
+ +
+

Elevation

+

{weatherData.elevation}

+
+ )} + {weatherData.precipitation && ( +
+
+ +
+

Precipitation (last 12 hours)

+

{weatherData.precipitation}

+
+ )} + {weatherData.snow && ( +
+
+ +
+

Snow (last 12 hours)

+

{weatherData.snow}

+
+ )} + {(weatherData.average_wind || + weatherData.maximum_wind) && ( +
+
+ +
+
+ {weatherData.average_wind && ( +
+

Average wind

+

{weatherData.average_wind}

+
+ )} + {weatherData.maximum_wind && ( +
+

Maximum wind

+

{weatherData.maximum_wind}

+
+ )} +
+
+ )} +
+
+
+
+ ); +} diff --git a/src/frontend/src/images/mapIcons/road-weather-active.png b/src/frontend/src/images/mapIcons/road-weather-active.png new file mode 100644 index 000000000..d2a6940ff Binary files /dev/null and b/src/frontend/src/images/mapIcons/road-weather-active.png differ diff --git a/src/frontend/src/images/mapIcons/road-weather-hover.png b/src/frontend/src/images/mapIcons/road-weather-hover.png new file mode 100644 index 000000000..c2fec0a37 Binary files /dev/null and b/src/frontend/src/images/mapIcons/road-weather-hover.png differ diff --git a/src/frontend/src/images/mapIcons/road-weather-static.png b/src/frontend/src/images/mapIcons/road-weather-static.png new file mode 100644 index 000000000..11c10e1ab Binary files /dev/null and b/src/frontend/src/images/mapIcons/road-weather-static.png differ diff --git a/src/frontend/src/slices/feedsSlice.js b/src/frontend/src/slices/feedsSlice.js index ef30a65a7..75e095cbf 100644 --- a/src/frontend/src/slices/feedsSlice.js +++ b/src/frontend/src/slices/feedsSlice.js @@ -11,7 +11,8 @@ export const feedsSlice = createSlice({ initialState: { cameras: feedsInitialState, events: feedsInitialState, - ferries: feedsInitialState + ferries: feedsInitialState, + weather: feedsInitialState }, reducers: { updateCameras: (state, action) => { @@ -23,9 +24,12 @@ export const feedsSlice = createSlice({ updateFerries: (state, action) => { state.ferries = action.payload; }, + updateWeather: (state, action) => { + state.weather = action.payload; + }, }, }); -export const { updateCameras, updateEvents, updateFerries } = feedsSlice.actions; +export const { updateCameras, updateEvents, updateFerries, updateWeather } = feedsSlice.actions; export default feedsSlice.reducer;