From 126eb6213628f2b559bee7bc3920d724e3e4180d Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 26 Jan 2026 13:54:19 -0500 Subject: [PATCH 01/26] add multiplot view for states can now see the sub metro areas on a state Metrocast selection --- app/src/components/MetroCastView.jsx | 406 ++++++++++++++------------- 1 file changed, 206 insertions(+), 200 deletions(-) diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/MetroCastView.jsx index 96f8527..fe8b252 100644 --- a/app/src/components/MetroCastView.jsx +++ b/app/src/components/MetroCastView.jsx @@ -1,257 +1,263 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { useMantineColorScheme, Stack, Text, Center } from '@mantine/core'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useMantineColorScheme, Stack, Text, Center, SimpleGrid, Paper, Loader, Box } from '@mantine/core'; import Plot from 'react-plotly.js'; -import Plotly from 'plotly.js/dist/plotly'; import ModelSelector from './ModelSelector'; import LastFetched from './LastFetched'; import { MODEL_COLORS } from '../config/datasets'; import { CHART_CONSTANTS } from '../constants/chart'; import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils'; +import { getDataPath } from '../utils/paths'; -const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, windowSize, getDefaultRange, selectedTarget }) => { - const [yAxisRange, setYAxisRange] = useState(null); - const [xAxisRange, setXAxisRange] = useState(null); - const plotRef = useRef(null); - const isResettingRef = useRef(false); - - const getDefaultRangeRef = useRef(getDefaultRange); - const projectionsDataRef = useRef([]); +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 []; + if (!groundTruth || !forecasts || !selectedTarget) return []; + const gtValues = groundTruth[selectedTarget]; + if (!gtValues) return []; - const groundTruthTrace = { - x: groundTruth.dates || [], - y: groundTruthValues, - name: 'Observed', - type: 'scatter', - mode: 'lines+markers', - line: { color: 'black', width: 2, dash: 'dash' }, - marker: { size: 4, color: 'black' } - }; - - const modelTraces = selectedModels.flatMap(model => - selectedDates.flatMap((date, dateIndex) => { - const forecastsForDate = forecasts[date] || {}; - const forecast = forecastsForDate[selectedTarget]?.[model]; - if (!forecast || forecast.type !== 'quantile') return []; + const 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' } + }]; - const forecastDates = [], medianValues = [], ci95Upper = [], ci95Lower = [], ci50Upper = [], ci50Lower = []; - const sortedHorizons = Object.keys(forecast.predictions || {}).sort((a, b) => Number(a) - Number(b)); + selectedModels.forEach(model => { + selectedDates.forEach((date, dateIdx) => { + const forecast = forecasts[date]?.[selectedTarget]?.[model]; + if (forecast?.type !== 'quantile') return; - sortedHorizons.forEach((h) => { - const pred = forecast.predictions[h]; - forecastDates.push(pred.date); - const { quantiles = [], values = [] } = pred; - - const findValue = (q) => { - const idx = quantiles.indexOf(q); - return idx !== -1 ? values[idx] : null; - }; - - const v025 = findValue(0.025), v25 = findValue(0.25), v50 = findValue(0.5), v75 = findValue(0.75), v975 = findValue(0.975); + const fDates = [], median = [], q95U = [], q95L = [], q50U = [], q50L = []; + const sorted = Object.keys(forecast.predictions || {}).sort((a, b) => Number(a) - Number(b)); + sorted.forEach(h => { + const p = forecast.predictions[h]; + fDates.push(p.date); + 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); + q95L.push(findQ(0.025) ?? v50); q50L.push(findQ(0.25) ?? v50); + q50U.push(findQ(0.75) ?? v50); q95U.push(findQ(0.975) ?? v50); } }); - 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 }, + { x: [...fDates, ...fDates.slice().reverse()], y: [...q50U, ...q50L.slice().reverse()], fill: 'toself', fillcolor: `${color}30`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', legendgroup: model }, + { 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 } + ); + }); + }); + 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]); + // conditionally returns one plot OR multiple if it is a state location + return ( + + {title} + + {!hasForecasts && ( + +
No forecast data for selection
+
+ )} - const activeModels = useMemo(() => { - const activeModelSet = new Set(); - if (!forecasts || !selectedTarget || !selectedDates.length) return activeModelSet; - selectedDates.forEach(date => { - const targetData = forecasts[date]?.[selectedTarget]; - if (targetData) Object.keys(targetData).forEach(m => activeModelSet.add(m)); - }); - return activeModelSet; - }, [forecasts, selectedDates, selectedTarget]); + { + const 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' } + }, + hovermode: isSmall ? false : 'x unified', + 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 }} + onRelayout={(e) => { + // If the range slider or zoom is used, update the shared range in parent + if (e['xaxis.range']) { + setXAxisRange(e['xaxis.range']); + } else if (e['xaxis.autorange']) { + setXAxisRange(null); + } + }} + /> +
+ ); +}; + +const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, windowSize, getDefaultRange, selectedTarget }) => { + const { colorScheme } = useMantineColorScheme(); + const [childData, setChildData] = useState({}); + const [loadingChildren, setLoadingChildren] = useState(false); + const [xAxisRange, setXAxisRange] = useState(null); // Shared state for all plots - const defaultRange = useMemo(() => getDefaultRange(), [getDefaultRange]); + const stateName = data?.metadata?.location_name; + const stateCode = METRO_STATE_MAP[stateName]; 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 fetchChildren = async () => { + setLoadingChildren(true); + const results = {}; + const cityList = metadata.locations.filter(l => l.location_name.includes(`, ${stateCode}`)); - 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]); + 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); } + })); - 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]); + setChildData(results); + setLoadingChildren(false); + }; + + fetchChildren(); + }, [stateCode, metadata, selectedTarget]); - if (!selectedTarget) { - return ( -
- Please select a target to view MetroCast data. -
- ); - } + if (!selectedTarget) return
Please select a target.
; return ( - + -
- -
+ -
-

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

-
+ {stateCode && ( + + + {loadingChildren ? ( +
+ ) : ( + + {Object.entries(childData).map(([abbr, cityData]) => ( + + ))} + + )} +
+ )} { - const index = selectedModels.indexOf(model); - return MODEL_COLORS[index % MODEL_COLORS.length]; - }} + getModelColor={(m, sel) => MODEL_COLORS[sel.indexOf(m) % MODEL_COLORS.length]} />
); From f0fe4920c31102a7e64d6b6e1431fef61c2b366d Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 26 Jan 2026 14:14:57 -0500 Subject: [PATCH 02/26] add back in availableModels somehow it was removed witm the last commit --- app/src/components/MetroCastView.jsx | 88 ++++++++++++++++------------ 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/MetroCastView.jsx index fe8b252..456d77b 100644 --- a/app/src/components/MetroCastView.jsx +++ b/app/src/components/MetroCastView.jsx @@ -24,8 +24,8 @@ const MetroPlotCard = ({ selectedModels, selectedDates, getDefaultRange, - xAxisRange, - setXAxisRange + xAxisRange, + setXAxisRange }) => { const [yAxisRange, setYAxisRange] = useState(null); const groundTruth = locationData?.ground_truth; @@ -104,8 +104,8 @@ const MetroPlotCard = ({ const hasForecasts = projectionsData.length > 1; // conditionally returns one plot OR multiple if it is a state location - return ( - + const PlotContent = ( + <> {title} {!hasForecasts && ( @@ -119,7 +119,7 @@ const MetroPlotCard = ({ data={projectionsData} layout={{ autosize: true, - template: colorScheme === 'plotly_white', + 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' }, @@ -133,11 +133,7 @@ const MetroPlotCard = ({ xaxis: { range: xAxisRange || defRange, showticklabels: !isSmall, - // only show range slider on the main plot - rangeslider: { - visible: !isSmall, - range: getDefaultRange(true) - }, + rangeslider: { visible: !isSmall, range: getDefaultRange(true) }, showline: true, linewidth: 1, linecolor: colorScheme === 'dark' ? '#aaa' : '#444' }, @@ -158,15 +154,21 @@ const MetroPlotCard = ({ }} config={{ displayModeBar: !isSmall, responsive: true, displaylogo: false }} onRelayout={(e) => { - // If the range slider or zoom is used, update the shared range in parent - if (e['xaxis.range']) { - setXAxisRange(e['xaxis.range']); - } else if (e['xaxis.autorange']) { - setXAxisRange(null); - } + if (e['xaxis.range']) { setXAxisRange(e['xaxis.range']); } + else if (e['xaxis.autorange']) { setXAxisRange(null); } }} /> + + ); + + return isSmall ? ( + + {PlotContent} + ) : ( + + {PlotContent} + ); }; @@ -174,10 +176,21 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, const { colorScheme } = useMantineColorScheme(); const [childData, setChildData] = useState({}); const [loadingChildren, setLoadingChildren] = useState(false); - const [xAxisRange, setXAxisRange] = useState(null); // Shared state for all plots + 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(); + if (!forecasts || !selectedTarget || !selectedDates.length) return activeModelSet; + selectedDates.forEach(date => { + const targetData = forecasts[date]?.[selectedTarget]; + if (targetData) Object.keys(targetData).forEach(m => activeModelSet.add(m)); + }); + return activeModelSet; + }, [forecasts, selectedDates, selectedTarget]); useEffect(() => { setXAxisRange(null); }, [selectedTarget]); @@ -223,32 +236,34 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, getDefaultRange={getDefaultRange} xAxisRange={xAxisRange} setXAxisRange={setXAxisRange} + isSmall={false} /> {stateCode && ( - {loadingChildren ? (
) : ( - - {Object.entries(childData).map(([abbr, cityData]) => ( - - ))} - + <> + + {Object.entries(childData).map(([abbr, cityData]) => ( + + ))} + + )}
)} @@ -257,6 +272,7 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, models={models} selectedModels={selectedModels} setSelectedModels={setSelectedModels} + activeModels={activeModels} getModelColor={(m, sel) => MODEL_COLORS[sel.indexOf(m) % MODEL_COLORS.length]} />
From 1db10f07663219d37c325fa69054e94b30eb6230 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 26 Jan 2026 15:13:04 -0500 Subject: [PATCH 03/26] make metro area cards clickable on the aggregated metrocast state view --- app/src/components/MetroCastView.jsx | 80 +++++++++++++++++++++------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/MetroCastView.jsx index 456d77b..37a4f57 100644 --- a/app/src/components/MetroCastView.jsx +++ b/app/src/components/MetroCastView.jsx @@ -1,8 +1,9 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; -import { useMantineColorScheme, Stack, Text, Center, SimpleGrid, Paper, Loader, Box } from '@mantine/core'; +import { useMantineColorScheme, Stack, Text, Center, SimpleGrid, Paper, Loader, Box, UnstyledButton } from '@mantine/core'; import Plot from 'react-plotly.js'; import ModelSelector from './ModelSelector'; import LastFetched from './LastFetched'; +import { useView } from '../hooks/useView'; // need this so the metro area cards can link to other pages import { MODEL_COLORS } from '../config/datasets'; import { CHART_CONSTANTS } from '../constants/chart'; import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils'; @@ -103,7 +104,6 @@ const MetroPlotCard = ({ const hasForecasts = projectionsData.length > 1; - // conditionally returns one plot OR multiple if it is a state location const PlotContent = ( <> {title} @@ -120,8 +120,8 @@ const MetroPlotCard = ({ layout={{ autosize: true, template: colorScheme === 'dark' ? 'plotly_dark' : 'plotly_white', - paper_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', - plot_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', + paper_bgcolor: 'rgba(0,0,0,0)', + plot_bgcolor: 'rgba(0,0,0,0)', font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' }, margin: { l: isSmall ? 45 : 60, r: 20, t: 10, b: isSmall ? 25 : 80 }, showlegend: !isSmall, @@ -152,7 +152,8 @@ const MetroPlotCard = ({ hovermode: isSmall ? false : 'x unified', 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: true for small charts to ensure clicks trigger navigation button + 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); } @@ -162,8 +163,41 @@ const MetroPlotCard = ({ ); 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'; + }} + /> ) : ( @@ -174,6 +208,7 @@ const MetroPlotCard = ({ 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); @@ -247,20 +282,25 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, <> {Object.entries(childData).map(([abbr, cityData]) => ( - + handleLocationSelect(abbr)} + style={{ width: '100%' }} + > + + ))} From 6117f92cfb850f3b5a2869a74c6874779ee35f5b Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Tue, 27 Jan 2026 10:46:58 -0500 Subject: [PATCH 04/26] add gt history for metrocast data --- scripts/processors/flu_metrocast_hub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/processors/flu_metrocast_hub.py b/scripts/processors/flu_metrocast_hub.py index 9b0100a..55f5264 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, From 9d0daa4d770aadf8a510acb3b435f6aa27a1e091 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Tue, 27 Jan 2026 12:22:32 -0500 Subject: [PATCH 05/26] add locations underneath every plot for every view --- app/src/components/COVID19View.jsx | 4 ++ app/src/components/FluPeak.jsx | 7 +++- app/src/components/FluView.jsx | 4 ++ app/src/components/RSVView.jsx | 59 +++++++++++------------------- 4 files changed, 34 insertions(+), 40 deletions(-) diff --git a/app/src/components/COVID19View.jsx b/app/src/components/COVID19View.jsx index 8daa93e..4ab0bcb 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); @@ -300,6 +301,9 @@ const COVID19View = ({ data, metadata, selectedDates, selectedModels, models, se onRelayout={handlePlotUpdate} /> + + {stateName} +

{ const d = new Date(dateStr); @@ -433,7 +434,6 @@ const FluPeak = ({ hoverlabel: { namelength: -1 }, dragmode: false, xaxis: { - title: 'Month', tickformat: '%b' }, yaxis: { title: 'Flu Hospitalizations', rangemode: 'tozero' }, @@ -498,6 +498,9 @@ const FluPeak = ({ useResizeHandler={true} />

+ + {stateName} +

handlePlotUpdate(figure)} />

+ + {stateName} +

{ 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([]); @@ -73,22 +73,19 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel 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 sortedPredictions = Object.values(forecast.predictions || {}).sort((a, b) => new Date(a.date) - new Date(b.date)); sortedPredictions.forEach((pred) => { forecastDates.push(pred.date); 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,7 +94,6 @@ 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); @@ -105,17 +101,14 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel ci50Upper.push(val_75); ci95Upper.push(val_975); } 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 }, @@ -127,16 +120,11 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel 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 +138,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 +148,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 +163,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, @@ -219,12 +200,12 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel } }, hovermode: 'x unified', - dragmode: false, // Disable drag mode to prevent interference with clicks on mobile + dragmode: false, margin: { l: 60, r: 30, t: 30, b: 30 }, xaxis: { domain: [0, 1], // Full width rangeslider: { - range: getDefaultRange(true) // Rangeslider always shows full extent + range: getDefaultRange(true) }, rangeselector: { buttons: [ @@ -233,19 +214,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 +250,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 +304,9 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel onRelayout={(figure) => handlePlotUpdate(figure)} />

+ + {stateName} +

Date: Tue, 27 Jan 2026 14:10:43 -0500 Subject: [PATCH 06/26] add disclaimer to metrocast view --- app/src/components/MetroCastView.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/MetroCastView.jsx index 37a4f57..6f0f63e 100644 --- a/app/src/components/MetroCastView.jsx +++ b/app/src/components/MetroCastView.jsx @@ -307,7 +307,17 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, )} )} - +

+

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

+
Date: Tue, 27 Jan 2026 14:38:14 -0500 Subject: [PATCH 07/26] change metrocast default location to `colorado` --- app/src/config/datasets.js | 2 +- app/src/hooks/useForecastData.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/config/datasets.js b/app/src/config/datasets.js index 3e8e12d..4d85303 100644 --- a/app/src/config/datasets.js +++ b/app/src/config/datasets.js @@ -64,7 +64,7 @@ export const DATASETS = { ], defaultView: 'metrocast_projs', defaultModel: 'epiENGAGE-ensemble_mean', - defaultLocation: 'boulder', + defaultLocation: 'colorado', hasDateSelector: true, hasModelSelector: true, prefix: 'metrocast', diff --git a/app/src/hooks/useForecastData.js b/app/src/hooks/useForecastData.js index c53f4b7..0629768 100644 --- a/app/src/hooks/useForecastData.js +++ b/app/src/hooks/useForecastData.js @@ -18,7 +18,7 @@ export const useForecastData = (location, viewType) => { useEffect(() => { const isMetrocastView = viewType === 'metrocast_projs'; const isDefaultUS = location === 'US'; - if ((isMetrocastView && isDefaultUS) || (!isMetrocastView && location === 'boulder')) { + if ((isMetrocastView && isDefaultUS) || (!isMetrocastView && location === 'colorado')) { setLoading(false); return; } From a557dd15e492fc2d9365f24b9b341090c827b34e Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Wed, 28 Jan 2026 09:26:40 -0500 Subject: [PATCH 08/26] transparent plotly tool bar, improved hover text in `MetroCastView.jsx` --- app/src/components/MetroCastView.jsx | 97 +++++++++++++++++++++------- 1 file changed, 72 insertions(+), 25 deletions(-) diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/MetroCastView.jsx index 6f0f63e..f032af8 100644 --- a/app/src/components/MetroCastView.jsx +++ b/app/src/components/MetroCastView.jsx @@ -61,7 +61,8 @@ const MetroPlotCard = ({ 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' } + marker: { size: isSmall ? 2 : 4, color: 'black' }, + hovertemplate: 'Observed Data
Date: %{x}
Value: %{y:.2f}%' }]; selectedModels.forEach(model => { @@ -70,25 +71,66 @@ const MetroPlotCard = ({ 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)); sorted.forEach(h => { const p = forecast.predictions[h]; - fDates.push(p.date); - const findQ = (q) => { const i = p.quantiles.indexOf(q); return i !== -1 ? p.values[i] : null; }; + 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) { median.push(v50); - q95L.push(findQ(0.025) ?? v50); q50L.push(findQ(0.25) ?? v50); - q50U.push(findQ(0.75) ?? v50); q95U.push(findQ(0.975) ?? 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}` + + `` + ); } }); 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 }, - { x: [...fDates, ...fDates.slice().reverse()], y: [...q50U, ...q50L.slice().reverse()], fill: 'toself', fillcolor: `${color}30`, line: { color: 'transparent' }, showlegend: false, type: 'scatter', legendgroup: model }, - { 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 } + { 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}' + } ); }); }); @@ -120,8 +162,8 @@ const MetroPlotCard = ({ layout={{ autosize: true, template: colorScheme === 'dark' ? 'plotly_dark' : 'plotly_white', - paper_bgcolor: 'rgba(0,0,0,0)', - plot_bgcolor: 'rgba(0,0,0,0)', + paper_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', + plot_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' }, margin: { l: isSmall ? 45 : 60, r: 20, t: 10, b: isSmall ? 25 : 80 }, showlegend: !isSmall, @@ -147,12 +189,19 @@ const MetroPlotCard = ({ } : undefined, range: yAxisRange, autorange: yAxisRange === null, - tickfont: { size: 9, color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' } + tickfont: { size: 9, color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' }, + tickformat: '.2f', + ticksuffix: '%' + }, + hovermode: isSmall ? false : 'closest', + hoverlabel: { + namelength: -1, + bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#fff', + bordercolor: colorScheme === 'dark' ? '#444' : '#ccc', + font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000' } }, - hovermode: isSmall ? false : 'x unified', shapes: selectedDates.map(d => ({ type: 'line', x0: d, x1: d, y0: 0, y1: 1, yref: 'paper', line: { color: 'red', width: 1, dash: 'dash' } })) }} - // staticPlot: true for small charts to ensure clicks trigger navigation button config={{ displayModeBar: !isSmall, responsive: true, displaylogo: false, staticPlot: isSmall }} onRelayout={(e) => { if (e['xaxis.range']) { setXAxisRange(e['xaxis.range']); } @@ -174,9 +223,7 @@ const MetroPlotCard = ({ border: '1px solid #dee2e6' }} > - {PlotContent} - {Object.entries(childData).map(([abbr, cityData]) => ( - handleLocationSelect(abbr)} style={{ width: '100%' }} @@ -308,15 +355,15 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, )}
-

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

+

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

Date: Wed, 28 Jan 2026 09:46:25 -0500 Subject: [PATCH 09/26] make hover text background color match model selection color --- app/src/components/MetroCastView.jsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/MetroCastView.jsx index f032af8..d6632dc 100644 --- a/app/src/components/MetroCastView.jsx +++ b/app/src/components/MetroCastView.jsx @@ -62,7 +62,7 @@ const MetroPlotCard = ({ 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: 'Observed Data
Date: %{x}
Value: %{y:.2f}%' + hovertemplate: 'Ground Truth Data
Date: %{x}
Value: %{y:.2f}%' }]; selectedModels.forEach(model => { @@ -108,7 +108,7 @@ const MetroPlotCard = ({ `Median: ${formattedMedian}%
` + `50% CI: [${formatted50}%]
` + `95% CI: [${formatted95}%]
` + - `predicted as of ${date}` + + `predicted as of ${date}` + `` ); } @@ -129,7 +129,12 @@ const MetroPlotCard = ({ showlegend: dateIdx === 0 && !isSmall, legendgroup: model, text: hoverTexts, - hovertemplate: '%{text}' + hovertemplate: '%{text}', + hoverlabel: { + bgcolor: color, + font: { color: '#ffffff' }, + bordercolor: '#ffffff' + } } ); }); @@ -195,10 +200,7 @@ const MetroPlotCard = ({ }, hovermode: isSmall ? false : 'closest', hoverlabel: { - namelength: -1, - bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#fff', - bordercolor: colorScheme === 'dark' ? '#444' : '#ccc', - font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000' } + 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' } })) }} From 20907681380387600b392730dcb8680a53a5aa54 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Wed, 28 Jan 2026 09:57:12 -0500 Subject: [PATCH 10/26] add state label to NHSN --- app/src/components/NHSNView.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/components/NHSNView.jsx b/app/src/components/NHSNView.jsx index de68a84..6dc4f1a 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} + Date: Wed, 28 Jan 2026 10:30:48 -0500 Subject: [PATCH 11/26] better hover label for COVID view --- app/src/components/COVID19View.jsx | 44 +++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/app/src/components/COVID19View.jsx b/app/src/components/COVID19View.jsx index 4ab0bcb..c9996ef 100644 --- a/app/src/components/COVID19View.jsx +++ b/app/src/components/COVID19View.jsx @@ -70,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 => @@ -80,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); @@ -102,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}`); } @@ -115,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' + } + } ]; }) ); @@ -196,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: { From 38963fbf67955143e718ef6b3260601c6704c061 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Wed, 28 Jan 2026 10:33:17 -0500 Subject: [PATCH 12/26] better hover label for RSV view --- app/src/components/RSVView.jsx | 47 +++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/app/src/components/RSVView.jsx b/app/src/components/RSVView.jsx index f695b15..558b9e1 100644 --- a/app/src/components/RSVView.jsx +++ b/app/src/components/RSVView.jsx @@ -67,7 +67,8 @@ 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 => @@ -77,10 +78,13 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel 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) => { @@ -100,6 +104,20 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel 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 { console.warn(`Missing quantiles for model ${model}, date ${date}, target ${selectedTarget}, prediction date ${pred.date}`); } @@ -113,7 +131,24 @@ const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSel 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' + } + } ]; }) ); @@ -186,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, @@ -199,11 +234,11 @@ const RSVView = ({ 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: { - domain: [0, 1], // Full width + domain: [0, 1], rangeslider: { range: getDefaultRange(true) }, From b289662c90d09492e73bbae3dd85ef299ec3bdfd Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Wed, 28 Jan 2026 10:38:19 -0500 Subject: [PATCH 13/26] better hover label for flu views (detailed and projections) --- app/src/components/FluView.jsx | 90 ++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 20 deletions(-) diff --git a/app/src/components/FluView.jsx b/app/src/components/FluView.jsx index dfebca8..ae7e907 100644 --- a/app/src/components/FluView.jsx +++ b/app/src/components/FluView.jsx @@ -17,7 +17,6 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel const debounceTimerRef = useRef(null); const stateName = data?.metadata?.location_name; - // Refs to hold the latest versions of props/data for the reset button const getDefaultRangeRef = useRef(getDefaultRange); const projectionsDataRef = useRef([]); @@ -27,7 +26,6 @@ const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSel const lastSelectedDate = useMemo(() => { if (selectedDates.length === 0) return null; - // Find the latest date return selectedDates.slice().sort().pop(); }, [selectedDates]); @@ -82,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 => @@ -92,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; @@ -103,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; @@ -141,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]); @@ -158,10 +210,8 @@ 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; } @@ -227,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]); @@ -261,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: { @@ -302,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], From 32386c6ed9c9a8a1a862f7777963306e4f6ca3e8 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Wed, 28 Jan 2026 13:21:25 -0500 Subject: [PATCH 14/26] keydown ability on date (left-right keys) functionality is disabled when there are multiple dates selected --- app/src/components/DateSelector.jsx | 168 ++++++++++++++++------------ 1 file changed, 97 insertions(+), 71 deletions(-) diff --git a/app/src/components/DateSelector.jsx b/app/src/components/DateSelector.jsx index 8f40bbe..95a0bb5 100644 --- a/app/src/components/DateSelector.jsx +++ b/app/src/components/DateSelector.jsx @@ -1,28 +1,67 @@ -import { Group, Text, ActionIcon, Button } from '@mantine/core'; +import { useEffect, useCallback } from 'react'; +import { Group, Text, ActionIcon, Button, Box } from '@mantine/core'; import { IconChevronLeft, IconChevronRight, IconX, IconPlus } from '@tabler/icons-react'; -const DateSelector = ({ availableDates, selectedDates, setSelectedDates, activeDate, setActiveDate, multi = true }) => { +const DateSelector = ({ + availableDates, + selectedDates, + setSelectedDates, + activeDate, + setActiveDate, + multi = true +}) => { + const handleMove = useCallback((dateToMove, direction) => { + if (!dateToMove) return; + + const sortedDates = [...selectedDates].sort(); + const dateIndex = availableDates.indexOf(dateToMove); + const currentPositionInSelected = sortedDates.indexOf(dateToMove); + + const targetDate = availableDates[dateIndex + direction]; + + if (!targetDate) return; + + const isBlocked = direction === -1 + ? (currentPositionInSelected > 0 && targetDate === sortedDates[currentPositionInSelected - 1]) + : (currentPositionInSelected < sortedDates.length - 1 && targetDate === sortedDates[currentPositionInSelected + 1]); + + if (!isBlocked) { + const newDates = [...selectedDates]; + const indexInOriginal = selectedDates.indexOf(dateToMove); + newDates[indexInOriginal] = targetDate; + + setSelectedDates(newDates.sort()); + setActiveDate(targetDate); + } + }, [availableDates, selectedDates, setSelectedDates, setActiveDate]); + + useEffect(() => { + const handleKeyDown = (event) => { + if (selectedDates.length !== 1) return; + if (['INPUT', 'TEXTAREA'].includes(event.target.tagName)) return; + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + handleMove(activeDate, -1); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + handleMove(activeDate, 1); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [activeDate, handleMove, selectedDates.length]); + return ( {selectedDates.map((date) => ( { - const sortedDates = selectedDates.slice().sort(); - const dateIndex = availableDates.indexOf(date); - const currentPosition = sortedDates.indexOf(date); - const prevDate = availableDates[dateIndex - 1]; - - if (prevDate && (!sortedDates[currentPosition - 1] || new Date(prevDate) > new Date(sortedDates[currentPosition - 1]))) { - const newDates = [...selectedDates]; - newDates[selectedDates.indexOf(date)] = prevDate; - setSelectedDates(newDates.sort()); - setActiveDate(prevDate); - } - }} + onClick={() => handleMove(date, -1)} disabled={ availableDates.indexOf(date) === 0 || - (selectedDates.includes(availableDates[availableDates.indexOf(date) - 1])) + selectedDates.includes(availableDates[availableDates.indexOf(date) - 1]) } variant="subtle" size={{ base: 'sm', sm: 'md' }} @@ -31,49 +70,44 @@ const DateSelector = ({ availableDates, selectedDates, setSelectedDates, activeD - - - {date} - - {multi && ( // only show `x` icon when multi == True - setSelectedDates(dates => dates.filter(d => d !== date))} - disabled={selectedDates.length === 1} - variant="subtle" - size="xs" - color="red" - aria-label={`Remove date ${date}`} + setActiveDate(date)} + style={{ outline: 'none', cursor: 'pointer' }} + > + + - - - )} - + {date} + + + {multi && ( + setSelectedDates(dates => dates.filter(d => d !== date))} + disabled={selectedDates.length === 1} + variant="subtle" + size="xs" + color="red" + aria-label={`Remove date ${date}`} + > + + + )} + +
{ - const sortedDates = selectedDates.slice().sort(); - const dateIndex = availableDates.indexOf(date); - const currentPosition = sortedDates.indexOf(date); - const nextDate = availableDates[dateIndex + 1]; - - if (nextDate && (!sortedDates[currentPosition + 1] || new Date(nextDate) < new Date(sortedDates[currentPosition + 1]))) { - const newDates = [...selectedDates]; - newDates[selectedDates.indexOf(date)] = nextDate; - setSelectedDates(newDates.sort()); - setActiveDate(nextDate); - } - }} + onClick={() => handleMove(date, 1)} disabled={ availableDates.indexOf(date) === availableDates.length - 1 || - (selectedDates.includes(availableDates[availableDates.indexOf(date) + 1])) + selectedDates.includes(availableDates[availableDates.indexOf(date) + 1]) } variant="subtle" size={{ base: 'sm', sm: 'md' }} @@ -84,25 +118,18 @@ const DateSelector = ({ availableDates, selectedDates, setSelectedDates, activeD ))} - {multi && selectedDates.length < 5 && ( // only show add button if multi == True + {multi && selectedDates.length < 5 && ( + {locationLabel} + + + + ); +}; + +export default NHSNOverviewGraph; \ No newline at end of file diff --git a/app/src/components/PathogenFrontPage.jsx b/app/src/components/PathogenFrontPage.jsx index 6a29ae4..5f202bb 100644 --- a/app/src/components/PathogenFrontPage.jsx +++ b/app/src/components/PathogenFrontPage.jsx @@ -1,21 +1,32 @@ import { SimpleGrid, Stack, Title, Paper } from '@mantine/core'; import PathogenOverviewGraph from './PathogenOverviewGraph'; +import NHSNOverviewGraph from './NHSNOverviewGraph' import { useView } from '../hooks/useView'; const PathogenFrontPage = () => { const { selectedLocation } = useView(); return ( - - - Explore forecasts by pathogen - - - - - - - + + + + Explore forecasts by pathogen + + + + + + + + + + Explore ground truth data + + + + + + ); }; diff --git a/app/src/config/datasets.js b/app/src/config/datasets.js index 2702bb5..066e759 100644 --- a/app/src/config/datasets.js +++ b/app/src/config/datasets.js @@ -45,7 +45,7 @@ export const DATASETS = { }, nhsn: { shortName: 'nhsn', - fullName: 'CDC Respiratory Data', + fullName: 'NHSN Respiratory Data', views: [ { key: 'all', label: 'All Data', value: 'nhsnall' } ], From 3ff2f1b98207ed3d88ed3c124198bb9e349b49ea Mon Sep 17 00:00:00 2001 From: Emily Przykucki <100221052+emprzy@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:51:54 -0500 Subject: [PATCH 20/26] add an extra year to flusight gt data for joe --- scripts/processors/flusight.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/processors/flusight.py b/scripts/processors/flusight.py index 904fbaa..88b14d0 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, From 0773de3f8e32cd0df0d71e1d0698c8493da413f9 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Fri, 30 Jan 2026 14:35:47 -0500 Subject: [PATCH 21/26] make NHSN overview graph responsive to selected location --- app/src/components/NHSNOverviewGraph.jsx | 7 +++---- app/src/components/PathogenFrontPage.jsx | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/components/NHSNOverviewGraph.jsx b/app/src/components/NHSNOverviewGraph.jsx index f5a5dcf..c650340 100644 --- a/app/src/components/NHSNOverviewGraph.jsx +++ b/app/src/components/NHSNOverviewGraph.jsx @@ -13,13 +13,13 @@ const PATHOGEN_COLORS = { 'Total RSV Admissions': '#7f7f7f' }; -const NHSNOverviewGraph = () => { - const { setViewType, selectedLocation, viewType: activeViewType } = useView(); +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 = selectedLocation || 'US'; + const resolvedLocation = location || 'US'; const isActive = activeViewType === 'nhsn'; useEffect(() => { @@ -85,7 +85,6 @@ const NHSNOverviewGraph = () => { automargin: true, tickfont: { size: 10 }, fixedrange: true, - range: [0, 100000] }, showlegend: true, legend: { diff --git a/app/src/components/PathogenFrontPage.jsx b/app/src/components/PathogenFrontPage.jsx index 5f202bb..876c78d 100644 --- a/app/src/components/PathogenFrontPage.jsx +++ b/app/src/components/PathogenFrontPage.jsx @@ -22,7 +22,7 @@ const PathogenFrontPage = () => { Explore ground truth data - + From 01bdec7998abacdcb19ed7579ec94c7378c88f81 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 2 Feb 2026 12:44:03 -0500 Subject: [PATCH 22/26] basic dynamic announcement banner --- app/src/components/Announcement.jsx | 66 ++++++++++++++++++++++++ app/src/components/PathogenFrontPage.jsx | 2 + app/src/components/ShutdownBanner.jsx | 28 ---------- 3 files changed, 68 insertions(+), 28 deletions(-) create mode 100644 app/src/components/Announcement.jsx delete mode 100644 app/src/components/ShutdownBanner.jsx diff --git a/app/src/components/Announcement.jsx b/app/src/components/Announcement.jsx new file mode 100644 index 0000000..2020ad3 --- /dev/null +++ b/app/src/components/Announcement.jsx @@ -0,0 +1,66 @@ +import { Paper, Group, Text, ThemeIcon, Stack } from '@mantine/core'; +import { IconSpeakerphone, IconAlertSquareRounded } from '@tabler/icons-react'; + +const Announcement = ({ startDate, endDate, text, announcementType }) => { + const currentDate = new Date(); + const start = new Date(startDate); + const end = new Date(endDate); + + const isVisible = currentDate >= start && currentDate <= end; + + if (!isVisible) return null; + + return ( + + {announcementType === 'alert' ? ( + /* Alert Banner */ + + + + + + + + Alert: {text} + + + + + ) : ( + /* Update Banner */ + + + + + + + + New features: {text} + + + + + )} + + ); +}; + +export default Announcement; \ No newline at end of file diff --git a/app/src/components/PathogenFrontPage.jsx b/app/src/components/PathogenFrontPage.jsx index 876c78d..59da359 100644 --- a/app/src/components/PathogenFrontPage.jsx +++ b/app/src/components/PathogenFrontPage.jsx @@ -1,6 +1,7 @@ import { SimpleGrid, Stack, Title, Paper } from '@mantine/core'; import PathogenOverviewGraph from './PathogenOverviewGraph'; import NHSNOverviewGraph from './NHSNOverviewGraph' +import Announcement from './Announcement' import { useView } from '../hooks/useView'; const PathogenFrontPage = () => { @@ -8,6 +9,7 @@ const PathogenFrontPage = () => { return ( + Explore forecasts by pathogen diff --git a/app/src/components/ShutdownBanner.jsx b/app/src/components/ShutdownBanner.jsx deleted file mode 100644 index eda780b..0000000 --- a/app/src/components/ShutdownBanner.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Alert, Text } from '@mantine/core'; -import { useState } from 'react'; -import { IconAlertTriangle } from '@tabler/icons-react'; - -function ShutdownBanner() { - const [isVisible, setIsVisible] = useState(true); - if (!isVisible) { - return null; - } - - return ( - } - 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 From d37d775cc84c96feebaf7998cad4fa284240757a Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 2 Feb 2026 13:37:39 -0500 Subject: [PATCH 23/26] link patch unfortunately routes to RespiLens absolute URL (just putting the parameter doesn't trigger the correct load of the site) --- app/src/components/Announcement.jsx | 6 +++++- app/src/components/PathogenFrontPage.jsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/components/Announcement.jsx b/app/src/components/Announcement.jsx index 2020ad3..da89acd 100644 --- a/app/src/components/Announcement.jsx +++ b/app/src/components/Announcement.jsx @@ -1,6 +1,10 @@ import { Paper, Group, Text, ThemeIcon, Stack } from '@mantine/core'; import { IconSpeakerphone, IconAlertSquareRounded } from '@tabler/icons-react'; +// An announcement (per this component) can be one of two things: +// an `alert`: text is displayed in a yellow bubble +// an `update`: text is displayed in a blue bubble + const Announcement = ({ startDate, endDate, text, announcementType }) => { const currentDate = new Date(); const start = new Date(startDate); @@ -53,7 +57,7 @@ const Announcement = ({ startDate, endDate, text, announcementType }) => { - New features: {text} + Update: {text} diff --git a/app/src/components/PathogenFrontPage.jsx b/app/src/components/PathogenFrontPage.jsx index 59da359..dc19da8 100644 --- a/app/src/components/PathogenFrontPage.jsx +++ b/app/src/components/PathogenFrontPage.jsx @@ -9,7 +9,11 @@ const PathogenFrontPage = () => { return ( - + + Check out our new Flu MetroCast forecasts! + + } announcementType={"update"} /> Explore forecasts by pathogen From 003fd5e75434d9ca5db3b9bb64b9fab6af64cdcf Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 2 Feb 2026 13:47:33 -0500 Subject: [PATCH 24/26] `endDate` fix --- app/src/components/PathogenFrontPage.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/PathogenFrontPage.jsx b/app/src/components/PathogenFrontPage.jsx index dc19da8..e890d57 100644 --- a/app/src/components/PathogenFrontPage.jsx +++ b/app/src/components/PathogenFrontPage.jsx @@ -9,7 +9,7 @@ const PathogenFrontPage = () => { return ( - Check out our new Flu MetroCast forecasts! From 4b42a7f65c9a5d3857ca3542ae57869d9cc6bf40 Mon Sep 17 00:00:00 2001 From: Emily Przykucki Date: Mon, 2 Feb 2026 17:15:01 -0500 Subject: [PATCH 25/26] add error safety net for `announcementType` property --- app/src/components/Announcement.jsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/components/Announcement.jsx b/app/src/components/Announcement.jsx index da89acd..341a93d 100644 --- a/app/src/components/Announcement.jsx +++ b/app/src/components/Announcement.jsx @@ -10,14 +10,21 @@ const Announcement = ({ startDate, endDate, text, announcementType }) => { const start = new Date(startDate); const end = new Date(endDate); - const isVisible = currentDate >= start && currentDate <= end; + const validTypes = ['update', 'alert']; + if (!validTypes.includes(announcementType)) { + console.error( + `[Announcement Error]: Invalid type "${announcementType}". ` + + `Expected "update" or "alert". Check props in PathogenFrontPage.jsx.` + ); + } + const isVisible = currentDate >= start && currentDate <= end; if (!isVisible) return null; return ( {announcementType === 'alert' ? ( - /* Alert Banner */ + /* alert Banner */ { ) : ( - /* Update Banner */ + /* update Banner */ Date: Mon, 2 Feb 2026 17:55:52 -0500 Subject: [PATCH 26/26] make announcement dismissible --- app/src/components/Announcement.jsx | 118 ++++++++++++----------- app/src/components/PathogenFrontPage.jsx | 6 +- 2 files changed, 67 insertions(+), 57 deletions(-) diff --git a/app/src/components/Announcement.jsx b/app/src/components/Announcement.jsx index 341a93d..ff6c198 100644 --- a/app/src/components/Announcement.jsx +++ b/app/src/components/Announcement.jsx @@ -1,75 +1,81 @@ -import { Paper, Group, Text, ThemeIcon, Stack } from '@mantine/core'; +import { useState } from 'react'; +import { Paper, Group, Text, ThemeIcon, Stack, CloseButton } from '@mantine/core'; import { IconSpeakerphone, IconAlertSquareRounded } from '@tabler/icons-react'; -// An announcement (per this component) can be one of two things: -// an `alert`: text is displayed in a yellow bubble -// an `update`: text is displayed in a blue bubble +// 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 Announcement = ({ startDate, endDate, text, announcementType }) => { 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}". ` + - `Expected "update" or "alert". Check props in PathogenFrontPage.jsx.` - ); + console.error(`[Announcement Error]: Invalid type "${announcementType}".`); } const isVisible = currentDate >= start && currentDate <= end; - if (!isVisible) return null; + if (!isVisible || dismissed) return null; + + const handleDismiss = () => { + localStorage.setItem(storageKey, 'true'); + setDismissed(true); + }; + + const isAlert = announcementType === 'alert'; return ( - {announcementType === 'alert' ? ( - /* alert Banner */ - - - - - - - - Alert: {text} - - + + + + + {isAlert ? : } + + + {isAlert ? 'Alert' : 'Update'}: {text} + - - ) : ( - /* update Banner */ - - - - - - - - Update: {text} - - - - - )} + + + + ); }; diff --git a/app/src/components/PathogenFrontPage.jsx b/app/src/components/PathogenFrontPage.jsx index e890d57..ab7d3ac 100644 --- a/app/src/components/PathogenFrontPage.jsx +++ b/app/src/components/PathogenFrontPage.jsx @@ -9,11 +9,15 @@ const PathogenFrontPage = () => { return ( - Check out our new Flu MetroCast forecasts! } announcementType={"update"} /> + Explore forecasts by pathogen