From 68a4441cf99d0aecf39130b370672e7bb316bb8d Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Tue, 20 Jan 2026 13:12:32 -0500 Subject: [PATCH 01/13] add `flu-metrocast` repo to GitHub workflow cloning --- .gitignore | 1 + update_all_data_source.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 06e5806c..7a5dbd4a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ FluSight-forecast-hub/ rsv-forecast-hub/ covid19-forecast-hub/ +flu-metrocast/ app/public/processed_data processed_data/ .DS_Store diff --git a/update_all_data_source.sh b/update_all_data_source.sh index c723f0fe..f986050f 100755 --- a/update_all_data_source.sh +++ b/update_all_data_source.sh @@ -11,6 +11,7 @@ repos=( "FluSight-forecast-hub|https://github.com/cdcepi/FluSight-forecast-hub.git" "rsv-forecast-hub|https://github.com/CDCgov/rsv-forecast-hub.git" "covid19-forecast-hub|https://github.com/CDCgov/covid19-forecast-hub.git" + "flu-metrocast|https://github.com/reichlab/flu-metrocast.git" ) for entry in "${repos[@]}"; do From 41822a71fceb96adebc8e523e31574995faf38be Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Tue, 20 Jan 2026 13:35:07 -0500 Subject: [PATCH 02/13] add basic `flu-metrocast` skeleton to python data processing script --- scripts/process_RespiLens_data.py | 18 +++++++++++++++++- update_all_data_source.sh | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/process_RespiLens_data.py b/scripts/process_RespiLens_data.py index 8e3c9cf0..a23bf099 100644 --- a/scripts/process_RespiLens_data.py +++ b/scripts/process_RespiLens_data.py @@ -39,13 +39,17 @@ def main(): type=str, required=False, help="Absolute path to local clone of COVID19 forecast repo.") + parser.add_argument("--flu-metrocast-hub-path", + type=str, + required=False, + help="Absolute path to local clone of flu-metrocast repo.") parser.add_argument("--NHSN", action='store_true', required=False, help="If set, pull NHSN data.") args = parser.parse_args() - if not (args.flusight_hub_path or args.rsv_hub_path or args.covid_hub_path or args.NHSN): + if not (args.flusight_hub_path or args.rsv_hub_path or args.covid_hub_path or args.NHSN or args.flu_metrocast_hub_path): print("🛑 No hub paths or NHSN flag provided 🛑, so no data will be fetched.") print("Please re-run script with hub path(s) specified or NHSN flag set.") sys.exit(1) @@ -136,6 +140,18 @@ def main(): overwrite=True ) logger.info("Success ✅") + + if args.flu_metrocast_hub_path: + # Use HubdataPy to get all flu-metrocast data in one df + logger.info("Establishing conneciton to local flu metrocast repository...") + flu_metrocast_hub_conn = connect_hub(args.flu_metrocast_hub_path) + logger.info("Success ✅") + logger.info("Collecting data from flu metrocast repo...") + flu_metrocast_hubverse_df = clean_nan_values(hubverse_df_preprocessor(df=flu_metrocast_hub_conn.get_dataset().to_table().to_pandas(), filter_nowcasts=True)) + flu_metrocast_locations_data = clean_nan_values(pd.read_csv(Path(args.flu_metrocast_hub_path) / 'auxiliary-data/locations.csv')) + flu_metrocast_target_data = clean_nan_values(connect_target_data(hub_path=args.flu_metrocast_hub_path, target_type=TargetType.TIME_SERIES).to_table().to_pandas()) + logger.info("Success ✅") + # Initialize converter oject if args.NHSN: NHSN_processor_object = NHSNDataProcessor(resource_id='ua7e-t2fy', replace_column_names=True) diff --git a/update_all_data_source.sh b/update_all_data_source.sh index f986050f..5e28a377 100755 --- a/update_all_data_source.sh +++ b/update_all_data_source.sh @@ -35,4 +35,5 @@ python scripts/process_RespiLens_data.py \ --flusight-hub-path "${SCRIPT_DIR}/FluSight-forecast-hub" \ --rsv-hub-path "${SCRIPT_DIR}/rsv-forecast-hub" \ --covid-hub-path "${SCRIPT_DIR}/covid19-forecast-hub" \ + --flu-metrocast-hub-path "${SCRIPT_DIR}/flu-metrocast" \ --NHSN From f93a8a125fbde6a7ccb289d299971ddf6b3e81ea Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Wed, 21 Jan 2026 12:05:15 -0500 Subject: [PATCH 03/13] Build full `flu-metrocast` data processing --- scripts/helper.py | 9 +- scripts/hub_dataset_processor.py | 107 ++++++++++++++++-------- scripts/process_RespiLens_data.py | 18 +++- scripts/processors/__init__.py | 3 +- scripts/processors/flu_metrocast_hub.py | 22 +++++ 5 files changed, 121 insertions(+), 38 deletions(-) create mode 100644 scripts/processors/flu_metrocast_hub.py diff --git a/scripts/helper.py b/scripts/helper.py index 26b2070c..564610cb 100644 --- a/scripts/helper.py +++ b/scripts/helper.py @@ -77,7 +77,7 @@ def hubverse_df_preprocessor(df: pd.DataFrame, filter_quantiles: bool = True, fi def get_location_info( location_data: pd.DataFrame, location: str, - value_needed: Literal['abbreviation', 'location_name', 'population'] + value_needed: Literal['abbreviation', 'location_name', 'population', 'original_location_code'], ) -> str: """ Get a variety of location metadata information given the FIPS code of a location. @@ -85,7 +85,7 @@ def get_location_info( Args: location_data: The df of location metadata location: FIPS code for location for which info will be retrieved ('US' for US) - value_needed: Which piece of info to retrieve (one of 'abbreviation', 'location_name', 'population') + value_needed: Which piece of info to retrieve (one of 'abbreviation', 'location_name', 'population', 'original_location_code') Returns: The value requested (as a str) @@ -104,7 +104,7 @@ def get_location_info( def save_json_file( - pathogen: Literal['flusight','rsv','covid','covid19','rsvforecasthub','covid19forecasthub','nhsn'], + pathogen: Literal['flusight', 'flu', 'flusightforecasthub', 'rsv','covid','covid19','rsvforecasthub','covid19forecasthub','nhsn', 'flumetrocast', 'flumetrocasthub'], output_path: str, output_filename: str, file_contents: dict, @@ -127,12 +127,15 @@ def save_json_file( output_dir_map = { 'flu': 'flusight', 'flusight': 'flusight', + 'flusightforecasthub': 'flusight', 'rsv': 'rsvforecasthub', 'rsvforecasthub': 'rsvforecasthub', 'covid': 'covid19forecasthub', 'covid19': 'covid19forecasthub', 'covid19forecasthub': 'covid19forecasthub', 'nhsn': 'nhsn', + 'flumetrocast': 'flumetrocast', + 'flumetrocashtub': 'flumetrocast', } if pathogen not in output_dir_map: diff --git a/scripts/hub_dataset_processor.py b/scripts/hub_dataset_processor.py index c7f6ddd4..c037385c 100644 --- a/scripts/hub_dataset_processor.py +++ b/scripts/hub_dataset_processor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Dict, Any, Optional, Tuple import logging +import datetime import pandas as pd @@ -40,12 +41,16 @@ def __init__( locations_data: pd.DataFrame, target_data: pd.DataFrame, config: HubDatasetConfig, + is_metro_cast: bool = False ) -> None: self.output_dict: Dict[str, Dict[str, Any]] = {} self.df_data = data self.locations_data = locations_data self.target_data = target_data self.config = config + self.is_metro_cast = is_metro_cast + if self.is_metro_cast: # necessary date filter for metrocast data + self.df_data = self.df_data[self.df_data['reference_date'] >= datetime.date(2025, 11, 19)] self.logger = logging.getLogger(self.__class__.__name__) self.location_dataframes: Dict[str, pd.DataFrame] = {} @@ -73,9 +78,12 @@ def _build_outputs(self) -> None: loc_df = loc_df.copy() self.location_dataframes[loc_str] = loc_df - location_abbreviation = get_location_info( - location_data=self.locations_data, location=loc_str, value_needed="abbreviation" - ) + if self.is_metro_cast: + location_abbreviation = loc_df['location'].iloc[0] + else: + location_abbreviation = get_location_info( + location_data=self.locations_data, location=loc_str, value_needed="abbreviation" + ) file_name = f"{location_abbreviation}_{self.config.file_suffix}.json" ground_truth_df = self._prepare_ground_truth_df(location=loc_str) @@ -102,28 +110,52 @@ def _build_outputs(self) -> None: def _build_metadata_key(self, df: pd.DataFrame) -> Dict[str, Any]: """Build metadata section of an individual JSON file.""" location = str(df["location"].iloc[0]) - metadata = { - "location": location, - "abbreviation": get_location_info( - self.locations_data, location=location, value_needed="abbreviation" - ), - "location_name": get_location_info( - self.locations_data, location=location, value_needed="location_name" - ), - "population": get_location_info( - self.locations_data, location=location, value_needed="population" - ), - "dataset": self.config.dataset_label, - "series_type": self.config.series_type, - "hubverse_keys": { - "models": self._build_available_models_list(df=df), - "targets": list(dict.fromkeys(df["target"])), - "horizons": [str(h) for h in pd.unique(df["horizon"])], - "output_types": [ - item for item in pd.unique(df["output_type"]) if item not in self.config.drop_output_types - ], - }, - } + if self.is_metro_cast: # location.csv slightly different for MetroCast, requires different metadata building + metadata = { + "location": get_location_info( + self.locations_data, location=location, value_needed="original_location_code" + ), + "abbreviation": location, + "location_name": get_location_info( + self.locations_data, location=location, value_needed="location_name" + ), + "population": get_location_info( + self.locations_data, location=location, value_needed="population" + ), + "dataset": self.config.dataset_label, + "series_type": self.config.series_type, + "hubverse_keys": { + "models": self._build_available_models_list(df=df), + "targets": list(dict.fromkeys(df["target"])), + "horizons": [str(h) for h in pd.unique(df["horizon"])], + "output_types": [ + item for item in pd.unique(df["output_type"]) if item not in self.config.drop_output_types + ], + }, + } + else: + metadata = { + "location": location, + "abbreviation": get_location_info( + self.locations_data, location=location, value_needed="abbreviation" + ), + "location_name": get_location_info( + self.locations_data, location=location, value_needed="location_name" + ), + "population": get_location_info( + self.locations_data, location=location, value_needed="population" + ), + "dataset": self.config.dataset_label, + "series_type": self.config.series_type, + "hubverse_keys": { + "models": self._build_available_models_list(df=df), + "targets": list(dict.fromkeys(df["target"])), + "horizons": [str(h) for h in pd.unique(df["horizon"])], + "output_types": [ + item for item in pd.unique(df["output_type"]) if item not in self.config.drop_output_types + ], + }, + } return metadata def _prepare_ground_truth_df(self, location: str) -> pd.DataFrame: @@ -305,13 +337,22 @@ def _build_metadata_file(self, all_models: list[str]) -> Dict[str, Any]: "models": sorted(all_models), "locations": [], } - for _, row in self.locations_data.iterrows(): - location_info = { - "location": str(row["location"]), - "abbreviation": str(row["abbreviation"]), - "location_name": str(row["location_name"]), - "population": None if row["population"] is None else float(row["population"]), - } - metadata_file_contents["locations"].append(location_info) + if self.is_metro_cast: # different building for metrocast (stems from locations.csv structure) + for _, row in self.locations_data.iterrows(): + location_info = { + "location": str(row["original_location_code"]), + "abbreviation": str(row["location"]), + "location_name": str(row["location_name"]), + "population": None if row["population"] is None else float(row["population"]), + } + else: + for _, row in self.locations_data.iterrows(): + location_info = { + "location": str(row["location"]), + "abbreviation": str(row["abbreviation"]), + "location_name": str(row["location_name"]), + "population": None if row["population"] is None else float(row["population"]), + } + metadata_file_contents["locations"].append(location_info) return metadata_file_contents diff --git a/scripts/process_RespiLens_data.py b/scripts/process_RespiLens_data.py index a23bf099..de064faf 100644 --- a/scripts/process_RespiLens_data.py +++ b/scripts/process_RespiLens_data.py @@ -10,7 +10,7 @@ from hubdata.create_target_data_schema import TargetType -from processors import FlusightDataProcessor, RSVDataProcessor, COVIDDataProcessor +from processors import FlusightDataProcessor, RSVDataProcessor, COVIDDataProcessor, FluMetrocastDataProcessor from nhsn_data_processor import NHSNDataProcessor from helper import save_json_file, hubverse_df_preprocessor, clean_nan_values @@ -152,6 +152,22 @@ def main(): flu_metrocast_target_data = clean_nan_values(connect_target_data(hub_path=args.flu_metrocast_hub_path, target_type=TargetType.TIME_SERIES).to_table().to_pandas()) logger.info("Success ✅") # Initialize converter oject + flu_metrocast_processor_object = FluMetrocastDataProcessor( + data=flu_metrocast_hubverse_df, + locations_data=flu_metrocast_locations_data, + target_data=flu_metrocast_target_data + ) + # Iteratively save output files + logger.info("Saving flu metrocast JSON files...") + for filename, contents in flu_metrocast_processor_object.output_dict.items(): + save_json_file( + pathogen='flumetrocast', + output_path=args.output_path, + output_filename=filename, + file_contents=contents, + overwrite=True + ) + logger.info("Success ✅") if args.NHSN: NHSN_processor_object = NHSNDataProcessor(resource_id='ua7e-t2fy', replace_column_names=True) diff --git a/scripts/processors/__init__.py b/scripts/processors/__init__.py index 4185d883..1564eca9 100644 --- a/scripts/processors/__init__.py +++ b/scripts/processors/__init__.py @@ -3,5 +3,6 @@ from .flusight import FlusightDataProcessor from .rsv_forecast_hub import RSVDataProcessor from .covid19_forecast_hub import COVIDDataProcessor +from .flu_metrocast_hub import FluMetrocastDataProcessor -__all__ = ["FlusightDataProcessor", "RSVDataProcessor", "COVIDDataProcessor"] +__all__ = ["FlusightDataProcessor", "RSVDataProcessor", "COVIDDataProcessor", "FluMetrocastDataProcessor"] diff --git a/scripts/processors/flu_metrocast_hub.py b/scripts/processors/flu_metrocast_hub.py new file mode 100644 index 00000000..9b0100a5 --- /dev/null +++ b/scripts/processors/flu_metrocast_hub.py @@ -0,0 +1,22 @@ +"""RespiLens processor for flu Metrocast Hubverse exports.""" + +import pandas as pd + +from hub_dataset_processor import HubDataProcessorBase, HubDatasetConfig + + +class FluMetrocastDataProcessor(HubDataProcessorBase): + def __init__(self, data: pd.DataFrame, locations_data: pd.DataFrame, target_data: pd.DataFrame): + config = HubDatasetConfig( + file_suffix="flu_metrocast", + dataset_label="flu metrocast forecasts", + ground_truth_date_column="target_end_date", + ground_truth_min_date=pd.Timestamp("2025-11-19"), + ) + super().__init__( + data=data, + locations_data=locations_data, + target_data=target_data, + config=config, + is_metro_cast=True + ) \ No newline at end of file From 634987fd0626eaa96d00e9f8e5d32014b4c4bc3c Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Thu, 22 Jan 2026 10:29:31 -0500 Subject: [PATCH 04/13] simplest possible frontend implementation of metrocast view --- app/package-lock.json | 6 +-- app/src/App.jsx | 1 - .../components/DataVisualizationContainer.jsx | 16 +++++++ app/src/components/MetroCastView.jsx | 45 +++++++++++++++++ app/src/components/StateSelector.jsx | 32 ++++++------- app/src/components/ViewSwitchboard.jsx | 16 +++++++ app/src/config/app.js | 24 +--------- app/src/config/datasets.js | 16 ++++++- app/src/contexts/ViewContext.jsx | 2 +- app/src/hooks/useForecastData.js | 4 +- app/src/utils/urlManager.js | 48 +++++-------------- scripts/hub_dataset_processor.py | 3 +- 12 files changed, 124 insertions(+), 89 deletions(-) create mode 100644 app/src/components/MetroCastView.jsx diff --git a/app/package-lock.json b/app/package-lock.json index 77df6eb0..5c734a96 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -46,10 +46,6 @@ "vite": "^6.0.1" } }, - "node_modules/driver.js": { - "version": "1.3.0", - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -9171,4 +9167,4 @@ } } } -} \ No newline at end of file +} diff --git a/app/src/App.jsx b/app/src/App.jsx index 87ef0ddb..4f8f3ddb 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -56,7 +56,6 @@ const App = () => { return ( - {/* The ViewProvider now wraps everything, making the context available to all components */} diff --git a/app/src/components/DataVisualizationContainer.jsx b/app/src/components/DataVisualizationContainer.jsx index 796b3bcd..a447fbb1 100644 --- a/app/src/components/DataVisualizationContainer.jsx +++ b/app/src/components/DataVisualizationContainer.jsx @@ -207,6 +207,22 @@ const DataVisualizationContainer = () => { ) }, + 'metrocast_projs': { + title: ( + + Flu MetroCast + + ), + buttonLabel: "About MetroCast", + content: ( + <> +

+ MetroCast provides high-resolution influenza forecasting. + This view is currently under development. +

+ + ) + }, 'nhsnall': { title: ( diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/MetroCastView.jsx new file mode 100644 index 00000000..5ac0176f --- /dev/null +++ b/app/src/components/MetroCastView.jsx @@ -0,0 +1,45 @@ +import { Stack, Text, Title, Center } from '@mantine/core'; +import LastFetched from './LastFetched'; + +const MetroCastView = ({ metadata, windowSize }) => { + return ( + + {/* Keeps the consistent header with the last updated timestamp */} + + + {/* Placeholder content area */} +
+ + MetroCast View + Coming Soon + + We are currently integrating flu-metrocast hubverse datasets. + + +
+ + {/* Keeps the consistent footer disclaimer */} +
+

+ Note that forecasts should be interpreted with great caution and may not reliably predict rapid changes in disease trends. +

+
+
+ ); +}; + +export default MetroCastView; \ No newline at end of file diff --git a/app/src/components/StateSelector.jsx b/app/src/components/StateSelector.jsx index 3e1023fa..be49db72 100644 --- a/app/src/components/StateSelector.jsx +++ b/app/src/components/StateSelector.jsx @@ -7,7 +7,7 @@ import TargetSelector from './TargetSelector'; import { getDataPath } from '../utils/paths'; const StateSelector = () => { - const { selectedLocation, handleLocationSelect } = useView(); + const { selectedLocation, handleLocationSelect, viewType, currentDataset } = useView(); const [states, setStates] = useState([]); const [loading, setLoading] = useState(true); @@ -19,29 +19,36 @@ const StateSelector = () => { useEffect(() => { const fetchStates = async () => { try { - const manifestResponse = await fetch(getDataPath('flusight/metadata.json')); + setLoading(true); + const directory = (viewType === 'metrocast_projs') + ? 'flumetrocast' + : 'flusight'; + + const manifestResponse = await fetch(getDataPath(`${directory}/metadata.json`)); + if (!manifestResponse.ok) { throw new Error(`Failed to fetch metadata: ${manifestResponse.statusText}`); } + const metadata = await manifestResponse.json(); - if (!metadata.locations || !Array.isArray(metadata.locations)) { - throw new Error('Invalid metadata format'); - } + const sortedLocations = metadata.locations.sort((a, b) => { if (a.abbreviation === 'US') return -1; if (b.abbreviation === 'US') return 1; return (a.location_name || '').localeCompare(b.location_name || ''); }); + setStates(sortedLocations); } catch (err) { - console.error('Error in data loading:', err); + console.error('Error loading locations:', err); setError(err.message); } finally { setLoading(false); } }; + fetchStates(); - }, []); + }, [viewType]); // This ensures the keyboard focus starts on the dark blue selected item. useEffect(() => { @@ -78,7 +85,6 @@ const StateSelector = () => { if (event.key === 'ArrowDown') { event.preventDefault(); - // Use filteredStates length here for wrapping newIndex = (highlightedIndex + 1) % filteredStates.length; } else if (event.key === 'ArrowUp') { event.preventDefault(); @@ -99,10 +105,6 @@ const StateSelector = () => { setHighlightedIndex(newIndex); }; - - // NOTE: The previous useEffect to handle out-of-bounds index is no longer strictly needed - // because we calculate the index based on filteredStates length in handleKeyDown, - // and reset it when the search term changes. if (loading) { return
; @@ -140,7 +142,6 @@ const StateSelector = () => { /> - {/* Map over filteredStates but still need the index */} {filteredStates.map((state, index) => { const isSelected = selectedLocation === state.abbreviation; const isKeyboardHighlighted = (searchTerm.length > 0 || index === highlightedIndex) && @@ -154,7 +155,6 @@ const StateSelector = () => { variant = 'filled'; color = 'blue'; } else if (isKeyboardHighlighted) { - // Style for the keyboard-highlighted state (light blue) only during search/nav variant = 'light'; color = 'blue'; } @@ -162,20 +162,16 @@ const StateSelector = () => { return ( diff --git a/app/src/config/datasets.js b/app/src/config/datasets.js index 73677616..3e8e12d2 100644 --- a/app/src/config/datasets.js +++ b/app/src/config/datasets.js @@ -64,7 +64,7 @@ export const DATASETS = { ], defaultView: 'metrocast_projs', defaultModel: 'epiENGAGE-ensemble_mean', - defaultLocation: 'athens', + defaultLocation: 'boulder', hasDateSelector: true, hasModelSelector: true, prefix: 'metrocast', diff --git a/app/src/hooks/useForecastData.js b/app/src/hooks/useForecastData.js index f61e47d6..c53f4b7b 100644 --- a/app/src/hooks/useForecastData.js +++ b/app/src/hooks/useForecastData.js @@ -18,7 +18,7 @@ export const useForecastData = (location, viewType) => { useEffect(() => { const isMetrocastView = viewType === 'metrocast_projs'; const isDefaultUS = location === 'US'; - if ((isMetrocastView && isDefaultUS) || (!isMetrocastView && location === 'athens')) { + if ((isMetrocastView && isDefaultUS) || (!isMetrocastView && location === 'boulder')) { setLoading(false); return; } From fc4ff6333dbed4398b25a99848adeba35ab546ce Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Fri, 23 Jan 2026 14:42:06 -0500 Subject: [PATCH 08/13] confirmation of correct data piping into metrocast view --- app/src/components/MetroCastView.jsx | 53 +++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/MetroCastView.jsx index 5ac0176f..bb1e1a16 100644 --- a/app/src/components/MetroCastView.jsx +++ b/app/src/components/MetroCastView.jsx @@ -1,13 +1,46 @@ -import { Stack, Text, Title, Center } from '@mantine/core'; +import { useMemo } from 'react'; +import { Stack, Text, Title, Center, Alert, Code, Group } from '@mantine/core'; +import { IconCheck } from '@tabler/icons-react'; import LastFetched from './LastFetched'; -const MetroCastView = ({ metadata, windowSize }) => { +const MetroCastView = ({ data, metadata, selectedTarget, windowSize }) => { + // 1. Data Presence Logic + const hasForecasts = data && data.forecasts; + const forecastCount = hasForecasts ? Object.keys(data.forecasts).length : 0; + + // 2. Extract location name from the nested metadata object in the JSON + // Path: data -> metadata -> location_name + const displayName = data?.metadata?.location_name || 'Unknown'; + + // 3. Prepare for Charting (Mirroring COVID19View processing) + const groundTruth = data?.ground_truth; + const forecasts = data?.forecasts; + return ( - {/* Keeps the consistent header with the last updated timestamp */} + {/* Consistent header with the last updated timestamp from global metadata */} - {/* Placeholder content area */} + {/* Data Pipeline Verification Alert */} + {hasForecasts ? ( + } title="Data Pipeline Active" color="teal" variant="outline"> + + + Successfully received {forecastCount} forecast dates for location: {displayName}. + + + Current Target: + {selectedTarget || 'No Target Selected'} + + + + ) : ( + + The component is not receiving forecast data for this selection. + + )} + + {/* Main Visualization Area */}
{ }} > - MetroCast View - Coming Soon - - We are currently integrating flu-metrocast hubverse datasets. + MetroCast Visualization + Currently Viewing: {displayName} + + {hasForecasts + ? `Ready to plot ground truth and ${forecastCount} forecast horizons.` + : "Waiting for data reception..."}
- {/* Keeps the consistent footer disclaimer */} + {/* Standard footer disclaimer */}

Date: Sun, 25 Jan 2026 08:30:49 -0500 Subject: [PATCH 09/13] Add visualization to metrocast view --- app/src/components/MetroCastView.jsx | 302 +++++++++++++++++++++------ 1 file changed, 241 insertions(+), 61 deletions(-) diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/MetroCastView.jsx index bb1e1a16..96f8527d 100644 --- a/app/src/components/MetroCastView.jsx +++ b/app/src/components/MetroCastView.jsx @@ -1,78 +1,258 @@ -import { useMemo } from 'react'; -import { Stack, Text, Title, Center, Alert, Code, Group } from '@mantine/core'; -import { IconCheck } from '@tabler/icons-react'; +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useMantineColorScheme, Stack, Text, Center } from '@mantine/core'; +import Plot from 'react-plotly.js'; +import Plotly from 'plotly.js/dist/plotly'; +import ModelSelector from './ModelSelector'; import LastFetched from './LastFetched'; +import { MODEL_COLORS } from '../config/datasets'; +import { CHART_CONSTANTS } from '../constants/chart'; +import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils'; -const MetroCastView = ({ data, metadata, selectedTarget, windowSize }) => { - // 1. Data Presence Logic - const hasForecasts = data && data.forecasts; - const forecastCount = hasForecasts ? Object.keys(data.forecasts).length : 0; +const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, windowSize, getDefaultRange, selectedTarget }) => { + const [yAxisRange, setYAxisRange] = useState(null); + const [xAxisRange, setXAxisRange] = useState(null); + const plotRef = useRef(null); + const isResettingRef = useRef(false); + + const getDefaultRangeRef = useRef(getDefaultRange); + const projectionsDataRef = useRef([]); - // 2. Extract location name from the nested metadata object in the JSON - // Path: data -> metadata -> location_name - const displayName = data?.metadata?.location_name || 'Unknown'; - - // 3. Prepare for Charting (Mirroring COVID19View processing) + const { colorScheme } = useMantineColorScheme(); const groundTruth = data?.ground_truth; const forecasts = data?.forecasts; + const calculateYRange = useCallback((plotData, xRange) => { + if (!plotData || !xRange || !Array.isArray(plotData) || plotData.length === 0 || !selectedTarget) return null; + let minY = Infinity; + let maxY = -Infinity; + const [startX, endX] = xRange; + const startDate = new Date(startX); + const endDate = new Date(endX); + + plotData.forEach(trace => { + if (!trace.x || !trace.y) return; + for (let i = 0; i < trace.x.length; i++) { + const pointDate = new Date(trace.x[i]); + if (pointDate >= startDate && pointDate <= endDate) { + const value = Number(trace.y[i]); + if (!isNaN(value)) { + minY = Math.min(minY, value); + maxY = Math.max(maxY, value); + } + } + } + }); + + if (minY !== Infinity && maxY !== -Infinity) { + const padding = maxY * (CHART_CONSTANTS.Y_AXIS_PADDING_PERCENT / 100); + return [Math.max(0, minY - padding), maxY + padding]; + } + return null; + }, [selectedTarget]); + + const projectionsData = useMemo(() => { + if (!groundTruth || !forecasts || selectedDates.length === 0 || !selectedTarget) return []; + + const groundTruthValues = groundTruth[selectedTarget]; + if (!groundTruthValues) return []; + + const groundTruthTrace = { + x: groundTruth.dates || [], + y: groundTruthValues, + name: 'Observed', + type: 'scatter', + mode: 'lines+markers', + line: { color: 'black', width: 2, dash: 'dash' }, + marker: { size: 4, color: 'black' } + }; + + const modelTraces = selectedModels.flatMap(model => + selectedDates.flatMap((date, dateIndex) => { + const forecastsForDate = forecasts[date] || {}; + const forecast = forecastsForDate[selectedTarget]?.[model]; + if (!forecast || forecast.type !== 'quantile') return []; + + const forecastDates = [], medianValues = [], ci95Upper = [], ci95Lower = [], ci50Upper = [], ci50Lower = []; + const sortedHorizons = Object.keys(forecast.predictions || {}).sort((a, b) => Number(a) - Number(b)); + + sortedHorizons.forEach((h) => { + const pred = forecast.predictions[h]; + forecastDates.push(pred.date); + const { quantiles = [], values = [] } = pred; + + const findValue = (q) => { + const idx = quantiles.indexOf(q); + return idx !== -1 ? values[idx] : null; + }; + + const v025 = findValue(0.025), v25 = findValue(0.25), v50 = findValue(0.5), v75 = findValue(0.75), v975 = findValue(0.975); + + if (v50 !== null) { + medianValues.push(v50); + ci95Lower.push(v025 ?? v50); + ci50Lower.push(v25 ?? v50); + ci50Upper.push(v75 ?? v50); + ci95Upper.push(v975 ?? v50); + } + }); + + if (forecastDates.length === 0) return []; + + const modelColor = MODEL_COLORS[selectedModels.indexOf(model) % MODEL_COLORS.length]; + const isFirstDate = dateIndex === 0; + + return [ + { x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci95Upper, ...ci95Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}10`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 95% CI`, hoverinfo: 'none', legendgroup: model }, + { x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci50Upper, ...ci50Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}30`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 50% CI`, hoverinfo: 'none', legendgroup: model }, + { x: forecastDates, y: medianValues, name: model, type: 'scatter', mode: 'lines+markers', line: { color: modelColor, width: 2, dash: 'solid' }, marker: { size: 6, color: modelColor }, showlegend: isFirstDate, legendgroup: model } + ]; + }) + ); + + return [groundTruthTrace, ...modelTraces]; + }, [groundTruth, forecasts, selectedDates, selectedModels, selectedTarget]); + + useEffect(() => { + getDefaultRangeRef.current = getDefaultRange; + projectionsDataRef.current = projectionsData; + }, [getDefaultRange, projectionsData]); + + const activeModels = useMemo(() => { + const activeModelSet = new Set(); + if (!forecasts || !selectedTarget || !selectedDates.length) return activeModelSet; + selectedDates.forEach(date => { + const targetData = forecasts[date]?.[selectedTarget]; + if (targetData) Object.keys(targetData).forEach(m => activeModelSet.add(m)); + }); + return activeModelSet; + }, [forecasts, selectedDates, selectedTarget]); + + const defaultRange = useMemo(() => getDefaultRange(), [getDefaultRange]); + + useEffect(() => { setXAxisRange(null); }, [selectedTarget]); + + useEffect(() => { + const currentXRange = xAxisRange || defaultRange; + if (projectionsData.length > 0 && currentXRange) { + setYAxisRange(calculateYRange(projectionsData, currentXRange)); + } else { + setYAxisRange(null); + } + }, [projectionsData, xAxisRange, defaultRange, calculateYRange]); + + const handlePlotUpdate = useCallback((figure) => { + if (isResettingRef.current) { isResettingRef.current = false; return; } + if (figure?.['xaxis.range']) { + const newXRange = figure['xaxis.range']; + if (JSON.stringify(newXRange) !== JSON.stringify(xAxisRange)) setXAxisRange(newXRange); + } + }, [xAxisRange]); + + const layout = useMemo(() => ({ + width: Math.min(CHART_CONSTANTS.MAX_WIDTH, windowSize.width * CHART_CONSTANTS.WIDTH_RATIO), + height: Math.min(CHART_CONSTANTS.MAX_HEIGHT, windowSize.height * CHART_CONSTANTS.HEIGHT_RATIO), + autosize: true, + template: colorScheme === 'dark' ? 'plotly_dark' : 'plotly_white', + paper_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', + plot_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', + font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' }, + showlegend: selectedModels.length < 15, + legend: { + x: 0, y: 1, xanchor: 'left', yanchor: 'top', + bgcolor: colorScheme === 'dark' ? 'rgba(26, 27, 30, 0.8)' : 'rgba(255, 255, 255, 0.8)', + bordercolor: colorScheme === 'dark' ? '#444' : '#ccc', + borderwidth: 1, font: { size: 10 } + }, + hovermode: 'x unified', + dragmode: false, + margin: { l: 60, r: 30, t: 30, b: 30 }, + xaxis: { + domain: [0, 1], + rangeslider: { range: getDefaultRange(true) }, + rangeselector: { + buttons: [ + {count: 1, label: '1m', step: 'month', stepmode: 'backward'}, + {count: 6, label: '6m', step: 'month', stepmode: 'backward'}, + {step: 'all', label: 'all'} + ] + }, + range: xAxisRange || defaultRange, + showline: true, linewidth: 1, + linecolor: colorScheme === 'dark' ? '#aaa' : '#444' + }, + yaxis: { + title: (() => { + const longName = targetDisplayNameMap[selectedTarget]; + return targetYAxisLabelMap[longName] || longName || selectedTarget || 'Value'; + })(), + range: yAxisRange, + autorange: yAxisRange === null, + }, + shapes: selectedDates.map(date => ({ + type: 'line', x0: date, x1: date, y0: 0, y1: 1, yref: 'paper', + line: { color: 'red', width: 1, dash: 'dash' } + })) + }), [colorScheme, windowSize, defaultRange, selectedTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange]); + + const config = useMemo(() => ({ + responsive: true, + displayModeBar: true, + displaylogo: false, + modeBarButtonsToRemove: ['resetScale2d', 'select2d', 'lasso2d'], + modeBarButtonsToAdd: [{ + name: 'Reset view', + icon: Plotly.Icons.home, + click: function(gd) { + const range = getDefaultRangeRef.current(); + if (!range) return; + const newYRange = projectionsDataRef.current.length > 0 ? calculateYRange(projectionsDataRef.current, range) : null; + isResettingRef.current = true; + setXAxisRange(null); + setYAxisRange(newYRange); + Plotly.relayout(gd, { 'xaxis.range': range, 'yaxis.range': newYRange, 'yaxis.autorange': newYRange === null }); + } + }] + }), [calculateYRange]); + + if (!selectedTarget) { + return ( +

+ Please select a target to view MetroCast data. +
+ ); + } + return ( - {/* Consistent header with the last updated timestamp from global metadata */} + +
+ +
- {/* Data Pipeline Verification Alert */} - {hasForecasts ? ( - } title="Data Pipeline Active" color="teal" variant="outline"> - - - Successfully received {forecastCount} forecast dates for location: {displayName}. - - - Current Target: - {selectedTarget || 'No Target Selected'} - - - - ) : ( - - The component is not receiving forecast data for this selection. - - )} - - {/* Main Visualization Area */} -
- - MetroCast Visualization - Currently Viewing: {displayName} - - {hasForecasts - ? `Ready to plot ground truth and ${forecastCount} forecast horizons.` - : "Waiting for data reception..."} - - -
- - {/* Standard footer disclaimer */}
-

+

Note that forecasts should be interpreted with great caution and may not reliably predict rapid changes in disease trends.

+ + { + const index = selectedModels.indexOf(model); + return MODEL_COLORS[index % MODEL_COLORS.length]; + }} + />
); }; From c31153eb94d18fb071b4f3dc7de72e4b7bb22a99 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Sun, 25 Jan 2026 08:39:24 -0500 Subject: [PATCH 10/13] add map for target/axis names --- app/src/utils/mapUtils.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/utils/mapUtils.js b/app/src/utils/mapUtils.js index c004dc77..424f2d56 100644 --- a/app/src/utils/mapUtils.js +++ b/app/src/utils/mapUtils.js @@ -4,7 +4,9 @@ export const targetDisplayNameMap = { 'wk inc flu hosp': 'Weekly Incident Flu Hospitalizations', 'wk inc flu prop ed visits': "Proportion of ED Visits due to Flu", 'wk inc rsv hosp': 'Weekly Incident RSV Hospitalizations', - 'wk inc rsv prop ed visits': 'Proportion of ED Visits due to RSV' + 'wk inc rsv prop ed visits': 'Proportion of ED Visits due to RSV', + 'Flu ED visits pct': 'Percent of ED Visits due to Flu', + 'ILI ED visits pct': 'Percent of ED Visits due to Influenza-like Illness' }; export const targetYAxisLabelMap = { @@ -13,7 +15,9 @@ export const targetYAxisLabelMap = { "Weekly Incident Flu Hospitalizations": 'Flu Hospitalizations', "Proportion of ED Visits due to Flu": "Proportion of ED Visits due to Flu", "Weekly Incident RSV Hospitalizations": "RSV Hospitalizations", - "Proportion of ED Visits due to RSV": 'Proportion of ED Visits due to RSV' + "Proportion of ED Visits due to RSV": 'Proportion of ED Visits due to RSV', + 'Percent of ED Visits due to Flu': '% of ED Visits', + "Percent ofED Visits due to Influenza-like Illness": '% of ED Visits' } export const nhsnTargetsToColumnsMap = { From d4da86f303749574fa25163cf04f94e64515be79 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Sun, 25 Jan 2026 09:03:37 -0500 Subject: [PATCH 11/13] Attribution info overlay --- app/src/components/DataVisualizationContainer.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/components/DataVisualizationContainer.jsx b/app/src/components/DataVisualizationContainer.jsx index 5511d31f..cec4029f 100644 --- a/app/src/components/DataVisualizationContainer.jsx +++ b/app/src/components/DataVisualizationContainer.jsx @@ -216,8 +216,15 @@ const DataVisualizationContainer = () => { content: ( <>

- COMING SOON + Data for the RespiLens Flu Metrocast view is retrieved from the Flu MetroCast Hub, which is a collaborative modeling project that collects and shares weekly probabilistic forecasts of influenza activity at the metropolitan level in the United States. The hub is run by epiENGAGE – an Insight Net Center for Implementation within the U.S. Centers for Disease Control and Prevention (CDC)’s Center for Forecasting and Outbreak Analytics (CFA).

+

For more info and attribution on the Flu MetroCast Hub, please visit their site, or visit their visualization dashboard to engage with their original visualization scheme.

+
+ Forecasts +

+ Forecasting teams submit a probabilistic forecasts of targets every week of the flu season. RespiLens displays the 50% and 95% confidence intervals for each model's forecast for a chosen date, shown on the plot with a shadow. +

+
) }, From 3b5d5da23f0b17dd87f35042774d1dfd0969ed34 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Sun, 25 Jan 2026 09:46:56 -0500 Subject: [PATCH 12/13] remove default metrocast location from URL --- app/src/contexts/ViewContext.jsx | 11 ++--------- app/src/utils/urlManager.js | 10 ++++++---- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/app/src/contexts/ViewContext.jsx b/app/src/contexts/ViewContext.jsx index 72cf4496..d186ec56 100644 --- a/app/src/contexts/ViewContext.jsx +++ b/app/src/contexts/ViewContext.jsx @@ -152,14 +152,7 @@ export const ViewProvider = ({ children }) => { const handleLocationSelect = (newLocation) => { const currentDataset = urlManager.getDatasetFromView(viewType); const effectiveDefault = currentDataset?.defaultLocation || APP_CONFIG.defaultLocation; - - if (newLocation !== effectiveDefault) { - urlManager.updateLocation(newLocation); - } else { - const newParams = new URLSearchParams(searchParams); - newParams.delete('location'); - setSearchParams(newParams, { replace: true }); - } + urlManager.updateLocation(newLocation, effectiveDefault); setSelectedLocation(newLocation); }; @@ -185,7 +178,7 @@ export const ViewProvider = ({ children }) => { if (needsCityDefault && newDataset?.defaultLocation) { setSelectedLocation(newDataset.defaultLocation); - newSearchParams.set('location', newDataset.defaultLocation); + newSearchParams.delete('location'); } } else { if (selectedLocation !== APP_CONFIG.defaultLocation && selectedLocation.length > 2) { diff --git a/app/src/utils/urlManager.js b/app/src/utils/urlManager.js index 819a980e..b3a40c3d 100644 --- a/app/src/utils/urlManager.js +++ b/app/src/utils/urlManager.js @@ -123,14 +123,16 @@ export class URLParameterManager { } - // Update location parameter while preserving all other params - updateLocation(location) { + updateLocation(location, effectiveDefault = APP_CONFIG.defaultLocation) { const newParams = new URLSearchParams(this.searchParams); - if (location && location !== APP_CONFIG.defaultLocation) { + + // If the location matches the specific default for this view, remove it from URL + if (location && location !== effectiveDefault) { newParams.set('location', location); } else { - newParams.delete('location'); // Remove if default location or falsy + newParams.delete('location'); } + if (newParams.toString() !== this.searchParams.toString()) { this.setSearchParams(newParams, { replace: true }); } From 7a045d31af35e87d0f1bfb8c348e8479849d995c Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Sun, 25 Jan 2026 09:59:03 -0500 Subject: [PATCH 13/13] Update InfoOverlay.jsx --- app/src/components/InfoOverlay.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/components/InfoOverlay.jsx b/app/src/components/InfoOverlay.jsx index c6c36e1d..4d712fce 100644 --- a/app/src/components/InfoOverlay.jsx +++ b/app/src/components/InfoOverlay.jsx @@ -83,6 +83,9 @@ const InfoOverlay = () => { COVID-19 Forecast Hub: official CDC page – Hubverse dashboard – official GitHub repository + + Flu MetroCast Hub: official dashboard – site – official GitHub repository +