diff --git a/app/src/components/Announcement.jsx b/app/src/components/Announcement.jsx
new file mode 100644
index 00000000..ff6c198a
--- /dev/null
+++ b/app/src/components/Announcement.jsx
@@ -0,0 +1,83 @@
+import { useState } from 'react';
+import { Paper, Group, Text, ThemeIcon, Stack, CloseButton } from '@mantine/core';
+import { IconSpeakerphone, IconAlertSquareRounded } from '@tabler/icons-react';
+
+// Announcement component params:
+// `id` | unique ID for the announcement
+// `startDate` | date for announcement to start being displayed
+// `endDate` | date for announcement to stop being displayed
+// `text` | text for the announcement
+// `announcementType` | alert or update
+const Announcement = ({ id, startDate, endDate, text, announcementType }) => {
+ const storageKey = `dismissed-announcement-${id}`;
+
+ const [dismissed, setDismissed] = useState(() => {
+ if (typeof window === 'undefined') return false;
+ return localStorage.getItem(storageKey) === 'true';
+ });
+
+ const currentDate = new Date();
+ const start = new Date(startDate);
+ const end = new Date(endDate);
+
+ const validTypes = ['update', 'alert'];
+ if (!validTypes.includes(announcementType)) {
+ console.error(`[Announcement Error]: Invalid type "${announcementType}".`);
+ }
+
+ const isVisible = currentDate >= start && currentDate <= end;
+ if (!isVisible || dismissed) return null;
+
+ const handleDismiss = () => {
+ localStorage.setItem(storageKey, 'true');
+ setDismissed(true);
+ };
+
+ const isAlert = announcementType === 'alert';
+
+ return (
+
+
+
+
+
+ {isAlert ? : }
+
+
+ {isAlert ? 'Alert' : 'Update'}: {text}
+
+
+
+
+
+
+
+ );
+};
+
+export default Announcement;
\ No newline at end of file
diff --git a/app/src/components/COVID19View.jsx b/app/src/components/COVID19View.jsx
index 8daa93e4..c9996ef5 100644
--- a/app/src/components/COVID19View.jsx
+++ b/app/src/components/COVID19View.jsx
@@ -14,6 +14,7 @@ const COVID19View = ({ data, metadata, selectedDates, selectedModels, models, se
const [xAxisRange, setXAxisRange] = useState(null); // Track user's zoom/rangeslider selection
const plotRef = useRef(null);
const isResettingRef = useRef(false); // Flag to prevent capturing programmatic resets
+ const stateName = data?.metadata?.location_name;
// This allows the "frozen" Plotly button to access fresh data
const getDefaultRangeRef = useRef(getDefaultRange);
@@ -69,7 +70,8 @@ const COVID19View = ({ data, metadata, selectedDates, selectedModels, models, se
type: 'scatter',
mode: 'lines+markers',
line: { color: 'black', width: 2, dash: 'dash' },
- marker: { size: 4, color: 'black' }
+ marker: { size: 4, color: 'black' },
+ hovertemplate: 'Observed Data
Date: %{x}
Value: %{y}'
};
const modelTraces = selectedModels.flatMap(model =>
@@ -79,10 +81,13 @@ const COVID19View = ({ data, metadata, selectedDates, selectedModels, models, se
if (!forecast || forecast.type !== 'quantile') return [];
const forecastDates = [], medianValues = [], ci95Upper = [], ci95Lower = [], ci50Upper = [], ci50Lower = [];
+ const hoverTexts = [];
+
const sortedPredictions = Object.values(forecast.predictions || {}).sort((a, b) => new Date(a.date) - new Date(b.date));
sortedPredictions.forEach((pred) => {
- forecastDates.push(pred.date);
+ const pointDate = pred.date;
+ forecastDates.push(pointDate);
const { quantiles = [], values = [] } = pred;
const findValue = (q) => {
const index = quantiles.indexOf(q);
@@ -101,6 +106,21 @@ const COVID19View = ({ data, metadata, selectedDates, selectedModels, models, se
medianValues.push(val_50);
ci50Upper.push(val_75);
ci95Upper.push(val_975);
+
+ // Build dynamic hover string
+ const formattedMedian = val_50.toLocaleString(undefined, { maximumFractionDigits: 2 });
+ const formatted50 = `${val_25.toLocaleString(undefined, { maximumFractionDigits: 2 })} - ${val_75.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
+ const formatted95 = `${val_025.toLocaleString(undefined, { maximumFractionDigits: 2 })} - ${val_975.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
+
+ hoverTexts.push(
+ `${model}
` +
+ `Date: ${pointDate}
` +
+ `Median: ${formattedMedian}
` +
+ `50% CI: [${formatted50}]
` +
+ `95% CI: [${formatted95}]
` +
+ `predicted as of ${date}` +
+ ``
+ );
} else {
console.warn(`Missing quantiles for model ${model}, date ${date}, target ${selectedTarget}, prediction date ${pred.date}`);
}
@@ -114,7 +134,24 @@ const COVID19View = ({ data, metadata, selectedDates, selectedModels, models, se
return [
{ x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci95Upper, ...ci95Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}10`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 95% CI`, hoverinfo: 'none', legendgroup: model },
{ x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci50Upper, ...ci50Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}30`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 50% CI`, hoverinfo: 'none', legendgroup: model },
- { x: forecastDates, y: medianValues, name: model, type: 'scatter', mode: 'lines+markers', line: { color: modelColor, width: 2, dash: 'solid' }, marker: { size: 6, color: modelColor }, showlegend: isFirstDate, legendgroup: model }
+ {
+ x: forecastDates,
+ y: medianValues,
+ name: model,
+ type: 'scatter',
+ mode: 'lines+markers',
+ line: { color: modelColor, width: 2, dash: 'solid' },
+ marker: { size: 6, color: modelColor },
+ showlegend: isFirstDate,
+ legendgroup: model,
+ text: hoverTexts,
+ hovertemplate: '%{text}',
+ hoverlabel: {
+ bgcolor: modelColor,
+ font: { color: '#ffffff' },
+ bordercolor: '#ffffff'
+ }
+ }
];
})
);
@@ -195,7 +232,7 @@ const COVID19View = ({ data, metadata, selectedDates, selectedModels, models, se
size: 10
}
},
- hovermode: 'x unified',
+ hovermode: 'closest',
dragmode: false,
margin: { l: 60, r: 30, t: 30, b: 30 },
xaxis: {
@@ -300,6 +337,9 @@ const COVID19View = ({ data, metadata, selectedDates, selectedModels, models, se
onRelayout={handlePlotUpdate}
/>
+
+ {stateName}
+
{
if (selectedDates.length === 0) return null;
- // Find the latest date
return selectedDates.slice().sort().pop();
}, [selectedDates]);
@@ -61,7 +60,7 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel
}, []);
const projectionsData = useMemo(() => {
- const targetForProjections = (viewType === 'flu' || viewType === 'flu_projs')
+ const targetForProjections = (viewType === 'flu' || viewType === 'flu_forecasts')
? selectedTarget
: 'wk inc flu hosp';
@@ -81,7 +80,8 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel
type: 'scatter',
mode: 'lines+markers',
line: { color: 'black', width: 2, dash: 'dash' },
- marker: { size: 4, color: 'black' }
+ marker: { size: 4, color: 'black' },
+ hovertemplate: 'Observed Data
Date: %{x}
Value: %{y}'
};
const modelTraces = selectedModels.flatMap(model =>
@@ -91,9 +91,13 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel
if (!forecast) return [];
const forecastDates = [], medianValues = [], ci95Upper = [], ci95Lower = [], ci50Upper = [], ci50Lower = [];
+ const hoverTexts = [];
+
const sortedPredictions = Object.entries(forecast.predictions || {}).sort((a, b) => new Date(a[1].date) - new Date(b[1].date));
+
sortedPredictions.forEach(([, pred]) => {
- forecastDates.push(pred.date);
+ const pointDate = pred.date;
+ forecastDates.push(pointDate);
if (forecast.type !== 'quantile') return;
const { quantiles = [], values = [] } = pred;
@@ -102,26 +106,63 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel
return idx !== -1 ? values[idx] : 0;
};
- ci95Lower.push(findValue(0.025));
- ci50Lower.push(findValue(0.25));
- medianValues.push(findValue(0.5));
- ci50Upper.push(findValue(0.75));
- ci95Upper.push(findValue(0.975));
+ const v025 = findValue(0.025);
+ const v25 = findValue(0.25);
+ const v50 = findValue(0.5);
+ const v75 = findValue(0.75);
+ const v975 = findValue(0.975);
+
+ ci95Lower.push(v025);
+ ci50Lower.push(v25);
+ medianValues.push(v50);
+ ci50Upper.push(v75);
+ ci95Upper.push(v975);
+
+ const formattedMedian = v50.toLocaleString(undefined, { maximumFractionDigits: 2 });
+ const formatted50 = `${v25.toLocaleString(undefined, { maximumFractionDigits: 2 })} - ${v75.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
+ const formatted95 = `${v025.toLocaleString(undefined, { maximumFractionDigits: 2 })} - ${v975.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
+
+ hoverTexts.push(
+ `${model}
` +
+ `Date: ${pointDate}
` +
+ `Median: ${formattedMedian}
` +
+ `50% CI: [${formatted50}]
` +
+ `95% CI: [${formatted95}]
` +
+ `predicted as of ${date}` +
+ ``
+ );
});
+
const modelColor = MODEL_COLORS[selectedModels.indexOf(model) % MODEL_COLORS.length];
const isFirstDate = dateIndex === 0;
return [
- { x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci95Upper, ...ci95Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}10`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 95% CI`, legendgroup: model },
- { x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci50Upper, ...ci50Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}30`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 50% CI`, legendgroup: model },
- { x: forecastDates, y: medianValues, name: model, type: 'scatter', mode: 'lines+markers', line: { color: modelColor, width: 2, dash: 'solid' }, marker: { size: 6, color: modelColor }, showlegend: isFirstDate, legendgroup: model }
+ { x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci95Upper, ...ci95Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}10`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 95% CI`, legendgroup: model, hoverinfo: 'none' },
+ { x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci50Upper, ...ci50Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}30`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 50% CI`, legendgroup: model, hoverinfo: 'none' },
+ {
+ x: forecastDates,
+ y: medianValues,
+ name: model,
+ type: 'scatter',
+ mode: 'lines+markers',
+ line: { color: modelColor, width: 2, dash: 'solid' },
+ marker: { size: 6, color: modelColor },
+ showlegend: isFirstDate,
+ legendgroup: model,
+ text: hoverTexts,
+ hovertemplate: '%{text}',
+ hoverlabel: {
+ bgcolor: modelColor,
+ font: { color: '#ffffff' },
+ bordercolor: '#ffffff'
+ }
+ }
];
})
);
return [groundTruthTrace, ...modelTraces];
}, [groundTruth, forecasts, selectedDates, selectedModels, viewType, selectedTarget]);
- // Update Refs on every render
useEffect(() => {
getDefaultRangeRef.current = getDefaultRange;
projectionsDataRef.current = projectionsData;
@@ -140,7 +181,19 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel
category: cat.replace('_', '
'),
value: (horizon0.probabilities[horizon0.categories.indexOf(cat)] || 0) * 100
}));
- return { name: `${model} (${lastSelectedDate})`, y: orderedData.map(d => d.category), x: orderedData.map(d => d.value), type: 'bar', orientation: 'h', marker: { color: modelColor }, showlegend: true, legendgroup: 'histogram', xaxis: 'x2', yaxis: 'y2' };
+ return {
+ name: `${model} (${lastSelectedDate})`,
+ y: orderedData.map(d => d.category),
+ x: orderedData.map(d => d.value),
+ type: 'bar',
+ orientation: 'h',
+ marker: { color: modelColor },
+ showlegend: true,
+ legendgroup: 'histogram',
+ xaxis: 'x2',
+ yaxis: 'y2',
+ hovertemplate: '%{fullData.name}
%{y}: %{x:.1f}%'
+ };
}).filter(Boolean);
}, [forecasts, selectedDates, selectedModels, lastSelectedDate]);
@@ -157,19 +210,17 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel
return [...projectionsData, ...histogramTraces];
}, [projectionsData, rateChangeData, viewType]);
- // activeModel logic for flu_projs and flu_detailed views
const activeModels = useMemo(() => {
const activeModelSet = new Set();
- // Don't run this logic if we are in peak view
if (viewType === 'flu_peak' || !forecasts || !selectedDates.length) {
return activeModelSet;
}
- const targetForProjections = (viewType === 'flu' || viewType === 'flu_projs')
+ const targetForProjections = (viewType === 'flu' || viewType === 'flu_forecasts')
? selectedTarget
: 'wk inc flu hosp';
- if ((viewType === 'flu' || viewType === 'flu_projs') && !targetForProjections) return activeModelSet;
+ if ((viewType === 'flu' || viewType === 'flu_forecasts') && !targetForProjections) return activeModelSet;
selectedDates.forEach(date => {
const forecastsForDate = forecasts[date];
@@ -226,7 +277,7 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel
if (JSON.stringify(newXRange) !== JSON.stringify(xAxisRange)) {
setXAxisRange(newXRange);
}
- }, 100); // 100ms debounce window
+ }, 100);
}
}, [xAxisRange]);
@@ -260,7 +311,7 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel
size: 10
}
},
- hovermode: 'x unified',
+ hovermode: 'closest',
dragmode: false,
margin: { l: 60, r: 30, t: 30, b: 30 },
xaxis: {
@@ -301,16 +352,16 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel
}
};
}),
+ hoverlabel: { namelength: -1 },
...(viewType === 'fludetailed' ? {
xaxis2: {
title: {
text: `displaying date ${lastSelectedDate || 'N/A'}`,
font: {
family: 'Arial, sans-serif',
- size: 13,
+ size: 13,
color: '#1f77b4'
},
- // Add space below the tick labels
standoff: 10
},
domain: [0.85, 1],
@@ -407,6 +458,9 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel
onRelayout={(figure) => handlePlotUpdate(figure)}
/>
+
{
- const [yAxisRange, setYAxisRange] = useState(null);
- const [xAxisRange, setXAxisRange] = useState(null);
- const plotRef = useRef(null);
- const isResettingRef = useRef(false);
-
- const getDefaultRangeRef = useRef(getDefaultRange);
- const projectionsDataRef = useRef([]);
+const METRO_STATE_MAP = {
+ 'Colorado': 'CO', 'Georgia': 'GA', 'Indiana': 'IN', 'Maine': 'ME',
+ 'Maryland': 'MD', 'Massachusetts': 'MA', 'Minnesota': 'MN',
+ 'South Carolina': 'SC', 'Texas': 'TX', 'Utah': 'UT',
+ 'Virginia': 'VA', 'North Carolina': 'NC', 'Oregon': 'OR'
+};
- const { colorScheme } = useMantineColorScheme();
- const groundTruth = data?.ground_truth;
- const forecasts = data?.forecasts;
+const MetroPlotCard = ({
+ locationData,
+ title,
+ isSmall = false,
+ colorScheme,
+ selectedTarget,
+ selectedModels,
+ selectedDates,
+ getDefaultRange,
+ xAxisRange,
+ setXAxisRange
+}) => {
+ const [yAxisRange, setYAxisRange] = useState(null);
+ const groundTruth = locationData?.ground_truth;
+ const forecasts = locationData?.forecasts;
const calculateYRange = useCallback((plotData, xRange) => {
- if (!plotData || !xRange || !Array.isArray(plotData) || plotData.length === 0 || !selectedTarget) return null;
- let minY = Infinity;
- let maxY = -Infinity;
+ if (!plotData?.length || !xRange || !selectedTarget) return null;
+ let minY = Infinity, maxY = -Infinity;
const [startX, endX] = xRange;
- const startDate = new Date(startX);
- const endDate = new Date(endX);
+ const start = new Date(startX), end = new Date(endX);
plotData.forEach(trace => {
if (!trace.x || !trace.y) return;
for (let i = 0; i < trace.x.length; i++) {
- const pointDate = new Date(trace.x[i]);
- if (pointDate >= startDate && pointDate <= endDate) {
- const value = Number(trace.y[i]);
- if (!isNaN(value)) {
- minY = Math.min(minY, value);
- maxY = Math.max(maxY, value);
- }
+ const d = new Date(trace.x[i]);
+ if (d >= start && d <= end) {
+ const v = Number(trace.y[i]);
+ if (!isNaN(v)) { minY = Math.min(minY, v); maxY = Math.max(maxY, v); }
}
}
});
-
- if (minY !== Infinity && maxY !== -Infinity) {
- const padding = maxY * (CHART_CONSTANTS.Y_AXIS_PADDING_PERCENT / 100);
- return [Math.max(0, minY - padding), maxY + padding];
- }
- return null;
+ if (minY === Infinity) return null;
+ const pad = maxY * (CHART_CONSTANTS.Y_AXIS_PADDING_PERCENT / 100);
+ return [Math.max(0, minY - pad), maxY + pad];
}, [selectedTarget]);
const projectionsData = useMemo(() => {
- if (!groundTruth || !forecasts || selectedDates.length === 0 || !selectedTarget) return [];
-
- const groundTruthValues = groundTruth[selectedTarget];
- if (!groundTruthValues) return [];
-
- const groundTruthTrace = {
- x: groundTruth.dates || [],
- y: groundTruthValues,
- name: 'Observed',
- type: 'scatter',
- mode: 'lines+markers',
- line: { color: 'black', width: 2, dash: 'dash' },
- marker: { size: 4, color: 'black' }
- };
+ if (!groundTruth || !forecasts || !selectedTarget) return [];
+ const gtValues = groundTruth[selectedTarget];
+ if (!gtValues) return [];
- const modelTraces = selectedModels.flatMap(model =>
- selectedDates.flatMap((date, dateIndex) => {
- const forecastsForDate = forecasts[date] || {};
- const forecast = forecastsForDate[selectedTarget]?.[model];
- if (!forecast || forecast.type !== 'quantile') return [];
-
- const forecastDates = [], medianValues = [], ci95Upper = [], ci95Lower = [], ci50Upper = [], ci50Lower = [];
- const sortedHorizons = Object.keys(forecast.predictions || {}).sort((a, b) => Number(a) - Number(b));
-
- sortedHorizons.forEach((h) => {
- const pred = forecast.predictions[h];
- forecastDates.push(pred.date);
- const { quantiles = [], values = [] } = pred;
-
- const findValue = (q) => {
- const idx = quantiles.indexOf(q);
- return idx !== -1 ? values[idx] : null;
- };
+ const traces = [{
+ x: groundTruth.dates || [], y: gtValues, name: 'Observed', type: 'scatter',
+ mode: 'lines+markers', line: { color: 'black', width: isSmall ? 1 : 2, dash: 'dash' },
+ marker: { size: isSmall ? 2 : 4, color: 'black' },
+ hovertemplate: 'Ground Truth Data
Date: %{x}
Value: %{y:.2f}%'
+ }];
+
+ selectedModels.forEach(model => {
+ selectedDates.forEach((date, dateIdx) => {
+ const forecast = forecasts[date]?.[selectedTarget]?.[model];
+ if (forecast?.type !== 'quantile') return;
+
+ const fDates = [], median = [], q95U = [], q95L = [], q50U = [], q50L = [];
+ const hoverTexts = [];
+
+ const sorted = Object.keys(forecast.predictions || {}).sort((a, b) => Number(a) - Number(b));
- const v025 = findValue(0.025), v25 = findValue(0.25), v50 = findValue(0.5), v75 = findValue(0.75), v975 = findValue(0.975);
+ sorted.forEach(h => {
+ const p = forecast.predictions[h];
+ const pointDate = p.date;
+ fDates.push(pointDate);
+ const findQ = (q) => {
+ const i = p.quantiles.indexOf(q);
+ return i !== -1 ? p.values[i] : null;
+ };
+
+ const v50 = findQ(0.5);
if (v50 !== null) {
- medianValues.push(v50);
- ci95Lower.push(v025 ?? v50);
- ci50Lower.push(v25 ?? v50);
- ci50Upper.push(v75 ?? v50);
- ci95Upper.push(v975 ?? v50);
+ median.push(v50);
+ const v025 = findQ(0.025) ?? v50;
+ const v25 = findQ(0.25) ?? v50;
+ const v75 = findQ(0.75) ?? v50;
+ const v975 = findQ(0.975) ?? v50;
+
+ q95L.push(v025);
+ q50L.push(v25);
+ q50U.push(v75);
+ q95U.push(v975);
+
+ const formattedMedian = v50.toFixed(2);
+ const formatted50 = `${v25.toFixed(2)} - ${v75.toFixed(2)}`;
+ const formatted95 = `${v025.toFixed(2)} - ${v975.toFixed(2)}`;
+
+ hoverTexts.push(
+ `${model}
` +
+ `Date: ${pointDate}
` +
+ `Median: ${formattedMedian}%
` +
+ `50% CI: [${formatted50}%]
` +
+ `95% CI: [${formatted95}%]
` +
+ `predicted as of ${date}` +
+ ``
+ );
}
});
- if (forecastDates.length === 0) return [];
+ const color = MODEL_COLORS[selectedModels.indexOf(model) % MODEL_COLORS.length];
+ traces.push(
+ { x: [...fDates, ...fDates.slice().reverse()], y: [...q95U, ...q95L.slice().reverse()], fill: 'toself', fillcolor: `${color}10`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', legendgroup: model, hoverinfo: 'skip' },
+ { x: [...fDates, ...fDates.slice().reverse()], y: [...q50U, ...q50L.slice().reverse()], fill: 'toself', fillcolor: `${color}30`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', legendgroup: model, hoverinfo: 'skip' },
+ {
+ x: fDates,
+ y: median,
+ name: model,
+ type: 'scatter',
+ mode: 'lines+markers',
+ line: { color, width: isSmall ? 1 : 2 },
+ marker: { size: isSmall ? 3 : 6, color },
+ showlegend: dateIdx === 0 && !isSmall,
+ legendgroup: model,
+ text: hoverTexts,
+ hovertemplate: '%{text}',
+ hoverlabel: {
+ bgcolor: color,
+ font: { color: '#ffffff' },
+ bordercolor: '#ffffff'
+ }
+ }
+ );
+ });
+ });
+ return traces;
+ }, [groundTruth, forecasts, selectedDates, selectedModels, selectedTarget, isSmall]);
- const modelColor = MODEL_COLORS[selectedModels.indexOf(model) % MODEL_COLORS.length];
- const isFirstDate = dateIndex === 0;
+ const defRange = useMemo(() => getDefaultRange(), [getDefaultRange]);
- return [
- { x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci95Upper, ...ci95Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}10`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 95% CI`, hoverinfo: 'none', legendgroup: model },
- { x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci50Upper, ...ci50Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}30`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 50% CI`, hoverinfo: 'none', legendgroup: model },
- { x: forecastDates, y: medianValues, name: model, type: 'scatter', mode: 'lines+markers', line: { color: modelColor, width: 2, dash: 'solid' }, marker: { size: 6, color: modelColor }, showlegend: isFirstDate, legendgroup: model }
- ];
- })
- );
+ useEffect(() => {
+ const range = xAxisRange || defRange;
+ setYAxisRange(calculateYRange(projectionsData, range));
+ }, [projectionsData, xAxisRange, defRange, calculateYRange]);
- return [groundTruthTrace, ...modelTraces];
- }, [groundTruth, forecasts, selectedDates, selectedModels, selectedTarget]);
+ const hasForecasts = projectionsData.length > 1;
- useEffect(() => {
- getDefaultRangeRef.current = getDefaultRange;
- projectionsDataRef.current = projectionsData;
- }, [getDefaultRange, projectionsData]);
+ const PlotContent = (
+ <>
+ {title}
+
+ {!hasForecasts && (
+
+ No forecast data for selection
+
+ )}
+
+ {
+ const longName = targetDisplayNameMap[selectedTarget];
+ return targetYAxisLabelMap[longName] || longName || selectedTarget || 'Value';
+ })(),
+ font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000', size: 12 }
+ } : undefined,
+ range: yAxisRange,
+ autorange: yAxisRange === null,
+ tickfont: { size: 9, color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' },
+ tickformat: '.2f',
+ ticksuffix: '%'
+ },
+ hovermode: isSmall ? false : 'closest',
+ hoverlabel: {
+ namelength: -1
+ },
+ shapes: selectedDates.map(d => ({ type: 'line', x0: d, x1: d, y0: 0, y1: 1, yref: 'paper', line: { color: 'red', width: 1, dash: 'dash' } }))
+ }}
+ config={{ displayModeBar: !isSmall, responsive: true, displaylogo: false, staticPlot: isSmall }}
+ onRelayout={(e) => {
+ if (e['xaxis.range']) { setXAxisRange(e['xaxis.range']); }
+ else if (e['xaxis.autorange']) { setXAxisRange(null); }
+ }}
+ />
+ >
+ );
+
+ return isSmall ? (
+
+ {PlotContent}
+ {
+ e.currentTarget.parentElement.style.transform = 'translateY(-4px)';
+ e.currentTarget.parentElement.style.borderColor = '#2563eb';
+ e.currentTarget.parentElement.style.boxShadow = '0 10px 15px -3px rgba(0, 0, 0, 0.1)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.parentElement.style.transform = 'translateY(0)';
+ e.currentTarget.parentElement.style.borderColor = '#dee2e6';
+ e.currentTarget.parentElement.style.boxShadow = 'none';
+ }}
+ />
+
+ ) : (
+
+ {PlotContent}
+
+ );
+};
+
+const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, windowSize, getDefaultRange, selectedTarget }) => {
+ const { colorScheme } = useMantineColorScheme();
+ const { handleLocationSelect } = useView();
+ const [childData, setChildData] = useState({});
+ const [loadingChildren, setLoadingChildren] = useState(false);
+ const [xAxisRange, setXAxisRange] = useState(null);
+
+ const stateName = data?.metadata?.location_name;
+ const stateCode = METRO_STATE_MAP[stateName];
+ const forecasts = data?.forecasts;
const activeModels = useMemo(() => {
const activeModelSet = new Set();
@@ -127,131 +276,103 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models,
return activeModelSet;
}, [forecasts, selectedDates, selectedTarget]);
- const defaultRange = useMemo(() => getDefaultRange(), [getDefaultRange]);
-
useEffect(() => { setXAxisRange(null); }, [selectedTarget]);
useEffect(() => {
- const currentXRange = xAxisRange || defaultRange;
- if (projectionsData.length > 0 && currentXRange) {
- setYAxisRange(calculateYRange(projectionsData, currentXRange));
- } else {
- setYAxisRange(null);
+ if (!stateCode || !metadata?.locations) {
+ setChildData({});
+ return;
}
- }, [projectionsData, xAxisRange, defaultRange, calculateYRange]);
- const handlePlotUpdate = useCallback((figure) => {
- if (isResettingRef.current) { isResettingRef.current = false; return; }
- if (figure?.['xaxis.range']) {
- const newXRange = figure['xaxis.range'];
- if (JSON.stringify(newXRange) !== JSON.stringify(xAxisRange)) setXAxisRange(newXRange);
- }
- }, [xAxisRange]);
-
- const layout = useMemo(() => ({
- width: Math.min(CHART_CONSTANTS.MAX_WIDTH, windowSize.width * CHART_CONSTANTS.WIDTH_RATIO),
- height: Math.min(CHART_CONSTANTS.MAX_HEIGHT, windowSize.height * CHART_CONSTANTS.HEIGHT_RATIO),
- autosize: true,
- template: colorScheme === 'dark' ? 'plotly_dark' : 'plotly_white',
- paper_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff',
- plot_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff',
- font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' },
- showlegend: selectedModels.length < 15,
- legend: {
- x: 0, y: 1, xanchor: 'left', yanchor: 'top',
- bgcolor: colorScheme === 'dark' ? 'rgba(26, 27, 30, 0.8)' : 'rgba(255, 255, 255, 0.8)',
- bordercolor: colorScheme === 'dark' ? '#444' : '#ccc',
- borderwidth: 1, font: { size: 10 }
- },
- hovermode: 'x unified',
- dragmode: false,
- margin: { l: 60, r: 30, t: 30, b: 30 },
- xaxis: {
- domain: [0, 1],
- rangeslider: { range: getDefaultRange(true) },
- rangeselector: {
- buttons: [
- {count: 1, label: '1m', step: 'month', stepmode: 'backward'},
- {count: 6, label: '6m', step: 'month', stepmode: 'backward'},
- {step: 'all', label: 'all'}
- ]
- },
- range: xAxisRange || defaultRange,
- showline: true, linewidth: 1,
- linecolor: colorScheme === 'dark' ? '#aaa' : '#444'
- },
- yaxis: {
- title: (() => {
- const longName = targetDisplayNameMap[selectedTarget];
- return targetYAxisLabelMap[longName] || longName || selectedTarget || 'Value';
- })(),
- range: yAxisRange,
- autorange: yAxisRange === null,
- },
- shapes: selectedDates.map(date => ({
- type: 'line', x0: date, x1: date, y0: 0, y1: 1, yref: 'paper',
- line: { color: 'red', width: 1, dash: 'dash' }
- }))
- }), [colorScheme, windowSize, defaultRange, selectedTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange]);
-
- const config = useMemo(() => ({
- responsive: true,
- displayModeBar: true,
- displaylogo: false,
- modeBarButtonsToRemove: ['resetScale2d', 'select2d', 'lasso2d'],
- modeBarButtonsToAdd: [{
- name: 'Reset view',
- icon: Plotly.Icons.home,
- click: function(gd) {
- const range = getDefaultRangeRef.current();
- if (!range) return;
- const newYRange = projectionsDataRef.current.length > 0 ? calculateYRange(projectionsDataRef.current, range) : null;
- isResettingRef.current = true;
- setXAxisRange(null);
- setYAxisRange(newYRange);
- Plotly.relayout(gd, { 'xaxis.range': range, 'yaxis.range': newYRange, 'yaxis.autorange': newYRange === null });
- }
- }]
- }), [calculateYRange]);
+ const fetchChildren = async () => {
+ setLoadingChildren(true);
+ const results = {};
+ const cityList = metadata.locations.filter(l => l.location_name.includes(`, ${stateCode}`));
+
+ await Promise.all(cityList.map(async (city) => {
+ try {
+ const res = await fetch(getDataPath(`flumetrocast/${city.abbreviation}_flu_metrocast.json`));
+ if (res.ok) { results[city.abbreviation] = await res.json(); }
+ } catch (e) { console.error(e); }
+ }));
+
+ setChildData(results);
+ setLoadingChildren(false);
+ };
- if (!selectedTarget) {
- return (
-
- Please select a target to view MetroCast data.
-
- );
- }
+ fetchChildren();
+ }, [stateCode, metadata, selectedTarget]);
+
+ if (!selectedTarget) return Please select a target.;
return (
-
+
-
+
-
-
+ {stateCode && (
+
+ {loadingChildren ? (
+
+ ) : (
+ <>
+
+ {Object.entries(childData).map(([abbr, cityData]) => (
+ handleLocationSelect(abbr)}
+ style={{ width: '100%' }}
+ >
+
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+
Note that forecasts should be interpreted with great caution and may not reliably predict rapid changes in disease trends.
-
{
- const index = selectedModels.indexOf(model);
- return MODEL_COLORS[index % MODEL_COLORS.length];
- }}
+ activeModels={activeModels}
+ getModelColor={(m, sel) => MODEL_COLORS[sel.indexOf(m) % MODEL_COLORS.length]}
/>
);
diff --git a/app/src/components/NHSNOverviewGraph.jsx b/app/src/components/NHSNOverviewGraph.jsx
new file mode 100644
index 00000000..c6503402
--- /dev/null
+++ b/app/src/components/NHSNOverviewGraph.jsx
@@ -0,0 +1,150 @@
+import { useMemo, useState, useEffect } from 'react';
+import { Card, Stack, Group, Title, Text, Loader, Button } from '@mantine/core';
+import { IconChevronRight } from '@tabler/icons-react';
+import Plot from 'react-plotly.js';
+import { getDataPath } from '../utils/paths';
+import { useView } from '../hooks/useView';
+
+const DEFAULT_COLS = ['Total COVID-19 Admissions', 'Total Influenza Admissions', 'Total RSV Admissions'];
+
+const PATHOGEN_COLORS = {
+ 'Total COVID-19 Admissions': '#e377c2',
+ 'Total Influenza Admissions': '#1f77b4',
+ 'Total RSV Admissions': '#7f7f7f'
+};
+
+const NHSNOverviewGraph = ( {location} ) => {
+ const { setViewType, viewType: activeViewType } = useView();
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const resolvedLocation = location || 'US';
+ const isActive = activeViewType === 'nhsn';
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const response = await fetch(getDataPath(`nhsn/${resolvedLocation}_nhsn.json`));
+
+ if (!response.ok) {
+ throw new Error('Data not available');
+ }
+
+ const json = await response.json();
+ setData(json);
+ } catch (err) {
+ console.error("Failed to fetch NHSN snapshot", err);
+ setError(err.message);
+ setData(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [resolvedLocation]);
+
+ const { traces, layout } = useMemo(() => {
+ if (!data || !data.series) return { traces: [], layout: {} };
+
+ const activeTraces = DEFAULT_COLS.map((col) => {
+ const yData = data.series[col];
+ if (!yData) return null;
+
+ return {
+ x: data.series.dates,
+ y: yData,
+ name: col.replace('Total ', '').replace(' Admissions', ''),
+ type: 'scatter',
+ mode: 'lines',
+ line: {
+ color: PATHOGEN_COLORS[col],
+ width: 2
+ },
+ hovertemplate: '%{y}'
+ };
+ }).filter(Boolean);
+
+ const dates = data.series.dates;
+ const lastDate = new Date(dates[dates.length - 1]);
+ const twoMonthsAgo = new Date(lastDate);
+ twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2);
+
+ const layoutConfig = {
+ height: 280,
+ margin: { l: 40, r: 20, t: 10, b: 40 },
+ xaxis: {
+ range: [twoMonthsAgo.toISOString().split('T')[0], lastDate.toISOString().split('T')[0]],
+ showgrid: false,
+ tickfont: { size: 10 }
+ },
+ yaxis: {
+ automargin: true,
+ tickfont: { size: 10 },
+ fixedrange: true,
+ },
+ showlegend: true,
+ legend: {
+ orientation: 'h',
+ y: -0.2,
+ x: 0.5,
+ xanchor: 'center',
+ font: { size: 9 }
+ },
+ hovermode: 'x unified'
+ };
+
+ return { traces: activeTraces, layout: layoutConfig };
+ }, [data]);
+
+ const locationLabel = resolvedLocation === 'US' ? 'US national view' : resolvedLocation;
+
+ return (
+
+
+
+ NHSN data
+
+
+ {loading && (
+
+
+ Loading CDC data...
+
+ )}
+
+ {!loading && error && (
+
+ No NHSN data for {resolvedLocation}
+
+ )}
+
+ {!loading && !error && traces.length > 0 && (
+
+ )}
+
+
+
+ {locationLabel}
+
+
+
+ );
+};
+
+export default NHSNOverviewGraph;
\ No newline at end of file
diff --git a/app/src/components/NHSNView.jsx b/app/src/components/NHSNView.jsx
index de68a844..6dc4f1a2 100644
--- a/app/src/components/NHSNView.jsx
+++ b/app/src/components/NHSNView.jsx
@@ -41,6 +41,7 @@ const NHSNView = ({ location }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const { colorScheme } = useMantineColorScheme();
+ const stateName = data?.metadata?.location_name;
const [allDataColumns, setAllDataColumns] = useState([]); // All columns from JSON
const [filteredAvailableColumns, setFilteredAvailableColumns] = useState([]); // Columns for the selected target
@@ -438,7 +439,9 @@ const NHSNView = ({ location }) => {
return (
-
+
+ {stateName}
+
{
const { selectedLocation } = useView();
return (
-
-
- Explore forecasts by pathogen
-
-
-
-
-
-
-
+
+
+ Check out our new Flu MetroCast forecasts!
+
+ } announcementType={"update"} />
+
+
+
+ Explore forecasts by pathogen
+
+
+
+
+
+
+
+
+
+ Explore ground truth data
+
+
+
+
+
+
);
};
diff --git a/app/src/components/PathogenOverviewGraph.jsx b/app/src/components/PathogenOverviewGraph.jsx
index 00995e32..e76f72c1 100644
--- a/app/src/components/PathogenOverviewGraph.jsx
+++ b/app/src/components/PathogenOverviewGraph.jsx
@@ -7,15 +7,15 @@ import { DATASETS } from '../config';
import { useView } from '../hooks/useView';
const DEFAULT_TARGETS = {
- covid_projs: 'wk inc covid hosp',
- flu_projs: 'wk inc flu hosp',
- rsv_projs: 'wk inc rsv hosp'
+ covid_forecasts: 'wk inc covid hosp',
+ flu_forecasts: 'wk inc flu hosp',
+ rsv_forecasts: 'wk inc rsv hosp'
};
const VIEW_TO_DATASET = {
- covid_projs: 'covid',
- flu_projs: 'flu',
- rsv_projs: 'rsv'
+ covid_forecasts: 'covid',
+ flu_forecasts: 'flu',
+ rsv_forecasts: 'rsv'
};
const getRangeAroundDate = (dateStr, weeksBefore = 4, weeksAfter = 4) => {
diff --git a/app/src/components/RSVView.jsx b/app/src/components/RSVView.jsx
index 8aebca14..558b9e1d 100644
--- a/app/src/components/RSVView.jsx
+++ b/app/src/components/RSVView.jsx
@@ -11,11 +11,11 @@ import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils';
const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, windowSize, getDefaultRange, selectedTarget }) => {
const [yAxisRange, setYAxisRange] = useState(null);
- const [xAxisRange, setXAxisRange] = useState(null); // Track user's zoom/rangeslider selection
+ const [xAxisRange, setXAxisRange] = useState(null);
const plotRef = useRef(null);
- const isResettingRef = useRef(false); // Flag to prevent capturing programmatic resets
-
- // --- FIX 1: Create Refs to hold the latest versions of props/data ---
+ const isResettingRef = useRef(false);
+ const stateName = data?.metadata?.location_name;
+
const getDefaultRangeRef = useRef(getDefaultRange);
const projectionsDataRef = useRef([]);
@@ -67,28 +67,29 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel
type: 'scatter',
mode: 'lines+markers',
line: { color: 'black', width: 2, dash: 'dash' },
- marker: { size: 4, color: 'black' }
+ marker: { size: 4, color: 'black' },
+ hovertemplate: 'Observed Data
Date: %{x}
Value: %{y:.2f}'
};
const modelTraces = selectedModels.flatMap(model =>
selectedDates.flatMap((date, dateIndex) => {
const forecastsForDate = forecasts[date] || {};
- // Access forecast using selectedTarget
const forecast = forecastsForDate[selectedTarget]?.[model];
- if (!forecast || forecast.type !== 'quantile') return []; // Ensure it's quantile data
+ if (!forecast || forecast.type !== 'quantile') return [];
const forecastDates = [], medianValues = [], ci95Upper = [], ci95Lower = [], ci50Upper = [], ci50Lower = [];
- // Sort predictions by date, accessing the nested prediction object
+ const hoverTexts = [];
+
const sortedPredictions = Object.values(forecast.predictions || {}).sort((a, b) => new Date(a.date) - new Date(b.date));
sortedPredictions.forEach((pred) => {
- forecastDates.push(pred.date);
+ const pointDate = pred.date;
+ forecastDates.push(pointDate);
const { quantiles = [], values = [] } = pred;
- // Find values for specific quantiles, defaulting to null or 0 if not found
const findValue = (q) => {
const index = quantiles.indexOf(q);
- return index !== -1 ? values[index] : null; // Use null if quantile is missing
+ return index !== -1 ? values[index] : null;
};
const val_025 = findValue(0.025);
@@ -97,46 +98,68 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel
const val_75 = findValue(0.75);
const val_975 = findValue(0.975);
- // Only add points if median and CIs are available
if (val_50 !== null && val_025 !== null && val_975 !== null && val_25 !== null && val_75 !== null) {
ci95Lower.push(val_025);
ci50Lower.push(val_25);
medianValues.push(val_50);
ci50Upper.push(val_75);
ci95Upper.push(val_975);
+
+ const formattedMedian = val_50.toLocaleString(undefined, { maximumFractionDigits: 2 });
+ const formatted50 = `${val_25.toLocaleString(undefined, { maximumFractionDigits: 2 })} - ${val_75.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
+ const formatted95 = `${val_025.toLocaleString(undefined, { maximumFractionDigits: 2 })} - ${val_975.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
+
+ hoverTexts.push(
+ `${model}
` +
+ `Date: ${pointDate}
` +
+ `Median: ${formattedMedian}
` +
+ `50% CI: [${formatted50}]
` +
+ `95% CI: [${formatted95}]
` +
+ `predicted as of ${date}` +
+ ``
+ );
} else {
- // If essential quantiles are missing, we might skip this point or handle it differently
- // For now, let's just skip adding to the arrays to avoid breaking the CI shapes
console.warn(`Missing quantiles for model ${model}, date ${date}, target ${selectedTarget}, prediction date ${pred.date}`);
}
});
- // Ensure we have data points before creating traces
if (forecastDates.length === 0) return [];
const modelColor = MODEL_COLORS[selectedModels.indexOf(model) % MODEL_COLORS.length];
- const isFirstDate = dateIndex === 0; // Only show legend for first date of each model
+ const isFirstDate = dateIndex === 0;
return [
{ x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci95Upper, ...ci95Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}10`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 95% CI`, hoverinfo: 'none', legendgroup: model },
{ x: [...forecastDates, ...forecastDates.slice().reverse()], y: [...ci50Upper, ...ci50Lower.slice().reverse()], fill: 'toself', fillcolor: `${modelColor}30`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', name: `${model} 50% CI`, hoverinfo: 'none', legendgroup: model },
- { x: forecastDates, y: medianValues, name: model, type: 'scatter', mode: 'lines+markers', line: { color: modelColor, width: 2, dash: 'solid' }, marker: { size: 6, color: modelColor }, showlegend: isFirstDate, legendgroup: model }
+ {
+ x: forecastDates,
+ y: medianValues,
+ name: model,
+ type: 'scatter',
+ mode: 'lines+markers',
+ line: { color: modelColor, width: 2, dash: 'solid' },
+ marker: { size: 6, color: modelColor },
+ showlegend: isFirstDate,
+ legendgroup: model,
+ text: hoverTexts,
+ hovertemplate: '%{text}',
+ hoverlabel: {
+ bgcolor: modelColor,
+ font: { color: '#ffffff' },
+ bordercolor: '#ffffff'
+ }
+ }
];
})
);
return [groundTruthTrace, ...modelTraces];
}, [groundTruth, forecasts, selectedDates, selectedModels, selectedTarget]);
- // --- FIX 2: Update the Refs on every render so they are always fresh ---
useEffect(() => {
getDefaultRangeRef.current = getDefaultRange;
projectionsDataRef.current = projectionsData;
}, [getDefaultRange, projectionsData]);
- /**
- * Create a Set of all models that have forecast data for
- * the currently selected target AND at least one of the selected dates.
- */
const activeModels = useMemo(() => {
const activeModelSet = new Set();
if (!forecasts || !selectedTarget || !selectedDates.length) {
@@ -150,7 +173,6 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel
const targetData = forecastsForDate[selectedTarget];
if (!targetData) return;
- // Add all models found for this target on this date
Object.keys(targetData).forEach(model => {
activeModelSet.add(model);
});
@@ -161,12 +183,10 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel
const defaultRange = useMemo(() => getDefaultRange(), [getDefaultRange]);
- // Reset xaxis range only when target changes (null = auto-follow date changes)
useEffect(() => {
- setXAxisRange(null); // Reset to auto-update mode on target change
+ setXAxisRange(null);
}, [selectedTarget]);
- // Recalculate y-axis when data or x-range changes
useEffect(() => {
const currentXRange = xAxisRange || defaultRange;
if (projectionsData.length > 0 && currentXRange) {
@@ -178,24 +198,20 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel
}, [projectionsData, xAxisRange, defaultRange, calculateYRange]);
const handlePlotUpdate = useCallback((figure) => {
- // Don't capture range changes during programmatic resets
if (isResettingRef.current) {
- isResettingRef.current = false; // Reset flag after ignoring the event
+ isResettingRef.current = false;
return;
}
- // Capture xaxis range changes (from rangeslider or zoom) to preserve user's selection
if (figure && figure['xaxis.range']) {
const newXRange = figure['xaxis.range'];
- // Only update if different to avoid loops
if (JSON.stringify(newXRange) !== JSON.stringify(xAxisRange)) {
setXAxisRange(newXRange);
- // Y-axis will be recalculated by useEffect when xAxisRange changes
}
}
}, [xAxisRange]);
- const layout = useMemo(() => ({ // Memoize layout to update only when dependencies change
+ const layout = useMemo(() => ({
width: Math.min(CHART_CONSTANTS.MAX_WIDTH, windowSize.width * CHART_CONSTANTS.WIDTH_RATIO),
height: Math.min(CHART_CONSTANTS.MAX_HEIGHT, windowSize.height * CHART_CONSTANTS.HEIGHT_RATIO),
autosize: true,
@@ -205,7 +221,7 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel
font: {
color: colorScheme === 'dark' ? '#c1c2c5' : '#000000'
},
- showlegend: selectedModels.length < 15, // Show legend only when fewer than 15 models selected
+ showlegend: selectedModels.length < 15,
legend: {
x: 0,
y: 1,
@@ -218,13 +234,13 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel
size: 10
}
},
- hovermode: 'x unified',
- dragmode: false, // Disable drag mode to prevent interference with clicks on mobile
+ hovermode: 'closest',
+ dragmode: false,
margin: { l: 60, r: 30, t: 30, b: 30 },
xaxis: {
- domain: [0, 1], // Full width
+ domain: [0, 1],
rangeslider: {
- range: getDefaultRange(true) // Rangeslider always shows full extent
+ range: getDefaultRange(true)
},
rangeselector: {
buttons: [
@@ -233,19 +249,18 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel
{step: 'all', label: 'all'}
]
},
- range: xAxisRange || defaultRange, // Use user's selection or default
+ range: xAxisRange || defaultRange,
showline: true,
linewidth: 1,
linecolor: colorScheme === 'dark' ? '#aaa' : '#444'
},
yaxis: {
- // Use the map for a user-friendly title
title: (() => {
const longName = targetDisplayNameMap[selectedTarget];
return targetYAxisLabelMap[longName] || longName || selectedTarget || 'Value';
})(),
- range: yAxisRange, // Use state for dynamic range updates
- autorange: yAxisRange === null, // Enable autorange if yAxisRange is null
+ range: yAxisRange,
+ autorange: yAxisRange === null,
},
shapes: selectedDates.map(date => {
return {
@@ -270,9 +285,9 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel
displaylogo: false,
showSendToCloud: false,
plotlyServerURL: "",
- scrollZoom: false, // Disable scroll zoom to prevent conflicts on mobile
- doubleClick: 'reset', // Allow double-click to reset view
- modeBarButtonsToRemove: ['select2d', 'lasso2d', 'resetScale2d'], // Remove selection tools and default home
+ scrollZoom: false,
+ doubleClick: 'reset',
+ modeBarButtonsToRemove: ['select2d', 'lasso2d', 'resetScale2d'],
toImageButtonOptions: {
format: 'png',
filename: 'forecast_plot'
@@ -324,6 +339,9 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel
onRelayout={(figure) => handlePlotUpdate(figure)}
/>
+
+ {stateName}
+
}
- withCloseButton
- onClose={() => setIsVisible(false)}
- closeButtonLabel="Dismiss notification"
- radius={0}
- >
-
- Due to the U.S. government shutdown, forecasting hubs (flu, RSV, COVID-19) are delayed in producing projections. RespiLens will update with new forecasts as soon as they are available.
-
-
- );
-}
-
-export default ShutdownBanner;
\ No newline at end of file
diff --git a/app/src/components/ViewSwitchboard.jsx b/app/src/components/ViewSwitchboard.jsx
index 85a0c1a4..f3431b9a 100644
--- a/app/src/components/ViewSwitchboard.jsx
+++ b/app/src/components/ViewSwitchboard.jsx
@@ -103,7 +103,7 @@ const ViewSwitchboard = ({
// Render appropriate view based on viewType
switch (viewType) {
case 'fludetailed':
- case 'flu_projs':
+ case 'flu_forecasts':
case 'flu_peak':
return (
);
- case 'rsv_projs':
+ case 'rsv_forecasts':
return (
);
- case 'covid_projs':
+ case 'covid_forecasts':
return(
);
- case 'metrocast_projs':
+ case 'metrocast_forecasts':
return (
{
setSelectedModels(current => JSON.stringify(current) !== JSON.stringify(modelsToSet) ? modelsToSet : current);
setSelectedDates(current => JSON.stringify(current) !== JSON.stringify(datesToSet) ? datesToSet : current);
- setActiveDate(datesToSet.length > 0 ? datesToSet[datesToSet.length - 1] : null);
+ setActiveDate(currentActive => {
+ if (currentActive && datesToSet.includes(currentActive)) {
+ return currentActive;
+ }
+ return datesToSet.length > 0 ? datesToSet[datesToSet.length - 1] : null;
+ });
if (targetToSet && targetToSet !== selectedTarget) {
setSelectedTarget(targetToSet);
diff --git a/app/src/hooks/useForecastData.js b/app/src/hooks/useForecastData.js
index c53f4b7b..85743a8b 100644
--- a/app/src/hooks/useForecastData.js
+++ b/app/src/hooks/useForecastData.js
@@ -16,9 +16,9 @@ export const useForecastData = (location, viewType) => {
const peaks = data?.peaks || null;
useEffect(() => {
- const isMetrocastView = viewType === 'metrocast_projs';
+ const isMetrocastView = viewType === 'metrocast_forecasts';
const isDefaultUS = location === 'US';
- if ((isMetrocastView && isDefaultUS) || (!isMetrocastView && location === 'boulder')) {
+ if ((isMetrocastView && isDefaultUS) || (!isMetrocastView && location === 'colorado')) {
setLoading(false);
return;
}
@@ -44,12 +44,12 @@ export const useForecastData = (location, viewType) => {
try {
const datasetMap = {
'fludetailed': { directory: 'flusight', suffix: 'flu' },
- 'flu_projs': { directory: 'flusight', suffix: 'flu' },
+ 'flu_forecasts': { directory: 'flusight', suffix: 'flu' },
'flu_peak': { directory: 'flusight', suffix: 'flu' },
- 'covid_projs': { directory: 'covid19forecasthub', suffix: 'covid19' },
- 'rsv_projs': { directory: 'rsvforecasthub', suffix: 'rsv' },
+ 'covid_forecasts': { directory: 'covid19forecasthub', suffix: 'covid19' },
+ 'rsv_forecasts': { directory: 'rsvforecasthub', suffix: 'rsv' },
'nhsnall': { directory: 'nhsn', suffix: 'nhsn' },
- 'metrocast_projs': {directory: 'flumetrocast', suffix: 'flu_metrocast'}
+ 'metrocast_forecasts': {directory: 'flumetrocast', suffix: 'flu_metrocast'}
};
const datasetConfig = datasetMap[viewType];
diff --git a/scripts/processors/flu_metrocast_hub.py b/scripts/processors/flu_metrocast_hub.py
index 9b0100a5..55f52642 100644
--- a/scripts/processors/flu_metrocast_hub.py
+++ b/scripts/processors/flu_metrocast_hub.py
@@ -11,7 +11,7 @@ def __init__(self, data: pd.DataFrame, locations_data: pd.DataFrame, target_data
file_suffix="flu_metrocast",
dataset_label="flu metrocast forecasts",
ground_truth_date_column="target_end_date",
- ground_truth_min_date=pd.Timestamp("2025-11-19"),
+ ground_truth_min_date=pd.Timestamp("2024-08-01"),
)
super().__init__(
data=data,
diff --git a/scripts/processors/flusight.py b/scripts/processors/flusight.py
index 904fbaa6..88b14d08 100644
--- a/scripts/processors/flusight.py
+++ b/scripts/processors/flusight.py
@@ -11,7 +11,7 @@ def __init__(self, data: pd.DataFrame, locations_data: pd.DataFrame, target_data
file_suffix="flu",
dataset_label="flusight forecasts",
ground_truth_date_column="target_end_date",
- ground_truth_min_date=pd.Timestamp("2023-10-01"),
+ ground_truth_min_date=pd.Timestamp("2022-10-01"),
)
super().__init__(
data=data,