diff --git a/app/src/App.jsx b/app/src/App.jsx
index 026be490..cb0df091 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/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/DataVisualizationContainer.jsx b/app/src/components/DataVisualizationContainer.jsx
index 810831c9..478b8904 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,17 @@ const DataVisualizationContainer = () => {
});
const clipboard = useClipboard({ timeout: 2000 });
+ const [isAdded, setIsAdded] = useState(false);
+ const handleSaveToMyPlots = () => {
+ const plotData = extractPlotData(viewType, window.location.href, data);
+ // 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);
+ };
+
// Configuration for AboutHubOverlay based on viewType
const aboutHubConfig = {
covid_forecasts: {
@@ -552,22 +568,38 @@ const DataVisualizationContainer = () => {
)}
{windowSize.width <= 800 && (
-
- }
- onClick={handleShare}
+
+ {viewType !== "flu_peak" && (
+ }
+ onClick={handleSaveToMyPlots}
+ >
+ {isAdded ? "Added!" : "Add to My Plots"}
+
+ )}
+
- {clipboard.copied ? "URL Copied" : "Share View"}
-
-
+ }
+ onClick={handleShare}
+ >
+ {clipboard.copied ? "URL Copied!" : "Share View"}
+
+
+
)}
{currentDataset?.hasDateSelector && windowSize.width > 800 && (
@@ -585,6 +617,19 @@ const DataVisualizationContainer = () => {
)}
{windowSize.width > 800 && (
+ {viewType !== "flu_peak" && (
+ }
+ onClick={handleSaveToMyPlots}
+ >
+ {isAdded ? "Added!" : "Add to My Plots"}
+
+ )}
{
}
onClick={handleShare}
>
- {clipboard.copied ? "URL Copied" : "Share View"}
+ {clipboard.copied ? "URL Copied!" : "Share View"}
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
+
+
+
+ )}
+
{
+ 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={ }
/>
+ }
+ />
{
opened={opened}
onClose={close}
title={
-
+
-
+
RespiLens
@@ -78,12 +79,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 +99,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 +109,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 +122,7 @@ const InfoOverlay = () => {
>
official CDC page
{" "}
- –{" "}
+ |{" "}
{
>
Hubverse dashboard
{" "}
- –{" "}
+ |{" "}
{
>
official CDC page
{" "}
- –{" "}
+ |{" "}
{
>
Hubverse dashboard
{" "}
- –
+ |
{
>
official dashboard
{" "}
- –{" "}
+ |{" "}
{
>
site
{" "}
- –
+ |
{
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 = () => {
diff --git a/app/src/components/myplots/MiniPlot.jsx b/app/src/components/myplots/MiniPlot.jsx
new file mode 100644
index 00000000..ea23303e
--- /dev/null
+++ b/app/src/components/myplots/MiniPlot.jsx
@@ -0,0 +1,297 @@
+import { useState, useEffect, useMemo } from "react";
+import {
+ Center,
+ Loader,
+ Text,
+ Box,
+ Stack,
+ Group,
+ Badge,
+ Tooltip,
+ useMantineColorScheme,
+} from "@mantine/core";
+import Plot from "react-plotly.js";
+import useQuantileForecastTraces from "../../hooks/useQuantileForecastTraces";
+import { MODEL_COLORS } from "../../config/datasets";
+import { nhsnSlugToNameMap, targetDisplayNameMap } from "../../utils/mapUtils";
+
+const MiniPlot = ({ plot }) => {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { colorScheme } = useMantineColorScheme();
+
+ const isNHSN = plot.viewType === "nhsnall";
+
+ 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`);
+ const json = await response.json();
+ setData(json);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchData();
+ }, [plot.fullDataPath]);
+
+ const { traces: forecastTraces } = useQuantileForecastTraces({
+ groundTruth: isNHSN ? null : data?.ground_truth,
+ forecasts: isNHSN ? null : data?.forecasts,
+ selectedDates: plot.settings.dates || [],
+ selectedModels: plot.settings.models || [],
+ target: plot.settings.target,
+ showMedian: plot.settings.intervals?.includes("median") ?? true,
+ show50: plot.settings.intervals?.includes("ci50") ?? true,
+ show95: plot.settings.intervals?.includes("ci95") ?? true,
+ showLegendForFirstDate: false,
+ modelLineWidth: 1.5,
+ modelMarkerSize: 4,
+ });
+
+ const nhsnTraces = useMemo(() => {
+ if (!isNHSN || !data?.series) return [];
+
+ const dateAxis = data.series.dates;
+ const applySqrt = plot.settings.scale === "sqrt";
+
+ return (plot.settings.columns || [])
+ .map((slug, index) => {
+ const longformName = nhsnSlugToNameMap[slug] || slug;
+ const rawY = data.series[longformName] || [];
+ const yValues = applySqrt
+ ? rawY.map((v) => (v !== null ? Math.sqrt(Math.max(0, v)) : v))
+ : rawY;
+
+ return {
+ x: dateAxis,
+ y: yValues,
+ name: longformName,
+ type: "scatter",
+ mode: "lines",
+ line: {
+ color: MODEL_COLORS[index % MODEL_COLORS.length],
+ width: 2,
+ },
+ };
+ })
+ .filter((trace) => trace.y.length > 0);
+ }, [isNHSN, data, plot.settings]);
+
+ const finalTraces = isNHSN ? nhsnTraces : forecastTraces;
+
+ const layout = useMemo(() => {
+ let xRange = undefined;
+ let yRange = undefined;
+
+ if (data) {
+ if (isNHSN && data.series?.dates?.length > 0) {
+ const lastDate = new Date(
+ data.series.dates[data.series.dates.length - 1],
+ );
+ const startDate = new Date(lastDate);
+ startDate.setMonth(startDate.getMonth() - 3);
+ xRange = [
+ startDate.toISOString().split("T")[0],
+ data.series.dates[data.series.dates.length - 1],
+ ];
+ } else if (!isNHSN && plot.settings.dates?.length > 0) {
+ const sortedDates = [...plot.settings.dates].sort();
+ const earliestDate = new Date(sortedDates[0]);
+ const latestDate = new Date(sortedDates[sortedDates.length - 1]);
+ const startDate = new Date(earliestDate);
+ startDate.setMonth(startDate.getMonth() - 3);
+ const endDate = new Date(latestDate);
+ endDate.setDate(endDate.getDate() + 42);
+
+ xRange = [
+ startDate.toISOString().split("T")[0],
+ endDate.toISOString().split("T")[0],
+ ];
+ }
+
+ if (xRange && finalTraces?.length > 0) {
+ const [viewStart, viewEnd] = xRange;
+ let maxY = 0;
+ finalTraces.forEach((trace) => {
+ if (!trace.x || !trace.y) return;
+ trace.x.forEach((xVal, i) => {
+ if (xVal >= viewStart && xVal <= viewEnd) {
+ const yVal = trace.y[i];
+ if (yVal !== null && !isNaN(yVal)) {
+ maxY = Math.max(maxY, yVal);
+ }
+ }
+ });
+ });
+ const padding = maxY === 0 ? 1 : maxY * 0.2;
+ yRange = [0, maxY + padding];
+ }
+ }
+
+ return {
+ autosize: true,
+ height: 230,
+ margin: { l: 45, r: 10, t: 10, b: 35 },
+ showlegend: false,
+ template: colorScheme === "dark" ? "plotly_dark" : "plotly_white",
+ paper_bgcolor: "rgba(0,0,0,0)",
+ plot_bgcolor: "rgba(0,0,0,0)",
+ dragmode: "pan",
+ xaxis: {
+ showgrid: false,
+ fixedrange: false,
+ tickfont: { size: 8 },
+ range: xRange,
+ },
+ yaxis: {
+ showgrid: true,
+ gridcolor: colorScheme === "dark" ? "#333" : "#eee",
+ fixedrange: true,
+ tickfont: { size: 8 },
+ type: plot.settings.scale === "log" ? "log" : "linear",
+ range: plot.settings.scale === "log" ? undefined : yRange,
+ nticks: 5,
+ ticksuffix:
+ plot.settings.target?.includes("%") ||
+ plot.settings.target?.includes("pct") ||
+ plot.settings.target?.includes("Percent") ||
+ plot.settings.target?.includes("percent")
+ ? "%"
+ : "",
+ },
+ shapes: !isNHSN
+ ? (plot.settings.dates || []).map((date) => ({
+ type: "line",
+ x0: date,
+ x1: date,
+ y0: 0,
+ y1: 1,
+ yref: "paper",
+ line: { color: "red", width: 1, dash: "dash" },
+ }))
+ : [],
+ };
+ }, [colorScheme, plot.settings, isNHSN, data, finalTraces]);
+
+ // Helper for hover label content
+ const tooltipContent = useMemo(() => {
+ const resolvedTarget =
+ targetDisplayNameMap[plot.settings.target] || plot.settings.target;
+
+ return (
+
+
+ PLOT INFO
+
+
+
+
+ TARGET:
+
+ {resolvedTarget}
+
+
+
+
+ SCALE:
+
+
+ {plot.settings.scale?.toUpperCase()}
+
+
+
+
+
+ {isNHSN ? "COLUMNS:" : "DATES:"}
+
+
+ {isNHSN
+ ? plot.settings.columns?.map((slug) => (
+
+ {nhsnSlugToNameMap[slug] || slug}
+
+ ))
+ : plot.settings.dates?.map((date) => (
+
+ {date}
+
+ ))}
+
+
+
+ {!isNHSN && (
+
+
+ MODELS:
+
+
+ {plot.settings.models?.map((model) => (
+
+ {model}
+
+ ))}
+
+
+ )}
+
+ );
+ }, [plot.settings, isNHSN]);
+
+ if (loading)
+ return (
+
+
+
+ );
+ if (error)
+ return (
+
+
+ Error loading chart
+
+
+ );
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default MiniPlot;
diff --git a/app/src/components/myplots/MyPlots.jsx b/app/src/components/myplots/MyPlots.jsx
new file mode 100644
index 00000000..075bb9f5
--- /dev/null
+++ b/app/src/components/myplots/MyPlots.jsx
@@ -0,0 +1,199 @@
+import { useState, useEffect } from "react";
+import {
+ Title,
+ Text,
+ Paper,
+ Stack,
+ ThemeIcon,
+ Center,
+ SimpleGrid,
+ Box,
+ Badge,
+ Group,
+ Button,
+} from "@mantine/core";
+import {
+ IconChartScatter,
+ IconExternalLink,
+ IconTrash,
+} from "@tabler/icons-react";
+import { getSavedPlots, deletePlot } from "../../utils/plotStorage";
+import MiniPlot from "./MiniPlot";
+
+const MyPlots = () => {
+ const [userSavedPlots, setUserSavedPlots] = useState([]);
+
+ useEffect(() => {
+ const plots = getSavedPlots();
+ setUserSavedPlots(plots);
+ }, []);
+
+ const handleDelete = (id) => {
+ if (deletePlot(id)) {
+ setUserSavedPlots(getSavedPlots());
+ }
+ };
+
+ const hasPlots = userSavedPlots.length > 0;
+
+ const pageContainerStyle = {
+ width: "100%",
+ minHeight: "calc(100vh - 80px)",
+ backgroundColor: "var(--mantine-color-body)",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ padding: "40px",
+ };
+
+ return (
+
+ {!hasPlots ? (
+
+
+
+
+
+
+
+
+
+ No plots saved yet...
+
+
+ You haven't added any visualizations to My Plots yet.
+ 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.
+
+
+
+
+
+ Plots are stored locally in your browser.
+
+
+
+
+ ) : (
+
+
+
+
+
My Plots
+
+ 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
+
+
+
+
+
+ {userSavedPlots.map((plot) => (
+
+
+
+
+
+
+ {plot.viewDisplayName.toUpperCase()}
+
+
+ {plot.settings.location.toUpperCase()}
+
+
+
+ }
+ onClick={() => handleDelete(plot.id)}
+ style={{ flexShrink: 0 }}
+ >
+ Remove
+
+
+
+
+
+
+
+ }
+ >
+ Visit view
+
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+export default MyPlots;
diff --git a/app/src/components/views/NHSNView.jsx b/app/src/components/views/NHSNView.jsx
index 703f6575..51671bfb 100644
--- a/app/src/components/views/NHSNView.jsx
+++ b/app/src/components/views/NHSNView.jsx
@@ -27,7 +27,7 @@ import {
const nhsnYAxisLabelMap = {
"Hospital Admissions (count)": "Patient Count",
- "Hospital Admissions (rates)": "Rate per 100k",
+ "Hospital Admissions (rate)": "Rate per 100k",
"Hospital Admissions (%)": "Percent (%)",
"Bed Capacity (count)": "Bed Count",
"Bed Capacity (%)": "Percent (%)",
@@ -41,7 +41,7 @@ const getDefaultColumnsForTarget = (target) => {
"Total Influenza Admissions",
"Total RSV Admissions",
],
- "Hospital Admissions (rates)": [
+ "Hospital Admissions (rate)": [
"Total number of COVID-19 Admissions per 100,000 population",
"Total number of Influenza Admissions per 100,000 population",
"Total number of RSV Admissions per 100,000 population",
@@ -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"
@@ -88,6 +89,20 @@ const NHSNView = ({ location }) => {
const plotRef = useRef(null);
const isResettingRef = useRef(false);
+ const getProcessedYValues = useCallback(
+ (columnName, rawValues) => {
+ if (!rawValues) return [];
+ return rawValues.map((val) => {
+ if (val === null || val === undefined) return val;
+ const transformed = val;
+ return chartScale === "sqrt"
+ ? Math.sqrt(Math.max(0, transformed))
+ : transformed;
+ });
+ },
+ [chartScale],
+ );
+
useEffect(() => {
const fetchData = async () => {
if (!location) return;
@@ -181,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),
@@ -200,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;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -218,18 +244,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),
);
@@ -239,20 +272,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()
) {
@@ -267,6 +302,11 @@ const NHSNView = ({ location }) => {
setSearchParams,
]);
+ const handleSetSelectedColumns = useCallback((newCols) => {
+ hasInteractedRef.current = true;
+ setSelectedColumns(newCols);
+ }, []);
+
useEffect(() => {
if (data) setPlotRevision((p) => p + 1);
}, [data, selectedTarget]);
@@ -341,19 +381,10 @@ const NHSNView = ({ location }) => {
return;
}
- const isPercentage = selectedTarget && selectedTarget.includes("%");
- const traces = selectedColumns.map((column) => {
- const yValues = data.series[column];
- const processedYValues = isPercentage
- ? yValues.map((val) =>
- val !== null && val !== undefined ? val * 100 : val,
- )
- : yValues;
- return {
- x: data.series.dates,
- y: processedYValues,
- };
- });
+ const currentTraces = selectedColumns.map((column) => ({
+ x: data.series.dates,
+ y: getProcessedYValues(column, data.series[column]),
+ }));
const currentXRange = xAxisRange || defaultRange;
@@ -362,15 +393,8 @@ const NHSNView = ({ location }) => {
return;
}
- const newYRange = calculateYRange(traces, currentXRange);
- if (chartScale === "sqrt" && newYRange) {
- const [minY, maxY] = newYRange;
- const sqrtMin = Math.sqrt(Math.max(0, minY));
- const sqrtMax = Math.sqrt(Math.max(0, maxY));
- setYAxisRange([sqrtMin, sqrtMax]);
- } else {
- setYAxisRange(newYRange);
- }
+ const newYRange = calculateYRange(currentTraces, currentXRange);
+ setYAxisRange(newYRange);
}, [
data,
selectedColumns,
@@ -378,7 +402,7 @@ const NHSNView = ({ location }) => {
selectedTarget,
defaultRange,
calculateYRange,
- chartScale,
+ getProcessedYValues,
]);
const handleRelayout = useCallback(
@@ -399,21 +423,12 @@ const NHSNView = ({ location }) => {
const rawTraces = useMemo(() => {
if (!data) return [];
- const isPercentage = selectedTarget && selectedTarget.includes("%");
- return selectedColumns.map((column) => {
- const yValues = data.series[column];
- const processedYValues = isPercentage
- ? yValues.map((val) =>
- val !== null && val !== undefined ? val * 100 : val,
- )
- : yValues;
- return {
- x: data.series.dates,
- y: processedYValues,
- name: column,
- };
- });
- }, [data, selectedTarget, selectedColumns]);
+ return selectedColumns.map((column) => ({
+ x: data.series.dates,
+ y: getProcessedYValues(column, data.series[column]),
+ name: column,
+ }));
+ }, [data, getProcessedYValues, selectedColumns]);
const rawYRange = useMemo(() => getYRangeFromTraces(rawTraces), [rawTraces]);
@@ -424,22 +439,29 @@ const NHSNView = ({ location }) => {
const traces = useMemo(() => {
if (!data) return [];
- const applySqrt = chartScale === "sqrt";
-
- return rawTraces.map((trace) => {
- const columnIndex = filteredAvailableColumns.indexOf(trace.name);
- const transformedY = applySqrt
- ? trace.y.map((val) =>
- val === null || val === undefined
- ? val
- : Math.sqrt(Math.max(0, val)),
- )
- : trace.y;
+ 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);
+ const processedY = getProcessedYValues(
+ columnName,
+ data.series[columnName],
+ );
return {
- x: trace.x,
- y: transformedY,
- name: trace.name,
+ x: data.series.dates,
+ y: processedY,
+ name: columnName,
type: "scatter",
mode: "lines+markers",
line: {
@@ -449,7 +471,7 @@ const NHSNView = ({ location }) => {
marker: { size: 6 },
};
});
- }, [data, rawTraces, filteredAvailableColumns, chartScale]);
+ }, [data, selectedColumns, filteredAvailableColumns, getProcessedYValues]);
const layout = useMemo(
() => ({
@@ -481,7 +503,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:
@@ -505,6 +530,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,
@@ -543,16 +583,10 @@ const NHSNView = ({ location }) => {
const newDefaultRange = getDefaultXRange();
if (!newDefaultRange || newDefaultRange[0] === null) return;
- const isPct = selectedTarget && selectedTarget.includes("%");
- const currentTraces = selectedColumns.map((column) => {
- const yValues = data.series[column];
- const pYValues = isPct
- ? yValues.map((val) =>
- val !== null && val !== undefined ? val * 100 : val,
- )
- : yValues;
- return { x: data.series.dates, y: pYValues };
- });
+ const currentTraces = selectedColumns.map((column) => ({
+ x: data.series.dates,
+ y: getProcessedYValues(column, data.series[column]),
+ }));
const newYRange = calculateYRange(currentTraces, newDefaultRange);
@@ -569,7 +603,13 @@ const NHSNView = ({ location }) => {
},
],
}),
- [data, selectedTarget, selectedColumns, getDefaultXRange, calculateYRange],
+ [
+ data,
+ selectedColumns,
+ getDefaultXRange,
+ calculateYRange,
+ getProcessedYValues,
+ ],
);
if (loading)
@@ -618,11 +658,14 @@ const NHSNView = ({ location }) => {
{
+ hasInteractedRef.current = false;
+ setSelectedTarget(val);
+ }}
loading={loading}
/>
diff --git a/app/src/config/datasets.js b/app/src/config/datasets.js
index c9c8fd36..0cff87d9 100644
--- a/app/src/config/datasets.js
+++ b/app/src/config/datasets.js
@@ -48,8 +48,8 @@ export const DATASETS = {
},
nhsn: {
shortName: "nhsn",
- fullName: "NHSN Respiratory Data",
- titleName: "NHSN Respiratory Data",
+ fullName: "NHSN Surveillance Data",
+ titleName: "NHSN Surveillance Data",
views: [{ key: "all", label: "All Data", value: "nhsnall" }],
defaultView: "nhsnall",
defaultColumn: "Number of Adult COVID-19 Admissions, 18-49 years",
diff --git a/app/src/hooks/extractPlotDataFromURL.js b/app/src/hooks/extractPlotDataFromURL.js
new file mode 100644
index 00000000..85725d04
--- /dev/null
+++ b/app/src/hooks/extractPlotDataFromURL.js
@@ -0,0 +1,237 @@
+import { savePlot } from "../utils/plotStorage";
+
+/**
+ * Parses the current URL and viewType to extract a serialized state
+ * for the "My Plots" persistence feature.
+ * * @param {string} viewType - The current active view
+ * @param {string} href - The full window.location.href string
+ * @returns {Object} The processed plotData object
+ */
+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 fileName = "";
+ let fullDataPath = "";
+
+ // plot settings
+ let location = "";
+ let target = "";
+ let columns = [];
+ let models = [];
+ let dates = [];
+ let scale = "";
+ let intervals = [];
+ let viewDisplayName = "";
+
+ // forecast views set date dynamically if it is the default date (not present in URL)
+ switch (viewType) {
+ case "covid_forecasts": {
+ dataSuffix = "covid19";
+ location = params.has("location") ? params.get("location") : "US";
+ fileName = `${location}_${dataSuffix}.json`;
+ fullDataPath = `covid19forecasthub/${fileName}`;
+ target = params.has("covid_target")
+ ? params.get("covid_target")
+ : "wk inc covid hosp";
+ const covidModelsString = params.get("covid_models");
+ models = covidModelsString
+ ? covidModelsString.split(",")
+ : ["CovidHub-ensemble"];
+ const covidDatesString = params.get("covid_dates");
+ if (covidDatesString) {
+ dates = covidDatesString.split(",");
+ } else {
+ const availableDates = Object.keys(data?.forecasts || {});
+ if (availableDates.length > 0) {
+ const mostRecent = availableDates.sort().pop();
+ dates = [mostRecent];
+ } else {
+ throw new Error(
+ `Unable to extract plot data: No dates found in URL and no forecast data available for ${viewType}.`,
+ );
+ }
+ }
+ scale = params.has("scale") ? params.get("scale") : "linear";
+ const covidIntervalsString = params.get("intervals");
+ intervals = covidIntervalsString
+ ? covidIntervalsString.split(",")
+ : ["median", "ci50", "ci95"];
+ viewDisplayName = "COVID-19 Forecasts";
+ break;
+ }
+
+ case "flu_forecasts":
+ case "fludetailed": {
+ dataSuffix = "flu";
+ location = params.has("location") ? params.get("location") : "US";
+ fileName = `${location}_${dataSuffix}.json`;
+ fullDataPath = `flusight/${fileName}`;
+ target = params.has("flu_target")
+ ? params.get("flu_target")
+ : "wk inc flu hosp";
+ const fluModelsString = params.get("flu_models");
+ models = fluModelsString
+ ? fluModelsString.split(",")
+ : ["FluSight-ensemble"];
+ const fluDatesString = params.get("flu_dates");
+ if (fluDatesString) {
+ dates = fluDatesString.split(",");
+ } else {
+ const availableDates = Object.keys(data?.forecasts || {});
+ if (availableDates.length > 0) {
+ const mostRecent = availableDates.sort().pop();
+ dates = [mostRecent];
+ } else {
+ throw new Error(
+ `Unable to extract plot data: No dates found in URL and no forecast data available for ${viewType}.`,
+ );
+ }
+ }
+ scale = params.has("scale") ? params.get("scale") : "linear";
+ const fluIntervalsString = params.get("intervals");
+ intervals = fluIntervalsString
+ ? fluIntervalsString.split(",")
+ : ["median", "ci50", "ci95"];
+ viewDisplayName = "Flu Forecasts";
+ break;
+ }
+
+ case "rsv_forecasts": {
+ dataSuffix = "rsv";
+ location = params.has("location") ? params.get("location") : "US";
+ fileName = `${location}_${dataSuffix}.json`;
+ fullDataPath = `rsvforecasthub/${fileName}`;
+ target = params.has("rsv_target")
+ ? params.get("rsv_target")
+ : "wk inc rsv hosp";
+ const rsvModelsString = params.get("rsv_models");
+ models = rsvModelsString
+ ? rsvModelsString.split(",")
+ : ["RSVHub-ensemble"];
+ const rsvDatesString = params.get("rsv_dates");
+ if (rsvDatesString) {
+ dates = rsvDatesString.split(",");
+ } else {
+ const availableDates = Object.keys(data?.forecasts || {});
+ if (availableDates.length > 0) {
+ const mostRecent = availableDates.sort().pop();
+ dates = [mostRecent];
+ } else {
+ throw new Error(
+ `Unable to extract plot data: No dates found in URL and no forecast data available for ${viewType}.`,
+ );
+ }
+ }
+ scale = params.has("scale") ? params.get("scale") : "linear";
+ const rsvIntervalsString = params.get("intervals");
+ intervals = rsvIntervalsString
+ ? rsvIntervalsString.split(",")
+ : ["median", "ci50", "ci95"];
+ viewDisplayName = "RSV Forecasts";
+ break;
+ }
+
+ case "metrocast_forecasts": {
+ dataSuffix = "flu_metrocast";
+ location = params.has("location") ? params.get("location") : "colorado";
+ fileName = `${location}_${dataSuffix}.json`;
+ 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 {
+ const availableDates = Object.keys(data?.forecasts || {});
+ if (availableDates.length > 0) {
+ const mostRecent = availableDates.sort().pop();
+ dates = [mostRecent];
+ } else {
+ throw new Error(
+ `Unable to extract plot data: No dates found in URL and no forecast data available for ${viewType}.`,
+ );
+ }
+ }
+ scale = params.has("scale") ? params.get("scale") : "linear";
+ const metrocastIntervalsString = params.get("intervals");
+ intervals = metrocastIntervalsString
+ ? metrocastIntervalsString.split(",")
+ : ["median", "ci50", "ci95"];
+ viewDisplayName = "Flu Forecasts";
+ break;
+ }
+
+ case "nhsnall": {
+ dataSuffix = "nhsn";
+ location = params.has("location") ? params.get("location") : "US";
+ fileName = `${location}_${dataSuffix}.json`;
+ fullDataPath = `nhsn/${fileName}`;
+ target = params.has("nhsn_target")
+ ? params.get("nhsn_target")
+ : "Hospital Admissions (count)";
+ const nhsnColsFromUrl = params.getAll("nhsn_cols");
+ const defaultColumnSlugsByTarget = {
+ "Hospital Admissions (count)": [
+ "totalconfc19newadm",
+ "totalconfflunewadm",
+ "totalconfrsvnewadm",
+ ],
+ "Hospital Admissions (rate)": [
+ "totalconfc19newadmper100k",
+ "totalconfflunewadmper100k",
+ "totalconfrsvnewadmper100k",
+ ],
+ "Hospital Admissions (%)": [
+ "pctconfc19newadmadult",
+ "pctconfflunewadmadult",
+ "pctconfrsvnewadmadult",
+ ],
+ "Bed Capacity (count)": ["numinptbeds", "numinptbedsocc"],
+ "Bed Capacity (%)": ["pctinptbedsocc"],
+ };
+ if (nhsnColsFromUrl.length > 0) {
+ columns = nhsnColsFromUrl;
+ // default columns are dependent on target (aka column unit)
+ } else {
+ columns = defaultColumnSlugsByTarget[target] || [];
+ }
+ dates = [currentDate];
+ scale = params.has("scale") ? params.get("scale") : "linear";
+ viewDisplayName = "NHSN Data";
+ break;
+ }
+
+ default:
+ throw new Error(`Unknown view type: ${viewType}`);
+ }
+
+ const plotData = {
+ viewType: viewType,
+ fullUrl: href,
+ viewDisplayName,
+ id,
+ currentDate,
+ dataSuffix,
+ fileName,
+ fullDataPath,
+ settings: {
+ location,
+ target,
+ columns,
+ models,
+ dates,
+ scale,
+ intervals,
+ },
+ };
+
+ savePlot(plotData);
+
+ return plotData;
+};
diff --git a/app/src/utils/mapUtils.js b/app/src/utils/mapUtils.js
index af654940..a52bb2de 100644
--- a/app/src/utils/mapUtils.js
+++ b/app/src/utils/mapUtils.js
@@ -72,7 +72,7 @@ export const nhsnTargetsToColumnsMap = {
"Number of Pediatric RSV Admissions, 5-17 years",
"Number of RSV Admissions, unknown age",
],
- "Hospital Admissions (rates)": [
+ "Hospital Admissions (rate)": [
"Total Number of Adult COVID-19 Admissions per 100,000 population",
"Total Number of Adult Influenza Admissions per 100,000 population",
"Total Number of Adult RSV Admissions per 100,000 population",
@@ -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":
diff --git a/app/src/utils/plotStorage.js b/app/src/utils/plotStorage.js
new file mode 100644
index 00000000..1470817d
--- /dev/null
+++ b/app/src/utils/plotStorage.js
@@ -0,0 +1,113 @@
+/**
+ * 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;
+ }
+}
+
+/**
+ * Validates the plot object against the schema defined in extractPlotData.js
+ */
+function isValidPlot(plot) {
+ return (
+ plot &&
+ typeof plot === "object" &&
+ typeof plot.id === "string" &&
+ typeof plot.fullUrl === "string" &&
+ typeof plot.viewType === "string" &&
+ typeof plot.viewDisplayName === "string" &&
+ typeof plot.fullDataPath === "string" &&
+ plot.settings &&
+ typeof plot.settings === "object" &&
+ typeof plot.settings.location === "string" &&
+ typeof plot.settings.target === "string" &&
+ Array.isArray(plot.settings.dates)
+ );
+}
+
+/**
+ * Get all stored saved plots
+ */
+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 [];
+
+ // Filters out any old/legacy schemas that don't match the new extractPlotData structure
+ return parsed.filter((plot) => isValidPlot(plot));
+ } catch (error) {
+ console.error("Error reading plots from localStorage:", error);
+ return [];
+ }
+}
+
+/**
+ * Save a plot to My Plots
+ */
+export function savePlot(plotData) {
+ if (!isLocalStorageAvailable()) return false;
+
+ try {
+ const plots = getSavedPlots();
+
+ // Check for duplicates based on URL to prevent clutter
+ const existingIndex = plots.findIndex(
+ (p) => p.fullUrl === plotData.fullUrl,
+ );
+
+ const plotEntry = {
+ ...plotData,
+ // Ensure we have a UUID and a timestamp for sorting
+ id: plotData.id || crypto.randomUUID(),
+ savedAt: new Date().toISOString(),
+ };
+
+ if (existingIndex >= 0) {
+ // Update existing entry
+ plots[existingIndex] = plotEntry;
+ } else {
+ // Add new entry 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.");
+ } else {
+ console.error("Failed to save plot:", error);
+ }
+ 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 {
+ 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";
/**
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/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
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
diff --git a/scripts/processors/covid19_forecast_hub.py b/scripts/processors/covid19_forecast_hub.py
index d5ab5e2c..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="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 206f4405..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="date",
ground_truth_min_date=pd.Timestamp("2023-10-01"),
)
super().__init__(