From 4624e0ae0c3d9c10b7d20defac89b88fa8249ab1 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 9 Mar 2026 15:08:00 -0400 Subject: [PATCH 01/50] shell of `MyPlots` feature --- app/src/App.jsx | 3 +- app/src/components/MyPlots.jsx | 82 +++++++++++++++++++ app/src/components/layout/MainNavigation.jsx | 13 ++- app/src/components/layout/UnifiedAppShell.jsx | 7 ++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 app/src/components/MyPlots.jsx diff --git a/app/src/App.jsx b/app/src/App.jsx index 026be490..b60904cc 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -10,6 +10,7 @@ import { HelmetProvider } from "react-helmet-async"; import { ViewProvider } from "./contexts/ViewContext"; import { useView } from "./hooks/useView"; import DataVisualizationContainer from "./components/DataVisualizationContainer"; +import MyPlots from "./components/MyPlots"; import NarrativeBrowser from "./components/narratives/NarrativeBrowser"; import SlideNarrativeViewer from "./components/narratives/SlideNarrativeViewer"; import ForecastleGame from "./components/forecastle/ForecastleGame"; @@ -20,7 +21,6 @@ import Documentation from "./components/Documentation"; import ReportingDelayPage from "./components/reporting/ReportingDelayPage"; import ToolsPage from "./components/tools/ToolsPage"; import { Center, Text } from "@mantine/core"; -// import ShutdownBanner from './components/ShutdownBanner';, no longer necessary const ForecastApp = () => { // This component uses the view context, so it must be inside the provider. @@ -59,6 +59,7 @@ const AppLayout = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/src/components/MyPlots.jsx b/app/src/components/MyPlots.jsx new file mode 100644 index 00000000..41ac74c9 --- /dev/null +++ b/app/src/components/MyPlots.jsx @@ -0,0 +1,82 @@ +import { + Title, + Text, + Container, + Paper, + Stack, + ThemeIcon, + Center, + SimpleGrid, + Box, +} from "@mantine/core"; +import { IconChartScatter } from "@tabler/icons-react"; + +const MyPlots = () => { + const userSavedPlots = []; + const hasPlots = userSavedPlots.length > 0; + + return ( + +
+ {!hasPlots ? ( + /* when there's no plots*/ + + + + + + +
+ + No plots yet + + + You haven't saved any visualizations yet. Click the "Add to My + Plots" button on any forecast to see them here. + +
+ + + Plots are stored locally in your browser + +
+
+ ) : ( + /* plot grid -- unfinished */ + + My Plots + + + + Plot Placeholder + + + + + )} +
+
+ ); +}; + +export default MyPlots; diff --git a/app/src/components/layout/MainNavigation.jsx b/app/src/components/layout/MainNavigation.jsx index dbe33ca0..f3558c2c 100644 --- a/app/src/components/layout/MainNavigation.jsx +++ b/app/src/components/layout/MainNavigation.jsx @@ -1,6 +1,11 @@ import { useLocation, Link } from "react-router-dom"; import { Group, Button, Image, Title, Anchor } from "@mantine/core"; -import { IconChartLine, IconTarget, IconDashboard } from "@tabler/icons-react"; +import { + IconChartLine, + IconTarget, + IconDashboard, + IconChartScatter, +} from "@tabler/icons-react"; import InfoOverlay from "../InfoOverlay"; import { useView } from "../../hooks/useView"; @@ -30,6 +35,12 @@ const MainNavigation = () => { icon: IconDashboard, active: isActive("/myrespilens"), }, + { + href: "/myplots", + label: "My Plots", + icon: IconChartScatter, + active: isActive("/myplots"), + }, // { href: '/documentation', label: 'Documentation', icon: IconClipboard, active: isActive('/documentation')} ]; diff --git a/app/src/components/layout/UnifiedAppShell.jsx b/app/src/components/layout/UnifiedAppShell.jsx index 8f3b67bf..f0c2f24e 100644 --- a/app/src/components/layout/UnifiedAppShell.jsx +++ b/app/src/components/layout/UnifiedAppShell.jsx @@ -14,6 +14,7 @@ import { IconTrophy, IconDashboard, IconClipboard, + IconChartScatter, } from "@tabler/icons-react"; import MainNavigation from "./MainNavigation"; import StateSelector from "../StateSelector"; @@ -95,6 +96,12 @@ const UnifiedAppShell = ({ children, forecastProps = {} }) => { icon: IconClipboard, active: location.pathname.startsWith("/documentation"), }, + { + href: "/myplots", + label: "My Plots", + icon: IconChartScatter, + active: location.pathname.startsWith("/myplots"), + }, ]; const renderNavbar = () => { From e144d761555abf1f1c7a794e3d3f42380d60ca6b Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 9 Mar 2026 15:39:48 -0400 Subject: [PATCH 02/50] grid background, directory re-route --- app/src/App.jsx | 2 +- app/src/components/MyPlots.jsx | 82 ------------------- app/src/components/myplots/MyPlots.jsx | 108 +++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 83 deletions(-) delete mode 100644 app/src/components/MyPlots.jsx create mode 100644 app/src/components/myplots/MyPlots.jsx diff --git a/app/src/App.jsx b/app/src/App.jsx index b60904cc..cb0df091 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -10,7 +10,7 @@ import { HelmetProvider } from "react-helmet-async"; import { ViewProvider } from "./contexts/ViewContext"; import { useView } from "./hooks/useView"; import DataVisualizationContainer from "./components/DataVisualizationContainer"; -import MyPlots from "./components/MyPlots"; +import MyPlots from "./components/myplots/MyPlots"; import NarrativeBrowser from "./components/narratives/NarrativeBrowser"; import SlideNarrativeViewer from "./components/narratives/SlideNarrativeViewer"; import ForecastleGame from "./components/forecastle/ForecastleGame"; diff --git a/app/src/components/MyPlots.jsx b/app/src/components/MyPlots.jsx deleted file mode 100644 index 41ac74c9..00000000 --- a/app/src/components/MyPlots.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import { - Title, - Text, - Container, - Paper, - Stack, - ThemeIcon, - Center, - SimpleGrid, - Box, -} from "@mantine/core"; -import { IconChartScatter } from "@tabler/icons-react"; - -const MyPlots = () => { - const userSavedPlots = []; - const hasPlots = userSavedPlots.length > 0; - - return ( - -
- {!hasPlots ? ( - /* when there's no plots*/ - - - - - - -
- - No plots yet - - - You haven't saved any visualizations yet. Click the "Add to My - Plots" button on any forecast to see them here. - -
- - - Plots are stored locally in your browser - -
-
- ) : ( - /* plot grid -- unfinished */ - - My Plots - - - - Plot Placeholder - - - - - )} -
-
- ); -}; - -export default MyPlots; diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx new file mode 100644 index 00000000..15a28264 --- /dev/null +++ b/app/src/components/myplots/MyPlots.jsx @@ -0,0 +1,108 @@ +import { + Title, + Text, + Paper, + Stack, + ThemeIcon, + Center, + SimpleGrid, + Box, +} from "@mantine/core"; +import { IconChartScatter } from "@tabler/icons-react"; + +const MyPlots = () => { + const userSavedPlots = []; + const hasPlots = userSavedPlots.length > 0; + + // filler grid pattern (for if you have no plots saved yet) + const fullGridPattern = { + width: "100%", + minHeight: "calc(100vh - 80px)", + backgroundPosition: "top left", + backgroundImage: ` + linear-gradient(to right, var(--mantine-color-gray-2) 1px, transparent 1px), + linear-gradient(to bottom, var(--mantine-color-gray-2) 1px, transparent 1px) + `, + backgroundSize: "60px 60px", + backgroundColor: "var(--mantine-color-body)", + display: "flex", + flexDirection: "column", + }; + + return ( + +
+ {!hasPlots ? ( + /* empty state: no plots chosen yet */ + + + + + + +
+ + No plots saved yet... + + + You haven't added any visualizations to My Plots yet. + Click the "Add to My Plots" button on any plot view to see + them here with any editorializations you choose. + +
+ + + Plots are stored locally in your browser. + +
+
+ ) : ( + /* render for if there are actually plots -- unfinished */ + + My Plots + + + + Plot Placeholder + + + + + )} +
+
+ ); +}; + +export default MyPlots; From bf096475154b3eb06a90f82be2de9cf2335608dc Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Thu, 12 Mar 2026 13:33:10 -0400 Subject: [PATCH 03/50] basic implementation of `Add to My Plots` button rudimentary data fetching, does not save to storage or pipe data anywhere --- .../components/DataVisualizationContainer.jsx | 57 +++++++++--- app/src/hooks/extractPlotDataFromURL.js | 89 +++++++++++++++++++ 2 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 app/src/hooks/extractPlotDataFromURL.js diff --git a/app/src/components/DataVisualizationContainer.jsx b/app/src/components/DataVisualizationContainer.jsx index 810831c9..ea332cc6 100644 --- a/app/src/components/DataVisualizationContainer.jsx +++ b/app/src/components/DataVisualizationContainer.jsx @@ -12,12 +12,17 @@ import { List, } from "@mantine/core"; import { useView } from "../hooks/useView"; +import { extractPlotData } from "../hooks/extractPlotDataFromURL"; import DateSelector from "./DateSelector"; import ViewSwitchboard from "./ViewSwitchboard"; import ErrorBoundary from "./ErrorBoundary"; import AboutHubOverlay from "./AboutHubOverlay"; import FrontPage from "./FrontPage"; -import { IconShare, IconBrandGithub } from "@tabler/icons-react"; +import { + IconShare, + IconBrandGithub, + IconChartScatter, +} from "@tabler/icons-react"; import { useClipboard } from "@mantine/hooks"; const DataVisualizationContainer = () => { @@ -51,6 +56,13 @@ const DataVisualizationContainer = () => { }); const clipboard = useClipboard({ timeout: 2000 }); + const handleSaveToMyPlots = () => { + const plotData = extractPlotData(viewType, window.location.href); + + // Temporary console feedback for development + console.log("Saved the plot", plotData); + }; + // Configuration for AboutHubOverlay based on viewType const aboutHubConfig = { covid_forecasts: { @@ -552,22 +564,34 @@ const DataVisualizationContainer = () => { )} {windowSize.width <= 800 && ( - + + {" "} + {/* Wrapped in Group to maintain horizontal alignment */} - + + + + )} {currentDataset?.hasDateSelector && windowSize.width > 800 && ( @@ -585,6 +609,15 @@ const DataVisualizationContainer = () => { )} {windowSize.width > 800 && (
+ { + const url = new URL(href); + const params = url.searchParams; + + let dataSuffix = ""; + let location = ""; + let fileName = ""; + let fullDataPath = ""; + let target = ""; + + // Translate pseudo-code logic into JavaScript + switch (viewType) { + case "covid_forecasts": + dataSuffix = "covid19"; + location = params.has("location") ? params.get("location") : "US"; + fileName = `${location}_${dataSuffix}.json`; + target = params.has("covid_target") + ? params.get("covid_target") + : "wk inc covid hosp"; // TODO: get right target + break; + + case "flu_forecasts": + case "fludetailed": + dataSuffix = "flu"; + location = params.has("location") ? params.get("location") : "US"; + fileName = `${location}_${dataSuffix}.json`; + fullDataPath = `processed_data/flusight/${fileName}`; + target = params.has("flu_target") + ? params.get("flu_target") + : "wk inc flu hosp"; // TODO: get right target + break; + + case "rsv_forecasts": + dataSuffix = "rsv"; + location = params.has("location") ? params.get("location") : "US"; + fileName = `${location}_${dataSuffix}.json`; + fullDataPath = `processed_data/flusight/${fileName}`; + target = params.has("rsv_target") + ? params.get("rsv_target") + : "wk inc rsv hosp"; // TODO: get right target + break; + + case "metrocast_forecasts": + dataSuffix = "flu_metrocast"; + location = params.has("location") ? params.get("location") : "colorado"; + fileName = `${location}_${dataSuffix}.json`; + fullDataPath = `processed_data/flusight/${fileName}`; + target = "pct ed visits due to flu"; // TODO: get right target + break; + + case "nhsnall": + dataSuffix = "nhsn"; + location = params.has("location") ? params.get("location") : "US"; + fileName = `${location}_${dataSuffix}.json`; + fullDataPath = `processed_data/flusight/${fileName}`; + // TODO: need to handle target (aka column+column unit combo for nhsn) + break; + + default: + throw new Error(`Unknown view type: ${viewType}`); + } + + // Construct the finalized plotData object + const plotData = { + viewType: viewType, + fullUrl: href, + // add editorializations from logic above + settings: { + dataSuffix, + location, + fileName, + fullDataPath, + target, + }, + }; + + // Persist to Web Storage (for later) + // const currentSaved = JSON.parse(localStorage.getItem("userSavedPlots") || "[]"); + // localStorage.setItem("userSavedPlots", JSON.stringify([plotData, ...currentSaved])); + + return plotData; +}; From 8125919a02e290e8179563f4fc7204e33c204666 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Fri, 13 Mar 2026 12:22:32 -0400 Subject: [PATCH 04/50] add more detail to `plotData` object --- app/src/hooks/extractPlotDataFromURL.js | 48 +++++++++++++++++++------ 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/app/src/hooks/extractPlotDataFromURL.js b/app/src/hooks/extractPlotDataFromURL.js index e93725e9..b5c52ed0 100644 --- a/app/src/hooks/extractPlotDataFromURL.js +++ b/app/src/hooks/extractPlotDataFromURL.js @@ -14,18 +14,23 @@ export const extractPlotData = (viewType, href) => { let fileName = ""; let fullDataPath = ""; let target = ""; + let columns = []; + let models = []; - // Translate pseudo-code logic into JavaScript switch (viewType) { case "covid_forecasts": dataSuffix = "covid19"; location = params.has("location") ? params.get("location") : "US"; fileName = `${location}_${dataSuffix}.json`; + fullDataPath = `processed_data/covid19forecasthub/${fileName}`; target = params.has("covid_target") ? params.get("covid_target") - : "wk inc covid hosp"; // TODO: get right target + : "wk inc covid hosp"; + const covidModelsString = params.get("covid_models"); + models = covidModelsString + ? covidModelsString.split(",") + : ["CovidHub-ensemble"]; break; - case "flu_forecasts": case "fludetailed": dataSuffix = "flu"; @@ -34,37 +39,56 @@ export const extractPlotData = (viewType, href) => { fullDataPath = `processed_data/flusight/${fileName}`; target = params.has("flu_target") ? params.get("flu_target") - : "wk inc flu hosp"; // TODO: get right target + : "wk inc flu hosp"; + const fluModelsString = params.get("flu_models"); + models = fluModelsString + ? fluModelsString.split(",") + : ["FluSight-ensemble"]; break; case "rsv_forecasts": dataSuffix = "rsv"; location = params.has("location") ? params.get("location") : "US"; fileName = `${location}_${dataSuffix}.json`; - fullDataPath = `processed_data/flusight/${fileName}`; + fullDataPath = `processed_data/rsvforecasthub/${fileName}`; target = params.has("rsv_target") ? params.get("rsv_target") - : "wk inc rsv hosp"; // TODO: get right target + : "wk inc rsv hosp"; + const rsvModelsString = params.get("rsv_models"); + models = rsvModelsString + ? rsvModelsString.split(",") + : ["RSVHub-ensemble"]; break; case "metrocast_forecasts": dataSuffix = "flu_metrocast"; location = params.has("location") ? params.get("location") : "colorado"; fileName = `${location}_${dataSuffix}.json`; - fullDataPath = `processed_data/flusight/${fileName}`; - target = "pct ed visits due to flu"; // TODO: get right target + fullDataPath = `processed_data/flumetrocast/${fileName}`; + target = "Flu ED visits pct"; + const metrocastModelsString = params.get("metrocast_models"); + models = metrocastModelsString + ? metrocastModelsString.split(",") + : ["epiENGAGE-ensemble_mean"]; break; case "nhsnall": dataSuffix = "nhsn"; location = params.has("location") ? params.get("location") : "US"; fileName = `${location}_${dataSuffix}.json`; - fullDataPath = `processed_data/flusight/${fileName}`; - // TODO: need to handle target (aka column+column unit combo for nhsn) + fullDataPath = `processed_data/nhsn/${fileName}`; + target = params.has("nhsn_target") + ? params.get("nhsn_target") + : "Hospital Admissions (rates)"; + const nhsnColsFromUrl = params.getAll("nhsn_cols"); + columns = + nhsnColsFromUrl.length > 0 + ? nhsnColsFromUrl + : ["totalconfflunewadm", "totalconfc19newadm", "totalconfrsvnewadm"]; // TODO: slug:longform mapping break; default: - throw new Error(`Unknown view type: ${viewType}`); + throw new Error(`Unknown view type: ${viewType}`); // TODO: handle peak view?? } // Construct the finalized plotData object @@ -78,6 +102,8 @@ export const extractPlotData = (viewType, href) => { fileName, fullDataPath, target, + columns, + models, }, }; From d8f2166537fa064d1ca94f80b2c79806010617de Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Fri, 13 Mar 2026 13:34:07 -0400 Subject: [PATCH 05/50] test storage implementation --- app/src/components/myplots/MyPlots.jsx | 15 +++- app/src/hooks/extractPlotDataFromURL.js | 8 +- app/src/utils/plotStorage.js | 104 ++++++++++++++++++++++++ app/src/utils/respilensStorage.js | 8 -- 4 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 app/src/utils/plotStorage.js diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx index 15a28264..7ffc3ab2 100644 --- a/app/src/components/myplots/MyPlots.jsx +++ b/app/src/components/myplots/MyPlots.jsx @@ -9,9 +9,22 @@ import { Box, } from "@mantine/core"; import { IconChartScatter } from "@tabler/icons-react"; +import { useState, useEffect } from "react"; +import { getSavedPlots, deletePlot } from "../utils/plotStorage"; const MyPlots = () => { - const userSavedPlots = []; + const [userSavedPlots, setUserSavedPlots] = useState([]); + + useEffect(() => { + // Using your new utility to get validated plots + setUserSavedPlots(getSavedPlots()); + }, []); + + const handleDelete = (id) => { + if (deletePlot(id)) { + setUserSavedPlots(getSavedPlots()); // Refresh list + } + }; const hasPlots = userSavedPlots.length > 0; // filler grid pattern (for if you have no plots saved yet) diff --git a/app/src/hooks/extractPlotDataFromURL.js b/app/src/hooks/extractPlotDataFromURL.js index b5c52ed0..e17a085e 100644 --- a/app/src/hooks/extractPlotDataFromURL.js +++ b/app/src/hooks/extractPlotDataFromURL.js @@ -1,3 +1,5 @@ +import { savePlot } from "../utils/plotStorage"; + /** * Parses the current URL and viewType to extract a serialized state * for the "My Plots" persistence feature. @@ -8,6 +10,7 @@ export const extractPlotData = (viewType, href) => { const url = new URL(href); const params = url.searchParams; + const id = crypto.randomUUID(); let dataSuffix = ""; let location = ""; @@ -95,6 +98,7 @@ export const extractPlotData = (viewType, href) => { const plotData = { viewType: viewType, fullUrl: href, + id, // add editorializations from logic above settings: { dataSuffix, @@ -107,9 +111,7 @@ export const extractPlotData = (viewType, href) => { }, }; - // Persist to Web Storage (for later) - // const currentSaved = JSON.parse(localStorage.getItem("userSavedPlots") || "[]"); - // localStorage.setItem("userSavedPlots", JSON.stringify([plotData, ...currentSaved])); + savePlot(plotData); return plotData; }; diff --git a/app/src/utils/plotStorage.js b/app/src/utils/plotStorage.js new file mode 100644 index 00000000..05d38aeb --- /dev/null +++ b/app/src/utils/plotStorage.js @@ -0,0 +1,104 @@ +/** + * RespiLens My Plots Storage Utility + */ + +const STORAGE_KEY = "respilens_user_saved_plots"; + +function isLocalStorageAvailable() { + try { + const test = "__storage_test__"; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return true; + } catch { + return false; + } +} + +function isValidPlot(plot) { + return ( + plot && + typeof plot === "object" && + typeof plot.id === "string" && + typeof plot.fullUrl === "string" && + typeof plot.viewType === "string" && + plot.settings && + typeof plot.settings === "object" + ); +} + +/** + * Get all stored saved plots + * @returns {Array} Array of plot objects + */ +export function getSavedPlots() { + if (!isLocalStorageAvailable()) return []; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return []; + + const parsed = JSON.parse(stored); + if (!Array.isArray(parsed)) return []; + + return parsed.filter((plot) => isValidPlot(plot)); + } catch (error) { + console.error("Error reading plots from localStorage:", error); + return []; + } +} + +/** + * Save a plot to My Plots + * @param {Object} plotData - The processed plot object + * @returns {boolean} Success status + */ +export function savePlot(plotData) { + if (!isLocalStorageAvailable()) return false; + + try { + const plots = getSavedPlots(); + + // Check if this specific view/URL already exists to prevent duplicates + const existingIndex = plots.findIndex( + (p) => p.fullUrl === plotData.fullUrl, + ); + + const plotEntry = { + ...plotData, + id: plotData.id || crypto.randomUUID(), + savedAt: new Date().toISOString(), + }; + + if (existingIndex >= 0) { + // Update the timestamp on the existing entry + plots[existingIndex] = plotEntry; + } else { + // Add new plot to the top of the list + plots.unshift(plotEntry); + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(plots)); + return true; + } catch (error) { + if (error.name === "QuotaExceededError" || error.code === 22) { + console.error("Storage quota exceeded."); + } + return false; + } +} + +/** + * Delete a specific saved plot + */ +export function deletePlot(id) { + if (!isLocalStorageAvailable()) return false; + try { + const plots = getSavedPlots(); + const filtered = plots.filter((p) => p.id !== id); + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); + return true; + } catch (error) { + return false; + } +} diff --git a/app/src/utils/respilensStorage.js b/app/src/utils/respilensStorage.js index eefeb8ea..1ed480e0 100644 --- a/app/src/utils/respilensStorage.js +++ b/app/src/utils/respilensStorage.js @@ -1,11 +1,3 @@ -/** - * RespiLens Storage Utility - * - * Manages localStorage for RespiLens games (currently Forecastle). - * Stores raw game data (user predictions, intervals, ground truth). - * Statistics are computed on-the-fly from stored data. - */ - const STORAGE_KEY = "respilens_forecastle_games"; /** From 157c34dae723a0bd207b4faea2e937dc79c0cc02 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Fri, 13 Mar 2026 14:15:27 -0400 Subject: [PATCH 06/50] proof that storage method works --- app/src/components/InfoOverlay.jsx | 26 ++-- app/src/components/myplots/MyPlots.jsx | 157 +++++++++++++++++-------- 2 files changed, 120 insertions(+), 63 deletions(-) diff --git a/app/src/components/InfoOverlay.jsx b/app/src/components/InfoOverlay.jsx index 97a94280..64cf1dce 100644 --- a/app/src/components/InfoOverlay.jsx +++ b/app/src/components/InfoOverlay.jsx @@ -78,12 +78,14 @@ const InfoOverlay = () => { URL-shareable views for specific forecast settings - Responsive and mobile-friendly site - Frequent and automatic site updates + + Responsive and mobile-friendly site with frequent and automatic + updates + Multi date, target, and model comparison the Forecastle game! - MyRespiLens, a safe visualization tool for your own data + MyRespiLens: a safe visualization tool for your own data @@ -96,8 +98,8 @@ const InfoOverlay = () => { Hubverse {" "} - project which standardizes and consolidates forecast data formats. - For each of the hub displayed on RespiLens, the data, organization + project, which standardizes and consolidates forecast data formats. + For each of the hubs displayed on RespiLens, the data, organization, and forecasts belong to their respective teams.{" "} RespiLens is only a visualization layer, and contains no original @@ -106,7 +108,7 @@ const InfoOverlay = () => {
- You can find information and alternative visualization for each + You can find information (and alternative visualization) for each pathogen at the following locations: @@ -119,7 +121,7 @@ const InfoOverlay = () => { > official CDC page {" "} - –{" "} + |{" "} { > Hubverse dashboard {" "} - –{" "} + |{" "} { > official CDC page {" "} - –{" "} + |{" "} { > Hubverse dashboard {" "} - –  + |  { > official dashboard {" "} - –{" "} + |{" "} { > site {" "} - –  + | { const [userSavedPlots, setUserSavedPlots] = useState([]); useEffect(() => { - // Using your new utility to get validated plots - setUserSavedPlots(getSavedPlots()); + const plots = getSavedPlots(); + setUserSavedPlots(plots); }, []); const handleDelete = (id) => { if (deletePlot(id)) { - setUserSavedPlots(getSavedPlots()); // Refresh list + setUserSavedPlots(getSavedPlots()); } }; + const hasPlots = userSavedPlots.length > 0; - // filler grid pattern (for if you have no plots saved yet) - const fullGridPattern = { + // Clean container style without the grid + const pageContainerStyle = { width: "100%", minHeight: "calc(100vh - 80px)", - backgroundPosition: "top left", - backgroundImage: ` - linear-gradient(to right, var(--mantine-color-gray-2) 1px, transparent 1px), - linear-gradient(to bottom, var(--mantine-color-gray-2) 1px, transparent 1px) - `, - backgroundSize: "60px 60px", backgroundColor: "var(--mantine-color-body)", display: "flex", flexDirection: "column", + alignItems: "center", + padding: "40px", }; return ( - -
- {!hasPlots ? ( - /* empty state: no plots chosen yet */ + + {!hasPlots ? ( + /* Empty state: Vertically centered */ +
{ - ) : ( - /* render for if there are actually plots -- unfinished */ - - My Plots - - + ) : ( + /* Has plots: Top-aligned */ + + +
+ My Plots + + Your personalized library of saved visualizations. + +
+ + {userSavedPlots.length} Saved + +
+ + + {userSavedPlots.map((plot) => ( + - - Plot Placeholder - -
-
-
- )} -
+ + + + {plot.viewType.replace(/_/g, " ").toUpperCase()} + + + {new Date(plot.timestamp).toLocaleDateString()} + + + + + {plot.settings.location.toUpperCase()} + + + + + + + TARGET + + + {plot.settings.target} + + + + {plot.settings.models && plot.settings.models.length > 0 && ( + + + MODELS + + + {plot.settings.models.slice(0, 3).map((m) => ( + + {m} + + ))} + {plot.settings.models.length > 3 && ( + + +{plot.settings.models.length - 3} more + + )} + + + )} + + + + + ))} + + + )}
); }; From 582916759bbb790e312a1270c9b33d3e339f3b85 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 16 Mar 2026 11:24:56 -0400 Subject: [PATCH 07/50] data piped in successfully --- app/src/components/myplots/MyPlots.jsx | 63 +++++++++++++++++--------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx index d788c0b6..bf800158 100644 --- a/app/src/components/myplots/MyPlots.jsx +++ b/app/src/components/myplots/MyPlots.jsx @@ -32,7 +32,6 @@ const MyPlots = () => { const hasPlots = userSavedPlots.length > 0; - // Clean container style without the grid const pageContainerStyle = { width: "100%", minHeight: "calc(100vh - 80px)", @@ -46,7 +45,6 @@ const MyPlots = () => { return ( {!hasPlots ? ( - /* Empty state: Vertically centered */
{
) : ( - /* Has plots: Top-aligned */ - -
- My Plots - - Your personalized library of saved visualizations. - -
- - {userSavedPlots.length} Saved - -
+ + +
+ My Plots + + Your personalized library of saved visualizations. + +
+ + {userSavedPlots.length} Saved + +
+
{userSavedPlots.map((plot) => ( @@ -113,16 +112,13 @@ const MyPlots = () => { {plot.viewType.replace(/_/g, " ").toUpperCase()} - {new Date(plot.timestamp).toLocaleDateString()} + text placeholder - {plot.settings.location.toUpperCase()} - - TARGET @@ -131,7 +127,6 @@ const MyPlots = () => { {plot.settings.target} - {plot.settings.models && plot.settings.models.length > 0 && ( @@ -150,8 +145,32 @@ const MyPlots = () => { )} - )} - + )}{" "} + {plot.settings.columns && + plot.settings.columns.length > 0 && ( + + + COLUMNS + + + {plot.settings.columns.slice(0, 3).map((m) => ( + + {m} + + ))} + {plot.settings.columns.length > 3 && ( + + +{plot.settings.columns.length - 3} more + + )} + + + )}
From f11498e2771a3c800514b02dfe7d9d805360552d Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 16 Mar 2026 12:11:29 -0400 Subject: [PATCH 08/50] add animation for "Add to My Plots" button --- .../components/DataVisualizationContainer.jsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/src/components/DataVisualizationContainer.jsx b/app/src/components/DataVisualizationContainer.jsx index ea332cc6..deeea3d3 100644 --- a/app/src/components/DataVisualizationContainer.jsx +++ b/app/src/components/DataVisualizationContainer.jsx @@ -56,9 +56,13 @@ const DataVisualizationContainer = () => { }); const clipboard = useClipboard({ timeout: 2000 }); + const [isAdded, setIsAdded] = useState(false); const handleSaveToMyPlots = () => { const plotData = extractPlotData(viewType, window.location.href); - + // visual cue to signal it has been added + setIsAdded(true); + // Reset text after 2 seconds + setTimeout(() => setIsAdded(false), 2000); // Temporary console feedback for development console.log("Saved the plot", plotData); }; @@ -565,15 +569,16 @@ const DataVisualizationContainer = () => { )} {windowSize.width <= 800 && ( - {" "} - {/* Wrapped in Group to maintain horizontal alignment */} { Date: Mon, 16 Mar 2026 12:20:02 -0400 Subject: [PATCH 09/50] match button animation between 'share view' and 'add to my plots' --- app/src/components/DataVisualizationContainer.jsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/components/DataVisualizationContainer.jsx b/app/src/components/DataVisualizationContainer.jsx index deeea3d3..979d42f1 100644 --- a/app/src/components/DataVisualizationContainer.jsx +++ b/app/src/components/DataVisualizationContainer.jsx @@ -590,10 +590,11 @@ const DataVisualizationContainer = () => { @@ -635,10 +636,11 @@ const DataVisualizationContainer = () => { From 1e2ac2a507461cf95310aff198d8b012bddc0bdb Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 16 Mar 2026 12:45:45 -0400 Subject: [PATCH 10/50] add ability to delete plots from My Plots tab --- app/src/components/myplots/MyPlots.jsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx index bf800158..a55eda7c 100644 --- a/app/src/components/myplots/MyPlots.jsx +++ b/app/src/components/myplots/MyPlots.jsx @@ -13,7 +13,11 @@ import { Button, Divider, } from "@mantine/core"; -import { IconChartScatter, IconExternalLink } from "@tabler/icons-react"; +import { + IconChartScatter, + IconExternalLink, + IconTrash, +} from "@tabler/icons-react"; import { getSavedPlots, deletePlot } from "../../utils/plotStorage"; const MyPlots = () => { @@ -111,9 +115,15 @@ const MyPlots = () => { {plot.viewType.replace(/_/g, " ").toUpperCase()} - - text placeholder - + {plot.settings.location.toUpperCase()} From 50feb4a1a4ed0ff25357518f00cac7876e82812d Mon Sep 17 00:00:00 2001 From: Emily Przykucki <emily.przykucki@gmail.com> Date: Tue, 17 Mar 2026 16:05:19 -0400 Subject: [PATCH 11/50] add `dates` as a feature of `plotData` now even default date selections are captured (hardcoded) into plotData --- .../components/DataVisualizationContainer.jsx | 2 +- app/src/components/myplots/MyPlots.jsx | 2 + app/src/hooks/extractPlotDataFromURL.js | 93 ++++++++++++++++--- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/app/src/components/DataVisualizationContainer.jsx b/app/src/components/DataVisualizationContainer.jsx index 979d42f1..e50ea80d 100644 --- a/app/src/components/DataVisualizationContainer.jsx +++ b/app/src/components/DataVisualizationContainer.jsx @@ -58,7 +58,7 @@ const DataVisualizationContainer = () => { const [isAdded, setIsAdded] = useState(false); const handleSaveToMyPlots = () => { - const plotData = extractPlotData(viewType, window.location.href); + const plotData = extractPlotData(viewType, window.location.href, data); // visual cue to signal it has been added setIsAdded(true); // Reset text after 2 seconds diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx index a55eda7c..fb62c594 100644 --- a/app/src/components/myplots/MyPlots.jsx +++ b/app/src/components/myplots/MyPlots.jsx @@ -184,6 +184,8 @@ const MyPlots = () => { <Button component="a" href={plot.fullUrl} + target="_blank" + rel="noopener noreferrer" variant="light" color="blue" fullWidth diff --git a/app/src/hooks/extractPlotDataFromURL.js b/app/src/hooks/extractPlotDataFromURL.js index e17a085e..fac5f316 100644 --- a/app/src/hooks/extractPlotDataFromURL.js +++ b/app/src/hooks/extractPlotDataFromURL.js @@ -7,25 +7,28 @@ import { savePlot } from "../utils/plotStorage"; * @param {string} href - The full window.location.href string * @returns {Object} The processed plotData object */ -export const extractPlotData = (viewType, href) => { +export const extractPlotData = (viewType, href, data) => { const url = new URL(href); const params = url.searchParams; const id = crypto.randomUUID(); - + const currentDate = new Date().toISOString().split("T")[0]; let dataSuffix = ""; - let location = ""; let fileName = ""; let fullDataPath = ""; + + // plot settings + let location = ""; let target = ""; let columns = []; let models = []; + let dates = []; switch (viewType) { case "covid_forecasts": dataSuffix = "covid19"; location = params.has("location") ? params.get("location") : "US"; fileName = `${location}_${dataSuffix}.json`; - fullDataPath = `processed_data/covid19forecasthub/${fileName}`; + fullDataPath = `covid19forecasthub/${fileName}`; target = params.has("covid_target") ? params.get("covid_target") : "wk inc covid hosp"; @@ -33,13 +36,30 @@ export const extractPlotData = (viewType, href) => { models = covidModelsString ? covidModelsString.split(",") : ["CovidHub-ensemble"]; + const covidDatesString = params.get("covid_dates"); + if (covidDatesString) { + dates = covidDatesString.split(","); + } else { + // extract most recent date key from forecasts key of data + const availableDates = Object.keys(data?.forecasts || {}); + if (availableDates.length > 0) { + const mostRecent = availableDates.sort().pop(); + dates = [mostRecent]; + } else { + // throw error otherwise + throw new Error( + `Unable to extract plot data: No dates found in URL and no forecast data available for ${viewType}.`, + ); + } + } break; + case "flu_forecasts": case "fludetailed": dataSuffix = "flu"; location = params.has("location") ? params.get("location") : "US"; fileName = `${location}_${dataSuffix}.json`; - fullDataPath = `processed_data/flusight/${fileName}`; + fullDataPath = `flusight/${fileName}`; target = params.has("flu_target") ? params.get("flu_target") : "wk inc flu hosp"; @@ -47,13 +67,29 @@ export const extractPlotData = (viewType, href) => { models = fluModelsString ? fluModelsString.split(",") : ["FluSight-ensemble"]; + const fluDatesString = params.get("flu_dates"); + if (fluDatesString) { + dates = fluDatesString.split(","); + } else { + // extract most recent date key from forecasts key of data + const availableDates = Object.keys(data?.forecasts || {}); + if (availableDates.length > 0) { + const mostRecent = availableDates.sort().pop(); + dates = [mostRecent]; + } else { + // throw error otherwise + throw new Error( + `Unable to extract plot data: No dates found in URL and no forecast data available for ${viewType}.`, + ); + } + } break; case "rsv_forecasts": dataSuffix = "rsv"; location = params.has("location") ? params.get("location") : "US"; fileName = `${location}_${dataSuffix}.json`; - fullDataPath = `processed_data/rsvforecasthub/${fileName}`; + fullDataPath = `rsvforecasthub/${fileName}`; target = params.has("rsv_target") ? params.get("rsv_target") : "wk inc rsv hosp"; @@ -61,25 +97,57 @@ export const extractPlotData = (viewType, href) => { models = rsvModelsString ? rsvModelsString.split(",") : ["RSVHub-ensemble"]; + const rsvDatesString = params.get("rsv_dates"); + if (rsvDatesString) { + dates = rsvDatesString.split(","); + } else { + // extract most recent date key from forecasts key of data + const availableDates = Object.keys(data?.forecasts || {}); + if (availableDates.length > 0) { + const mostRecent = availableDates.sort().pop(); + dates = [mostRecent]; + } else { + // throw error otherwise + throw new Error( + `Unable to extract plot data: No dates found in URL and no forecast data available for ${viewType}.`, + ); + } + } break; case "metrocast_forecasts": dataSuffix = "flu_metrocast"; location = params.has("location") ? params.get("location") : "colorado"; fileName = `${location}_${dataSuffix}.json`; - fullDataPath = `processed_data/flumetrocast/${fileName}`; + fullDataPath = `flumetrocast/${fileName}`; target = "Flu ED visits pct"; const metrocastModelsString = params.get("metrocast_models"); models = metrocastModelsString ? metrocastModelsString.split(",") : ["epiENGAGE-ensemble_mean"]; + const metrocastDatesString = params.get("metrocast_dates"); + if (metrocastDatesString) { + dates = metrocastDatesString.split(","); + } else { + // extract most recent date key from forecasts key of data + const availableDates = Object.keys(data?.forecasts || {}); + if (availableDates.length > 0) { + const mostRecent = availableDates.sort().pop(); + dates = [mostRecent]; + } else { + // throw error otherwise + throw new Error( + `Unable to extract plot data: No dates found in URL and no forecast data available for ${viewType}.`, + ); + } + } break; case "nhsnall": dataSuffix = "nhsn"; location = params.has("location") ? params.get("location") : "US"; fileName = `${location}_${dataSuffix}.json`; - fullDataPath = `processed_data/nhsn/${fileName}`; + fullDataPath = `nhsn/${fileName}`; target = params.has("nhsn_target") ? params.get("nhsn_target") : "Hospital Admissions (rates)"; @@ -88,6 +156,7 @@ export const extractPlotData = (viewType, href) => { nhsnColsFromUrl.length > 0 ? nhsnColsFromUrl : ["totalconfflunewadm", "totalconfc19newadm", "totalconfrsvnewadm"]; // TODO: slug:longform mapping + dates = [currentDate]; // TODO: handle the range slider?? if they have moved it, it is uncaptured break; default: @@ -99,15 +168,17 @@ export const extractPlotData = (viewType, href) => { viewType: viewType, fullUrl: href, id, + currentDate, + dataSuffix, + fileName, + fullDataPath, // add editorializations from logic above settings: { - dataSuffix, location, - fileName, - fullDataPath, target, columns, models, + dates, }, }; From 954f7baa17bdb2ecb295dd7451f9452ca818dbde Mon Sep 17 00:00:00 2001 From: Emily Przykucki <emily.przykucki@gmail.com> Date: Tue, 17 Mar 2026 16:25:36 -0400 Subject: [PATCH 12/50] add dates to storage and to `MyPlots.jsx` --- app/src/components/myplots/MyPlots.jsx | 26 +++++++++++++++++++++++++- app/src/utils/plotStorage.js | 10 +++------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx index fb62c594..0435e85f 100644 --- a/app/src/components/myplots/MyPlots.jsx +++ b/app/src/components/myplots/MyPlots.jsx @@ -129,6 +129,28 @@ const MyPlots = () => { {plot.settings.location.toUpperCase()} + + {/* DATES SECTION */} + {plot.settings.dates && plot.settings.dates.length > 0 && ( + + + DATES + + + {plot.settings.dates.slice(0, 2).map((d) => ( + + {d} + + ))} + {plot.settings.dates.length > 2 && ( + + +{plot.settings.dates.length - 2} more + + )} + + + )} + TARGET @@ -137,6 +159,7 @@ const MyPlots = () => { {plot.settings.target} + {plot.settings.models && plot.settings.models.length > 0 && ( @@ -155,7 +178,8 @@ const MyPlots = () => { )} - )}{" "} + )} + {plot.settings.columns && plot.settings.columns.length > 0 && ( diff --git a/app/src/utils/plotStorage.js b/app/src/utils/plotStorage.js index 05d38aeb..481a9d55 100644 --- a/app/src/utils/plotStorage.js +++ b/app/src/utils/plotStorage.js @@ -23,13 +23,13 @@ function isValidPlot(plot) { typeof plot.fullUrl === "string" && typeof plot.viewType === "string" && plot.settings && - typeof plot.settings === "object" + typeof plot.settings === "object" && + Array.isArray(plot.settings.dates) // Added check for dates array ); } /** * Get all stored saved plots - * @returns {Array} Array of plot objects */ export function getSavedPlots() { if (!isLocalStorageAvailable()) return []; @@ -50,8 +50,6 @@ export function getSavedPlots() { /** * Save a plot to My Plots - * @param {Object} plotData - The processed plot object - * @returns {boolean} Success status */ export function savePlot(plotData) { if (!isLocalStorageAvailable()) return false; @@ -59,7 +57,7 @@ export function savePlot(plotData) { try { const plots = getSavedPlots(); - // Check if this specific view/URL already exists to prevent duplicates + // Check for duplicates based on URL const existingIndex = plots.findIndex( (p) => p.fullUrl === plotData.fullUrl, ); @@ -71,10 +69,8 @@ export function savePlot(plotData) { }; if (existingIndex >= 0) { - // Update the timestamp on the existing entry plots[existingIndex] = plotEntry; } else { - // Add new plot to the top of the list plots.unshift(plotEntry); } From fdb2a89e8b986a0ec9454c742bfc8d1ea9e0e45a Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Wed, 25 Mar 2026 14:48:47 -0400 Subject: [PATCH 13/50] capture advanced controls in `plotData` --- app/src/hooks/extractPlotDataFromURL.js | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/hooks/extractPlotDataFromURL.js b/app/src/hooks/extractPlotDataFromURL.js index fac5f316..33effa1b 100644 --- a/app/src/hooks/extractPlotDataFromURL.js +++ b/app/src/hooks/extractPlotDataFromURL.js @@ -22,6 +22,8 @@ export const extractPlotData = (viewType, href, data) => { let columns = []; let models = []; let dates = []; + let scale = ""; + let intervals = []; switch (viewType) { case "covid_forecasts": @@ -52,6 +54,12 @@ export const extractPlotData = (viewType, href, data) => { ); } } + // advanced controls: + scale = params.has("scale") ? params.get("scale") : "linear"; + const covidIntervalsString = params.get("intervals"); + intervals = covidIntervalsString + ? covidIntervalsString.split(",") + : ["median", "ci50", "ci95"]; break; case "flu_forecasts": @@ -83,6 +91,11 @@ export const extractPlotData = (viewType, href, data) => { ); } } + scale = params.has("scale") ? params.get("scale") : "linear"; + const fluIntervalsString = params.get("intervals"); + intervals = fluIntervalsString + ? fluIntervalsString.split(",") + : ["median", "ci50", "ci95"]; break; case "rsv_forecasts": @@ -113,6 +126,11 @@ export const extractPlotData = (viewType, href, data) => { ); } } + scale = params.has("scale") ? params.get("scale") : "linear"; + const rsvIntervalsString = params.get("intervals"); + intervals = rsvIntervalsString + ? rsvIntervalsString.split(",") + : ["median", "ci50", "ci95"]; break; case "metrocast_forecasts": @@ -141,6 +159,11 @@ export const extractPlotData = (viewType, href, data) => { ); } } + scale = params.has("scale") ? params.get("scale") : "linear"; + const metrocastIntervalsString = params.get("intervals"); + intervals = metrocastIntervalsString + ? metrocastIntervalsString.split(",") + : ["median", "ci50", "ci95"]; break; case "nhsnall": @@ -157,6 +180,7 @@ export const extractPlotData = (viewType, href, data) => { ? nhsnColsFromUrl : ["totalconfflunewadm", "totalconfc19newadm", "totalconfrsvnewadm"]; // TODO: slug:longform mapping dates = [currentDate]; // TODO: handle the range slider?? if they have moved it, it is uncaptured + scale = params.has("scale") ? params.get("scale") : "linear"; break; default: @@ -179,6 +203,8 @@ export const extractPlotData = (viewType, href, data) => { columns, models, dates, + scale, + intervals, }, }; From b45165e96e5de19f2c697f3d16b3daf3fdaee247 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Wed, 25 Mar 2026 15:22:06 -0400 Subject: [PATCH 14/50] outsource visualization logic to `MiniPlot.jsx` --- app/src/components/myplots/MiniPlot.jsx | 79 ++++++++++ app/src/components/myplots/MyPlots.jsx | 190 ++++++++++++++---------- 2 files changed, 190 insertions(+), 79 deletions(-) create mode 100644 app/src/components/myplots/MiniPlot.jsx diff --git a/app/src/components/myplots/MiniPlot.jsx b/app/src/components/myplots/MiniPlot.jsx new file mode 100644 index 00000000..4b926952 --- /dev/null +++ b/app/src/components/myplots/MiniPlot.jsx @@ -0,0 +1,79 @@ +import { useState, useEffect } from "react"; +import { Center, Loader, Text, Box, Stack } from "@mantine/core"; + +const MiniPlot = ({ plot }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const dataUrl = `/processed_data/${plot.fullDataPath}`; + + const response = await fetch(dataUrl); + + if (!response.ok) { + throw new Error(`Data not found at ${dataUrl}`); + } + + const json = await response.json(); + setData(json); + } catch (err) { + console.error("Fetch error:", err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + if (plot?.fullDataPath) { + fetchData(); + } + }, [plot.fullDataPath]); + + if (loading) + return ( +
+ +
+ ); + + if (error) + return ( +
+ + + Error loading chart + + + {plot.fullDataPath} + + +
+ ); + + return ( + + {/* Plot logic will eventually go here */} +
+ + + Data Loaded Successfully + + + {plot.settings.location} - {plot.settings.target} + + + {data?.forecasts + ? `${Object.keys(data.forecasts).length} dates available` + : "No forecast keys"} + + +
+
+ ); +}; + +export default MiniPlot; diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx index 0435e85f..570869a1 100644 --- a/app/src/components/myplots/MyPlots.jsx +++ b/app/src/components/myplots/MyPlots.jsx @@ -19,6 +19,7 @@ import { IconTrash, } from "@tabler/icons-react"; import { getSavedPlots, deletePlot } from "../../utils/plotStorage"; +import MiniPlot from "./MiniPlot"; const MyPlots = () => { const [userSavedPlots, setUserSavedPlots] = useState([]); @@ -108,103 +109,134 @@ const MyPlots = () => { radius="md" withBorder shadow="md" - style={{ backgroundColor: "var(--mantine-color-body)" }} + style={{ + backgroundColor: "var(--mantine-color-body)", + display: "flex", + flexDirection: "column", + }} > - - - - {plot.viewType.replace(/_/g, " ").toUpperCase()} - - + + + {/* MiniPlot visualization */} + - Remove - - - - {plot.settings.location.toUpperCase()} - - - - {/* DATES SECTION */} - {plot.settings.dates && plot.settings.dates.length > 0 && ( - - - DATES - - - {plot.settings.dates.slice(0, 2).map((d) => ( - - {d} - - ))} - {plot.settings.dates.length > 2 && ( - - +{plot.settings.dates.length - 2} more - - )} - - - )} - - - - TARGET - - - {plot.settings.target} - - - - {plot.settings.models && plot.settings.models.length > 0 && ( - - - MODELS - - - {plot.settings.models.slice(0, 3).map((m) => ( - - {m} - - ))} - {plot.settings.models.length > 3 && ( - - +{plot.settings.models.length - 3} more - - )} - - - )} + + + + + {plot.settings.location.toUpperCase()} + + + - {plot.settings.columns && - plot.settings.columns.length > 0 && ( - + {/* dates */} + {plot.settings.dates && plot.settings.dates.length > 0 && ( + - COLUMNS + DATES - {plot.settings.columns.slice(0, 3).map((m) => ( + {plot.settings.dates.slice(0, 2).map((d) => ( - {m} + {d} ))} - {plot.settings.columns.length > 3 && ( + {plot.settings.dates.length > 2 && ( - +{plot.settings.columns.length - 3} more + +{plot.settings.dates.length - 2} more )} )} + + + + TARGET + + + {plot.settings.target} + + + + {plot.settings.models && + plot.settings.models.length > 0 && ( + + + MODELS + + + {plot.settings.models.slice(0, 3).map((m) => ( + + {m} + + ))} + {plot.settings.models.length > 3 && ( + + +{plot.settings.models.length - 3} more + + )} + + + )} + + {plot.settings.columns && + plot.settings.columns.length > 0 && ( + + + COLUMNS + + + {plot.settings.columns.slice(0, 3).map((m) => ( + + {m} + + ))} + {plot.settings.columns.length > 3 && ( + + +{plot.settings.columns.length - 3} more + + )} + + + )} +
+ - - {/* MiniPlot visualization */} { {plot.settings.location.toUpperCase()} - - - - {/* dates */} - {plot.settings.dates && plot.settings.dates.length > 0 && ( - - - DATES - - - {plot.settings.dates.slice(0, 2).map((d) => ( - - {d} - - ))} - {plot.settings.dates.length > 2 && ( - - +{plot.settings.dates.length - 2} more - - )} - - - )} - - - - TARGET - - - {plot.settings.target} - - - - {plot.settings.models && - plot.settings.models.length > 0 && ( - - - MODELS - - - {plot.settings.models.slice(0, 3).map((m) => ( - - {m} - - ))} - {plot.settings.models.length > 3 && ( - - +{plot.settings.models.length - 3} more - - )} - - - )} - - {plot.settings.columns && - plot.settings.columns.length > 0 && ( - - - COLUMNS - - - {plot.settings.columns.slice(0, 3).map((m) => ( - - {m} - - ))} - {plot.settings.columns.length > 3 && ( - - +{plot.settings.columns.length - 3} more - - )} - - - )} @@ -134,15 +146,10 @@ const MyPlots = () => { withBorder radius="sm" bg="gray.0" - mb="sm" style={{ overflow: "hidden" }} > - - - {plot.settings.location.toUpperCase()} - + {viewType !== "flu_peak" && ( + + )} { )} {windowSize.width > 800 && (
- + {viewType !== "flu_peak" && ( + + )} Date: Wed, 1 Apr 2026 12:15:53 -0400 Subject: [PATCH 34/50] change height of plots --- app/src/components/myplots/MiniPlot.jsx | 8 ++++---- app/src/components/myplots/MyPlots.jsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/components/myplots/MiniPlot.jsx b/app/src/components/myplots/MiniPlot.jsx index da5665f4..ea23303e 100644 --- a/app/src/components/myplots/MiniPlot.jsx +++ b/app/src/components/myplots/MiniPlot.jsx @@ -137,7 +137,7 @@ const MiniPlot = ({ plot }) => { return { autosize: true, - height: 180, + height: 230, margin: { l: 45, r: 10, t: 10, b: 35 }, showlegend: false, template: colorScheme === "dark" ? "plotly_dark" : "plotly_white", @@ -254,13 +254,13 @@ const MiniPlot = ({ plot }) => { if (loading) return ( -
+
); if (error) return ( -
+
Error loading chart @@ -276,7 +276,7 @@ const MiniPlot = ({ plot }) => { w={350} events={{ hover: true, focus: false, touch: true }} > - + { withBorder radius="sm" bg="gray.0" - style={{ overflow: "hidden" }} + style={{ overflow: "hidden", height: 230 }} > From 7adb7e5cac131a255b6cabe86004e39bd8a91584 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Wed, 1 Apr 2026 12:56:22 -0400 Subject: [PATCH 35/50] column naming updates (NHSN) --- app/src/hooks/extractPlotDataFromURL.js | 2 +- app/src/utils/mapUtils.js | 41 ++++++++++++++++--------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/app/src/hooks/extractPlotDataFromURL.js b/app/src/hooks/extractPlotDataFromURL.js index 4befe431..85725d04 100644 --- a/app/src/hooks/extractPlotDataFromURL.js +++ b/app/src/hooks/extractPlotDataFromURL.js @@ -208,7 +208,7 @@ export const extractPlotData = (viewType, href, data) => { } default: - throw new Error(`Unknown view type: ${viewType}`); // TODO: handle peak view + throw new Error(`Unknown view type: ${viewType}`); } const plotData = { diff --git a/app/src/utils/mapUtils.js b/app/src/utils/mapUtils.js index 648336de..a52bb2de 100644 --- a/app/src/utils/mapUtils.js +++ b/app/src/utils/mapUtils.js @@ -135,6 +135,7 @@ export const nhsnTargetsToColumnsMap = { "Percent Adult Inpatient Beds Occupied", "Percent Pediatric Inpatient Beds Occupied", "Percent Adult ICU Beds Occupied", + "Percent Pediatric ICU Beds Occupied", ], }; @@ -805,26 +806,38 @@ export const nhsnNameToPrettyNameMap = { "Total Adult RSV Admissions": "Adult Admissions", "Number of RSV Admissions, unknown age": "Admissions (unknown age)", "Total RSV Admissions": "Total Admissions", - "Percent Inpatient Beds Occupied": "Inpatient Beds Occupied", + "Percent Adult ICU Beds Occupied": "Percent of Adult ICU Beds Occupied", + "Percent Pediatric ICU Beds Occupied": + "Percent of Pediatric ICU Beds Occupied", + "Percent Adult Inpatient Beds Occupied": + "Percent of Adult Inpatient Beds Occupied", + "Percent Pediatric Inpatient Beds Occupied": + "Percent of Pediatric Inpatient Beds Occupied", + "Percent Inpatient Beds Occupied": "Percent of Inpatient Beds Occupied", "Percent Inpatient Beds Occupied by COVID-19 Patients": - "Inpatient Beds Occupied by COVID-19 Patients", + "Percent of Inpatient Beds Occupied by COVID-19 Patients", "Percent Inpatient Beds Occupied by Influenza Patients": - "Inpatient Beds Occupied by Influenza Patients", + "Percent of Inpatient Beds Occupied by Influenza Patients", "Percent Inpatient Beds Occupied by RSV Patients": - "Inpatient Beds Occupied by RSV Patients", - "Percent ICU Beds Occupied": "ICU Beds Occupied", + "Percent of Inpatient Beds Occupied by RSV Patients", + "Percent ICU Beds Occupied": "Percent of ICU Beds Occupied", "Percent ICU Beds Occupied by COVID-19 Patients": - "ICU Beds Occupied by COVID-19 Patients", + "Percent of ICU Beds Occupied by COVID-19 Patients", "Percent ICU Beds Occupied by Influenza Patients": - "ICU Beds Occupied by Influenza Patients", + "Percent of ICU Beds Occupied by Influenza Patients", "Percent ICU Beds Occupied by RSV Patients": - "ICU Beds Occupied by RSV Patients", - "Percent Adult COVID-19 Admissions": "Adult Admissions", - "Percent Pediatric COVID-19 Admissions": "Pediatric Admissions", - "Percent Adult Influenza Admissions": "Adult Admissions", - "Percent Pediatric Influenza Admissions": "Pediatric Admissions", - "Percent Adult RSV Admissions": "Adult Admissions", - "Percent Pediatric RSV Admissions": "Pediatric Admissions", + "Percent of ICU Beds Occupied by RSV Patients", + "Percent Adult COVID-19 Admissions": + "Percent of COVID-19 Admissions that are Adults", + "Percent Pediatric COVID-19 Admissions": + "Percent of COVID-19 Admissions that are Pediatric", + "Percent Adult Influenza Admissions": + "Percent of Influenza Admissions that are Adults", + "Percent Pediatric Influenza Admissions": + "Percent of Influenza Admissions that are Pediatric", + "Percent Adult RSV Admissions": "Percent of RSV Admissions that are Adults", + "Percent Pediatric RSV Admissions": + "Percent of RSV Admissions that are Pediatric", "Number of Pediatric COVID-19 Admissions, 0-4 years, per 100,000 population": "Pediatric Admissions (0-4 years)", "Number of Pediatric COVID-19 Admissions, 5-17 years, per 100,000 population": From d0b01686259b9207c4e460451ca93f9366bf9155 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Wed, 1 Apr 2026 13:12:37 -0400 Subject: [PATCH 36/50] fix Info Overlay respilens logo being cut off --- app/src/components/InfoOverlay.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/components/InfoOverlay.jsx b/app/src/components/InfoOverlay.jsx index 64cf1dce..50ded0e7 100644 --- a/app/src/components/InfoOverlay.jsx +++ b/app/src/components/InfoOverlay.jsx @@ -52,14 +52,15 @@ const InfoOverlay = () => { opened={opened} onClose={close} title={ - + RespiLens logo - + <Title order={2} c="blue" style={{ lineHeight: 1 }}> RespiLens From 85a82d6a3d85ad26e0380171172a8a7300641ada Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Thu, 2 Apr 2026 12:47:10 -0400 Subject: [PATCH 37/50] fix no columns selected in NHSN view bug --- app/src/components/views/NHSNView.jsx | 108 ++++++++++++++++++++------ 1 file changed, 83 insertions(+), 25 deletions(-) diff --git a/app/src/components/views/NHSNView.jsx b/app/src/components/views/NHSNView.jsx index 4ef3656f..027d766c 100644 --- a/app/src/components/views/NHSNView.jsx +++ b/app/src/components/views/NHSNView.jsx @@ -74,6 +74,7 @@ const NHSNView = ({ location }) => { const [filteredAvailableColumns, setFilteredAvailableColumns] = useState([]); // Columns for the selected target const [selectedColumns, setSelectedColumns] = useState([]); + const hasInteractedRef = useRef(false); const [availableTargets, setAvailableTargets] = useState([]); const [selectedTarget, setSelectedTarget] = useState(null); // This is the string key, e.g., "Raw Patient Counts" @@ -195,14 +196,24 @@ const NHSNView = ({ location }) => { setFilteredAvailableColumns(filtered); const urlSlugs = searchParams.getAll("nhsn_cols"); + const urlTarget = searchParams.get("nhsn_target"); + + const isExplicitlyEmpty = urlSlugs.includes("none"); + const validUrlCols = urlSlugs .map((slug) => nhsnSlugToNameMap[slug]) .filter((colName) => colName && filtered.includes(colName)); let newSelectedCols; + if (validUrlCols.length > 0) { newSelectedCols = validUrlCols; - } else if (filtered.length > 0) { + } else if (isExplicitlyEmpty) { + newSelectedCols = []; + } else if ( + !hasInteractedRef.current || + (urlTarget !== selectedTarget && urlSlugs.length === 0) + ) { const defaultColumns = getDefaultColumnsForTarget(selectedTarget); const filteredDefaults = defaultColumns.filter((col) => filtered.includes(col), @@ -214,10 +225,11 @@ const NHSNView = ({ location }) => { } setSelectedColumns((currentCols) => { - const newSorted = [...newSelectedCols].sort(); - const currentSorted = [...currentCols].sort(); - if (JSON.stringify(newSorted) !== JSON.stringify(currentSorted)) + const sortedNew = [...newSelectedCols].sort(); + const sortedCurrent = [...currentCols].sort(); + if (JSON.stringify(sortedNew) !== JSON.stringify(sortedCurrent)) { return newSelectedCols; + } return currentCols; }); }, [loading, selectedTarget, allDataColumns, searchParams]); @@ -231,18 +243,25 @@ const NHSNView = ({ location }) => { ) { return; } + const currentSearch = window.location.search; const newParams = new URLSearchParams(currentSearch); + + // Target Sync const defaultTarget = availableTargets[0]; - if (selectedTarget && selectedTarget !== defaultTarget) + if (selectedTarget && selectedTarget !== defaultTarget) { newParams.set("nhsn_target", selectedTarget); - else newParams.delete("nhsn_target"); + } else { + newParams.delete("nhsn_target"); + } - const columnsForTarget = nhsnTargetsToColumnsMap[selectedTarget] || []; + // Column Sync + newParams.delete("nhsn_cols"); + + const defaultColumnsArray = getDefaultColumnsForTarget(selectedTarget); const filteredCols = allDataColumns.filter((col) => - columnsForTarget.includes(col), + (nhsnTargetsToColumnsMap[selectedTarget] || []).includes(col), ); - const defaultColumnsArray = getDefaultColumnsForTarget(selectedTarget); const filteredDefaults = defaultColumnsArray.filter((col) => filteredCols.includes(col), ); @@ -252,20 +271,22 @@ const NHSNView = ({ location }) => { : filteredCols.length > 0 ? [filteredCols[0]] : []; - const sortedSelected = [...selectedColumns].sort(); - const sortedDefault = [...defaultColumns].sort(); - const selectedSlugs = sortedSelected - .map((name) => nhsnNameToSlugMap[name]) - .filter(Boolean); - const defaultSlugs = sortedDefault - .map((name) => nhsnNameToSlugMap[name]) - .filter(Boolean); - if (JSON.stringify(selectedSlugs) !== JSON.stringify(defaultSlugs)) { - newParams.delete("nhsn_cols"); - selectedSlugs.forEach((slug) => newParams.append("nhsn_cols", slug)); - } else { - newParams.delete("nhsn_cols"); + + const isDefault = + JSON.stringify([...selectedColumns].sort()) === + JSON.stringify([...defaultColumns].sort()); + + if (!isDefault) { + if (selectedColumns.length > 0) { + selectedColumns.forEach((name) => { + const slug = nhsnNameToSlugMap[name]; + if (slug) newParams.append("nhsn_cols", slug); + }); + } else if (hasInteractedRef.current) { + newParams.set("nhsn_cols", "none"); + } } + if ( newParams.toString() !== new URLSearchParams(currentSearch).toString() ) { @@ -280,6 +301,11 @@ const NHSNView = ({ location }) => { setSearchParams, ]); + const handleSetSelectedColumns = useCallback((newCols) => { + hasInteractedRef.current = true; + setSelectedColumns(newCols); + }, []); + useEffect(() => { if (data) setPlotRevision((p) => p + 1); }, [data, selectedTarget]); @@ -412,6 +438,17 @@ const NHSNView = ({ location }) => { const traces = useMemo(() => { if (!data) return []; + if (selectedColumns.length === 0) { + return [ + { + x: [data.series.dates[0]], + y: [null], + type: "scatter", + mode: "lines", + showlegend: false, + }, + ]; + } return selectedColumns.map((columnName) => { const columnIndex = filteredAvailableColumns.indexOf(columnName); @@ -465,7 +502,10 @@ const NHSNView = ({ location }) => { yaxis: { title: nhsnYAxisLabelMap[selectedTarget] || "Value", range: chartScale === "log" ? undefined : yAxisRange, - autorange: chartScale === "log" ? true : yAxisRange === null, + autorange: + chartScale === "log" + ? true + : yAxisRange === null || selectedColumns.length === 0, type: chartScale === "log" ? "log" : "linear", tickmode: chartScale === "sqrt" && sqrtTicks ? "array" : undefined, tickvals: @@ -489,6 +529,21 @@ const NHSNView = ({ location }) => { }, margin: { t: 40, r: 10, l: 60, b: 120 }, uirevision: plotRevision, + annotations: + selectedColumns.length === 0 + ? [ + { + text: "No columns selected", + xref: "paper", + yref: "paper", + showarrow: false, + font: { + size: 20, + color: colorScheme === "dark" ? "#5c5f66" : "#adb5bd", + }, + }, + ] + : [], }), [ colorScheme, @@ -602,11 +657,14 @@ const NHSNView = ({ location }) => { { + hasInteractedRef.current = false; + setSelectedTarget(val); + }} loading={loading} /> From 0fbc7e094235320595b4abb33ebc02daefa4c6e9 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Thu, 2 Apr 2026 14:29:56 -0400 Subject: [PATCH 38/50] fix NHSN infinite re-load bug that made its way onto staging this bug came from a past PR, fixing it here when i noticed it --- app/src/components/views/NHSNView.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/components/views/NHSNView.jsx b/app/src/components/views/NHSNView.jsx index 027d766c..51671bfb 100644 --- a/app/src/components/views/NHSNView.jsx +++ b/app/src/components/views/NHSNView.jsx @@ -232,7 +232,8 @@ const NHSNView = ({ location }) => { } return currentCols; }); - }, [loading, selectedTarget, allDataColumns, searchParams]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading, selectedTarget, allDataColumns]); useEffect(() => { if ( From 0a59ff473a8a862e763dbc9fd8712bde9d4a2078 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Thu, 2 Apr 2026 14:40:56 -0400 Subject: [PATCH 39/50] add "no forecast data available for the current selection" message to all forecast views --- app/src/components/ForecastPlotView.jsx | 35 ++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx index 03779c5c..96fc0e23 100644 --- a/app/src/components/ForecastPlotView.jsx +++ b/app/src/components/ForecastPlotView.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useRef, useCallback } from "react"; -import { useMantineColorScheme, Stack, Text } from "@mantine/core"; +import { useMantineColorScheme, Stack, Text, Box, Center } from "@mantine/core"; import Plot from "react-plotly.js"; import Plotly from "plotly.js/dist/plotly"; import ModelSelector from "./ModelSelector"; @@ -359,6 +359,7 @@ const ForecastPlotView = ({ return baseConfig; }, [calculateYRange, configOverrides]); + const hasForecasts = projectionsData.length > 1; if (requireTarget && !selectedTarget) { return ( @@ -374,12 +375,40 @@ const ForecastPlotView = ({ timestamp={metadata?.last_updated} />
+ {!hasForecasts && ( + +
+ + No forecast data available for the current selection + +
+
+ )} + Date: Mon, 6 Apr 2026 11:55:01 -0400 Subject: [PATCH 40/50] update `locations.csv` --- scripts/locations.csv | 108 +++++++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/scripts/locations.csv b/scripts/locations.csv index 43f20155..a9b63f94 100644 --- a/scripts/locations.csv +++ b/scripts/locations.csv @@ -1,54 +1,54 @@ -abbreviation,location,location_name,population,count_rate0p3,count_rate0p5,count_rate0p7,count_rate1,count_rate1p7,count_rate3,count_rate4,count_rate5 -US,US,US,334914895,1005,1675,2344,3349,5694,10047,13397,16746 -AL,01,Alabama,5108468,15,26,36,51,87,153,204,255 -AK,02,Alaska,733406,2,4,5,7,12,22,29,37 -AZ,04,Arizona,7431344,22,37,52,74,126,223,297,372 -AR,05,Arkansas,3067732,9,15,21,31,52,92,123,153 -CA,06,California,38965193,117,195,273,390,662,1169,1559,1948 -CO,08,Colorado,5877610,18,29,41,59,100,176,235,294 -CT,09,Connecticut,3617176,11,18,25,36,61,109,145,181 -DE,10,Delaware,1031890,3,5,7,10,18,31,41,52 -DC,11,District of Columbia,678972,2,3,5,7,12,20,27,34 -FL,12,Florida,22610726,68,113,158,226,384,678,904,1131 -GA,13,Georgia,11029227,33,55,77,110,187,331,441,551 -HI,15,Hawaii,1435138,4,7,10,14,24,43,57,72 -ID,16,Idaho,1964726,6,10,14,20,33,59,79,98 -IL,17,Illinois,12549689,38,63,88,125,213,376,502,627 -IN,18,Indiana,6862199,21,34,48,69,117,206,274,343 -IA,19,Iowa,3207004,10,16,22,32,55,96,128,160 -KS,20,Kansas,2940546,9,15,21,29,50,88,118,147 -KY,21,Kentucky,4526154,14,23,32,45,77,136,181,226 -LA,22,Louisiana,4573749,14,23,32,46,78,137,183,229 -ME,23,Maine,1395722,4,7,10,14,24,42,56,70 -MD,24,Maryland,6180253,19,31,43,62,105,185,247,309 -MA,25,Massachusetts,7001399,21,35,49,70,119,210,280,350 -MI,26,Michigan,10037261,30,50,70,100,171,301,401,502 -MN,27,Minnesota,5737915,17,29,40,57,98,172,230,287 -MS,28,Mississippi,2939690,9,15,21,29,50,88,118,147 -MO,29,Missouri,6196156,19,31,43,62,105,186,248,310 -MT,30,Montana,1132812,3,6,8,11,19,34,45,57 -NE,31,Nebraska,1978379,6,10,14,20,34,59,79,99 -NV,32,Nevada,3194176,10,16,22,32,54,96,128,160 -NH,33,New Hampshire,1402054,4,7,10,14,24,42,56,70 -NJ,34,New Jersey,9290841,28,46,65,93,158,279,372,465 -NM,35,New Mexico,2114371,6,11,15,21,36,63,85,106 -NY,36,New York,19571216,59,98,137,196,333,587,783,979 -NC,37,North Carolina,10835491,33,54,76,108,184,325,433,542 -ND,38,North Dakota,783926,2,4,5,8,13,24,31,39 -OH,39,Ohio,11785935,35,59,83,118,200,354,471,589 -OK,40,Oklahoma,4053824,12,20,28,41,69,122,162,203 -OR,41,Oregon,4233358,13,21,30,42,72,127,169,212 -PA,42,Pennsylvania,12961683,39,65,91,130,220,389,518,648 -RI,44,Rhode Island,1095962,3,5,8,11,19,33,44,55 -SC,45,South Carolina,5373555,16,27,38,54,91,161,215,269 -SD,46,South Dakota,919318,3,5,6,9,16,28,37,46 -TN,47,Tennessee,7126489,21,36,50,71,121,214,285,356 -TX,48,Texas,30503301,92,153,214,305,519,915,1220,1525 -UT,49,Utah,3417734,10,17,24,34,58,103,137,171 -VT,50,Vermont,647464,2,3,5,6,11,19,26,32 -VA,51,Virginia,8715698,26,44,61,87,148,261,349,436 -WA,53,Washington,7812880,23,39,55,78,133,234,313,391 -WV,54,West Virginia,1770071,5,9,12,18,30,53,71,89 -WI,55,Wisconsin,5910955,18,30,41,59,100,177,236,296 -WY,56,Wyoming,584057,2,3,4,6,10,18,23,29 -PR,72,Puerto Rico,3205691,10,16,22,32,54,96,128,160 +abbreviation,location,location_name,population +US,US,US,341784857 +AL,01,Alabama,5193088 +AK,02,Alaska,737270 +AZ,04,Arizona,7623818 +AR,05,Arkansas,3114791 +CA,06,California,39355309 +CO,08,Colorado,6012561 +CT,09,Connecticut,3688496 +DE,10,Delaware,1059952 +DC,11,District of Columbia,693645 +FL,12,Florida,23462518 +GA,13,Georgia,11302748 +HI,15,Hawaii,1432820 +ID,16,Idaho,2029733 +IL,17,Illinois,12719141 +IN,18,Indiana,6973333 +IA,19,Iowa,3238387 +KS,20,Kansas,2977220 +KY,21,Kentucky,4606864 +LA,22,Louisiana,4618189 +ME,23,Maine,1414874 +MD,24,Maryland,6265347 +MA,25,Massachusetts,7154084 +MI,26,Michigan,10127884 +MN,27,Minnesota,5830405 +MS,28,Mississippi,2954160 +MO,29,Missouri,6270541 +MT,30,Montana,1144694 +NE,31,Nebraska,2018006 +NV,32,Nevada,3282188 +NH,33,New Hampshire,1415342 +NJ,34,New Jersey,9548215 +NM,35,New Mexico,2125498 +NY,36,New York,20002427 +NC,37,North Carolina,11197968 +ND,38,North Dakota,799358 +OH,39,Ohio,11900510 +OK,40,Oklahoma,4123288 +OR,41,Oregon,4273586 +PA,42,Pennsylvania,13059432 +RI,44,Rhode Island,1114521 +SC,45,South Carolina,5570274 +SD,46,South Dakota,935094 +TN,47,Tennessee,7315076 +TX,48,Texas,31709821 +UT,49,Utah,3538904 +VT,50,Vermont,644663 +VA,51,Virginia,8880107 +WA,53,Washington,8001020 +WV,54,West Virginia,1766147 +WI,55,Wisconsin,5972787 +WY,56,Wyoming,588753 +PR,72,Puerto Rico,3184835 \ No newline at end of file From 31eff428f4f505e2d64f70c71706e9bd444eada0 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 6 Apr 2026 12:18:29 -0400 Subject: [PATCH 41/50] remove dependency on flusight, covid19, rsv forecast hubs `locations.csv` files we still rely on the `locations.csv` in the flu metrocast hub repo though --- scripts/process_RespiLens_data.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/process_RespiLens_data.py b/scripts/process_RespiLens_data.py index de064faf..87195048 100644 --- a/scripts/process_RespiLens_data.py +++ b/scripts/process_RespiLens_data.py @@ -17,6 +17,9 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +SCRIPT_LOCATION = Path(__file__).resolve().parent +LOCATIONS_DATA = pd.read_csv(SCRIPT_LOCATION / "locations.csv") + def main(): """ @@ -63,13 +66,12 @@ def main(): logger.info("Success ✅") logger.info("Collecting data from FluSight repo...") flu_hubverse_df = clean_nan_values(hubverse_df_preprocessor(df=flu_hub_conn.get_dataset().to_table().to_pandas(), filter_nowcasts=True)) - flu_locations_data = clean_nan_values(pd.read_csv(Path(args.flusight_hub_path) / 'auxiliary-data/locations.csv')) flu_target_data = clean_nan_values(connect_target_data(hub_path=args.flusight_hub_path, target_type=TargetType.TIME_SERIES).to_table().to_pandas()) logger.info("Success ✅") # Initialize converter object flu_processor_object = FlusightDataProcessor( data=flu_hubverse_df, - locations_data=flu_locations_data, + locations_data=LOCATIONS_DATA, target_data=flu_target_data, ) # Iteratively save output files @@ -92,13 +94,12 @@ def main(): logger.info("Success ✅") logger.info("Collecting data from RSV repo...") rsv_hubverse_df = clean_nan_values(hubverse_df_preprocessor(df=rsv_hub_conn.get_dataset().to_table().to_pandas(), filter_nowcasts=True)) - rsv_locations_data = clean_nan_values(pd.read_csv(Path(args.rsv_hub_path) / 'auxiliary-data/locations.csv')) rsv_target_data = clean_nan_values(connect_target_data(hub_path=args.rsv_hub_path, target_type=TargetType.TIME_SERIES).to_table().to_pandas()) logger.info("Success ✅") # Initialize converter object rsv_processor_object = RSVDataProcessor( data=rsv_hubverse_df, - locations_data=rsv_locations_data, + locations_data=LOCATIONS_DATA, target_data=rsv_target_data, ) # Iteratively save output files @@ -120,13 +121,12 @@ def main(): logger.info("Success ✅") logger.info("Collecting data from covid19 repo...") covid_hubverse_df = clean_nan_values(hubverse_df_preprocessor(df=covid_hub_conn.get_dataset().to_table().to_pandas(), filter_nowcasts=True)) - covid_locations_data = clean_nan_values(pd.read_csv(Path(args.covid_hub_path) / 'auxiliary-data/locations.csv')) covid_target_data = clean_nan_values(connect_target_data(hub_path=args.covid_hub_path, target_type=TargetType.TIME_SERIES).to_table().to_pandas()) logger.info("Success ✅") # Initialize converter object covid_processor_object = COVIDDataProcessor( data=covid_hubverse_df, - locations_data=covid_locations_data, + locations_data=LOCATIONS_DATA, target_data=covid_target_data, ) # Iteratively save output files @@ -148,7 +148,7 @@ def main(): 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_locations_data = clean_nan_values(pd.read_csv(Path(args.flu_metrocast_hub_path) / 'auxiliary-data/locations.csv')) # DEP: metrocast still pulls hub 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 From db528564ba40f5805c8150d7061cfbe87c9de9cd Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 6 Apr 2026 14:10:44 -0400 Subject: [PATCH 42/50] remove granular hover label for My Plots --- app/src/components/myplots/MiniPlot.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/components/myplots/MiniPlot.jsx b/app/src/components/myplots/MiniPlot.jsx index ea23303e..3a91bea3 100644 --- a/app/src/components/myplots/MiniPlot.jsx +++ b/app/src/components/myplots/MiniPlot.jsx @@ -140,6 +140,7 @@ const MiniPlot = ({ plot }) => { height: 230, margin: { l: 45, r: 10, t: 10, b: 35 }, showlegend: false, + hovermode: false, template: colorScheme === "dark" ? "plotly_dark" : "plotly_white", paper_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)", From 984e3f17eaf97d7ccdbb7e726a2d9b849846aaba Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 6 Apr 2026 15:00:10 -0400 Subject: [PATCH 43/50] Add a banner for `myplots` --- app/src/components/FrontPage.jsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/components/FrontPage.jsx b/app/src/components/FrontPage.jsx index d0f31ac8..d19805bf 100644 --- a/app/src/components/FrontPage.jsx +++ b/app/src/components/FrontPage.jsx @@ -4,6 +4,23 @@ import NHSNOverviewGraph from "./NHSNOverviewGraph"; import Announcement from "./Announcement"; import { useView } from "../hooks/useView"; +const MyPlotsLink = () => { + return ( + + Check out the new{" "} + + My Plots + {" "} + feature, where you can assemble your own dashboard of saved plots. + + ); +}; + const MetroCastLink = () => { const { setViewType } = useView(); @@ -41,6 +58,13 @@ const FrontPage = () => { announcementType={"update"} text={} /> + } + /> Date: Mon, 6 Apr 2026 15:14:49 -0400 Subject: [PATCH 44/50] add alpha notice to My Plots --- app/src/components/layout/MainNavigation.jsx | 2 +- app/src/components/myplots/MyPlots.jsx | 25 ++++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/components/layout/MainNavigation.jsx b/app/src/components/layout/MainNavigation.jsx index f3558c2c..3523bc6d 100644 --- a/app/src/components/layout/MainNavigation.jsx +++ b/app/src/components/layout/MainNavigation.jsx @@ -37,7 +37,7 @@ const MainNavigation = () => { }, { href: "/myplots", - label: "My Plots", + label: "My Plots (α)", icon: IconChartScatter, active: isActive("/myplots"), }, diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx index 483f9916..0650678a 100644 --- a/app/src/components/myplots/MyPlots.jsx +++ b/app/src/components/myplots/MyPlots.jsx @@ -73,8 +73,17 @@ const MyPlots = () => { You haven't added any visualizations to My Plots yet. - Click the "Add to My Plots" button on any plot view to see - them here with any editorializations you choose. + Click the "Add to My Plots" button on any plot to see it here + with any editorializations you choose. This feature is in its + alpha release; if you encounter bugs or have suggestions, + please report them + + here. +
@@ -93,6 +102,18 @@ const MyPlots = () => { Your personalized library of saved visualizations. + + This feature is in its alpha release, and is still under + develoment. If you encounter a bug or have a suggestion, + please{" "} + + let us know. + +
{userSavedPlots.length} Saved From 8e72cf58ccac783df9627eac21751de8bdd219d2 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 6 Apr 2026 15:00:10 -0400 Subject: [PATCH 45/50] Add a banner for `myplots` --- app/src/components/FrontPage.jsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/components/FrontPage.jsx b/app/src/components/FrontPage.jsx index d0f31ac8..d19805bf 100644 --- a/app/src/components/FrontPage.jsx +++ b/app/src/components/FrontPage.jsx @@ -4,6 +4,23 @@ import NHSNOverviewGraph from "./NHSNOverviewGraph"; import Announcement from "./Announcement"; import { useView } from "../hooks/useView"; +const MyPlotsLink = () => { + return ( + + Check out the new{" "} + + My Plots + {" "} + feature, where you can assemble your own dashboard of saved plots. + + ); +}; + const MetroCastLink = () => { const { setViewType } = useView(); @@ -41,6 +58,13 @@ const FrontPage = () => { announcementType={"update"} text={} /> + } + /> Date: Mon, 6 Apr 2026 15:14:49 -0400 Subject: [PATCH 46/50] add alpha notice to My Plots --- app/src/components/layout/MainNavigation.jsx | 2 +- app/src/components/myplots/MyPlots.jsx | 25 ++++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/components/layout/MainNavigation.jsx b/app/src/components/layout/MainNavigation.jsx index f3558c2c..3523bc6d 100644 --- a/app/src/components/layout/MainNavigation.jsx +++ b/app/src/components/layout/MainNavigation.jsx @@ -37,7 +37,7 @@ const MainNavigation = () => { }, { href: "/myplots", - label: "My Plots", + label: "My Plots (α)", icon: IconChartScatter, active: isActive("/myplots"), }, diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx index 483f9916..0650678a 100644 --- a/app/src/components/myplots/MyPlots.jsx +++ b/app/src/components/myplots/MyPlots.jsx @@ -73,8 +73,17 @@ const MyPlots = () => { You haven't added any visualizations to My Plots yet. - Click the "Add to My Plots" button on any plot view to see - them here with any editorializations you choose. + Click the "Add to My Plots" button on any plot to see it here + with any editorializations you choose. This feature is in its + alpha release; if you encounter bugs or have suggestions, + please report them + + here. + @@ -93,6 +102,18 @@ const MyPlots = () => { Your personalized library of saved visualizations. + + This feature is in its alpha release, and is still under + develoment. If you encounter a bug or have a suggestion, + please{" "} + + let us know. + + {userSavedPlots.length} Saved From 1288debebee42f63921eced828f9e043a3223bbd Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 6 Apr 2026 15:38:14 -0400 Subject: [PATCH 47/50] put back granular hover label --- app/src/components/myplots/MiniPlot.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/components/myplots/MiniPlot.jsx b/app/src/components/myplots/MiniPlot.jsx index 3a91bea3..ea23303e 100644 --- a/app/src/components/myplots/MiniPlot.jsx +++ b/app/src/components/myplots/MiniPlot.jsx @@ -140,7 +140,6 @@ const MiniPlot = ({ plot }) => { height: 230, margin: { l: 45, r: 10, t: 10, b: 35 }, showlegend: false, - hovermode: false, template: colorScheme === "dark" ? "plotly_dark" : "plotly_white", paper_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)", From 069e874de44405472ab0cb81b2296abbc961378f Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Tue, 21 Apr 2026 09:51:49 -0400 Subject: [PATCH 48/50] hubs now all use `target_end_date` in their timeseries target data --- scripts/processors/covid19_forecast_hub.py | 2 +- scripts/processors/rsv_forecast_hub.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/processors/covid19_forecast_hub.py b/scripts/processors/covid19_forecast_hub.py index d5ab5e2c..818c047b 100644 --- a/scripts/processors/covid19_forecast_hub.py +++ b/scripts/processors/covid19_forecast_hub.py @@ -10,7 +10,7 @@ def __init__(self, data: pd.DataFrame, locations_data: pd.DataFrame, target_data config = HubDatasetConfig( file_suffix="covid19", dataset_label="covid19 forecast hub", - ground_truth_date_column="date", + ground_truth_date_column="target_end_date", ground_truth_min_date=pd.Timestamp("2023-10-01"), ) super().__init__( diff --git a/scripts/processors/rsv_forecast_hub.py b/scripts/processors/rsv_forecast_hub.py index 206f4405..60de23c0 100644 --- a/scripts/processors/rsv_forecast_hub.py +++ b/scripts/processors/rsv_forecast_hub.py @@ -10,7 +10,7 @@ def __init__(self, data: pd.DataFrame, locations_data: pd.DataFrame, target_data config = HubDatasetConfig( file_suffix="rsv", dataset_label="rsv forecast hub", - ground_truth_date_column="date", + ground_truth_date_column="target_end_date", ground_truth_min_date=pd.Timestamp("2023-10-01"), ) super().__init__( From 4ee364f311f1995fca8b24899d20d29997d57695 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Tue, 21 Apr 2026 11:55:48 -0400 Subject: [PATCH 49/50] remove `ground_truth_date_col` as a config for hubverse all columns are the same now! --- scripts/hub_dataset_processor.py | 7 +++---- scripts/processors/covid19_forecast_hub.py | 1 - scripts/processors/flu_metrocast_hub.py | 1 - scripts/processors/flusight.py | 1 - scripts/processors/rsv_forecast_hub.py | 1 - 5 files changed, 3 insertions(+), 8 deletions(-) diff --git a/scripts/hub_dataset_processor.py b/scripts/hub_dataset_processor.py index 957dc495..79dc7896 100644 --- a/scripts/hub_dataset_processor.py +++ b/scripts/hub_dataset_processor.py @@ -21,7 +21,6 @@ class HubDatasetConfig: file_suffix: str dataset_label: str - ground_truth_date_column: str ground_truth_min_date: Optional[pd.Timestamp] = None series_type: str = "projection" observation_column: str = "observation" @@ -34,7 +33,7 @@ class HubDataProcessorBase: Subclasses supply dataset-specific configuration via HubDatasetConfig. """ - + def __init__( self, data: pd.DataFrame, @@ -175,7 +174,7 @@ def _prepare_ground_truth_df(self, location: str) -> pd.DataFrame: if filtered.empty: return filtered - date_col = self.config.ground_truth_date_column + date_col = "target_end_date" filtered["as_of"] = pd.to_datetime(filtered["as_of"]) filtered[date_col] = pd.to_datetime(filtered[date_col]) @@ -197,7 +196,7 @@ def _format_ground_truth_output(self, ground_truth_df: pd.DataFrame) -> Dict[str if ground_truth_df.empty: return {"dates": []} - date_col = self.config.ground_truth_date_column + date_col = "target_end_date" pivot_truth = ground_truth_df.pivot( index=date_col, columns="target", diff --git a/scripts/processors/covid19_forecast_hub.py b/scripts/processors/covid19_forecast_hub.py index 818c047b..9fa96c4e 100644 --- a/scripts/processors/covid19_forecast_hub.py +++ b/scripts/processors/covid19_forecast_hub.py @@ -10,7 +10,6 @@ def __init__(self, data: pd.DataFrame, locations_data: pd.DataFrame, target_data config = HubDatasetConfig( file_suffix="covid19", dataset_label="covid19 forecast hub", - ground_truth_date_column="target_end_date", ground_truth_min_date=pd.Timestamp("2023-10-01"), ) super().__init__( diff --git a/scripts/processors/flu_metrocast_hub.py b/scripts/processors/flu_metrocast_hub.py index 55f52642..3d8c9d24 100644 --- a/scripts/processors/flu_metrocast_hub.py +++ b/scripts/processors/flu_metrocast_hub.py @@ -10,7 +10,6 @@ def __init__(self, data: pd.DataFrame, locations_data: pd.DataFrame, target_data config = HubDatasetConfig( file_suffix="flu_metrocast", dataset_label="flu metrocast forecasts", - ground_truth_date_column="target_end_date", ground_truth_min_date=pd.Timestamp("2024-08-01"), ) super().__init__( diff --git a/scripts/processors/flusight.py b/scripts/processors/flusight.py index 88b14d08..01455ab3 100644 --- a/scripts/processors/flusight.py +++ b/scripts/processors/flusight.py @@ -10,7 +10,6 @@ def __init__(self, data: pd.DataFrame, locations_data: pd.DataFrame, target_data config = HubDatasetConfig( file_suffix="flu", dataset_label="flusight forecasts", - ground_truth_date_column="target_end_date", ground_truth_min_date=pd.Timestamp("2022-10-01"), ) super().__init__( diff --git a/scripts/processors/rsv_forecast_hub.py b/scripts/processors/rsv_forecast_hub.py index 60de23c0..018c4cf7 100644 --- a/scripts/processors/rsv_forecast_hub.py +++ b/scripts/processors/rsv_forecast_hub.py @@ -10,7 +10,6 @@ def __init__(self, data: pd.DataFrame, locations_data: pd.DataFrame, target_data config = HubDatasetConfig( file_suffix="rsv", dataset_label="rsv forecast hub", - ground_truth_date_column="target_end_date", ground_truth_min_date=pd.Timestamp("2023-10-01"), ) super().__init__( From 27c33aba4c9a243d9520e85bdf37c8cdc43f7ac7 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Tue, 21 Apr 2026 12:34:29 -0400 Subject: [PATCH 50/50] fix space typo --- app/src/components/myplots/MyPlots.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx index 0650678a..075bb9f5 100644 --- a/app/src/components/myplots/MyPlots.jsx +++ b/app/src/components/myplots/MyPlots.jsx @@ -76,7 +76,7 @@ const MyPlots = () => { Click the "Add to My Plots" button on any plot to see it here with any editorializations you choose. This feature is in its alpha release; if you encounter bugs or have suggestions, - please report them + please report them{" "}