diff --git a/air-quality-backend/api/src/main.py b/air-quality-backend/api/src/main.py index 4f3664b6..656283dc 100644 --- a/air-quality-backend/api/src/main.py +++ b/air-quality-backend/api/src/main.py @@ -7,6 +7,8 @@ origins = [ "http://localhost:5173", "http://127.0.0.1:5173", + "http://localhost:8000", + "http://127.0.0.1:8000", ] load_dotenv() diff --git a/air-quality-backend/api/src/mappers/forecast_mapper.py b/air-quality-backend/api/src/mappers/forecast_mapper.py index b0ef0c3e..27345360 100644 --- a/air-quality-backend/api/src/mappers/forecast_mapper.py +++ b/air-quality-backend/api/src/mappers/forecast_mapper.py @@ -1,28 +1,45 @@ -from datetime import UTC -from typing import List - -from src.types import ForecastDto -from shared.src.database.forecasts import Forecast - - -def database_to_api_result(measurement: Forecast) -> ForecastDto: - return { - "base_time": measurement["forecast_base_time"].astimezone(UTC), - "valid_time": measurement["forecast_valid_time"].astimezone(UTC), - "location_type": measurement["location_type"], - "location_name": measurement["name"], - "location": { - "longitude": measurement["location"]["coordinates"][0], - "latitude": measurement["location"]["coordinates"][1], - }, - "overall_aqi_level": measurement["overall_aqi_level"], - "no2": measurement["no2"], - "o3": measurement["o3"], - "pm2_5": measurement["pm2_5"], - "pm10": measurement["pm10"], - "so2": measurement["so2"], - } - - -def map_forecast(measurements_from_database: List[Forecast]) -> List[ForecastDto]: - return list(map(database_to_api_result, measurements_from_database)) +from datetime import UTC +from typing import List + +from src.types import ForecastDto +from shared.src.database.forecasts import Forecast + + +def database_to_api_result(measurement: Forecast) -> ForecastDto: + return { + "base_time": measurement["forecast_base_time"].astimezone(UTC), + "valid_time": measurement["forecast_valid_time"].astimezone(UTC), + "location_type": measurement["location_type"], + "location_name": measurement["name"], + "location": { + "longitude": measurement["location"]["coordinates"][0], + "latitude": measurement["location"]["coordinates"][1], + }, + "overall_aqi_level": measurement["overall_aqi_level"], + "no2": measurement["no2"], + "o3": measurement["o3"], + "pm2_5": measurement["pm2_5"], + "pm10": measurement["pm10"], + "so2": measurement["so2"], + } + + +def map_forecast(measurements_from_database: List[Forecast]) -> List[ForecastDto]: + return list(map(database_to_api_result, measurements_from_database)) + + +def map_measurement_counts(measurements_from_database: List[Forecast]) -> dict: + """Maps database measurements to a count of measurements per city and pollutant""" + counts = {} + pollutants = ["no2", "o3", "pm2_5", "pm10", "so2"] + + for measurement in measurements_from_database: + city = measurement["location_name"] + if city not in counts: + counts[city] = {pollutant: 0 for pollutant in pollutants} + + for pollutant in pollutants: + if measurement[pollutant] is not None: + counts[city][pollutant] += 1 + + return counts diff --git a/air-quality-backend/api/src/mappers/measurements_mapper.py b/air-quality-backend/api/src/mappers/measurements_mapper.py index d96efd65..2b1c9769 100644 --- a/air-quality-backend/api/src/mappers/measurements_mapper.py +++ b/air-quality-backend/api/src/mappers/measurements_mapper.py @@ -1,68 +1,127 @@ -from datetime import UTC -from typing import List - -from src.types import MeasurementDto, MeasurementSummaryDto -from shared.src.aqi.calculator import get_pollutant_index_level, get_overall_aqi_level -from shared.src.aqi.pollutant_type import PollutantType -from shared.src.database.in_situ import InSituMeasurement, InSituAveragedMeasurement - - -def map_measurement(measurement: InSituMeasurement) -> MeasurementDto: - return { - "measurement_date": measurement["measurement_date"].astimezone(UTC), - "location_type": measurement["location_type"], - "location_name": measurement["name"], - "location": { - "longitude": measurement["location"]["coordinates"][0], - "latitude": measurement["location"]["coordinates"][1], - }, - **{ - pollutant_type.value: measurement[pollutant_type.literal()]["value"] - for pollutant_type in PollutantType - if pollutant_type.value in measurement - }, - "api_source": measurement["api_source"], - "entity": measurement["metadata"]["entity"], - "sensor_type": measurement["metadata"]["sensor_type"], - "site_name": measurement["location_name"], - } - - -def map_measurements(measurements: list[InSituMeasurement]) -> list[MeasurementDto]: - return list(map(map_measurement, measurements)) - - -def map_summarized_measurement( - measurement: InSituAveragedMeasurement, -) -> MeasurementSummaryDto: - pollutant_data = {} - mean_aqi_values = [] - for pollutant_type in PollutantType: - pollutant_value = pollutant_type.literal() - avg_value = measurement[pollutant_value]["mean"] - if avg_value is not None: - aqi = get_pollutant_index_level( - avg_value, - pollutant_type, - ) - if pollutant_value not in pollutant_data: - pollutant_data[pollutant_value] = {} - pollutant_data[pollutant_value]["mean"] = { - "aqi_level": aqi, - "value": avg_value, - } - mean_aqi_values.append(aqi) - - return { - "measurement_base_time": measurement["measurement_base_time"], - "location_type": measurement["location_type"], - "location_name": measurement["name"], - "overall_aqi_level": {"mean": get_overall_aqi_level(mean_aqi_values)}, - **pollutant_data, - } - - -def map_summarized_measurements( - averages: List[InSituAveragedMeasurement], -) -> List[MeasurementSummaryDto]: - return list(map(map_summarized_measurement, averages)) +from datetime import UTC +from typing import List + +from src.types import MeasurementDto, MeasurementSummaryDto +from shared.src.aqi.calculator import get_pollutant_index_level, get_overall_aqi_level +from shared.src.aqi.pollutant_type import PollutantType +from shared.src.database.in_situ import InSituMeasurement, InSituAveragedMeasurement + + +def map_measurement(measurement: InSituMeasurement) -> MeasurementDto: + return { + "measurement_date": measurement["measurement_date"].astimezone(UTC), + "location_type": measurement["location_type"], + "location_name": measurement["name"], + "location": { + "longitude": measurement["location"]["coordinates"][0], + "latitude": measurement["location"]["coordinates"][1], + }, + **{ + pollutant_type.value: measurement[pollutant_type.literal()]["value"] + for pollutant_type in PollutantType + if pollutant_type.value in measurement + }, + "api_source": measurement["api_source"], + "entity": measurement["metadata"]["entity"], + "sensor_type": measurement["metadata"]["sensor_type"], + "site_name": measurement["location_name"], + } + + +def map_measurements(measurements: list[InSituMeasurement]) -> list[MeasurementDto]: + return list(map(map_measurement, measurements)) + + +def map_summarized_measurement( + measurement: InSituAveragedMeasurement, +) -> MeasurementSummaryDto: + pollutant_data = {} + mean_aqi_values = [] + for pollutant_type in PollutantType: + pollutant_value = pollutant_type.literal() + avg_value = measurement[pollutant_value]["mean"] + if avg_value is not None: + aqi = get_pollutant_index_level( + avg_value, + pollutant_type, + ) + if pollutant_value not in pollutant_data: + pollutant_data[pollutant_value] = {} + pollutant_data[pollutant_value]["mean"] = { + "aqi_level": aqi, + "value": avg_value, + } + mean_aqi_values.append(aqi) + + return { + "measurement_base_time": measurement["measurement_base_time"], + "location_type": measurement["location_type"], + "location_name": measurement["name"], + "overall_aqi_level": {"mean": get_overall_aqi_level(mean_aqi_values)}, + **pollutant_data, + } + + +def map_summarized_measurements( + averages: List[InSituAveragedMeasurement], +) -> List[MeasurementSummaryDto]: + return list(map(map_summarized_measurement, averages)) + + +def map_measurement_counts(measurements): + """Map measurements to counts by city and pollutant, including unique location counts per pollutant""" + counts = {} + location_sets = {} # Track unique locations per city and pollutant + + for measurement in measurements: + city = measurement["name"] + if city not in counts: + counts[city] = { + "so2": 0, + "no2": 0, + "o3": 0, + "pm10": 0, + "pm2_5": 0, + "so2_locations": 0, + "no2_locations": 0, + "o3_locations": 0, + "pm10_locations": 0, + "pm2_5_locations": 0, + } + location_sets[city] = { + "so2": set(), + "no2": set(), + "o3": set(), + "pm10": set(), + "pm2_5": set(), + } + + location_name = measurement["location_name"] + + # Count measurements and track locations per pollutant + if "so2" in measurement and measurement["so2"] is not None: + counts[city]["so2"] += 1 + location_sets[city]["so2"].add(location_name) + + if "no2" in measurement and measurement["no2"] is not None: + counts[city]["no2"] += 1 + location_sets[city]["no2"].add(location_name) + + if "o3" in measurement and measurement["o3"] is not None: + counts[city]["o3"] += 1 + location_sets[city]["o3"].add(location_name) + + if "pm10" in measurement and measurement["pm10"] is not None: + counts[city]["pm10"] += 1 + location_sets[city]["pm10"].add(location_name) + + if "pm2_5" in measurement and measurement["pm2_5"] is not None: + counts[city]["pm2_5"] += 1 + location_sets[city]["pm2_5"].add(location_name) + + # Add location counts per pollutant to the final output + for city in counts: + for pollutant in ["so2", "no2", "o3", "pm10", "pm2_5"]: + counts[city][f"{pollutant}_locations"] = len(location_sets[city][pollutant]) + + return counts diff --git a/air-quality-backend/api/src/measurements_controller.py b/air-quality-backend/api/src/measurements_controller.py index 66475439..a2c19f01 100644 --- a/air-quality-backend/api/src/measurements_controller.py +++ b/air-quality-backend/api/src/measurements_controller.py @@ -1,12 +1,13 @@ import logging as log from datetime import datetime -from typing import List +from typing import List, Dict from fastapi import Query, APIRouter from src.mappers.measurements_mapper import ( map_measurements, map_summarized_measurements, + map_measurement_counts, ) from .types import MeasurementSummaryDto, MeasurementDto from shared.src.database.in_situ import ApiSource, get_averaged, find_by_criteria @@ -50,3 +51,26 @@ async def get_measurements_summary( ) log.info(f"Found results for {len(averaged_measurements)} locations") return map_summarized_measurements(averaged_measurements) + + +@router.get("/air-pollutant/measurements/counts") +async def get_measurement_counts( + date_from: datetime, + date_to: datetime, + location_type: AirQualityLocationType = "city", + location_names: List[str] = Query(None), +) -> Dict: + """Get count of measurements per city and pollutant for a given time range""" + log.info( + f"Fetching measurement counts between {date_from} - {date_to} for {location_type}" + ) + measurements = find_by_criteria( + date_from, + date_to, + location_type, + location_names, + ) + + counts = map_measurement_counts(measurements) + log.info(f"Found measurement counts for {len(counts)} locations") + return counts diff --git a/air-quality-backend/api/src/routes/measurement_counts.py b/air-quality-backend/api/src/routes/measurement_counts.py new file mode 100644 index 00000000..5b693bdf --- /dev/null +++ b/air-quality-backend/api/src/routes/measurement_counts.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Dict, List + +from fastapi import APIRouter, Query +from src.mappers.forecast_mapper import map_measurement_counts +from src.services.forecast_service import get_measurements_for_time_range +from src.types import LocationType + +router = APIRouter() + + +@router.get("/counts") +async def get_measurement_counts( + date_from: datetime, + date_to: datetime, + location_type: LocationType = "city", + location_names: List[str] = Query(None), +) -> Dict: + """Get count of measurements per city and pollutant for a given time range""" + measurements = await get_measurements_for_time_range( + date_from=date_from, + date_to=date_to, + location_type=location_type, + location_names=location_names, + ) + + counts = map_measurement_counts(measurements) + print("Measurement counts:", counts) # Debug logging + return counts diff --git a/air-quality-backend/api/tests/mappers/texture_mapper_test.py b/air-quality-backend/api/tests/mappers/texture_mapper_test.py index 091d7698..58fb8735 100644 --- a/air-quality-backend/api/tests/mappers/texture_mapper_test.py +++ b/air-quality-backend/api/tests/mappers/texture_mapper_test.py @@ -15,6 +15,6 @@ def test__map_forecast_database_data_to_api_output_data(): "source": "cams-production", "texture_uri": "/2024-07-24_00/no2_2024-07-24_00_CAMS", "min_value": 0.0, - "max_value": 100.0 + "max_value": 100.0, } assert result[0] == expected diff --git a/air-quality-backend/api/tests/texture_controller_test.py b/air-quality-backend/api/tests/texture_controller_test.py index 9731e5c9..9bb34a4b 100644 --- a/air-quality-backend/api/tests/texture_controller_test.py +++ b/air-quality-backend/api/tests/texture_controller_test.py @@ -18,8 +18,8 @@ def test__get_data_texture__no_results(): return_value=[], ) as mock_fetch_data: response = client.get( - "/air-pollutant/data_textures", - params=default_request_params) + "/air-pollutant/data_textures", params=default_request_params + ) assert response.status_code == 404 assert response.json() == {} mock_fetch_data.assert_called_once_with( @@ -36,28 +36,28 @@ def test__required_query_params__error_if_not_supplied(): def test__get_data_texture__with_results(): mock_db_results = [ { - 'time_start': datetime(2024, 7, 24, 0, 0), - 'source': 'cams-production', - 'forecast_base_time': datetime(2024, 7, 24, 0, 0).astimezone(UTC), - 'variable': 'no2', - 'time_end': datetime(2024, 7, 25, 21, 0), - 'chunk': '1 of 3', - 'max_value': 100.0, - 'min_value': 0.0, - 'texture_uri': '/app/data_textures/2024-07-24_00/no2_2024-07-24_00_CAMS', - 'units': 'kg m**-3 * 1e-9', + "time_start": datetime(2024, 7, 24, 0, 0), + "source": "cams-production", + "forecast_base_time": datetime(2024, 7, 24, 0, 0).astimezone(UTC), + "variable": "no2", + "time_end": datetime(2024, 7, 25, 21, 0), + "chunk": "1 of 3", + "max_value": 100.0, + "min_value": 0.0, + "texture_uri": "/app/data_textures/2024-07-24_00/no2_2024-07-24_00_CAMS", + "units": "kg m**-3 * 1e-9", }, { - 'time_start': datetime(2024, 7, 24, 0, 0), - 'forecast_base_time': datetime(2024, 7, 24, 0, 0).astimezone(UTC), - 'source': 'cams-production', - 'variable': 'o3', - 'time_end': datetime(2024, 7, 25, 21, 0), - 'chunk': '1 of 3', - 'max_value': 500.0, - 'min_value': 0.0, - 'texture_uri': '/app/data_textures/2024-07-24_00/o3_2024-07-24_00_CAMS', - 'units': 'kg m**-3 * 1e-9', + "time_start": datetime(2024, 7, 24, 0, 0), + "forecast_base_time": datetime(2024, 7, 24, 0, 0).astimezone(UTC), + "source": "cams-production", + "variable": "o3", + "time_end": datetime(2024, 7, 25, 21, 0), + "chunk": "1 of 3", + "max_value": 500.0, + "min_value": 0.0, + "texture_uri": "/app/data_textures/2024-07-24_00/o3_2024-07-24_00_CAMS", + "units": "kg m**-3 * 1e-9", }, ] mock_results = [ @@ -70,7 +70,7 @@ def test__get_data_texture__with_results(): "source": "cams-production", "texture_uri": "/2024-07-24_00/no2_2024-07-24_00_CAMS", "min_value": 0.0, - "max_value": 100.0 + "max_value": 100.0, }, { "base_time": "2024-07-24T00:00:00Z", @@ -81,7 +81,7 @@ def test__get_data_texture__with_results(): "source": "cams-production", "texture_uri": "/2024-07-24_00/o3_2024-07-24_00_CAMS", "min_value": 0.0, - "max_value": 500.0 + "max_value": 500.0, }, ] with patch( @@ -89,8 +89,8 @@ def test__get_data_texture__with_results(): return_value=mock_db_results, ) as mock_fetch_data: response = client.get( - "/air-pollutant/data_textures", - params=default_request_params) + "/air-pollutant/data_textures", params=default_request_params + ) assert response.status_code == 200 assert response.json() == mock_results @@ -105,8 +105,8 @@ def test__get_data_texture__exception_handling(): side_effect=Exception("Database error"), ) as mock_fetch_data: response = client.get( - "/air-pollutant/data_textures", - params=default_request_params) + "/air-pollutant/data_textures", params=default_request_params + ) assert response.status_code == 500 assert response.json() == {"detail": "Internal Server Error"} mock_fetch_data.assert_called_once_with( diff --git a/air-quality-backend/etl/scripts/run_delete_old_data.py b/air-quality-backend/etl/scripts/run_delete_old_data.py index 146bf7f5..7a385743 100644 --- a/air-quality-backend/etl/scripts/run_delete_old_data.py +++ b/air-quality-backend/etl/scripts/run_delete_old_data.py @@ -6,7 +6,9 @@ from etl.src.forecast.forecast_texture_storer import delete_data_textures_before from shared.src.database.forecasts import ( - delete_forecast_data_before, delete_data_texture_data_before) + delete_forecast_data_before, + delete_data_texture_data_before, +) from shared.src.database.in_situ import delete_in_situ_data_before config.fileConfig("./logging.ini") @@ -18,7 +20,7 @@ def main(): archive_limit_weeks = int(os.getenv("DELETE_LIMIT_WEEKS", 0)) if archive_limit_weeks <= 0: - logging.warning('Deletion has not received a valid limit so will not run') + logging.warning("Deletion has not received a valid limit so will not run") return initial_valid_date = datetime.utcnow() - timedelta(weeks=archive_limit_weeks) diff --git a/air-quality-backend/etl/src/forecast/forecast_texture_storer.py b/air-quality-backend/etl/src/forecast/forecast_texture_storer.py index e93da82a..5e8fd587 100644 --- a/air-quality-backend/etl/src/forecast/forecast_texture_storer.py +++ b/air-quality-backend/etl/src/forecast/forecast_texture_storer.py @@ -45,12 +45,18 @@ def _chunk_data_array( chunk = rgb_data_array[:, start_index:end_index, :] chunks.append(chunk) - time_start = pd.to_datetime( - time_vector.data[start_time_step:end_time_step].min(), unit="s" - ).isoformat(timespec='milliseconds') + '+00:00' - time_end = pd.to_datetime( - time_vector.data[start_time_step:end_time_step].max(), unit="s" - ).isoformat(timespec='milliseconds') + '+00:00' + time_start = ( + pd.to_datetime( + time_vector.data[start_time_step:end_time_step].min(), unit="s" + ).isoformat(timespec="milliseconds") + + "+00:00" + ) + time_end = ( + pd.to_datetime( + time_vector.data[start_time_step:end_time_step].max(), unit="s" + ).isoformat(timespec="milliseconds") + + "+00:00" + ) time_steps_dict[len(chunks) - 1] = { "time_start": time_start, "time_end": time_end, @@ -151,7 +157,7 @@ def save_data_textures( file_format, ) document = { - "forecast_base_time": datetime.strptime(forecast_date, '%Y-%m-%d_%H'), + "forecast_base_time": datetime.strptime(forecast_date, "%Y-%m-%d_%H"), "variable": variable_name, "source": "cams-production", "min_value": min_value, @@ -193,5 +199,7 @@ def delete_data_textures_before(archive_date: datetime): logging.info(f"Deleting folder '{folder_to_delete}'") shutil.rmtree(folder_to_delete) - logging.info(f"Deleted {len(folders_to_delete)} data texture folders " - f"from {data_textures_folder}") + logging.info( + f"Deleted {len(folders_to_delete)} data texture folders " + f"from {data_textures_folder}" + ) diff --git a/air-quality-backend/etl/src/in_situ/openaq_date_retriever.py b/air-quality-backend/etl/src/in_situ/openaq_date_retriever.py index ec30e84d..248d0761 100644 --- a/air-quality-backend/etl/src/in_situ/openaq_date_retriever.py +++ b/air-quality-backend/etl/src/in_situ/openaq_date_retriever.py @@ -7,8 +7,8 @@ def dates_without_measurements( - dates_from_db: list[datetime], - potential_dates: list[datetime]): + dates_from_db: list[datetime], potential_dates: list[datetime] +): date_without_measurement = [] for potential_date in potential_dates: for hour in range(0, 23): @@ -47,16 +47,15 @@ def retrieve_dates_requiring_in_situ_data() -> [datetime]: if search_end_date <= search_start_date: return [cur_date] - dates = pd.date_range(search_start_date, - search_end_date, - inclusive="right", - freq="24h") + dates = pd.date_range( + search_start_date, search_end_date, inclusive="right", freq="24h" + ) potential_dates = [i.to_pydatetime() for i in dates] dates_from_db = get_in_situ_dates_between(search_start_date, search_end_date) dates_requiring_in_situ_data = dates_without_measurements( - dates_from_db, - potential_dates) + dates_from_db, potential_dates + ) dates_requiring_in_situ_data.append(cur_date) diff --git a/air-quality-backend/etl/tests/forecast/forecast_date_retriever_test.py b/air-quality-backend/etl/tests/forecast/forecast_date_retriever_test.py index a0c4d548..e94b107f 100644 --- a/air-quality-backend/etl/tests/forecast/forecast_date_retriever_test.py +++ b/air-quality-backend/etl/tests/forecast/forecast_date_retriever_test.py @@ -5,16 +5,17 @@ import pytest from freezegun import freeze_time -from etl.src.forecast.forecast_date_retriever import align_to_cams_publish_time, \ - retrieve_dates_requiring_forecast +from etl.src.forecast.forecast_date_retriever import ( + align_to_cams_publish_time, + retrieve_dates_requiring_forecast, +) @freeze_time("2024-05-28 02:34:56") @patch("etl.src.forecast.forecast_date_retriever.align_to_cams_publish_time") @patch("etl.src.forecast.forecast_date_retriever.get_forecast_dates_between") def test__retrieve_dates_requiring_forecast__aligns_now_if_no_override( - mock_get_forecast_dates_from_db, - mock_align_date_to_cams_publish + mock_get_forecast_dates_from_db, mock_align_date_to_cams_publish ): mock_align_date_to_cams_publish.return_value = datetime(2024, 5, 28) mock_get_forecast_dates_from_db.return_value = [] @@ -29,8 +30,7 @@ def test__retrieve_dates_requiring_forecast__aligns_now_if_no_override( @patch("etl.src.forecast.forecast_date_retriever.align_to_cams_publish_time") @patch("etl.src.forecast.forecast_date_retriever.get_forecast_dates_between") def test__retrieve_dates_requiring_forecast__aligns_override_if_present( - mock_get_forecast_dates_from_db, - mock_align_date_to_cams_publish + mock_get_forecast_dates_from_db, mock_align_date_to_cams_publish ): mock_align_date_to_cams_publish.return_value = datetime(2024, 5, 28) mock_get_forecast_dates_from_db.return_value = [] @@ -44,8 +44,7 @@ def test__retrieve_dates_requiring_forecast__aligns_override_if_present( @patch("etl.src.forecast.forecast_date_retriever.align_to_cams_publish_time") @patch("etl.src.forecast.forecast_date_retriever.get_forecast_dates_between") def test__retrieve_dates_requiring_forecast__default_finds_db_dates_for_two_weeks( - mock_get_forecast_dates_from_db, - mock_align_date_to_cams_publish + mock_get_forecast_dates_from_db, mock_align_date_to_cams_publish ): mock_align_date_to_cams_publish.return_value = datetime(2024, 5, 28) mock_get_forecast_dates_from_db.return_value = [] @@ -53,8 +52,8 @@ def test__retrieve_dates_requiring_forecast__default_finds_db_dates_for_two_week retrieve_dates_requiring_forecast() mock_get_forecast_dates_from_db.assert_called_with( - datetime(2024, 5, 14), - datetime(2024, 5, 28)) + datetime(2024, 5, 14), datetime(2024, 5, 28) + ) @freeze_time("2024-05-28 02:34:56") @@ -62,8 +61,7 @@ def test__retrieve_dates_requiring_forecast__default_finds_db_dates_for_two_week @patch("etl.src.forecast.forecast_date_retriever.align_to_cams_publish_time") @patch("etl.src.forecast.forecast_date_retriever.get_forecast_dates_between") def test__retrieve_dates_requiring_forecast__finds_db_dates_for_override_period( - mock_get_forecast_dates_from_db, - mock_align_date_to_cams_publish + mock_get_forecast_dates_from_db, mock_align_date_to_cams_publish ): mock_align_date_to_cams_publish.return_value = datetime(2024, 5, 28) mock_get_forecast_dates_from_db.return_value = [] @@ -71,8 +69,8 @@ def test__retrieve_dates_requiring_forecast__finds_db_dates_for_override_period( retrieve_dates_requiring_forecast() mock_get_forecast_dates_from_db.assert_called_with( - datetime(2024, 5, 26), - datetime(2024, 5, 28)) + datetime(2024, 5, 26), datetime(2024, 5, 28) + ) @freeze_time("2024-05-28 02:34:56") @@ -80,8 +78,7 @@ def test__retrieve_dates_requiring_forecast__finds_db_dates_for_override_period( @patch("etl.src.forecast.forecast_date_retriever.align_to_cams_publish_time") @patch("etl.src.forecast.forecast_date_retriever.get_forecast_dates_between") def test__retrieve_dates_requiring_forecast__no_dates_in_db_all_required( - mock_get_forecast_dates_from_db, - mock_align_date_to_cams_publish + mock_get_forecast_dates_from_db, mock_align_date_to_cams_publish ): mock_align_date_to_cams_publish.return_value = datetime(2024, 5, 28) mock_get_forecast_dates_from_db.return_value = [] @@ -101,8 +98,7 @@ def test__retrieve_dates_requiring_forecast__no_dates_in_db_all_required( @patch("etl.src.forecast.forecast_date_retriever.align_to_cams_publish_time") @patch("etl.src.forecast.forecast_date_retriever.get_forecast_dates_between") def test__retrieve_dates_requiring_forecast__all_dates_in_db_none_required( - mock_get_forecast_dates_from_db, - mock_align_date_to_cams_publish + mock_get_forecast_dates_from_db, mock_align_date_to_cams_publish ): mock_align_date_to_cams_publish.return_value = datetime(2024, 5, 28) mock_get_forecast_dates_from_db.return_value = [ @@ -110,7 +106,7 @@ def test__retrieve_dates_requiring_forecast__all_dates_in_db_none_required( datetime(2024, 5, 26, 12), datetime(2024, 5, 27), datetime(2024, 5, 27, 12), - datetime(2024, 5, 28) + datetime(2024, 5, 28), ] response = retrieve_dates_requiring_forecast() @@ -123,13 +119,12 @@ def test__retrieve_dates_requiring_forecast__all_dates_in_db_none_required( @patch("etl.src.forecast.forecast_date_retriever.align_to_cams_publish_time") @patch("etl.src.forecast.forecast_date_retriever.get_forecast_dates_between") def test__retrieve_dates_requiring_forecast__some_dates_in_db_diff_required( - mock_get_forecast_dates_from_db, - mock_align_date_to_cams_publish + mock_get_forecast_dates_from_db, mock_align_date_to_cams_publish ): mock_align_date_to_cams_publish.return_value = datetime(2024, 5, 28) mock_get_forecast_dates_from_db.return_value = [ datetime(2024, 5, 26), - datetime(2024, 5, 28) + datetime(2024, 5, 28), ] response = retrieve_dates_requiring_forecast() diff --git a/air-quality-backend/etl/tests/forecast/forecast_texture_storer_test.py b/air-quality-backend/etl/tests/forecast/forecast_texture_storer_test.py index 84db8584..4d712ac6 100644 --- a/air-quality-backend/etl/tests/forecast/forecast_texture_storer_test.py +++ b/air-quality-backend/etl/tests/forecast/forecast_texture_storer_test.py @@ -9,7 +9,8 @@ _chunk_data_array, _create_output_directory, _write_texture_to_disk, - save_data_textures, delete_data_textures_before, + save_data_textures, + delete_data_textures_before, ) from shared.tests.util.mock_forecast_data import default_time @@ -139,9 +140,7 @@ def test__save_data_textures( mock_create_output_directory.return_value = "/mock/output/directory" mock_write_texture_to_disk.side_effect = ( - lambda chunk, output_directory, forecast_date, variable_name, chunk_num, - total_chunks, file_format: - f"/mock/output/directory/{forecast_date}_{variable_name}_chunk{chunk_num}." + lambda chunk, output_directory, forecast_date, variable_name, chunk_num, total_chunks, file_format: f"/mock/output/directory/{forecast_date}_{variable_name}_chunk{chunk_num}." f"{file_format}" ) @@ -158,7 +157,7 @@ def test__save_data_textures( assert len(documents) == 3 for i, doc in enumerate(documents): - expected_date = datetime.strptime(forecast_date, '%Y-%m-%d_%H') + expected_date = datetime.strptime(forecast_date, "%Y-%m-%d_%H") actual_date = doc["forecast_base_time"] assert actual_date == expected_date assert doc["variable"] == variable_name @@ -171,12 +170,12 @@ def test__save_data_textures( == f"/mock/output/directory/{forecast_date}_{variable_name}_chunk{i+1}.webp" ) time_start_datetime = datetime.fromisoformat( - mock_chunk_data_array.return_value[1][i]["time_start"]) - assert ( - doc["time_start"] == time_start_datetime + mock_chunk_data_array.return_value[1][i]["time_start"] ) + assert doc["time_start"] == time_start_datetime time_end_datetime = datetime.fromisoformat( - mock_chunk_data_array.return_value[1][i]["time_end"]) + mock_chunk_data_array.return_value[1][i]["time_end"] + ) assert doc["time_end"] == time_end_datetime assert doc["chunk"] == f"{i+1} of 3" diff --git a/air-quality-backend/etl/tests/in_situ/openaq_date_retriever_test.py b/air-quality-backend/etl/tests/in_situ/openaq_date_retriever_test.py index eaf4c4b6..4d35ddf9 100644 --- a/air-quality-backend/etl/tests/in_situ/openaq_date_retriever_test.py +++ b/air-quality-backend/etl/tests/in_situ/openaq_date_retriever_test.py @@ -14,10 +14,14 @@ def test__retrieve_dates_requiring_in_situ_data__invalid_period_raises_error(): retrieve_dates_requiring_in_situ_data() -@pytest.mark.parametrize("retrieval_period", ["-1", "0", "1"],) +@pytest.mark.parametrize( + "retrieval_period", + ["-1", "0", "1"], +) @freeze_time("2024-08-07T12:34:56") def test__retrieve_dates_requiring_in_situ_data__no_extra_dates_required( - retrieval_period): + retrieval_period, +): with patch.dict(os.environ, {"IN_SITU_RETRIEVAL_PERIOD": retrieval_period}): result = retrieve_dates_requiring_in_situ_data() @@ -29,15 +33,17 @@ def test__retrieve_dates_requiring_in_situ_data__no_extra_dates_required( @patch("etl.src.in_situ.openaq_date_retriever.get_in_situ_dates_between") @freeze_time("2024-08-07T12:34:56") def test__retrieve_dates_requiring_in_situ_data__one_extra_date_without_gap( - patch_db_get): + patch_db_get, +): patch_db_get.return_value = [ - datetime(2024, 8, 6, 12, 34, 56) - timedelta(hours=i + 1) for i in range(23)] + datetime(2024, 8, 6, 12, 34, 56) - timedelta(hours=i + 1) for i in range(23) + ] result = retrieve_dates_requiring_in_situ_data() patch_db_get.assert_called_with( - datetime(2024, 8, 5, 12, 34, 56), - datetime(2024, 8, 6, 12, 34, 56)) + datetime(2024, 8, 5, 12, 34, 56), datetime(2024, 8, 6, 12, 34, 56) + ) assert len(result) == 1 assert result[0] == datetime(2024, 8, 7, 12, 34, 56) @@ -46,16 +52,16 @@ def test__retrieve_dates_requiring_in_situ_data__one_extra_date_without_gap( @patch.dict(os.environ, {"IN_SITU_RETRIEVAL_PERIOD": "2"}) @patch("etl.src.in_situ.openaq_date_retriever.get_in_situ_dates_between") @freeze_time("2024-08-07T12:34:56") -def test__retrieve_dates_requiring_in_situ_data__one_extra_date_with_gaps( - patch_db_get): +def test__retrieve_dates_requiring_in_situ_data__one_extra_date_with_gaps(patch_db_get): patch_db_get.return_value = [ - datetime(2024, 8, 6, 12, 34, 56) - timedelta(hours=i + 1) for i in range(20)] + datetime(2024, 8, 6, 12, 34, 56) - timedelta(hours=i + 1) for i in range(20) + ] result = retrieve_dates_requiring_in_situ_data() patch_db_get.assert_called_with( - datetime(2024, 8, 5, 12, 34, 56), - datetime(2024, 8, 6, 12, 34, 56)) + datetime(2024, 8, 5, 12, 34, 56), datetime(2024, 8, 6, 12, 34, 56) + ) assert len(result) == 2 assert result[0] == datetime(2024, 8, 6, 12, 34, 56) @@ -66,15 +72,17 @@ def test__retrieve_dates_requiring_in_situ_data__one_extra_date_with_gaps( @patch("etl.src.in_situ.openaq_date_retriever.get_in_situ_dates_between") @freeze_time("2024-08-07T12:34:56") def test__retrieve_dates_requiring_in_situ_data__two_extra_dates_one_with_gap( - patch_db_get): + patch_db_get, +): patch_db_get.return_value = [ - datetime(2024, 8, 6, 12, 34, 56) - timedelta(hours=i + 1) for i in range(30)] + datetime(2024, 8, 6, 12, 34, 56) - timedelta(hours=i + 1) for i in range(30) + ] result = retrieve_dates_requiring_in_situ_data() patch_db_get.assert_called_with( - datetime(2024, 8, 4, 12, 34, 56), - datetime(2024, 8, 6, 12, 34, 56)) + datetime(2024, 8, 4, 12, 34, 56), datetime(2024, 8, 6, 12, 34, 56) + ) assert len(result) == 2 assert result[0] == datetime(2024, 8, 5, 12, 34, 56) diff --git a/air-quality-backend/etl/tests/scripts/run_forecast_etl_test.py b/air-quality-backend/etl/tests/scripts/run_forecast_etl_test.py index ad936508..886e4d39 100644 --- a/air-quality-backend/etl/tests/scripts/run_forecast_etl_test.py +++ b/air-quality-backend/etl/tests/scripts/run_forecast_etl_test.py @@ -12,9 +12,7 @@ @patch("etl.scripts.run_forecast_etl.retrieve_dates_requiring_forecast") @patch("etl.scripts.run_forecast_etl.process_forecast") def test__run_forecast_etl__no_dates_returns_without_processing( - mock_process_forecast, - mock_get_dates, - mock_get_locations + mock_process_forecast, mock_get_dates, mock_get_locations ): mock_get_locations.return_value = cities mock_get_dates.return_value = [] @@ -28,9 +26,7 @@ def test__run_forecast_etl__no_dates_returns_without_processing( @patch("etl.scripts.run_forecast_etl.retrieve_dates_requiring_forecast") @patch("etl.scripts.run_forecast_etl.process_forecast") def test__run_forecast_etl__single_date_processed( - mock_process_forecast, - mock_get_dates, - mock_get_locations + mock_process_forecast, mock_get_dates, mock_get_locations ): single_date = datetime(2024, 6, 1, 17, 0, 0, 0) @@ -46,9 +42,7 @@ def test__run_forecast_etl__single_date_processed( @patch("etl.scripts.run_forecast_etl.retrieve_dates_requiring_forecast") @patch("etl.scripts.run_forecast_etl.process_forecast") def test__run_forecast_etl__multiple_dates_processed( - mock_process_forecast, - mock_get_dates, - mock_get_locations + mock_process_forecast, mock_get_dates, mock_get_locations ): multi_date_1 = datetime(2024, 6, 1, 17, 0, 0, 0) multi_date_2 = datetime(2024, 6, 2, 18, 0, 0, 0) diff --git a/air-quality-backend/etl/tests/scripts/run_in_situ_etl_test.py b/air-quality-backend/etl/tests/scripts/run_in_situ_etl_test.py index c7472eaf..4e9ed518 100644 --- a/air-quality-backend/etl/tests/scripts/run_in_situ_etl_test.py +++ b/air-quality-backend/etl/tests/scripts/run_in_situ_etl_test.py @@ -20,7 +20,8 @@ @patch("etl.scripts.run_in_situ_etl.retrieve_dates_requiring_in_situ_data") @patch("etl.scripts.run_in_situ_etl.get_locations_by_type") def test__run_in_situ_etl__one_date_no_cities_override( - mock_locations, mock_dates, mock_fetch, mock_insert): + mock_locations, mock_dates, mock_fetch, mock_insert +): mock_dates.return_value = [datetime(2024, 6, 5)] mock_locations.return_value = cities @@ -38,12 +39,13 @@ def test__run_in_situ_etl__one_date_no_cities_override( @patch("etl.scripts.run_in_situ_etl.retrieve_dates_requiring_in_situ_data") @patch("etl.scripts.run_in_situ_etl.get_locations_by_type") def test__run_in_situ_etl__multiple_dates_no_cities_override( - mock_locations, mock_dates, mock_fetch, mock_insert): + mock_locations, mock_dates, mock_fetch, mock_insert +): mock_dates.return_value = [ datetime(2024, 6, 3), datetime(2024, 6, 4), - datetime(2024, 6, 5) + datetime(2024, 6, 5), ] mock_locations.return_value = cities mock_fetch.return_value = in_situ_data diff --git a/air-quality-backend/system_tests/api_suite/texture_api_seeded_data_test.py b/air-quality-backend/system_tests/api_suite/texture_api_seeded_data_test.py index 0f70734b..f3dbca89 100644 --- a/air-quality-backend/system_tests/api_suite/texture_api_seeded_data_test.py +++ b/air-quality-backend/system_tests/api_suite/texture_api_seeded_data_test.py @@ -13,7 +13,9 @@ headers = {"accept": "application/json"} textureChunkOne = { "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), + "forecast_base_time": datetime.datetime( + 2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), "variable": "aqi", "time_end": "2024-08-06T09:00:00.000Z", "time_start": "2024-08-04T12:00:00.000Z", @@ -23,13 +25,14 @@ "min_value": 1, "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_1_of_3.webp", "units": "fractional overall AQI", - "created_time": "2024-08-05T09:19:52.351Z" + "created_time": "2024-08-05T09:19:52.351Z", } textureChunkTwo = { - "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), + "forecast_base_time": datetime.datetime( + 2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), "variable": "aqi", "time_end": "2024-08-08T09:00:00.000Z", "time_start": "2024-08-06T12:00:00.000Z", @@ -39,51 +42,57 @@ "min_value": 1, "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_2_of_3.webp", "units": "fractional overall AQI", - "created_time": "2024-08-05T09:19:52.351Z" + "created_time": "2024-08-05T09:19:52.351Z", } textureChunkThree = { - "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), - "variable": "aqi", - "time_end": "2024-08-09T12:00:00.000Z", - "time_start": "2024-08-08T12:00:00.000Z", - "chunk": "3 of 3", - "last_modified_time": "2024-08-05T09:19:52.351Z", - "max_value": 7, - "min_value": 1, - "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", - "units": "fractional overall AQI", - "created_time": "2024-08-05T09:19:52.351Z" - } + "source": "cams-production", + "forecast_base_time": datetime.datetime( + 2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), + "variable": "aqi", + "time_end": "2024-08-09T12:00:00.000Z", + "time_start": "2024-08-08T12:00:00.000Z", + "chunk": "3 of 3", + "last_modified_time": "2024-08-05T09:19:52.351Z", + "max_value": 7, + "min_value": 1, + "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", + "units": "fractional overall AQI", + "created_time": "2024-08-05T09:19:52.351Z", +} outOfRangeOfRequestDateBelow = { # out of range of request date (below test date) - "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 3, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), - "variable": "aqi", - "time_end": "2024-08-09T12:00:00.000Z", - "time_start": "2024-08-08T12:00:00.000Z", - "chunk": "3 of 3", - "last_modified_time": "2024-08-05T09:19:52.351Z", - "max_value": 7, - "min_value": 1, - "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", - "units": "fractional overall AQI", - "created_time": "2024-08-05T09:19:52.351Z" - } + "source": "cams-production", + "forecast_base_time": datetime.datetime( + 2024, 8, 3, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), + "variable": "aqi", + "time_end": "2024-08-09T12:00:00.000Z", + "time_start": "2024-08-08T12:00:00.000Z", + "chunk": "3 of 3", + "last_modified_time": "2024-08-05T09:19:52.351Z", + "max_value": 7, + "min_value": 1, + "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", + "units": "fractional overall AQI", + "created_time": "2024-08-05T09:19:52.351Z", +} outOfRangeOfRequestDateAbove = { # out of range of request date (above test date) - "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 6, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), - "variable": "aqi", - "time_end": "2024-08-09T12:00:00.000Z", - "time_start": "2024-08-08T12:00:00.000Z", - "chunk": "3 of 3", - "last_modified_time": "2024-08-05T09:19:52.351Z", - "max_value": 7, - "min_value": 1, - "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", - "units": "fractional overall AQI", - "created_time": "2024-08-05T09:19:52.351Z" - } + "source": "cams-production", + "forecast_base_time": datetime.datetime( + 2024, 8, 6, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), + "variable": "aqi", + "time_end": "2024-08-09T12:00:00.000Z", + "time_start": "2024-08-08T12:00:00.000Z", + "chunk": "3 of 3", + "last_modified_time": "2024-08-05T09:19:52.351Z", + "max_value": 7, + "min_value": 1, + "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", + "units": "fractional overall AQI", + "created_time": "2024-08-05T09:19:52.351Z", +} @pytest.fixture() @@ -98,41 +107,53 @@ def setup_test(): textureChunkTwo, textureChunkThree, outOfRangeOfRequestDateBelow, - outOfRangeOfRequestDateAbove - ] + outOfRangeOfRequestDateAbove, + ], ) -def test__required_parameters_provided__verify_response_status_200_and_data_returned(setup_test): +def test__required_parameters_provided__verify_response_status_200_and_data_returned( + setup_test, +): response = requests.request( - "GET", base_url, headers=headers, params={"base_time": "2024-08-04T12:00:00.000Z"}, timeout=5.0 + "GET", + base_url, + headers=headers, + params={"base_time": "2024-08-04T12:00:00.000Z"}, + timeout=5.0, ) - assert response.json() == [{'base_time': '2024-08-04T12:00:00Z', - 'chunk': '1 of 3', - 'max_value': 7.0, - 'min_value': 1.0, - 'source': 'cams-production', - 'texture_uri': '/root/aqi_2024-08-04_12_CAMS_global.chunk_1_of_3.webp', - 'time_end': '2024-08-06T09:00:00Z', - 'time_start': '2024-08-04T12:00:00Z', - 'variable': 'aqi'}, - {'base_time': '2024-08-04T12:00:00Z', - 'chunk': '2 of 3', - 'max_value': 7.0, - 'min_value': 1.0, - 'source': 'cams-production', - 'texture_uri': '/root/aqi_2024-08-04_12_CAMS_global.chunk_2_of_3.webp', - 'time_end': '2024-08-08T09:00:00Z', - 'time_start': '2024-08-06T12:00:00Z', - 'variable': 'aqi'}, - {'base_time': '2024-08-04T12:00:00Z', - 'chunk': '3 of 3', - 'max_value': 7.0, - 'min_value': 1.0, - 'source': 'cams-production', - 'texture_uri': '/root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp', - 'time_end': '2024-08-09T12:00:00Z', - 'time_start': '2024-08-08T12:00:00Z', - 'variable': 'aqi'}, - - ] + assert response.json() == [ + { + "base_time": "2024-08-04T12:00:00Z", + "chunk": "1 of 3", + "max_value": 7.0, + "min_value": 1.0, + "source": "cams-production", + "texture_uri": "/root/aqi_2024-08-04_12_CAMS_global.chunk_1_of_3.webp", + "time_end": "2024-08-06T09:00:00Z", + "time_start": "2024-08-04T12:00:00Z", + "variable": "aqi", + }, + { + "base_time": "2024-08-04T12:00:00Z", + "chunk": "2 of 3", + "max_value": 7.0, + "min_value": 1.0, + "source": "cams-production", + "texture_uri": "/root/aqi_2024-08-04_12_CAMS_global.chunk_2_of_3.webp", + "time_end": "2024-08-08T09:00:00Z", + "time_start": "2024-08-06T12:00:00Z", + "variable": "aqi", + }, + { + "base_time": "2024-08-04T12:00:00Z", + "chunk": "3 of 3", + "max_value": 7.0, + "min_value": 1.0, + "source": "cams-production", + "texture_uri": "/root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", + "time_end": "2024-08-09T12:00:00Z", + "time_start": "2024-08-08T12:00:00Z", + "variable": "aqi", + }, + ] diff --git a/air-quality-backend/system_tests/api_suite/texture_api_validation_test.py b/air-quality-backend/system_tests/api_suite/texture_api_validation_test.py index 65655836..7724cc8e 100644 --- a/air-quality-backend/system_tests/api_suite/texture_api_validation_test.py +++ b/air-quality-backend/system_tests/api_suite/texture_api_validation_test.py @@ -13,7 +13,9 @@ textureChunkOne = { "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), + "forecast_base_time": datetime.datetime( + 2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), "variable": "aqi", "time_end": "2024-08-06T09:00:00.000Z", "time_start": "2024-08-04T12:00:00.000Z", @@ -23,13 +25,14 @@ "min_value": 1, "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_1_of_3.webp", "units": "fractional overall AQI", - "created_time": "2024-08-05T09:19:52.351Z" + "created_time": "2024-08-05T09:19:52.351Z", } textureChunkTwo = { - "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), + "forecast_base_time": datetime.datetime( + 2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), "variable": "aqi", "time_end": "2024-08-08T09:00:00.000Z", "time_start": "2024-08-06T12:00:00.000Z", @@ -39,56 +42,64 @@ "min_value": 1, "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_2_of_3.webp", "units": "fractional overall AQI", - "created_time": "2024-08-05T09:19:52.351Z" + "created_time": "2024-08-05T09:19:52.351Z", } textureChunkThree = { - "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), - "variable": "aqi", - "time_end": "2024-08-09T12:00:00.000Z", - "time_start": "2024-08-08T12:00:00.000Z", - "chunk": "3 of 3", - "last_modified_time": "2024-08-05T09:19:52.351Z", - "max_value": 7, - "min_value": 1, - "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", - "units": "fractional overall AQI", - "created_time": "2024-08-05T09:19:52.351Z" - } + "source": "cams-production", + "forecast_base_time": datetime.datetime( + 2024, 8, 4, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), + "variable": "aqi", + "time_end": "2024-08-09T12:00:00.000Z", + "time_start": "2024-08-08T12:00:00.000Z", + "chunk": "3 of 3", + "last_modified_time": "2024-08-05T09:19:52.351Z", + "max_value": 7, + "min_value": 1, + "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", + "units": "fractional overall AQI", + "created_time": "2024-08-05T09:19:52.351Z", +} outOfRangeOfRequestDateBelow = { # out of range of request date (below test date) - "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 3, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), - "variable": "aqi", - "time_end": "2024-08-09T12:00:00.000Z", - "time_start": "2024-08-08T12:00:00.000Z", - "chunk": "3 of 3", - "last_modified_time": "2024-08-05T09:19:52.351Z", - "max_value": 7, - "min_value": 1, - "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", - "units": "fractional overall AQI", - "created_time": "2024-08-05T09:19:52.351Z" - } + "source": "cams-production", + "forecast_base_time": datetime.datetime( + 2024, 8, 3, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), + "variable": "aqi", + "time_end": "2024-08-09T12:00:00.000Z", + "time_start": "2024-08-08T12:00:00.000Z", + "chunk": "3 of 3", + "last_modified_time": "2024-08-05T09:19:52.351Z", + "max_value": 7, + "min_value": 1, + "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", + "units": "fractional overall AQI", + "created_time": "2024-08-05T09:19:52.351Z", +} outOfRangeOfRequestDateAbove = { # out of range of request date (above test date) - "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 6, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), - "variable": "aqi", - "time_end": "2024-08-09T12:00:00.000Z", - "time_start": "2024-08-08T12:00:00.000Z", - "chunk": "3 of 3", - "last_modified_time": "2024-08-05T09:19:52.351Z", - "max_value": 7, - "min_value": 1, - "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", - "units": "fractional overall AQI", - "created_time": "2024-08-05T09:19:52.351Z" - } + "source": "cams-production", + "forecast_base_time": datetime.datetime( + 2024, 8, 6, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), + "variable": "aqi", + "time_end": "2024-08-09T12:00:00.000Z", + "time_start": "2024-08-08T12:00:00.000Z", + "chunk": "3 of 3", + "last_modified_time": "2024-08-05T09:19:52.351Z", + "max_value": 7, + "min_value": 1, + "texture_uri": "root/aqi_2024-08-04_12_CAMS_global.chunk_3_of_3.webp", + "units": "fractional overall AQI", + "created_time": "2024-08-05T09:19:52.351Z", +} invalidDatabaseEntry = { - "source": "cams-production", - "forecast_base_time": datetime.datetime(2024, 8, 5, 12, 00, 00, 00, tzinfo=datetime.timezone.utc), - "variable": True, - } + "source": "cams-production", + "forecast_base_time": datetime.datetime( + 2024, 8, 5, 12, 00, 00, 00, tzinfo=datetime.timezone.utc + ), + "variable": True, +} @pytest.fixture() @@ -104,14 +115,20 @@ def setup_test(): textureChunkThree, outOfRangeOfRequestDateBelow, outOfRangeOfRequestDateAbove, - invalidDatabaseEntry - ] + invalidDatabaseEntry, + ], ) -def test__required_parameters_provided__verify_response_status_200_and_data_returned(setup_test): +def test__required_parameters_provided__verify_response_status_200_and_data_returned( + setup_test, +): response = requests.request( - "GET", base_url, headers=headers, params={"base_time": "2024-08-04T12:00:00.000Z"}, timeout=5.0 + "GET", + base_url, + headers=headers, + params={"base_time": "2024-08-04T12:00:00.000Z"}, + timeout=5.0, ) assert response.status_code == 200 @@ -119,33 +136,15 @@ def test__required_parameters_provided__verify_response_status_200_and_data_retu @pytest.mark.parametrize( "api_parameters", [ - ( - {"base_time": ""} - ), - ( - {"base_time": " "} - ), - ( - {"": ""} - ), - ( - {} - ), - ( - {"base_time": "2024-08-04T13:00:00.000Z "} - ), - ( - {"base_time": " 2024-08-04T13:00:00.000Z"} - ), - ( - {"base_time": "2024-08-0413:00:00.000Z"} - ), - ( - {"base_time": "2024-0804T13:00:00.000Z"} - ), - ( - {"": "2024-08-04T13:00:00.000Z"} - ), + ({"base_time": ""}), + ({"base_time": " "}), + ({"": ""}), + ({}), + ({"base_time": "2024-08-04T13:00:00.000Z "}), + ({"base_time": " 2024-08-04T13:00:00.000Z"}), + ({"base_time": "2024-08-0413:00:00.000Z"}), + ({"base_time": "2024-0804T13:00:00.000Z"}), + ({"": "2024-08-04T13:00:00.000Z"}), ], ) def test__incorrect_dates__verify_response_status_422(setup_test, api_parameters: dict): @@ -158,24 +157,12 @@ def test__incorrect_dates__verify_response_status_422(setup_test, api_parameters @pytest.mark.parametrize( "api_parameters", [ - ( - {"base_time": "2024-08-07T12:00:00.000Z"} - ), - ( - {"base_time": "2024-08-07T00:00:00.000Z"} - ), - ( - {"base_time": "2024-08-04T00:10:00.000Z"} - ), - ( - {"base_time": "2024-08-04T00:00:10.000Z"} - ), - ( - {"base_time": "2024-08-04T00:00:00.010Z"} - ), - ( - {"base_time": "2024-08-04"} - ), + ({"base_time": "2024-08-07T12:00:00.000Z"}), + ({"base_time": "2024-08-07T00:00:00.000Z"}), + ({"base_time": "2024-08-04T00:10:00.000Z"}), + ({"base_time": "2024-08-04T00:00:10.000Z"}), + ({"base_time": "2024-08-04T00:00:00.010Z"}), + ({"base_time": "2024-08-04"}), ], ) def test__correct_dates_no_data__verify_response_status_404(setup_test, api_parameters): @@ -187,6 +174,10 @@ def test__correct_dates_no_data__verify_response_status_404(setup_test, api_para def test__invalid_data_in_database__verify_response_status_500(setup_test): response = requests.request( - "GET", base_url, headers=headers, params={"base_time": "2024-08-05T12:00:00Z"}, timeout=5.0 + "GET", + base_url, + headers=headers, + params={"base_time": "2024-08-05T12:00:00Z"}, + timeout=5.0, ) assert response.status_code == 500 diff --git a/air-quality-backend/system_tests/forecast_etl_suite/cams_grib_cache_test.py b/air-quality-backend/system_tests/forecast_etl_suite/cams_grib_cache_test.py index f440af82..da7e7e6d 100644 --- a/air-quality-backend/system_tests/forecast_etl_suite/cams_grib_cache_test.py +++ b/air-quality-backend/system_tests/forecast_etl_suite/cams_grib_cache_test.py @@ -31,11 +31,12 @@ def setup_grib_cache_tests(): def test__grib_cache__file_not_stored_when_not_set_to_cache(setup_grib_cache_tests): with mock.patch.dict( - os.environ, { + os.environ, + { "FORECAST_BASE_TIME": "2024-6-10 00", "STORE_GRIB_FILES": "False", - "FORECAST_RETRIEVAL_PERIOD": "0" - } + "FORECAST_RETRIEVAL_PERIOD": "0", + }, ): main() @@ -51,10 +52,7 @@ def test__grib_cache__file_not_stored_when_not_set_to_cache(setup_grib_cache_tes def test__grib_cache__file_not_stored_by_default(setup_grib_cache_tests): with mock.patch.dict( os.environ, - { - "FORECAST_BASE_TIME": "2024-6-10 00", - "FORECAST_RETRIEVAL_PERIOD": "0" - }, + {"FORECAST_BASE_TIME": "2024-6-10 00", "FORECAST_RETRIEVAL_PERIOD": "0"}, ): main() @@ -69,11 +67,12 @@ def test__grib_cache__file_not_stored_by_default(setup_grib_cache_tests): def test__grib_cache__file_stored_when_set_to_cache(setup_grib_cache_tests): with mock.patch.dict( - os.environ, { + os.environ, + { "FORECAST_BASE_TIME": "2024-6-10 00", "STORE_GRIB_FILES": "True", - "FORECAST_RETRIEVAL_PERIOD": "0" - } + "FORECAST_RETRIEVAL_PERIOD": "0", + }, ): main() diff --git a/air-quality-backend/system_tests/forecast_etl_suite/cams_known_grib_test.py b/air-quality-backend/system_tests/forecast_etl_suite/cams_known_grib_test.py index a653547d..10c512ca 100644 --- a/air-quality-backend/system_tests/forecast_etl_suite/cams_known_grib_test.py +++ b/air-quality-backend/system_tests/forecast_etl_suite/cams_known_grib_test.py @@ -21,10 +21,10 @@ @pytest.fixture(scope="module") def setup_data(): # Set up code - with mock.patch.dict(os.environ, { - "FORECAST_BASE_TIME": "2024-6-4 00", - "FORECAST_RETRIEVAL_PERIOD": "0" - }): + with mock.patch.dict( + os.environ, + {"FORECAST_BASE_TIME": "2024-6-4 00", "FORECAST_RETRIEVAL_PERIOD": "0"}, + ): delete_database_data("forecast_data", data_query) main() yield diff --git a/air-quality-backend/system_tests/forecast_etl_suite/fill_in_gaps_forecast.py b/air-quality-backend/system_tests/forecast_etl_suite/fill_in_gaps_forecast.py index 65d0a211..e8812f5e 100644 --- a/air-quality-backend/system_tests/forecast_etl_suite/fill_in_gaps_forecast.py +++ b/air-quality-backend/system_tests/forecast_etl_suite/fill_in_gaps_forecast.py @@ -13,15 +13,26 @@ load_dotenv() -@mock.patch.dict(os.environ, { - "FORECAST_BASE_TIME": "2024-6-4 00", - "STORE_GRIB_FILES": "True", - "FORECAST_RETRIEVAL_PERIOD": "1" -}) +@mock.patch.dict( + os.environ, + { + "FORECAST_BASE_TIME": "2024-6-4 00", + "STORE_GRIB_FILES": "True", + "FORECAST_RETRIEVAL_PERIOD": "1", + }, +) def test__missing_time_london__add_missing_data(): - data_query = {"forecast_base_time": {"$lte": datetime.datetime(2024, 6, 4, 00, tzinfo=datetime.timezone.utc), - "$gte": datetime.datetime(2024, 6, 3, 00, tzinfo=datetime.timezone.utc)}} - data_query_to_delete = {"forecast_base_time": datetime.datetime(2024, 6, 3, 12, tzinfo=datetime.timezone.utc)} + data_query = { + "forecast_base_time": { + "$lte": datetime.datetime(2024, 6, 4, 00, tzinfo=datetime.timezone.utc), + "$gte": datetime.datetime(2024, 6, 3, 00, tzinfo=datetime.timezone.utc), + } + } + data_query_to_delete = { + "forecast_base_time": datetime.datetime( + 2024, 6, 3, 12, tzinfo=datetime.timezone.utc + ) + } main() delete_database_data("forecast_data", data_query_to_delete) diff --git a/air-quality-backend/system_tests/in_situ_etl_suite/open_aq_etl_test.py b/air-quality-backend/system_tests/in_situ_etl_suite/open_aq_etl_test.py index a570d028..1503420e 100644 --- a/air-quality-backend/system_tests/in_situ_etl_suite/open_aq_etl_test.py +++ b/air-quality-backend/system_tests/in_situ_etl_suite/open_aq_etl_test.py @@ -35,12 +35,13 @@ "OPEN_AQ_CITIES": "London", "OPEN_AQ_CACHE": open_aq_cache_location, "STORE_GRIB_FILES": "True", - "IN_SITU_RETRIEVAL_PERIOD": "1" + "IN_SITU_RETRIEVAL_PERIOD": "1", } @mock.patch.dict( - os.environ, {"OPEN_AQ_CITIES": "London", "IN_SITU_RETRIEVAL_PERIOD": "1"}) + os.environ, {"OPEN_AQ_CITIES": "London", "IN_SITU_RETRIEVAL_PERIOD": "1"} +) def test__in_situ_etl__calling_actual_api_returns_values_and_stores(): query = {"name": "London"} delete_database_data(collection_name, query) @@ -387,7 +388,7 @@ def test__in_situ_etl__invalid_data_raises_error_and_does_not_store( "OPEN_AQ_CITIES": "London", "STORE_GRIB_FILES": "True", "IN_SITU_RETRIEVAL_PERIOD": "1", - } + }, ) def test__in_situ_etl__timeouts_retry_twice_then_stop( mock_get_conn, caplog, ensure_forecast_cache @@ -413,7 +414,7 @@ def test__in_situ_etl__timeouts_retry_twice_then_stop( "OPEN_AQ_CITIES": "London", "STORE_GRIB_FILES": "True", "IN_SITU_RETRIEVAL_PERIOD": "1", - } + }, ) def test__in_situ_etl__internal_error_fails_without_retry( mock_get_conn, caplog, ensure_forecast_cache @@ -439,7 +440,7 @@ def test__in_situ_etl__internal_error_fails_without_retry( "OPEN_AQ_CITIES": "London", "STORE_GRIB_FILES": "True", "IN_SITU_RETRIEVAL_PERIOD": "1", - } + }, ) def test__in_situ_etl__timeout_followed_by_success_returns_correctly( mock_get_conn, caplog, ensure_forecast_cache @@ -470,7 +471,7 @@ def test__in_situ_etl__timeout_followed_by_success_returns_correctly( "OPEN_AQ_CITIES": "Berlin", "OPEN_AQ_CACHE": open_aq_cache_location, "IN_SITU_RETRIEVAL_PERIOD": "1", - } + }, ) @freeze_time("2024-06-27T13:00:00") def test__convert_ppm_to_ugm3_and_store__only_no2_so2_o3(): diff --git a/air-quality-ui/src/components/globe/CameraSettings.tsx b/air-quality-ui/src/components/globe/CameraSettings.tsx index 9c6568f8..622835f2 100644 --- a/air-quality-ui/src/components/globe/CameraSettings.tsx +++ b/air-quality-ui/src/components/globe/CameraSettings.tsx @@ -5,13 +5,11 @@ import * as THREE from 'three' type CameraSettingsProps = { globeState: boolean cameraControlsRef: React.RefObject - toggle: string } const CameraSettings: React.FC = ({ globeState, - cameraControlsRef, - toggle, + cameraControlsRef }) => { useEffect(() => { if (cameraControlsRef.current) { @@ -37,7 +35,7 @@ const CameraSettings: React.FC = ({ controls.smoothTime = 1.5 controls.rotateTo(newTheta, newPhi, true) - controls.zoomTo(0.75, true) + controls.zoomTo(0.6, true) setTimeout(() => { controls.smoothTime = 1.0 @@ -63,7 +61,7 @@ const CameraSettings: React.FC = ({ }, 5000) } } - }, [globeState, cameraControlsRef, toggle]) + }, [globeState, cameraControlsRef]) return null } diff --git a/air-quality-ui/src/components/globe/ColorBar.tsx b/air-quality-ui/src/components/globe/ColorBar.tsx new file mode 100644 index 00000000..50227321 --- /dev/null +++ b/air-quality-ui/src/components/globe/ColorBar.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useRef } from 'react' +import { PollutantType } from '../../models/types' +import { getContourInfo } from '../../models/pollutant-contours' +import { pollutantTypeDisplay } from '../../models/pollutant-display' + +interface ColorBarProps { + pollutant: PollutantType | 'aqi' + width?: number + height?: number +} + +export const ColorBar: React.FC = ({ + pollutant, + width = 60, + height = 200 +}) => { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + if (!ctx) return + + const contourInfo = getContourInfo(pollutant) + if (!contourInfo) return + + // Clear canvas + ctx.clearRect(0, 0, width, height) + + // Draw white background with slight padding + const padding = 8 + ctx.fillStyle = 'white' + ctx.fillRect(0, 0, width, height) + + // Draw title and unit + ctx.fillStyle = '#000' + ctx.font = '12px Arial' + ctx.textAlign = 'center' + + // Calculate dimensions (adjusted for title) + const barWidth = width * 0.4 + const barLeft = width * 0.1 + const barHeight = height * 0.65 // Further reduced to make room for two lines + const barTop = pollutant === 'aqi' ? + height * 0.2 : // Less space for AQI (single line) + height * 0.25 // More space for pollutants (two lines) + + if (pollutant === 'aqi') { + ctx.fillText('AQI', width / 2, padding + 10) + } else { + // Split pollutant display into name and unit + const displayText = pollutantTypeDisplay[pollutant] + const [name, unit] = displayText.split(' ') + ctx.fillText(name, width / 2, padding + 10) + ctx.font = '10px Arial' // Smaller font for unit + ctx.fillText(unit, width / 2, padding + 25) + } + + // Calculate block height (equal for all levels) + const blockHeight = barHeight / contourInfo.levels.length + + // Draw each level as a discrete rectangle (from bottom to top) + contourInfo.levels.forEach((level, i) => { + const y = barTop + barHeight - ((i + 1) * blockHeight) + + // Draw colored rectangle + ctx.fillStyle = contourInfo.colors[i] + ctx.fillRect(barLeft, y, barWidth, blockHeight) + }) + + // Draw border + ctx.strokeStyle = '#000' + ctx.lineWidth = 1 + ctx.strokeRect(barLeft, barTop, barWidth, barHeight) + + // Draw ticks and labels + ctx.fillStyle = '#000' + ctx.font = '10px Arial' + ctx.textAlign = 'left' + + if (pollutant === 'aqi') { + // For AQI, draw labels in the middle of blocks + contourInfo.levels.forEach((level, i) => { + const yTop = barTop + barHeight - ((i + 1) * blockHeight) + const yMiddle = yTop + (blockHeight / 2) + + // Draw tick + ctx.beginPath() + ctx.moveTo(barLeft + barWidth, yMiddle) + ctx.lineTo(barLeft + barWidth + 5, yMiddle) + ctx.stroke() + + // Draw label (subtract 1 from the threshold) + ctx.fillText((level - 1).toString(), barLeft + barWidth + 8, yMiddle + 4) + }) + } else { + // For other pollutants, draw min value at bottom + ctx.beginPath() + ctx.moveTo(barLeft + barWidth, barTop + barHeight) + ctx.lineTo(barLeft + barWidth + 5, barTop + barHeight) + ctx.stroke() + ctx.fillText(contourInfo.minValue.toString(), barLeft + barWidth + 8, barTop + barHeight + 4) + + // Draw threshold labels at boundaries between blocks + contourInfo.levels.forEach((level, i) => { + // Skip the last threshold as it's the max value + if (i < contourInfo.levels.length - 1) { + const y = barTop + barHeight - ((i + 1) * blockHeight) + + // Draw tick + ctx.beginPath() + ctx.moveTo(barLeft + barWidth, y) + ctx.lineTo(barLeft + barWidth + 5, y) + ctx.stroke() + + // Draw label + ctx.fillText(level.toString(), barLeft + barWidth + 8, y + 4) + } + }) + } + + }, [pollutant, width, height]) + + return ( + + ) +} \ No newline at end of file diff --git a/air-quality-ui/src/components/globe/Controls.tsx b/air-quality-ui/src/components/globe/Controls.tsx index a8236a3b..7ba58003 100644 --- a/air-quality-ui/src/components/globe/Controls.tsx +++ b/air-quality-ui/src/components/globe/Controls.tsx @@ -7,6 +7,8 @@ import { PlayArrow, Public, Remove, + Fullscreen, + FullscreenExit, } from '@mui/icons-material' import { Button, MenuItem, Select, SelectChangeEvent } from '@mui/material' import React, { @@ -30,6 +32,9 @@ type ControlsProps = { onTimeInterpolationClick: (filterState: boolean) => void onVariableSelect: (variable: string) => void forecastData: Record + isFullscreen: boolean + onFullscreenToggle: () => void + selectedVariable?: string } const Controls: React.FC = ({ @@ -42,14 +47,21 @@ const Controls: React.FC = ({ onTimeInterpolationClick, onVariableSelect, forecastData, + isFullscreen = false, + onFullscreenToggle, + selectedVariable: externalSelectedVariable, }) => { + if (typeof onFullscreenToggle !== 'function') { + console.error('onFullscreenToggle is not a function in Controls:', onFullscreenToggle) + } + const [sliderValue, setSliderValue] = useState(0.0) const [globeAnimationState, setGlobeAnimationState] = useState(false) const [locationMarkerState, setLocationMarkerState] = useState(true) const [filterState, setGridFilterState] = useState(false) const [timeInterpolationState, setTimeInterpolationState] = useState(true) const [timeDelta, setTimeDelta] = useState(0.06) - const [selectedVariable, setSelectedVariable] = useState('aqi') + const [selectedVariable, setSelectedVariable] = useState(externalSelectedVariable || 'aqi') const handleSliderChange = (event: React.ChangeEvent) => { const value = parseFloat(event.target.value) @@ -126,6 +138,12 @@ const Controls: React.FC = ({ onVariableSelect(variable) } + useEffect(() => { + if (externalSelectedVariable && selectedVariable !== externalSelectedVariable) { + setSelectedVariable(externalSelectedVariable) + } + }, [externalSelectedVariable]) + const handleIncreaseTimeDelta = () => { setTimeDelta((prevDelta) => prevDelta + 0.02) } @@ -135,7 +153,11 @@ const Controls: React.FC = ({ } return ( -
+
- + {isFullscreen && ( + <> + - + + + )}
- + = ({ />
- + {isFullscreen && ( + <> + - + - + - + + + )} + +
) } @@ -246,45 +304,73 @@ const styles: { [key: string]: CSSProperties } = { display: 'flex', justifyContent: 'center', alignItems: 'center', - gap: '10px', - padding: '10px', + gap: '8px', backgroundColor: '#f4f4f4', borderTop: '1px solid #ccc', + maxWidth: '100%', + padding: '4px', }, controlButton: { - width: '50px', - height: '50px', + width: '32px', + height: '32px', + minWidth: '32px', display: 'flex', justifyContent: 'center', alignItems: 'center', border: 'none', - borderRadius: '10%', + borderRadius: '4px', cursor: 'pointer', - margin: '5px', - padding: '10px', + margin: '2px', + padding: '4px', }, sliderContainer: { display: 'flex', flexDirection: 'column', alignItems: 'center', + flex: 1, }, slider: { - width: '500px', + width: '140px', }, dropdown: { - width: '100px', - height: '40px', - fontSize: '16px', - borderRadius: '5px', + width: '80px', + height: '32px', + borderRadius: '4px', border: '1px solid lightgray', - padding: '5px', - margin: '5px', + padding: '4px', + margin: '2px', cursor: 'pointer', }, activeIcon: { color: 'white', fontWeight: 'bold', }, + globeButton: { + width: '32px', + height: '32px', + minWidth: '32px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + margin: '2px', + padding: '4px', + }, + checkerboardButton: { + width: '32px', + height: '32px', + minWidth: '32px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + margin: '2px', + padding: '4px', + } } export default Controls diff --git a/air-quality-ui/src/components/globe/ControlsHandler.tsx b/air-quality-ui/src/components/globe/ControlsHandler.tsx index 4140ead1..45b7ceda 100644 --- a/air-quality-ui/src/components/globe/ControlsHandler.tsx +++ b/air-quality-ui/src/components/globe/ControlsHandler.tsx @@ -14,6 +14,9 @@ type ControlsHandlerProps = { handleVariableSelect: (variable: string) => void isTimeRunning: boolean forecastData: Record + isFullscreen: boolean + onFullscreenToggle: () => void + selectedVariable?: string } const ControlsHandler: React.FC = ({ @@ -26,7 +29,11 @@ const ControlsHandler: React.FC = ({ handleVariableSelect, isTimeRunning, forecastData, + isFullscreen = false, + onFullscreenToggle, + selectedVariable, }) => { + return ( = ({ onTimeInterpolationClick={handleTimeInterpolationClick} onVariableSelect={handleVariableSelect} forecastData={forecastData} + isFullscreen={isFullscreen} + onFullscreenToggle={() => { + console.log('Fullscreen toggle called in ControlsHandler') + if (typeof onFullscreenToggle === 'function') { + onFullscreenToggle() + } else { + console.error('onFullscreenToggle is not a function:', onFullscreenToggle) + } + }} + selectedVariable={selectedVariable} /> ) } diff --git a/air-quality-ui/src/components/globe/LocationMarker.tsx b/air-quality-ui/src/components/globe/LocationMarker.tsx index e4714281..6d13c65c 100644 --- a/air-quality-ui/src/components/globe/LocationMarker.tsx +++ b/air-quality-ui/src/components/globe/LocationMarker.tsx @@ -17,6 +17,8 @@ import { MeasurementSummaryResponseDto, PollutantDataDto, } from '../../services/types' +import { createContourUniforms } from './utils/shaderUniforms' +import { getVariableIndex } from '../../models/variable-indices' type LocationMarkerProps = { forecastData: Record @@ -287,123 +289,20 @@ const LocationMarker = forwardRef( setVisible, })) - const variableIndex = - selectedVariable === 'aqi' - ? 1 - : selectedVariable === 'pm2_5' - ? 2 - : selectedVariable === 'pm10' - ? 3 - : selectedVariable === 'o3' - ? 4 - : selectedVariable === 'no2' - ? 5 - : selectedVariable === 'so2' - ? 6 - : undefined - - return ( - - - = 1.0 && value < 2.0) { - color = vec3(129.0, 237.0, 229.0); - } else if (value >= 2.0 && value < 3.0) { - color = vec3(116.0, 201.0, 172.0); - } else if (value >= 3.0 && value < 4.0) { - color = vec3(238.0, 230.0, 97.0); - } else if (value >= 4.0 && value < 5.0) { - color = vec3(236.0, 94.00, 87.0); - } else if (value >= 5.0 && value < 6.0) { - color = vec3(137.0, 26.0, 52.0); - } else if (value >= 6.0 && value < 7.0) { - color = vec3(115.0, 40.0, 125.0); - } else { - color = vec3(38, 38, 38); // Default to dark grey - } - } else if ( (uVariableIndex == 2.0) || (uVariableIndex == 3.0) ){ // "pm25 and pm10" - if (value == -1.0) { - color = vec3(38, 38, 38); // Default to dark grey for missing values - } else if (value < 30.0) { - color = vec3(255.0, 255.0, 255.0); - } else if (value < 40.0) { - color = vec3(233.0, 249.0, 188.0); // Green - } else if (value < 50.0) { - color = vec3(198.0, 255.0, 199.0); // Blue - } else if (value < 60.0) { - color = vec3(144.0, 237.0, 169.0); // Yellow - } else if (value < 80.0) { - color = vec3(76.0, 180.0, 148.0); // Orange - } else if (value < 100.0) { - color = vec3(48.0, 155.0, 138.0); // Purple - } else if (value < 150.0) { - color = vec3(47.0, 137.0, 169.0); // Yellow - } else if (value < 200.0) { - color = vec3(16.0, 99.0, 164.0); // Orange - } else if (value < 300.0) { - color = vec3(13.0, 69.0, 126.0); // Purple - } else if (value < 500.0) { - color = vec3(15.0, 26.0, 136.0); // Orange - } else if (value < 1000.0) { - color = vec3(38.0, 2.0, 60.0); // Purple - } else { - color = vec3(0.0, 0.0, 0.0); // Black for values out of range - } - } else if (uVariableIndex == 4.0) { // "o3" - if (value < 10.0) { - color = vec3(144.0, 190.0, 228.0); // Red - } else if (value < 20.0) { - color = vec3(20.0, 145.0, 216.0); // Green - } else if (value < 30.0) { - color = vec3(15.0, 109.0, 179.0); // Blue - } else if (value < 40.0) { - color = vec3(35.0, 79.0, 146.0); // Yellow - } else if (value < 50.0) { - color = vec3(37.0, 133.0, 100.0); // Orange - } else if (value < 60.0) { - color = vec3(96.0, 168.0, 83.0); // Purple - } else if (value < 70.0) { - color = vec3(157.0, 193.0, 99.0); // Yellow - } else if (value < 80.0) { - color = vec3(255.0,242.0, 148.0); // Orange - } else if (value < 90.0) { - color = vec3(240.0, 203.0, 62.0); // Purple - } else if (value < 100.0) { - color = vec3(229.0, 172.0, 59.0); // Orange - } else if (value < 120.0) { - color = vec3(214.0, 124.0, 62.0); // Purple - } else if (value < 150.0) { - color = vec3(196.0, 49.0, 50.0); // Purple - } else { - color = vec3(142.0, 25.0, 35.0); // Black for values out of range - } - } - - color = color / 255.0; - - return color; - } - - #define M_PI 3.14159265 - + // Declare uniform arrays with fixed size + uniform float uContourThresholds[20]; + uniform vec3 uContourColors[20]; + uniform int uNumLevels; + uniform float uMinValue; + uniform float uMaxValue; uniform float uVariableIndex; uniform float uSphereWrapAmount; uniform float uFrame; @@ -422,88 +321,100 @@ const LocationMarker = forwardRef( varying vec3 vColor; - void main() { + #define M_PI 3.14159265359 - vec2 thisTexCoord = vec2(markerIndex / (uMaxMarkers - 1.0), uFrame / uNumTimseSteps); - vec2 nextTexCoord = vec2(markerIndex / (uMaxMarkers - 1.0), (uFrame + 1.0 ) / uNumTimseSteps); + vec3 getColorForValue(float value) { + vec3 color = vec3(0.15, 0.15, 0.15); + + if (value > 0.0) { + bool colorFound = false; + for (int i = 0; i < 20; i++) { + if (i >= uNumLevels) break; + if (value < uContourThresholds[i]) { + color = uContourColors[i]; + colorFound = true; + break; + } + } + + // If no threshold matched, use the last color + if (!colorFound) { + color = uContourColors[uNumLevels - 1]; + } + } + + return color / 255.0; + } - float forecastValue = texture2D(forecastTexture, thisTexCoord).r; - float measurementValue = texture2D(measurementTexture, thisTexCoord).r; + void main() { + vec2 thisTexCoord = vec2(markerIndex / (uMaxMarkers - 1.0), uFrame / uNumTimseSteps); + vec2 nextTexCoord = vec2(markerIndex / (uMaxMarkers - 1.0), (uFrame + 1.0 ) / uNumTimseSteps); - float nextForecastValue = texture2D(forecastTexture, nextTexCoord).r; - float nextMeasurementValue = texture2D(measurementTexture, nextTexCoord).r; + float forecastValue = texture2D(forecastTexture, thisTexCoord).r; + float measurementValue = texture2D(measurementTexture, thisTexCoord).r; - float forecastValueInterpolated = mix(forecastValue, nextForecastValue, uFrameWeight); - float measurementValueInterpolated = mix(measurementValue, nextMeasurementValue, uFrameWeight); + float nextForecastValue = texture2D(forecastTexture, nextTexCoord).r; + float nextMeasurementValue = texture2D(measurementTexture, nextTexCoord).r; - float thisDiff; - float nextDiff; - float diff = 1.0; + float forecastValueInterpolated = mix(forecastValue, nextForecastValue, uFrameWeight); + float measurementValueInterpolated = mix(measurementValue, nextMeasurementValue, uFrameWeight); - float minValue; - float maxValue; + float thisDiff; + float nextDiff; + float diff = 1.0; - if (uVariableIndex == 1.0) { - minValue = 1.0; - maxValue = 6.0; - } else if ( (uVariableIndex == 2.0) || (uVariableIndex == 3.0) ) { - minValue = 1.0; - maxValue = 1000.0; - } else if (uVariableIndex == 4.0) { - minValue = 1.0; - maxValue = 500.0; - } - forecastValue = clamp(forecastValue, minValue, maxValue); - // if (measurementValue > 0.0) { - // measurementValue = clamp(measurementValue, minValue, maxValue); - // } - nextForecastValue = clamp(nextForecastValue, minValue, maxValue); - nextMeasurementValue = clamp(nextMeasurementValue, minValue, maxValue); - - if (measurementValue != -1.0) { - thisDiff = abs(measurementValue-forecastValue); - } else { - thisDiff = 0.0; - } - if (nextMeasurementValue != -1.0) { - nextDiff = abs(nextMeasurementValue-nextForecastValue); - } else { - nextDiff = 0.0; - } - diff = mix(thisDiff, nextDiff, uFrameWeight); - - if (uVariableIndex == 1.0) { - diff = clamp(diff * 0.8, 1.0, 6.0); - } else if ( (uVariableIndex == 2.0) || (uVariableIndex == 3.0) ) { - diff = clamp(diff/20.0, 1.0, 4.0); - } else if (uVariableIndex == 4.0) { - diff = clamp(diff/30.0, 1.0, 5.0); - } + // Clamp values to min/max range + forecastValue = clamp(forecastValue, uMinValue, uMaxValue); + if (measurementValue > 0.0) { + measurementValue = clamp(measurementValue, uMinValue, uMaxValue); + } + nextForecastValue = clamp(nextForecastValue, uMinValue, uMaxValue); + if (nextMeasurementValue > 0.0) { + nextMeasurementValue = clamp(nextMeasurementValue, uMinValue, uMaxValue); + } - if ( measurementValueInterpolated < 0.0 ) { - diff = 0.5; - } + if (measurementValue != -1.0) { + thisDiff = abs(measurementValue-forecastValue); + } else { + thisDiff = 0.0; + } + if (nextMeasurementValue != -1.0) { + nextDiff = abs(nextMeasurementValue-nextForecastValue); + } else { + nextDiff = 0.0; + } + diff = mix(thisDiff, nextDiff, uFrameWeight); + + // Clamp diff based on variable type + if (uVariableIndex == 1.0) { // aqi + diff = clamp(diff * 0.8, 1.0, 6.0); + } else if (uVariableIndex == 2.0 || uVariableIndex == 3.0) { // pm2_5 or pm10 + diff = clamp(diff/20.0, 1.0, 4.0); + } else if (uVariableIndex == 4.0) { // no2 + diff = clamp(diff/20.0, 1.0, 4.0); + } else if (uVariableIndex == 5.0) { // o3 + diff = clamp(diff/30.0, 1.0, 5.0); + } else if (uVariableIndex == 6.0) { // so2 + diff = clamp(diff/20.0, 1.0, 5.0); + } - vec3 color; - // if ( (measurementValueInterpolated > 0.0 ) || (diff > 1.0) ) { - if ( (measurementValueInterpolated > 0.0 ) ) { - color = getColorForValue(measurementValue, uVariableIndex); - } else { - // color = getColorForValue(0.0, uVariableIndex); - color = vec3(0.15, 0.15, 0.15); - } - // color = getColorForValue(measurementValue, uVariableIndex); + if (measurementValueInterpolated < 0.0) { + diff = 0.5; + } - - vColor = adjustSaturation(color, 2.0); // Increase saturation + vec3 color; + if (measurementValueInterpolated > 0.0) { + color = getColorForValue(measurementValue); + } else { + color = vec3(0.15, 0.15, 0.15); + } - // Apply initial scale to the position - vec3 posPlane = position * 0.3; + vColor = adjustSaturation(color, 2.0); - // Add longitude and latitude to position, normalizing for the spherical projection + // Rest of the positioning code remains the same + vec3 posPlane = position * 0.3; posPlane.x += lon / 180.0 * 2.0; posPlane.y += lat / 90.0; - float r = 1.0; float theta = 2. * M_PI * (posPlane.x / 4. + 0.5); @@ -524,25 +435,28 @@ const LocationMarker = forwardRef( } csm_Position = mix(posPlane, posSphere, uSphereWrapAmount) ; - - // csm_Position = posPlane; - } - `} - fragmentShader={` + ` + return ( + + + ( measurementTexture: { value: measurementDataTexture.current }, uVariableIndex: { value: variableIndex }, uMaxMarkers: { value: forecastDataTexture.current?.image.width }, - uNumTimseSteps: { - value: forecastDataTexture.current?.image.height, - }, + uNumTimseSteps: { value: forecastDataTexture.current?.image.height }, }} transparent /> diff --git a/air-quality-ui/src/components/globe/SurfaceLayer.tsx b/air-quality-ui/src/components/globe/SurfaceLayer.tsx index d3a199bf..ea43ca15 100644 --- a/air-quality-ui/src/components/globe/SurfaceLayer.tsx +++ b/air-quality-ui/src/components/globe/SurfaceLayer.tsx @@ -7,6 +7,8 @@ import fragmentShader from './shaders/surfaceFrag.glsl' import vertexShader from './shaders/surfaceVert.glsl' import { useDataTextures } from './useDataTextures' import { useForecastContext } from '../../context' +import { createContourUniforms } from './utils/shaderUniforms' +import { getVariableIndex } from '../../models/variable-indices' const shaderUniforms = { uSphereWrapAmount: { value: 0.0 }, @@ -72,24 +74,12 @@ const SurfaceLayer = memo( colorMapIndex: { value: 0.0 }, lsmTexture: { value: lsm }, uVariableIndex: { value: null }, + ...createContourUniforms(selectedVariable), }, }), ) - const variableIndex = - selectedVariable === 'aqi' - ? 1 - : selectedVariable === 'pm2_5' - ? 2 - : selectedVariable === 'pm10' - ? 3 - : selectedVariable === 'o3' - ? 4 - : selectedVariable === 'no2' - ? 5 - : selectedVariable === 'so2' - ? 6 - : undefined + const variableIndex = getVariableIndex(selectedVariable) materialRef.current.uniforms.uVariableIndex.value = variableIndex const windowIndexRef = useRef(0) @@ -114,6 +104,15 @@ const SurfaceLayer = memo( ) }, [selectedVariable, fetchAndUpdateTextures]) + useEffect(() => { + if (materialRef.current && selectedVariable) { + const contourUniforms = createContourUniforms(selectedVariable) + if (contourUniforms) { + Object.assign(materialRef.current.uniforms, contourUniforms) + } + } + }, [selectedVariable]) + const tick = (sliderValue: number) => { if (materialRef.current) { if (windowIndexRef.current != Math.floor(sliderValue)) { diff --git a/air-quality-ui/src/components/globe/World.tsx b/air-quality-ui/src/components/globe/World.tsx index 312a298b..6211c5a6 100644 --- a/air-quality-ui/src/components/globe/World.tsx +++ b/air-quality-ui/src/components/globe/World.tsx @@ -1,6 +1,7 @@ import { CameraControls } from '@react-three/drei' import { Canvas } from '@react-three/fiber' -import { CSSProperties, useRef, useState } from 'react' +import { CSSProperties, useRef, useState, useEffect } from 'react' +import * as THREE from 'three' import CameraSettings from './CameraSettings' import ControlsHandler from './ControlsHandler' @@ -10,29 +11,51 @@ import { ForecastResponseDto, MeasurementSummaryResponseDto, } from '../../services/types' +import { ColorBar } from './ColorBar' +import { PollutantType } from '../../models/types' +import { pollutantTypeDisplay } from '../../models/pollutant-display' -type WorldProps = { +interface WorldProps { forecastData: Record summarizedMeasurementData: Record - toggle: string + selectedCity?: { + name: string + latitude: number + longitude: number + } | null + selectedVariable?: string + isFullscreen: boolean + onToggleFullscreen: () => void } const World = ({ forecastData, summarizedMeasurementData, - toggle, + selectedCity, + selectedVariable: externalSelectedVariable, + isFullscreen = false, + onToggleFullscreen, }: WorldProps): JSX.Element => { + const [isFullscreenInternal, setIsFullscreenInternal] = useState(isFullscreen) + const surface_layer_ref = useRef(null) const markerRef = useRef(null) - const cameraControlsRef = useRef(null) + const cameraControlsRef = useRef(null) const [isTimeRunning, setIsTimeRunning] = useState(true) const [isLocationMarkerOn, setIsLocationMarkerOn] = useState(true) const [isFilterNearest, setGridFilterState] = useState(false) const [isTimeInterpolation, setTimeInterpolationState] = useState(true) - const [selectedVariable, setSelectedVariable] = useState('aqi') + const [selectedVariable, setSelectedVariable] = useState(externalSelectedVariable || 'aqi') const [globeState, setGlobeState] = useState(false) + // Default camera position + const defaultCameraPosition = { + phi: Math.PI / 2, // 90 degrees + theta: Math.PI, // 180 degrees + distance: 1.4 + } + const toggleTimeUpdate = () => setIsTimeRunning((prev) => !prev) const handleGlobeButtonClick = (globeState: boolean) => { @@ -65,67 +88,254 @@ const World = ({ markerRef.current?.tick(value) } - return ( -
- - - - + const handleFullscreenToggle = async () => { + console.log('Fullscreen toggle clicked. Current state:', isFullscreenInternal) + try { + if (!isFullscreenInternal) { + console.log('Attempting to enter fullscreen...') + await document.documentElement.requestFullscreen?.() + console.log('Entered fullscreen successfully') + setIsFullscreenInternal(true) + // Only call onToggleFullscreen if it exists + if (typeof onToggleFullscreen === 'function') { + onToggleFullscreen() + } + } else { + console.log('Attempting to exit fullscreen...') + await document.exitFullscreen?.() + console.log('Exited fullscreen successfully') + setIsFullscreenInternal(false) + // Only call onToggleFullscreen if it exists + if (typeof onToggleFullscreen === 'function') { + onToggleFullscreen() + } + } + } catch (err) { + console.error('Error toggling fullscreen:', err) + } + } - {!forecastData || - Object.keys(forecastData).length === 0 || - !summarizedMeasurementData || - Object.keys(summarizedMeasurementData).length === 0 ? null : ( - { + const handleFullscreenChange = () => { + console.log('Fullscreen change event fired') + console.log('document.fullscreenElement:', document.fullscreenElement) + if (!document.fullscreenElement) { + console.log('No fullscreen element, updating internal state') + setIsFullscreenInternal(false) + // Only call onToggleFullscreen if it exists + if (typeof onToggleFullscreen === 'function') { + console.log('Calling parent onToggleFullscreen') + onToggleFullscreen() + } + } + } + + document.addEventListener('fullscreenchange', handleFullscreenChange) + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange) + } + }, [onToggleFullscreen]) + + useEffect(() => { + if (selectedCity && cameraControlsRef.current) { + const controls = cameraControlsRef.current + const { latitude, longitude } = selectedCity + + // Switch to globe view when zooming to a city + if (!globeState) { + setGlobeState(true) + surface_layer_ref.current?.changeProjection(true) + markerRef.current?.changeProjection(true) + + const phi = -1.0 * (latitude - 90) * THREE.MathUtils.DEG2RAD + const theta = longitude * THREE.MathUtils.DEG2RAD + + // Wait 1 second before camera movement after switching to globe view + setTimeout(() => { + controls.rotateTo(theta, phi, true) + controls.dollyTo(0.3, true) + controls.smoothTime = 1.0 + }, 100) + } else { + const phi = -1.0 * (latitude - 90) * THREE.MathUtils.DEG2RAD + const theta = longitude * THREE.MathUtils.DEG2RAD + + controls.rotateTo(theta, phi, true) + controls.dollyTo(0.3, true) + controls.smoothTime = 1.0 + } + + } else if (cameraControlsRef.current) { + // Reset to default position when no city is selected + const controls = cameraControlsRef.current + + // Switch back to map view + if (globeState) { + setGlobeState(false) + surface_layer_ref.current?.changeProjection(false) + markerRef.current?.changeProjection(false) + + // Wait 1 second before camera movement after switching to map view + setTimeout(() => { + controls.rotateTo(defaultCameraPosition.theta, defaultCameraPosition.phi, true) + controls.dollyTo(defaultCameraPosition.distance, true) + controls.smoothTime = 1.0 + }, 1000) + } else { + controls.rotateTo(defaultCameraPosition.theta, defaultCameraPosition.phi, true) + controls.dollyTo(defaultCameraPosition.distance, true) + controls.smoothTime = 1.0 + } + } + }, [selectedCity]) + + // Update selectedVariable when external prop changes + useEffect(() => { + if (externalSelectedVariable) { + setSelectedVariable(externalSelectedVariable) + } + }, [externalSelectedVariable]) + + // Sync internal state with prop when it changes + useEffect(() => { + setIsFullscreenInternal(isFullscreen) + }, [isFullscreen]) + + return ( +
+
+ circle colour: obs value (black=no data); circle size: obs minus fc +
+
+ + + + - )} - + {!forecastData || + Object.keys(forecastData).length === 0 || + !summarizedMeasurementData || + Object.keys(summarizedMeasurementData).length === 0 ? null : ( + + )} + + - + + - - - +
+ +
+
) } -const styles: { worldContainer: CSSProperties } = { +const styles: { + worldContainer: CSSProperties + fullscreenContainer: CSSProperties + canvasContainer: CSSProperties + controlsOverlay: CSSProperties + title: CSSProperties +} = { worldContainer: { display: 'flex', flexDirection: 'column', alignItems: 'center', + width: '100%', + height: '270px', + position: 'relative', + maxWidth: 'none', + overflow: 'hidden', + paddingTop: '6px', + }, + fullscreenContainer: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 1000, + backgroundColor: 'white', + padding: '20px', + display: 'flex', + flexDirection: 'column', + maxWidth: 'none', + height: '100vh', + overflow: 'hidden', }, + canvasContainer: { + position: 'relative', + flex: 1, + width: '100%', + display: 'flex', + justifyContent: 'center', + minHeight: 0, + marginTop: '6px', + }, + controlsOverlay: { + position: 'absolute', + bottom: 0, + left: '50%', + transform: 'translateX(-50%)', + zIndex: 10, + backgroundColor: 'rgba(244, 244, 244, 0.9)', + borderRadius: '8px', + padding: '4px', + boxShadow: '0 2px 4px rgba(0,0,0,0.1)', + }, + title: { + fontSize: '12px', + textAlign: 'center', + position: 'absolute', + width: '100%', + top: '0px', + zIndex: 1, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + fontWeight: 'normal' + } } export default World diff --git a/air-quality-ui/src/components/globe/shaders/surfaceFrag.glsl b/air-quality-ui/src/components/globe/shaders/surfaceFrag.glsl index e3d67158..f37e3518 100644 --- a/air-quality-ui/src/components/globe/shaders/surfaceFrag.glsl +++ b/air-quality-ui/src/components/globe/shaders/surfaceFrag.glsl @@ -3,16 +3,14 @@ //////////////////////////////////////////////////////////////////////////////////////////////////////////////// uniform float uLayerOpacity; uniform float uFrameWeight; -uniform float thisDataMin[12]; -uniform float thisDataMax[12]; -uniform float uUserMinValue; -uniform float uUserMaxValue; -uniform float colorMapIndex; -uniform float uVariableIndex; +uniform float uContourThresholds[20]; +uniform vec3 uContourColors[20]; +uniform int uNumLevels; +uniform float uMinValue; +uniform float uMaxValue; uniform sampler2D thisDataTexture; uniform sampler2D nextDataTexture; -uniform sampler2D colorMap; uniform sampler2D lsmTexture; //////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -26,164 +24,65 @@ varying vec3 vPosition; // define functions //////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// convert float to color via colormap -vec4 applyColormap(float t, sampler2D colormap, float index){ - return(texture2D(colormap,vec2(t, index / 23.0 + 1.0 / 23.0 ))); -} // remap color range float remap(float value, float inMin, float inMax, float outMin, float outMax) { return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin); } -// Adjusted remap function to handle user-defined min and max values -float userRemap(float value) { - if (value < 0.0) { - return 0.5 * (value - uUserMinValue) / -uUserMinValue; - } else { - return 0.5 + 0.5 * value / uUserMaxValue; - } -} - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// // main program //////////////////////////////////////////////////////////////////////////////////////////////////////////////// void main() { - -float cmap_index = colorMapIndex; -float opacity_cutoff = 0.0; - -float remapMin; -float remapMax; - -if ( uVariableIndex == 1.0 ) { // AQI - remapMin = 1.0; - remapMax = 7.0; -} else if ( ( uVariableIndex == 2.0 ) || ( uVariableIndex == 3.0 ) ) { // PM10, PM2.5 - remapMin = 0.0; - remapMax = 1000.0; -} else if ( uVariableIndex == 4.0 ) { // O3 - remapMin = 0.0; - remapMax = 500.0; -} else if ( uVariableIndex == 5.0 ) { // NO2 - remapMin = 0.0; - remapMax = 100.0; -} else if ( uVariableIndex == 6.0 ) { // SO2 - remapMin = 0.0; - remapMax = 100.0; -} - - -// convert relative bitmap value to absolute value for both frames -float thisFrameData = remap( - texture2D( - thisDataTexture, - vUv + // convert relative bitmap value to absolute value for both frames + float thisFrameData = remap( + texture2D( + thisDataTexture, + vUv ).r, - 0.0, - 1.0, - remapMin, - remapMax); - -float nextFrameData = remap( - texture2D( - nextDataTexture, - vUv + 0.0, + 1.0, + uMinValue, + uMaxValue); + + float nextFrameData = remap( + texture2D( + nextDataTexture, + vUv ).r, - 0.0, - 1.0, - remapMin, - remapMax); - -// interpolate between absolute values of both frames -float intData = mix(thisFrameData, nextFrameData, uFrameWeight); - -// gl_FragColor = dataColor; -gl_FragColor = vec4(1.0); - -vec3 color; - -// Define colors for each range - -// AQI -if ( uVariableIndex == 1.0 ) { - if (intData >= 1.0 && intData < 2.0) { - color = vec3(129., 237., 229.); - } else if (intData >= 2.0 && intData < 3.0) { - color = vec3(116.0, 201.0, 172.0); - } else if (intData >= 3.0 && intData < 4.0) { - color = vec3(238.0, 230.0, 97.0); - } else if (intData >= 4.0 && intData < 5.0) { - color = vec3(236.0, 94.0, 87.0); - } else if (intData >= 5.0 && intData < 6.0) { - color = vec3(137.0, 26.0, 52.0); - } else if (intData >= 6.0 && intData < 7.0) { - color = vec3(115.0, 40.0, 125.0); - } else { - color = vec3(0.0, 0.0, 0.0); + 0.0, + 1.0, + uMinValue, + uMaxValue); + + // interpolate between absolute values of both frames + float intData = mix(thisFrameData, nextFrameData, uFrameWeight); + + gl_FragColor = vec4(1.0); + + vec3 color; + + // Find appropriate color from contour levels + bool colorFound = false; + for (int i = 0; i < 20; i++) { + if (i >= uNumLevels) break; + if (intData < uContourThresholds[i]) { + color = uContourColors[i]; + colorFound = true; + break; + } } -} else if ( ( uVariableIndex == 2.0) || ( uVariableIndex == 3.0) ) { - if (intData < 30.0) { - color = vec3(255.0); // Red - } else if (intData < 40.0) { - color = vec3(233.0, 249.0, 188.0); - } else if (intData < 50.0) { - color = vec3(198.0, 255.0, 199.0); - } else if (intData < 60.0) { - color = vec3(144.0, 237.0, 169.0); - } else if (intData < 80.0) { - color = vec3(76.0, 180.0, 148.0); - } else if (intData < 100.0) { - color = vec3(48.0, 155.0, 138.0); - } else if (intData < 150.0) { - color = vec3(47.0, 137.0, 169.0); - } else if (intData < 200.0) { - color = vec3(16.0, 99.0, 164.0); - } else if (intData < 300.0) { - color = vec3(13.0, 69.0, 126.0); - } else if (intData < 500.0) { - color = vec3(15.0, 26.0, 136.0); - } else if (intData < 1000.0) { - color = vec3(38.0, 2.0, 60.0); - } else { - color = vec3(0.0, 0.0, 0.0); - } -} else if ( uVariableIndex == 4.0 ) { // O3 - if (intData < 10.0) { - color = vec3(144.0, 190.0, 228.0); - } else if (intData < 20.0) { - color = vec3(20.0, 145.0, 216.0); - } else if (intData < 30.0) { - color = vec3(15.0, 109.0, 179.0); - } else if (intData < 40.0) { - color = vec3(35.0, 79.0, 146.0); - } else if (intData < 50.0) { - color = vec3(37.0, 133.0, 100.0); - } else if (intData < 60.0) { - color = vec3(96.0, 168.0, 83.0); - } else if (intData < 70.0) { - color = vec3(157.0, 193.0, 99.0); - } else if (intData < 80.0) { - color = vec3(255.0,242.0, 148.0); - } else if (intData < 90.0) { - color = vec3(240.0, 203.0, 62.0); - } else if (intData < 100.0) { - color = vec3(229.0, 172.0, 59.0); - } else if (intData < 120.0) { - color = vec3(214.0, 124.0, 62.0); - } else if (intData < 150.0) { - color = vec3(196.0, 49.0, 50.0); - } else { - color = vec3(142.0, 25.0, 35.0); - } -} -gl_FragColor = vec4(color/255., 1.0); + // If no threshold matched, use the last color + if (!colorFound) { + color = uContourColors[uNumLevels - 1]; + } -// overlay lsmTexture -vec4 lsmColor = texture2D(lsmTexture, vUv); + gl_FragColor = vec4(color/255., 1.0); -gl_FragColor = mix(vec4(0.,0.,0.,1.),gl_FragColor,lsmColor.r); + // overlay lsmTexture + vec4 lsmColor = texture2D(lsmTexture, vUv); + gl_FragColor = mix(vec4(0.,0.,0.,1.),gl_FragColor,lsmColor.r); } \ No newline at end of file diff --git a/air-quality-ui/src/components/globe/utils/shaderUniforms.ts b/air-quality-ui/src/components/globe/utils/shaderUniforms.ts new file mode 100644 index 00000000..73b9dcb1 --- /dev/null +++ b/air-quality-ui/src/components/globe/utils/shaderUniforms.ts @@ -0,0 +1,31 @@ +import { pollutantContours, hexToRgb } from '../../../models/pollutant-contours' + +const MAX_LEVELS = 20 // Maximum number of contour levels we'll support in the shader + +export function createContourUniforms(selectedVariable: string) { + const contour = pollutantContours[selectedVariable as keyof typeof pollutantContours] + if (!contour) return null + + // Create arrays for thresholds and colors + const thresholds = new Float32Array(MAX_LEVELS).fill(Infinity) + const colors = new Float32Array(MAX_LEVELS * 3).fill(0) + + // Fill arrays with actual values + contour.levels.forEach((level, i) => { + if (i < MAX_LEVELS) { + thresholds[i] = level.threshold + const [r, g, b] = hexToRgb(level.color) + colors[i * 3] = r + colors[i * 3 + 1] = g + colors[i * 3 + 2] = b + } + }) + + return { + uContourThresholds: { value: thresholds }, + uContourColors: { value: colors }, + uNumLevels: { value: contour.levels.length }, + uMinValue: { value: contour.minValue }, + uMaxValue: { value: contour.maxValue } + } +} \ No newline at end of file diff --git a/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.module.css b/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.module.css index e4fa369b..0fa6aa34 100644 --- a/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.module.css +++ b/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.module.css @@ -1,3 +1,4 @@ .summary-grid-wrapper { - height: 700px + /* 48px (header) + (48px * 10 rows) = 528px */ + height: 528px; } diff --git a/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.spec.tsx b/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.spec.tsx index 2797af89..d728422c 100644 --- a/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.spec.tsx +++ b/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.spec.tsx @@ -32,6 +32,8 @@ jest.mock('../../../services/summary-comparison-service', () => ({ }), })) +const mockOnCityHover = jest.fn() + const renderGrid = (showAllColoured: boolean) => { // The tests are driven by the mocked data returned from Create Summary Row return render( @@ -39,6 +41,7 @@ const renderGrid = (showAllColoured: boolean) => { forecast={{}} summarizedMeasurements={{}} showAllColoured={showAllColoured} + onCityHover={mockOnCityHover} />, { wrapper: BrowserRouter, @@ -47,6 +50,10 @@ const renderGrid = (showAllColoured: boolean) => { } describe('GlobalSummaryTable component', () => { + beforeEach(() => { + mockOnCityHover.mockClear() + }) + describe('renders the summary table', () => { it('displays message when data is loading', async () => { render( @@ -54,6 +61,7 @@ describe('GlobalSummaryTable component', () => { forecast={undefined} summarizedMeasurements={undefined} showAllColoured={true} + onCityHover={mockOnCityHover} />, { wrapper: BrowserRouter, diff --git a/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.tsx b/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.tsx index b78b63e1..3b15d7e8 100644 --- a/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.tsx +++ b/air-quality-ui/src/components/summary-grid/table/GlobalSummaryTable.tsx @@ -26,9 +26,10 @@ import { aqiCellRules, pollutantCellRules } from '../cell/cell-rules/CellRules' import { LocationCellRenderer } from '../cell/location-cell-renderer/LocationCellRenderer' export interface GlobalSummaryTableProps { - forecast: Record - summarizedMeasurements: Record + forecast: Record | undefined + summarizedMeasurements: Record | undefined showAllColoured: boolean + onCityHover: (cityName: string | null, latitude?: number, longitude?: number, columnId?: string) => void } const maxWidth = 115 @@ -136,17 +137,39 @@ const createColDefs = (showAllColoured: boolean): (ColDef | ColGroupDef)[] => [ })), ] -const createGridOptions = (): GridOptions => ({ +const createGridOptions = ( + forecast: Record | undefined, + onCityHover?: (cityName: string | null, latitude?: number, longitude?: number, columnId?: string) => void +): GridOptions => ({ autoSizeStrategy: { type: 'fitCellContents', }, + onCellMouseOver: (event) => { + const cityName = event.data.locationName + const columnId = event.column?.getColId() + + // Extract pollutant name from columnId, including aqiLevel + const pollutantMatch = columnId?.match(/(?:forecast|measurements)\.(pm2_5|pm10|o3|no2|so2|aqiLevel)/) + const pollutantName = pollutantMatch ? pollutantMatch[1] : 'aqiLevel' + + if (cityName && forecast?.[cityName]?.[0]) { + const { latitude, longitude } = forecast[cityName][0].location + onCityHover?.(cityName, latitude, longitude, columnId ? pollutantName : 'aqiLevel') + } else { + onCityHover?.(cityName, undefined, undefined, columnId ? pollutantName : 'aqiLevel') + } + }, + onCellMouseOut: () => { + onCityHover?.(null) + }, }) const GlobalSummaryTable = ({ forecast, summarizedMeasurements, showAllColoured, -}: Partial): JSX.Element => { + onCityHover, +}: GlobalSummaryTableProps): JSX.Element => { const rowData = useMemo(() => { if (!forecast || !summarizedMeasurements) { return null @@ -160,7 +183,10 @@ const GlobalSummaryTable = ({ if (showAllColoured != undefined) { columnDefs = createColDefs(showAllColoured) } - const gridOptions = createGridOptions() + const gridOptions = useMemo( + () => createGridOptions(forecast, onCityHover), + [forecast, onCityHover] + ) return (
{ const { forecastDetails } = useForecastContext() const [showAllColoured, setShowAllColoured] = useState(true) - const [showMap, setShowMap] = useState(false) + const enableHoverRef = useRef(false) + const [enableHover, setEnableHover] = useState(false) + const [measurementCounts, setMeasurementCounts] = useState(null) + const [hoveredCity, setHoveredCity] = useState(null) + const [hoveredVar, setHoveredVar] = useState(undefined) + const [selectedCityCoords, setSelectedCityCoords] = useState<{ + name: string + latitude: number + longitude: number + } | null>(null) + const [lastHoveredState, setLastHoveredState] = useState<{ + city: string | null, + coords: { name: string, latitude: number, longitude: number } | null + } | null>(null) + const [isFullscreen, setIsFullscreen] = useState(false) const wrapSetShowAllColoured = useCallback( (val: boolean) => { @@ -30,11 +45,27 @@ const GlobalSummary = (): JSX.Element => { [setShowAllColoured], ) - const wrapSetShowMap = useCallback( + const wrapSetEnableHover = useCallback( (val: boolean) => { - setShowMap(val) + + enableHoverRef.current = val + + if (!val) { + setLastHoveredState({ + city: hoveredCity, + coords: selectedCityCoords + }) + setHoveredCity(null) + setSelectedCityCoords(null) + } else { + if (lastHoveredState) { + setHoveredCity(lastHoveredState.city) + setSelectedCityCoords(lastHoveredState.coords) + } + } + setEnableHover(val) }, - [setShowMap], + [enableHover, hoveredCity, selectedCityCoords, lastHoveredState], ) const { @@ -106,6 +137,47 @@ const GlobalSummary = (): JSX.Element => { }, }) + useEffect(() => { + const fetchMeasurementCounts = async () => { + try { + const counts = await getMeasurementCounts( + forecastDetails.forecastBaseDate, + forecastDetails.maxForecastDate, + 'city' + ) + setMeasurementCounts(counts) + } catch (error) { + console.error('Error fetching measurement counts:', error) + } + } + + fetchMeasurementCounts() + }, [forecastDetails]) + + const handleCityHover = useCallback( + (cityName: string | null, latitude?: number, longitude?: number, columnId?: string) => { + + if (!enableHoverRef.current) return + + setHoveredCity(cityName) + setHoveredVar(columnId) + if (cityName && latitude !== undefined && longitude !== undefined) { + setSelectedCityCoords({ + name: cityName, + latitude, + longitude, + }) + } else { + setSelectedCityCoords(null) + } + }, + [], + ) + + const handleFullscreenToggle = useCallback(() => { + setIsFullscreen(prev => !prev) + }, []) + if (forecastDataError || summaryDataError) { return Error occurred } @@ -121,20 +193,43 @@ const GlobalSummary = (): JSX.Element => { - - {showMap && ( - - )} +
+
+ +
+
+ +
+
+ +
+
)} diff --git a/air-quality-ui/src/components/summary-view/MapViewHeader.module.css b/air-quality-ui/src/components/summary-view/MapViewHeader.module.css deleted file mode 100644 index e811a849..00000000 --- a/air-quality-ui/src/components/summary-view/MapViewHeader.module.css +++ /dev/null @@ -1,17 +0,0 @@ -.table-header { - width: 100%; - display: flex; - justify-content: space-between; - padding-top: 10px; - padding-bottom: 20px; -} - -.table-switch-label { - padding-bottom: 3px; -} - -.table-switch-container { - display: flex; - align-items: center; - margin-left: auto; -} diff --git a/air-quality-ui/src/components/summary-view/MapViewHeader.tsx b/air-quality-ui/src/components/summary-view/MapViewHeader.tsx deleted file mode 100644 index aaf7a0ca..00000000 --- a/air-quality-ui/src/components/summary-view/MapViewHeader.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import Switch from '@mui/material/Switch' -import { ChangeEvent, useId } from 'react' - -import classes from './MapViewHeader.module.css' - -export interface MapViewHeaderProps { - showMap: boolean - setShowMap: (showMap: boolean) => void -} - -export const MapViewHeader = ({ - showMap, - setShowMap, -}: MapViewHeaderProps): JSX.Element => { - const switchId = useId() - - return ( -
-
- - ) => { - setShowMap(event.target.checked) - }} - checked={showMap} - /> -
-
- ) -} diff --git a/air-quality-ui/src/components/summary-view/SummaryViewHeader.module.css b/air-quality-ui/src/components/summary-view/SummaryViewHeader.module.css index e3bcba30..2b158a12 100644 --- a/air-quality-ui/src/components/summary-view/SummaryViewHeader.module.css +++ b/air-quality-ui/src/components/summary-view/SummaryViewHeader.module.css @@ -1,20 +1,27 @@ .table-header { - width: 100%; display: flex; justify-content: space-between; - padding-bottom: 20px; + align-items: center; + padding: 0px; } .table-date { - display: flex; - align-items: center; + flex: 1; } -.table-switch-label { - padding-bottom: 3px; +.switches-container { + display: flex; + gap: 16px; + align-items: center; } .table-switch-container { display: flex; align-items: center; + gap: 8px; +} + +.table-switch-label { + font-size: 14px; + white-space: nowrap; } diff --git a/air-quality-ui/src/components/summary-view/SummaryViewHeader.spec.tsx b/air-quality-ui/src/components/summary-view/SummaryViewHeader.spec.tsx index dc31d211..3895a266 100644 --- a/air-quality-ui/src/components/summary-view/SummaryViewHeader.spec.tsx +++ b/air-quality-ui/src/components/summary-view/SummaryViewHeader.spec.tsx @@ -18,13 +18,21 @@ jest.mock('../../context', () => ({ })) const mockSetShowAllColoured: (val: boolean) => void = jest.fn() +const mockSetEnableHover: (val: boolean) => void = jest.fn() describe('SummaryViewHeader component', () => { + beforeEach(() => { + mockSetShowAllColoured.mockClear() + mockSetEnableHover.mockClear() + }) + it('Shows correct message for context dates', async () => { render( , ) await waitFor(() => { @@ -33,11 +41,14 @@ describe('SummaryViewHeader component', () => { ).toBeInTheDocument() }) }) + it('Calls setShowAllColoured on switch click', async () => { render( , ) await act(async () => { diff --git a/air-quality-ui/src/components/summary-view/SummaryViewHeader.tsx b/air-quality-ui/src/components/summary-view/SummaryViewHeader.tsx index d2d93e36..143b0a76 100644 --- a/air-quality-ui/src/components/summary-view/SummaryViewHeader.tsx +++ b/air-quality-ui/src/components/summary-view/SummaryViewHeader.tsx @@ -7,14 +7,19 @@ import { useForecastContext } from '../../context' export interface SummaryViewHeaderProps { showAllColoured: boolean setShowAllColoured: (showAllColoured: boolean) => void + setEnableHover: (val: boolean) => void + enableHover: boolean } export const SummaryViewHeader = ({ showAllColoured, setShowAllColoured, + setEnableHover, + enableHover, }: SummaryViewHeaderProps): JSX.Element => { const { forecastDetails } = useForecastContext() - const switchId = useId() + const aqiSwitchId = useId() + const hoverSwitchId = useId() return (
@@ -23,21 +28,39 @@ export const SummaryViewHeader = ({ {' - '} {forecastDetails.maxMeasurementDate.toFormat('dd MMM HH:mm ZZZZ')}
-
- - ) => { - setShowAllColoured(event.target.checked) - }} - checked={showAllColoured} - /> +
+
+ + ) => { + setEnableHover(event.target.checked) + }} + checked={enableHover} + /> +
+
+ + ) => { + setShowAllColoured(event.target.checked) + }} + checked={showAllColoured} + /> +
) diff --git a/air-quality-ui/src/components/summary-view/charts/SummaryBarChart.tsx b/air-quality-ui/src/components/summary-view/charts/SummaryBarChart.tsx new file mode 100644 index 00000000..0b0bbf03 --- /dev/null +++ b/air-quality-ui/src/components/summary-view/charts/SummaryBarChart.tsx @@ -0,0 +1,298 @@ +import ReactECharts from 'echarts-for-react' +import { MeasurementCounts } from '../../../services/measurement-data-service' +import { pollutantTypeDisplayShort, PollutantType } from '../../../models' +import { useMemo } from 'react' +import { pollutantColors } from '../../../models/pollutant-colors' + +interface SummaryBarChartProps { + measurementCounts?: MeasurementCounts | null + totalCities: number + selectedCity: string | null +} + +const SummaryBarChart = ({ selectedCity, measurementCounts, totalCities }: SummaryBarChartProps): JSX.Element => { + const pollutants: PollutantType[] = ['so2', 'no2', 'o3', 'pm10', 'pm2_5', ] + + const getChartData = () => { + if (!measurementCounts) return { pollutantsList: [], counts: [], coverage: [], maxCount: 0, maxPercentage: 0 } + + const pollutantLabels = pollutants.map(p => pollutantTypeDisplayShort[p]) + + const counts = pollutants.map(pollutant => ({ + value: Object.values(measurementCounts).reduce((sum, cityData) => { + return sum + (cityData[pollutant] || 0) + }, 0), + label: { + show: true, + position: 'left', + fontSize: 10 + } + })) + + const maxCount = Math.max(...counts.map(item => item.value)) + + const coverage = pollutants.map(pollutant => { + const citiesWithData = Object.values(measurementCounts).filter( + cityData => (cityData[pollutant] || 0) > 0 + ).length + const percentage = Number(((citiesWithData / totalCities) * 100).toFixed(1)) + return { + value: percentage, + label: { + show: true, + position: 'right', + fontSize: 10, + formatter: `${percentage}%` + } + } + }) + + const maxPercentage = Math.ceil(Math.max(...coverage.map(item => item.value))) + + return { + pollutantLabels, + counts, + coverage, + maxCount, + maxPercentage + } + } + + const { pollutantLabels, counts, coverage, maxCount, maxPercentage } = getChartData() + + const filteredData = useMemo(() => { + if (!selectedCity) { + return { pollutants, pollutantLabels, counts, coverage, maxCount, maxPercentage } + } + + // Check if the city has any measurements + if (!measurementCounts?.[selectedCity]) { + // Return zeros for all pollutants if city has no measurements + const emptyData = pollutants.map(() => ({ + value: 0, + itemStyle: { color: '#e0e0e0' } + })) + + return { + pollutants, + pollutantLabels, + counts: emptyData, + coverage: emptyData, + maxCount: 1, + maxPercentage: 1 + } + } + + const newCounts = pollutants.map(pollutant => ({ + value: measurementCounts[selectedCity][pollutant] || 0, + label: { + show: true, + position: 'left', + fontSize: 10 + } + })) + + // Calculate new maxCount based on selected city's values + const newMaxCount = Math.max(...newCounts.map(item => item.value)) + + // For a selected city, show number of locations with data for each pollutant + const locationCounts = pollutants.map(pollutant => { + // Safely access the location count for each pollutant + const locationCount = measurementCounts[selectedCity]?.[`${pollutant}_locations`] ?? 0 + return { + value: locationCount, + label: { + show: true, + position: 'right', + fontSize: 10, + formatter: locationCount > 0 ? locationCount.toString() : '0' + } + } + }) + + // Calculate max locations, ensuring it's at least 1 to avoid scale issues + const maxLocations = Math.max(1, ...locationCounts.map(item => item.value)) + + return { + pollutants, + pollutantLabels, + counts: newCounts, + coverage: locationCounts, + maxCount: newMaxCount || 1, // Use 1 as minimum to avoid empty chart + maxPercentage: maxLocations + } + }, [selectedCity, measurementCounts, pollutants, pollutantLabels, coverage, maxPercentage]) + + const getBarColor = (value: number, maxValue: number, isCount: boolean) => { + if (value === 0) return '#e0e0e0' + const pollutant = pollutants[Math.floor(value) % pollutants.length] + return pollutantColors[pollutant] + } + + const options = { + title: [ + { + text: 'Number of Measurements', + left: '25%', + top: 0, + textAlign: 'center', + textStyle: { + fontSize: 12 + } + }, + { + text: selectedCity ? 'Individual Locations' : 'Cities Covered', + left: '75%', + top: 0, + textAlign: 'center', + textStyle: { + fontSize: 12 + } + } + ], + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + grid: [ + { + left: '8%', + right: '55%', // Left chart + bottom: '3%', + top: '17%', + }, + { + left: '55%', // Right chart + right: '8%', + bottom: '3%', + top: '17%', + }, + { // Center grid for labels - use absolute positioning + left: '48.5%', // Fixed position in the middle + width: '10%', + top: '17%', + bottom: '3%', + + z: 999 + } + ], + xAxis: [ + { + type: 'value', + inverse: true, + position: 'top', + gridIndex: 0, + max: filteredData.maxCount, + axisLabel: { + formatter: '{value}', + fontSize: 10 + } + }, + { + type: 'value', + position: 'top', + gridIndex: 1, + max: filteredData.maxPercentage, + axisLabel: { + formatter: selectedCity ? '{value}' : '{value}%', + fontSize: 10 + } + }, + { // Add dummy xAxis for center grid + type: 'value', + gridIndex: 2, + show: false + } + ], + yAxis: [ + { + type: 'category', + data: filteredData.pollutantLabels, + gridIndex: 0, + position: 'center', + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false } + }, + { + type: 'category', + data: filteredData.pollutantLabels, + gridIndex: 1, + position: 'center', + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false } + }, + { // Center axis for labels + type: 'category', + data: filteredData.pollutantLabels, + gridIndex: 2, + position: 'center', + axisLabel: { + fontSize: 14, + align: 'center', + verticalAlign: 'middle', + inside: true, + width: '100%' // Use full width of the grid + }, + axisLine: { show: false }, + axisTick: { show: false } + } + ], + series: [ + { + name: 'Measurement Count', + type: 'bar', + xAxisIndex: 0, + yAxisIndex: 0, + data: filteredData.counts.map((item, index) => ({ + ...item, + itemStyle: { + color: selectedCity ? + (item.value === 0 ? '#e0e0e0' : pollutantColors[pollutants[index]]) : + pollutantColors[pollutants[index]] + } + })), + barWidth: '60%', + label: { + show: true, + position: 'left', + fontSize: 10 + } + }, + { + name: 'Cities Covered', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + data: filteredData.coverage.map((item, index) => ({ + ...item, + itemStyle: { + color: selectedCity ? + (item.value === 0 ? '#e0e0e0' : pollutantColors[pollutants[index]]) : + pollutantColors[pollutants[index]] + } + })), + barWidth: '60%', + label: { + show: true, + position: 'right', + fontSize: 10 + } + }, + { // Add dummy series for center grid + type: 'bar', + xAxisIndex: 2, + yAxisIndex: 2, + data: [], + silent: true + } + ] + } + + return +} + +export default SummaryBarChart \ No newline at end of file diff --git a/air-quality-ui/src/components/summary-view/charts/SummaryScatterChart.tsx b/air-quality-ui/src/components/summary-view/charts/SummaryScatterChart.tsx new file mode 100644 index 00000000..2419fff9 --- /dev/null +++ b/air-quality-ui/src/components/summary-view/charts/SummaryScatterChart.tsx @@ -0,0 +1,206 @@ +import ReactECharts from 'echarts-for-react' +import { useState } from 'react' +import { ForecastResponseDto, MeasurementSummaryResponseDto } from '../../../services/types' +import { pollutantTypeDisplay, pollutantTypeDisplayShort, PollutantType } from '../../../models' +import { DateTime } from 'luxon' +import { pollutantColors } from '../../../models/pollutant-colors' + +interface SummaryScatterChartProps { + title: string + summarizedMeasurements?: Record + forecast?: Record + selectedCity: string | null +} + +const SummaryScatterChart = ({ + title, + summarizedMeasurements, + forecast, + selectedCity +}: SummaryScatterChartProps): JSX.Element => { + const [selectedPollutants, setSelectedPollutants] = useState>( + new Set(['pm2_5', 'pm10', 'o3', 'no2', 'so2'] as PollutantType[]) + ) + + const togglePollutant = (pollutant: PollutantType) => { + const newSelection = new Set(selectedPollutants) + if (newSelection.has(pollutant)) { + newSelection.delete(pollutant) + } else { + newSelection.add(pollutant) + } + setSelectedPollutants(newSelection) + } + + const getChartData = () => { + if (!summarizedMeasurements || !forecast) return [] + + const allPollutants = ['pm2_5', 'pm10', 'o3', 'no2', 'so2'] as const; + const pollutants: PollutantType[] = allPollutants + .filter(p => selectedPollutants.has(p as PollutantType)); + + return Object.entries(summarizedMeasurements).flatMap(([cityName, measurements]) => { + if (selectedCity && selectedCity !== cityName) return [] + + const cityForecasts = forecast[cityName] || [] + + return measurements.flatMap(measurement => { + const matchingForecast = cityForecasts.find( + f => f.valid_time === measurement.measurement_base_time + ) + if (!matchingForecast) return [] + + return pollutants.map(pollutant => { + const measuredValue = measurement[pollutant]?.mean?.value + const forecastValue = matchingForecast[pollutant]?.value + + if (measuredValue === undefined || forecastValue === undefined) return null + + return { + value: [measuredValue, forecastValue], + name: cityName, + pollutant: pollutant as PollutantType, + timestamp: measurement.measurement_base_time, + itemStyle: { + color: pollutantColors[pollutant] + } + } + }).filter(item => item !== null) + }) + }) + } + + const data = getChartData() + + // Calculate axis bounds based on data + const maxValue = Math.max( + ...data.map(point => Math.max(point.value[0], point.value[1])), + 0 + ) + const axisMax = Math.ceil(maxValue * 1.1) // Add 10% padding + + const options = { + animation: false, // Disable transitions + title: { + text: title, + left: 'center', + top: 0, + textStyle: { + fontSize: 12 + } + }, + tooltip: { + trigger: 'item', + formatter: function(params: any) { + if (params.data) { + return `${params.data.name}
+ ${pollutantTypeDisplay[params.data.pollutant as PollutantType]}
+ Measured: ${params.data.value[0].toFixed(1)} µg/m³
+ Forecast: ${params.data.value[1].toFixed(1)} µg/m³
+ Time: ${DateTime.fromISO(params.data.timestamp).toFormat('yyyy-MM-dd HH:mm')}` + } + return '' + } + }, + grid: { + left: '12%', + right: '20%', // Make room for buttons + bottom: '14%', + top: '10%' + }, + dataZoom: [ + { + type: 'inside', + xAxisIndex: 0, + filterMode: 'none', + minSpan: 1 + }, + { + type: 'inside', + yAxisIndex: 0, + filterMode: 'none', + minSpan: 1 + } + ], + xAxis: { + type: 'value', + name: 'Measured (µg/m³)', + nameLocation: 'middle', + nameGap: 25, + min: 0, + max: axisMax, + axisLabel: { + formatter: '{value}' + } + }, + yAxis: { + type: 'value', + name: 'Forecast (µg/m³)', + nameLocation: 'middle', + nameGap: 35, + min: 0, + max: axisMax, + axisLabel: { + formatter: '{value}' + } + }, + series: [ + { + type: 'scatter', + data: data, + symbolSize: 8, + itemStyle: { + opacity: 0.7 + } + }, + { + type: 'line', + showSymbol: false, + data: [[0, 0], [axisMax, axisMax]], // Diagonal line from origin to max + lineStyle: { + color: '#999', + type: 'dashed' + }, + tooltip: { + show: false + } + } + ] + } + + return ( +
+ +
+ {Object.entries(pollutantColors).map(([pollutant, color]) => ( + + ))} +
+
+ ) +} + +export default SummaryScatterChart \ No newline at end of file diff --git a/air-quality-ui/src/main.tsx b/air-quality-ui/src/main.tsx index a13a76c7..2c935603 100644 --- a/air-quality-ui/src/main.tsx +++ b/air-quality-ui/src/main.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import React from 'react' -import ReactDOM from 'react-dom/client' +import * as React from 'react' +import { createRoot } from 'react-dom/client' import { Navigate, RouterProvider, @@ -47,7 +47,7 @@ const router = createBrowserRouter([ const queryClient = new QueryClient() -ReactDOM.createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById('root')!).render( diff --git a/air-quality-ui/src/models.ts b/air-quality-ui/src/models.ts index ff120184..4177da78 100644 --- a/air-quality-ui/src/models.ts +++ b/air-quality-ui/src/models.ts @@ -20,4 +20,12 @@ export const pollutantTypeDisplay: Record = { so2: 'Sulphur Dioxide', } +export const pollutantTypeDisplayShort: Record = { + no2: 'NO2', + o3: 'O3', + pm2_5: 'PM2.5', + pm10: 'PM10', + so2: 'SO2', +} + export type LocationType = 'city' diff --git a/air-quality-ui/src/models/pollutant-colors.ts b/air-quality-ui/src/models/pollutant-colors.ts new file mode 100644 index 00000000..31792471 --- /dev/null +++ b/air-quality-ui/src/models/pollutant-colors.ts @@ -0,0 +1,9 @@ +import { PollutantType } from './types' + +export const pollutantColors: Record = { + pm2_5: '#1f77b4', // blue + pm10: '#ff7f0e', // orange + no2: '#2ca02c', // green + o3: '#d62728', // red + so2: '#9467bd' // purple +} \ No newline at end of file diff --git a/air-quality-ui/src/models/pollutant-contours.ts b/air-quality-ui/src/models/pollutant-contours.ts new file mode 100644 index 00000000..ea00fda1 --- /dev/null +++ b/air-quality-ui/src/models/pollutant-contours.ts @@ -0,0 +1,156 @@ +import { PollutantType } from './types' + +export interface ContourLevel { + threshold: number + color: string +} + +export interface PollutantContours { + minValue: number + maxValue: number + levels: ContourLevel[] +} + +export const pollutantContours: Record = { + aqi: { + minValue: 1.0, + maxValue: 7.0, + levels: [ + { threshold: 2.0, color: '#81EDEA' }, // rgb(129, 237, 229) + { threshold: 3.0, color: '#74C9AC' }, // rgb(116, 201, 172) + { threshold: 4.0, color: '#EEE661' }, // rgb(238, 230, 97) + { threshold: 5.0, color: '#EC5E57' }, // rgb(236, 94, 87) + { threshold: 6.0, color: '#891A34' }, // rgb(137, 26, 52) + { threshold: 7.0, color: '#73287D' }, // rgb(115, 40, 125) + ] + }, + pm2_5: { + minValue: 0.0, + maxValue: 500.0, + levels: [ + { threshold: 2.0, color: '#C7EAF4' }, // rgb(199, 234, 244) + { threshold: 5.0, color: '#B4E0EC' }, // rgb(180, 224, 236) + { threshold: 10.0, color: '#A1D6E4' }, // rgb(161, 214, 228) + { threshold: 20.0, color: '#C0E0CE' }, // rgb(192, 224, 206) + { threshold: 30.0, color: '#ECEFB4' }, // rgb(236, 239, 180) + { threshold: 40.0, color: '#FBE98F' }, // rgb(251, 233, 143) + { threshold: 50.0, color: '#F6D463' }, // rgb(246, 212, 99) + { threshold: 75.0, color: '#EEB53D' }, // rgb(238, 181, 61) + { threshold: 100.0, color: '#E28721' }, // rgb(226, 135, 33) + { threshold: 150.0, color: '#D15D0C' }, // rgb(209, 93, 12) + { threshold: 200.0, color: '#AB4211' }, // rgb(171, 66, 17) + { threshold: 500.0, color: '#862716' }, // rgb(134, 39, 22) + ] + }, + pm10: { + minValue: 0.0, + maxValue: 500.0, + levels: [ + { threshold: 2.0, color: '#C7EAF4' }, // rgb(199, 234, 244) + { threshold: 5.0, color: '#B4E0EC' }, // rgb(180, 224, 236) + { threshold: 10.0, color: '#A1D6E4' }, // rgb(161, 214, 228) + { threshold: 20.0, color: '#C0E0CE' }, // rgb(192, 224, 206) + { threshold: 30.0, color: '#ECEFB4' }, // rgb(236, 239, 180) + { threshold: 40.0, color: '#FBE98F' }, // rgb(251, 233, 143) + { threshold: 50.0, color: '#F6D463' }, // rgb(246, 212, 99) + { threshold: 75.0, color: '#EEB53D' }, // rgb(238, 181, 61) + { threshold: 100.0, color: '#E28721' }, // rgb(226, 135, 33) + { threshold: 150.0, color: '#D15D0C' }, // rgb(209, 93, 12) + { threshold: 200.0, color: '#AB4211' }, // rgb(171, 66, 17) + { threshold: 500.0, color: '#862716' }, // rgb(134, 39, 22) + ] + }, + no2: { + minValue: 0.0, + maxValue: 300.0, + levels: [ + { threshold: 2.0, color: '#C7EAF4' }, // rgb(199, 234, 244) + { threshold: 5.0, color: '#B4E0EC' }, // rgb(180, 224, 236) + { threshold: 10.0, color: '#A1D6E4' }, // rgb(161, 214, 228) + { threshold: 20.0, color: '#C0E0CE' }, // rgb(192, 224, 206) + { threshold: 30.0, color: '#ECEFB4' }, // rgb(236, 239, 180) + { threshold: 40.0, color: '#FBE98F' }, // rgb(251, 233, 143) + { threshold: 50.0, color: '#F6D463' }, // rgb(246, 212, 99) + { threshold: 75.0, color: '#EEB53D' }, // rgb(238, 181, 61) + { threshold: 100.0, color: '#E28721' }, // rgb(226, 135, 33) + { threshold: 150.0, color: '#D15D0C' }, // rgb(209, 93, 12) + { threshold: 200.0, color: '#AB4211' }, // rgb(171, 66, 17) + { threshold: 300.0, color: '#862716' }, // rgb(134, 39, 22) + ] + }, + o3: { + minValue: 0.0, + maxValue: 500.0, + levels: [ + { threshold: 20.0, color: '#C7EAF4' }, // rgb(199, 234, 244) + { threshold: 40.0, color: '#B4E0EC' }, // rgb(180, 224, 236) + { threshold: 60.0, color: '#A1D6E4' }, // rgb(161, 214, 228) + { threshold: 80.0, color: '#C0E0CE' }, // rgb(192, 224, 206) + { threshold: 100.0, color: '#ECEFB4' }, // rgb(236, 239, 180) + { threshold: 120.0, color: '#FBE98F' }, // rgb(251, 233, 143) + { threshold: 140.0, color: '#F6D463' }, // rgb(246, 212, 99) + { threshold: 160.0, color: '#EEB53D' }, // rgb(238, 181, 61) + { threshold: 180.0, color: '#E28721' }, // rgb(226, 135, 33) + { threshold: 200.0, color: '#D15D0C' }, // rgb(209, 93, 12) + { threshold: 240.0, color: '#AB4211' }, // rgb(171, 66, 17) + { threshold: 500.0, color: '#862716' }, // rgb(134, 39, 22) + ] + }, + so2: { + minValue: 0.0, + maxValue: 800.0, + levels: [ + { threshold: 2.0, color: '#C7EAF4' }, // rgb(199, 234, 244) + { threshold: 5.0, color: '#B4E0EC' }, // rgb(180, 224, 236) + { threshold: 10.0, color: '#A1D6E4' }, // rgb(161, 214, 228) + { threshold: 20.0, color: '#C0E0CE' }, // rgb(192, 224, 206) + { threshold: 30.0, color: '#ECEFB4' }, // rgb(236, 239, 180) + { threshold: 40.0, color: '#FBE98F' }, // rgb(251, 233, 143) + { threshold: 50.0, color: '#F6D463' }, // rgb(246, 212, 99) + { threshold: 75.0, color: '#EEB53D' }, // rgb(238, 181, 61) + { threshold: 100.0, color: '#E28721' }, // rgb(226, 135, 33) + { threshold: 150.0, color: '#D15D0C' }, // rgb(209, 93, 12) + { threshold: 200.0, color: '#AB4211' }, // rgb(171, 66, 17) + { threshold: 800.0, color: '#862716' }, // rgb(134, 39, 22) + ] + } +} + +// Helper function to convert hex color to RGB array +export function hexToRgb(hex: string): [number, number, number] { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return result + ? [ + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16) + ] + : [0, 0, 0] +} + +// Helper function to get color for a value +export function getColorForValue(value: number, pollutant: PollutantType | 'aqi'): [number, number, number] { + const contours = pollutantContours[pollutant] + if (!contours) return [0, 0, 0] + + for (const level of contours.levels) { + if (value < level.threshold) { + return hexToRgb(level.color) + } + } + + return hexToRgb(contours.levels[contours.levels.length - 1].color) +} + +// Add a helper function to get contour info for plotting +export function getContourInfo(pollutant: PollutantType | 'aqi') { + const contours = pollutantContours[pollutant] + if (!contours) return null + + return { + levels: contours.levels.map(l => l.threshold), + colors: contours.levels.map(l => l.color), + minValue: contours.minValue, + maxValue: contours.maxValue + } +} \ No newline at end of file diff --git a/air-quality-ui/src/models/pollutant-display.ts b/air-quality-ui/src/models/pollutant-display.ts new file mode 100644 index 00000000..f6ca018e --- /dev/null +++ b/air-quality-ui/src/models/pollutant-display.ts @@ -0,0 +1,9 @@ +import { PollutantType } from './types' + +export const pollutantTypeDisplay: Record = { + pm2_5: 'PM2.5 (µg/m³)', + pm10: 'PM10 (µg/m³)', + no2: 'NO2 (µg/m³)', + o3: 'O3 (µg/m³)', + so2: 'SO2 (µg/m³)' +} \ No newline at end of file diff --git a/air-quality-ui/src/models/variable-indices.ts b/air-quality-ui/src/models/variable-indices.ts new file mode 100644 index 00000000..575d6cf6 --- /dev/null +++ b/air-quality-ui/src/models/variable-indices.ts @@ -0,0 +1,19 @@ +import { PollutantType } from './types' + +export type VariableType = PollutantType | 'aqi' + +export function getVariableIndex(selectedVariable: VariableType): number | undefined { + return selectedVariable === 'aqi' + ? 1 + : selectedVariable === 'pm2_5' + ? 2 + : selectedVariable === 'pm10' + ? 3 + : selectedVariable === 'no2' + ? 4 + : selectedVariable === 'o3' + ? 5 + : selectedVariable === 'so2' + ? 6 + : undefined +} \ No newline at end of file diff --git a/air-quality-ui/src/services/measurement-data-service.ts b/air-quality-ui/src/services/measurement-data-service.ts index 0e5d27d4..e741950e 100644 --- a/air-quality-ui/src/services/measurement-data-service.ts +++ b/air-quality-ui/src/services/measurement-data-service.ts @@ -46,3 +46,31 @@ export const getMeasurementSummary = async ( } return queryMeasurements('/measurements/summary', params) } + +export interface MeasurementCounts { + [city: string]: { + [pollutant: string]: number; + so2_locations: number; + no2_locations: number; + o3_locations: number; + pm10_locations: number; + pm2_5_locations: number; + } +} + +export const getMeasurementCounts = async ( + dateFrom: DateTime, + dateTo: DateTime, + locationType: LocationType = 'city', + locations?: string[], +): Promise => { + const params: Record = { + date_from: dateFrom.toJSDate().toISOString(), + date_to: dateTo.toJSDate().toISOString(), + location_type: locationType, + } + if (locations) { + params['location_names'] = locations + } + return queryMeasurements('/measurements/counts', params) +}