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 (
-
- {ferryData.image_url &&
+ {ferryData.image_url && (
- }
+ )}
{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;