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} +

{ // Configuration for AboutHubOverlay based on viewType const aboutHubConfig = { - 'covid_projs': { + 'covid_forecasts': { title: ( COVID-19 Forecast Hub @@ -71,7 +71,7 @@ const DataVisualizationContainer = () => { ) }, - 'rsv_projs': { + 'rsv_forecasts': { title: ( RSV Forecast Hub @@ -140,7 +140,7 @@ const DataVisualizationContainer = () => { ) }, - 'flu_projs': { + 'flu_forecasts': { title: ( FluSight Forecast Hub @@ -206,7 +206,7 @@ const DataVisualizationContainer = () => { ) }, - 'metrocast_projs': { + 'metrocast_forecasts': { title: ( Flu MetroCast diff --git a/app/src/components/DateSelector.jsx b/app/src/components/DateSelector.jsx index 8f40bbea..635f5d2f 100644 --- a/app/src/components/DateSelector.jsx +++ b/app/src/components/DateSelector.jsx @@ -1,116 +1,175 @@ -import { Group, Text, ActionIcon, Button } from '@mantine/core'; +import { useEffect, useCallback, useRef, useState } 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 [keyMovementAnchor, setKeyMovementAnchor] = useState(activeDate); // keyMovement responsible for date keydown movement + const firstDateBoxRef = useRef(null); + + useEffect(() => { + if (activeDate) { + setKeyMovementAnchor(activeDate); + } + }, [activeDate]); + const hasDate = !!activeDate; + useEffect(() => { + if (hasDate && firstDateBoxRef.current) { + const timeout = setTimeout(() => firstDateBoxRef.current?.focus(), 100); + return () => clearTimeout(timeout); + } + }, [hasDate]); + 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.map(d => d === dateToMove ? targetDate : d); + + setSelectedDates(newDates.sort()); + + setActiveDate(targetDate); + + setKeyMovementAnchor(targetDate); + } + }, [availableDates, selectedDates, setSelectedDates, setActiveDate]); + + useEffect(() => { + const handleKeyDown = (event) => { + if (!keyMovementAnchor) return; + if (['INPUT', 'TEXTAREA'].includes(event.target.tagName)) return; + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + handleMove(keyMovementAnchor, -1); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + handleMove(keyMovementAnchor, 1); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleMove, keyMovementAnchor]); + return ( - {selectedDates.map((date) => ( + {selectedDates.map((date, index) => ( { - 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' }} - aria-label={`Previous date from ${date}`} > - - - {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}`} + { + setKeyMovementAnchor(date); + setActiveDate(date); + }} + onClick={() => { + setKeyMovementAnchor(date); + setActiveDate(date); + }} + style={{ outline: 'none', cursor: 'pointer' }} + > + + - - - )} - + {date} + + + {multi && ( + { + e.stopPropagation(); + const newDates = selectedDates.filter(d => d !== date); + setSelectedDates(newDates); + if (date === keyMovementAnchor && newDates.length > 0) { + const fallback = newDates[0]; + setActiveDate(fallback); + setKeyMovementAnchor(fallback); + } + }} + disabled={selectedDates.length === 1} + variant="subtle" + size="xs" + color="red" + > + + + )} + + { - 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' }} - aria-label={`Next date from ${date}`} > ))} - {multi && selectedDates.length < 5 && ( // only show add button if multi == True + {/* Add Date Button */} + {multi && selectedDates.length < 5 && (

+ + {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)} />

+ + {stateName} +

{ - 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,