From 4aa26c66ce3ab2076d889dbd0c35de62f4df76b7 Mon Sep 17 00:00:00 2001 From: Tyrel Narciso Date: Fri, 1 Mar 2024 18:56:06 -0800 Subject: [PATCH] DBC22-1804: populate data, serializer, and starting frontend logic --- src/backend/apps/feed/client.py | 120 ++++++++++++++++++ src/backend/apps/feed/constants.py | 4 +- src/backend/apps/feed/serializers.py | 17 ++- src/backend/apps/weather/admin.py | 3 +- .../management/commands/populate_current.py | 7 + .../management/commands/test_current.py | 14 ++ ...shed_0005_alter_currentweather_datasets.py | 56 ++++++++ .../weather/migrations/0004_currentweather.py | 28 ++++ .../0005_alter_currentweather_datasets.py | 18 +++ ...rrentweather_location_latitude_and_more.py | 23 ++++ ...rrentweather_location_latitude_and_more.py | 23 ++++ src/backend/apps/weather/models.py | 16 +++ src/backend/apps/weather/serializers.py | 56 +++++++- src/backend/apps/weather/tasks.py | 28 +++- .../tests/test_current_weather_populate.py | 0 src/backend/apps/weather/urls.py | 1 + src/backend/apps/weather/views.py | 10 +- src/backend/config/settings/drivebc.py | 12 +- src/frontend/src/App.js | 1 + src/frontend/src/Components/Filters.js | 23 +++- src/frontend/src/Components/Map.js | 36 +++++- src/frontend/src/Components/data/weather.js | 11 ++ .../src/Components/map/layers/weatherLayer.js | 47 +++++++ src/frontend/src/slices/feedsSlice.js | 8 +- 24 files changed, 546 insertions(+), 16 deletions(-) create mode 100644 src/backend/apps/weather/management/commands/populate_current.py create mode 100644 src/backend/apps/weather/management/commands/test_current.py create mode 100644 src/backend/apps/weather/migrations/0001_squashed_0005_alter_currentweather_datasets.py create mode 100644 src/backend/apps/weather/migrations/0004_currentweather.py create mode 100644 src/backend/apps/weather/migrations/0005_alter_currentweather_datasets.py create mode 100644 src/backend/apps/weather/migrations/0006_currentweather_location_latitude_and_more.py create mode 100644 src/backend/apps/weather/migrations/0007_alter_currentweather_location_latitude_and_more.py create mode 100644 src/backend/apps/weather/tests/test_current_weather_populate.py create mode 100644 src/frontend/src/Components/data/weather.js create mode 100644 src/frontend/src/Components/map/layers/weatherLayer.js diff --git a/src/backend/apps/feed/client.py b/src/backend/apps/feed/client.py index a4398a750..b6094d2d0 100644 --- a/src/backend/apps/feed/client.py +++ b/src/backend/apps/feed/client.py @@ -1,10 +1,13 @@ import logging +from pprint import pprint from typing import Dict from urllib.parse import urljoin import httpx import requests from apps.feed.constants import ( + CURRENT_WEATHER, + CURRENT_WEATHER_STATIONS, DIT, INLAND_FERRY, OPEN511, @@ -14,6 +17,7 @@ ) from apps.feed.serializers import ( CarsClosureEventSerializer, + CurrentWeatherSerializer, EventAPISerializer, EventFeedSerializer, FerryAPISerializer, @@ -25,6 +29,23 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response +SERIALIZER_TO_DATASET_MAPPING = { + "air_temperature": ("air_temp", "Air Temp"), + "road_temperature": ("sfc_temp", "Pavement Temp"), + "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)") + } + +DATASETNAMES = [value[0] for value in SERIALIZER_TO_DATASET_MAPPING.values()] + +DISPLAYNAME_MAPPING = {value[0]: value[1] for value in SERIALIZER_TO_DATASET_MAPPING.values()} + +SERIALIZER_MAPPING = {value[0]: key for key, value in SERIALIZER_TO_DATASET_MAPPING.items()} + + logger = logging.getLogger(__name__) @@ -51,6 +72,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): @@ -323,3 +350,96 @@ 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 + 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 not None: + for dataset in datasets: + if (dataset["DataSetName"] in DATASETNAMES + and DISPLAYNAME_MAPPING[dataset["DataSetName"]] == dataset["DisplayName"]): + filtered_dataset[SERIALIZER_MAPPING[dataset["DataSetName"]]] = { + "value": dataset["Value"], "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, + + } + 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) + pprint(json_objects) + 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 as e: + return Response({"error": f"Error fetching data from weather API: {str(e)}"}, 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 793b0a38c..39fb3d376 100644 --- a/src/backend/apps/feed/serializers.py +++ b/src/backend/apps/feed/serializers.py @@ -15,7 +15,7 @@ WebcamRegionField, WebcamRegionGroupField, ) -from apps.weather.models import RegionalWeather +from apps.weather.models import CurrentWeather, RegionalWeather from rest_framework import serializers @@ -189,3 +189,18 @@ 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' + ) 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/management/commands/test_current.py b/src/backend/apps/weather/management/commands/test_current.py new file mode 100644 index 000000000..fbf921434 --- /dev/null +++ b/src/backend/apps/weather/management/commands/test_current.py @@ -0,0 +1,14 @@ +from pprint import pprint + +from apps.weather.models import CurrentWeather +from apps.weather.serializers import CurrentWeatherSerializer +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + + def handle(self, *args, **options): + c = CurrentWeather.objects.get(id=275) + serializer = CurrentWeatherSerializer(c) + serializer = serializer.data + pprint(serializer) 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/models.py b/src/backend/apps/weather/models.py index 8a0487fbf..b68a16f24 100644 --- a/src/backend/apps/weather/models.py +++ b/src/backend/apps/weather/models.py @@ -41,3 +41,19 @@ def convert_coordinates(self, latitude_str, longitude_str): longitude = -longitude return latitude, longitude + + +class CurrentWeather(BaseModel): + 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) + + def __str__(self): + return f"Current weather for {self.pk}" + + def save(self, *args, **kwargs): + self.location = Point(self.location_longitude, self.location_latitude) + super().save(*args, **kwargs) diff --git a/src/backend/apps/weather/serializers.py b/src/backend/apps/weather/serializers.py index b89173894..501815562 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,57 @@ 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', 'air_temperature', 'average_wind', 'precipitation', 'snow', 'road_temperature', 'maximum_wind', 'road_condition', 'location'] + + 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): + return "road condition placeholder" diff --git a/src/backend/apps/weather/tasks.py b/src/backend/apps/weather/tasks.py index 8ff9be9d9..8b0952f8b 100644 --- a/src/backend/apps/weather/tasks.py +++ b/src/backend/apps/weather/tasks.py @@ -2,7 +2,7 @@ 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__) @@ -42,3 +42,29 @@ def populate_all_regional_weather_data(): # Rebuild cache 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() + 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'), + } + 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..f175f68b5 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 = ("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 32388b3cc..57560d8f6 100644 --- a/src/frontend/src/Components/Filters.js +++ b/src/frontend/src/Components/Filters.js @@ -100,6 +100,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)'); @@ -291,7 +293,26 @@ 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 90d725dde..083c0778a 100644 --- a/src/frontend/src/Components/Map.js +++ b/src/frontend/src/Components/Map.js @@ -4,7 +4,7 @@ import React, { useContext, useRef, useEffect, useState, useCallback } from 'rea // Redux import { memoize } from 'proxy-memoize' import { useSelector, useDispatch } from 'react-redux' -import { updateCameras, updateEvents, updateFerries } from '../slices/feedsSlice'; +import { updateCameras, updateEvents, updateFerries, updateWeather } from '../slices/feedsSlice'; import { updateMapState } from '../slices/mapSlice'; // External Components @@ -23,7 +23,9 @@ import CamPopup from './map/camPopup.js' import { getCamerasLayer } from './map/layers/camerasLayer.js'; import { getEventPopup, getFerryPopup } 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, @@ -72,6 +74,7 @@ export default function MapWrapper({ cameras, camTimeStamp, // Cameras events, eventTimeStamp, // Events ferries, ferriesTimeStamp, // CMS + weather, weatherTimeStamp, // Weather searchLocationFrom, selectedRoute, // Routing zoom, pan, // Map @@ -85,6 +88,9 @@ export default function MapWrapper({ // 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, @@ -133,6 +139,13 @@ export default function MapWrapper({ 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; @@ -542,7 +555,26 @@ export default function MapWrapper({ } } + useEffect(() => { + console.log("checking map context", mapContext) + loadWeatherLayers(weather, mapContext, mapLayers, mapRef); + }, [weather]); + + const loadWeather = async (route) => { + const newRouteTimestamp = route ? route.searchTimestamp : null; + + // 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) => { + console.log("loading data"); if (selectedRoute && selectedRoute.routeFound) { const routeLayer = getRouteLayer(selectedRoute, mapRef.current.getView().getProjection().getCode()); mapLayers.current['routeLayer'] = routeLayer; @@ -552,6 +584,7 @@ export default function MapWrapper({ loadCameras(selectedRoute); loadEvents(selectedRoute); loadFerries(); + loadWeather(); // Zoom/pan to route on route updates if (!isInitialMount) { @@ -562,6 +595,7 @@ export default function MapWrapper({ loadCameras(); loadEvents(null); loadFerries(); + loadWeather(); } } diff --git a/src/frontend/src/Components/data/weather.js b/src/frontend/src/Components/data/weather.js new file mode 100644 index 000000000..d4e987ddd --- /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..ddbc0159d --- /dev/null +++ b/src/frontend/src/Components/map/layers/weatherLayer.js @@ -0,0 +1,47 @@ +// 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 { ferryStyles } from '../../data/featureStyleDefinitions.js'; + +export function loadWeatherLayers(weatherData, mapContext, projectionCode) { + console.log("visible layers: ", mapContext.visible_layers) + 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 + const olGeometry = new Point(weather.location.coordinates); + 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: ferryStyles['static'], + }); +} 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;