From 447dcc25efa073126f24ba4635c00dfdd66fac6c Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Wed, 4 Feb 2026 21:25:10 +0100 Subject: [PATCH 01/15] refactor(app): consolidate forecast Plotly views and overview card UI - add ForecastPlotView to centralize shared Plotly forecasting logic (data/trace building, range handling, reset button behavior, layout/config) - simplify COVID19View and RSVView to thin wrappers over ForecastPlotView, only varying ground-truth hover value formatting - add OverviewGraphCard to reuse shared card layout for overview graphs (loading/error/empty states, plot rendering, action button, location label) - refactor PathogenOverviewGraph and NHSNOverviewGraph to use OverviewGraphCard and remove duplicate card/plot markup No functional changes intended; primarily DRY consolidation of duplicated forecast and overview graph UI logic. --- app/src/components/COVID19View.jsx | 374 +----------------- app/src/components/NHSNOverviewGraph.jsx | 61 +-- app/src/components/PathogenOverviewGraph.jsx | 60 +-- app/src/components/RSVView.jsx | 376 +------------------ 4 files changed, 50 insertions(+), 821 deletions(-) diff --git a/app/src/components/COVID19View.jsx b/app/src/components/COVID19View.jsx index c9996ef..4ba6982 100644 --- a/app/src/components/COVID19View.jsx +++ b/app/src/components/COVID19View.jsx @@ -1,368 +1,10 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { useMantineColorScheme, Stack, Text } 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 ForecastPlotView from './ForecastPlotView'; +const COVID19View = (props) => ( + +); -const COVID19View = ({ 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 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); - const projectionsDataRef = useRef([]); - - const { colorScheme } = useMantineColorScheme(); - const groundTruth = data?.ground_truth; - const forecasts = data?.forecasts; - - const calculateYRange = useCallback((data, xRange) => { - if (!data || !xRange || !Array.isArray(data) || data.length === 0 || !selectedTarget) return null; - let minY = Infinity; - let maxY = -Infinity; - const [startX, endX] = xRange; - const startDate = new Date(startX); - const endDate = new Date(endX); - - data.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); - } - } - } - }); - if (minY !== Infinity && maxY !== -Infinity) { - const padding = maxY * (CHART_CONSTANTS.Y_AXIS_PADDING_PERCENT / 100); - const rangeMin = Math.max(0, minY - padding); - return [rangeMin, maxY + padding]; - } - return null; - }, [selectedTarget]); - - const projectionsData = useMemo(() => { - if (!groundTruth || !forecasts || selectedDates.length === 0 || !selectedTarget) { - return []; - } - const groundTruthValues = groundTruth[selectedTarget]; - if (!groundTruthValues) { - console.warn(`Ground truth data not found for target: ${selectedTarget}`); - 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' }, - hovertemplate: 'Observed Data
Date: %{x}
Value: %{y}' - }; - - 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 hoverTexts = []; - - const sortedPredictions = Object.values(forecast.predictions || {}).sort((a, b) => new Date(a.date) - new Date(b.date)); - - sortedPredictions.forEach((pred) => { - const pointDate = pred.date; - forecastDates.push(pointDate); - const { quantiles = [], values = [] } = pred; - const findValue = (q) => { - const index = quantiles.indexOf(q); - return index !== -1 ? values[index] : null; - }; - - const val_025 = findValue(0.025); - const val_25 = findValue(0.25); - const val_50 = findValue(0.5); - const val_75 = findValue(0.75); - const val_975 = findValue(0.975); - - 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); - - // 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}`); - } - }); - - if (forecastDates.length === 0) return []; - - 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`, 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, - text: hoverTexts, - hovertemplate: '%{text}', - hoverlabel: { - bgcolor: modelColor, - font: { color: '#ffffff' }, - bordercolor: '#ffffff' - } - } - ]; - }) - ); - return [groundTruthTrace, ...modelTraces]; - }, [groundTruth, forecasts, selectedDates, selectedModels, selectedTarget]); - - useEffect(() => { - getDefaultRangeRef.current = getDefaultRange; - projectionsDataRef.current = projectionsData; - }, [getDefaultRange, projectionsData]); - - const activeModels = useMemo(() => { - const activeModelSet = new Set(); - if (!forecasts || !selectedTarget || !selectedDates.length) { - return activeModelSet; - } - selectedDates.forEach(date => { - const forecastsForDate = forecasts[date]; - if (!forecastsForDate) return; - const targetData = forecastsForDate[selectedTarget]; - if (!targetData) return; - Object.keys(targetData).forEach(model => { - activeModelSet.add(model); - }); - }); - return activeModelSet; - }, [forecasts, selectedDates, selectedTarget]); - - const defaultRange = useMemo(() => getDefaultRange(), [getDefaultRange]); - - useEffect(() => { - setXAxisRange(null); - }, [selectedTarget]); - - useEffect(() => { - const currentXRange = xAxisRange || defaultRange; - if (projectionsData.length > 0 && currentXRange) { - const initialYRange = calculateYRange(projectionsData, currentXRange); - setYAxisRange(initialYRange); - } else { - setYAxisRange(null); - } - }, [projectionsData, xAxisRange, defaultRange, calculateYRange]); - - const handlePlotUpdate = useCallback((figure) => { - if (isResettingRef.current) { - isResettingRef.current = false; - return; - } - if (figure && 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: 'closest', - 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 => { - return { - 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, - showSendToCloud: false, - plotlyServerURL: "", - scrollZoom: false, - doubleClick: 'reset', - toImageButtonOptions: { - format: 'png', - filename: 'forecast_plot' - }, - modeBarButtonsToRemove: ['resetScale2d', 'select2d', 'lasso2d'], - modeBarButtonsToAdd: [{ - name: 'Reset view', - icon: Plotly.Icons.home, - click: function(gd) { - const currentGetDefaultRange = getDefaultRangeRef.current; - const currentProjectionsData = projectionsDataRef.current; - - const range = currentGetDefaultRange(); - if (!range) return; - - const newYRange = currentProjectionsData.length > 0 ? calculateYRange(currentProjectionsData, range) : null; - - isResettingRef.current = true; - - setXAxisRange(null); - setYAxisRange(newYRange); - - Plotly.relayout(gd, { - 'xaxis.range': range, - 'yaxis.range': newYRange, - 'yaxis.autorange': newYRange === null - }); - } - }] - }), [calculateYRange]); - - if (!selectedTarget) { - return ( - - Please select a target to view data. - - ); - } - - return ( - - -
- -
- - {stateName} - -
-

- 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]; - }} - /> -
- ); -}; - -export default COVID19View; \ No newline at end of file +export default COVID19View; diff --git a/app/src/components/NHSNOverviewGraph.jsx b/app/src/components/NHSNOverviewGraph.jsx index 68e913a..7f56066 100644 --- a/app/src/components/NHSNOverviewGraph.jsx +++ b/app/src/components/NHSNOverviewGraph.jsx @@ -1,9 +1,8 @@ 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'; +import OverviewGraphCard from './OverviewGraphCard'; const DEFAULT_COLS = ['Total COVID-19 Admissions', 'Total Influenza Admissions', 'Total RSV Admissions']; @@ -138,48 +137,22 @@ const NHSNOverviewGraph = ( {location} ) => { 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} - - - + setViewType('nhsn')} + actionIcon={} + locationLabel={locationLabel} + /> ); }; -export default NHSNOverviewGraph; \ No newline at end of file +export default NHSNOverviewGraph; diff --git a/app/src/components/PathogenOverviewGraph.jsx b/app/src/components/PathogenOverviewGraph.jsx index e76f72c..cf6e20c 100644 --- a/app/src/components/PathogenOverviewGraph.jsx +++ b/app/src/components/PathogenOverviewGraph.jsx @@ -1,10 +1,10 @@ import { useMemo } from 'react'; -import { Button, Card, Group, Loader, Stack, Text, Title } from '@mantine/core'; +import { Text } from '@mantine/core'; import { IconChevronRight } from '@tabler/icons-react'; -import Plot from 'react-plotly.js'; import { useForecastData } from '../hooks/useForecastData'; import { DATASETS } from '../config'; import { useView } from '../hooks/useView'; +import OverviewGraphCard from './OverviewGraphCard'; const DEFAULT_TARGETS = { covid_forecasts: 'wk inc covid hosp', @@ -235,47 +235,21 @@ const PathogenOverviewGraph = ({ viewType, title, location }) => { const locationLabel = resolvedLocation === 'US' ? 'US national view' : resolvedLocation; return ( - - - - {title} - {selectedDate && ( - {selectedDate} - )} - - {loading && ( - - - Loading data... - - )} - {!loading && error && ( - {error} - )} - {!loading && !error && traces.length > 0 && ( - - )} - {!loading && !error && traces.length === 0 && ( - No data available. - )} - - - {locationLabel} - - - + {selectedDate} : null} + loading={loading} + loadingLabel="Loading data..." + error={error} + traces={traces} + layout={layout} + emptyLabel="No data available." + actionLabel={isActive ? 'Viewing' : 'View forecasts'} + actionActive={isActive} + onAction={() => setViewType(datasetConfig?.defaultView || viewType)} + actionIcon={} + locationLabel={locationLabel} + /> ); }; diff --git a/app/src/components/RSVView.jsx b/app/src/components/RSVView.jsx index 558b9e1..44c9653 100644 --- a/app/src/components/RSVView.jsx +++ b/app/src/components/RSVView.jsx @@ -1,370 +1,10 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { useMantineColorScheme, Stack, Text } 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 ForecastPlotView from './ForecastPlotView'; +const RSVView = (props) => ( + +); -const RSVView = ({ 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 stateName = data?.metadata?.location_name; - - const getDefaultRangeRef = useRef(getDefaultRange); - const projectionsDataRef = useRef([]); - - const { colorScheme } = useMantineColorScheme(); - const groundTruth = data?.ground_truth; - const forecasts = data?.forecasts; - - const calculateYRange = useCallback((data, xRange) => { - if (!data || !xRange || !Array.isArray(data) || data.length === 0 || !selectedTarget) return null; - let minY = Infinity; - let maxY = -Infinity; - const [startX, endX] = xRange; - const startDate = new Date(startX); - const endDate = new Date(endX); - data.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); - } - } - } - }); - if (minY !== Infinity && maxY !== -Infinity) { - const padding = maxY * (CHART_CONSTANTS.Y_AXIS_PADDING_PERCENT / 100); - const rangeMin = Math.max(0, minY - padding); - return [rangeMin, maxY + padding]; - } - return null; - }, [selectedTarget]); - - const projectionsData = useMemo(() => { - if (!groundTruth || !forecasts || selectedDates.length === 0 || !selectedTarget) { - return []; - } - const groundTruthValues = groundTruth[selectedTarget]; - if (!groundTruthValues) { - console.warn(`Ground truth data not found for target: ${selectedTarget}`); - 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' }, - hovertemplate: 'Observed Data
Date: %{x}
Value: %{y:.2f}' - }; - - 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 hoverTexts = []; - - const sortedPredictions = Object.values(forecast.predictions || {}).sort((a, b) => new Date(a.date) - new Date(b.date)); - - sortedPredictions.forEach((pred) => { - const pointDate = pred.date; - forecastDates.push(pointDate); - const { quantiles = [], values = [] } = pred; - - const findValue = (q) => { - const index = quantiles.indexOf(q); - return index !== -1 ? values[index] : null; - }; - - const val_025 = findValue(0.025); - const val_25 = findValue(0.25); - const val_50 = findValue(0.5); - const val_75 = findValue(0.75); - const val_975 = findValue(0.975); - - 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 { - console.warn(`Missing quantiles for model ${model}, date ${date}, target ${selectedTarget}, prediction date ${pred.date}`); - } - }); - - if (forecastDates.length === 0) return []; - - 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`, 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, - text: hoverTexts, - hovertemplate: '%{text}', - hoverlabel: { - bgcolor: modelColor, - font: { color: '#ffffff' }, - bordercolor: '#ffffff' - } - } - ]; - }) - ); - return [groundTruthTrace, ...modelTraces]; - }, [groundTruth, forecasts, selectedDates, selectedModels, selectedTarget]); - - useEffect(() => { - getDefaultRangeRef.current = getDefaultRange; - projectionsDataRef.current = projectionsData; - }, [getDefaultRange, projectionsData]); - - const activeModels = useMemo(() => { - const activeModelSet = new Set(); - if (!forecasts || !selectedTarget || !selectedDates.length) { - return activeModelSet; - } - - selectedDates.forEach(date => { - const forecastsForDate = forecasts[date]; - if (!forecastsForDate) return; - - const targetData = forecastsForDate[selectedTarget]; - if (!targetData) return; - - Object.keys(targetData).forEach(model => { - activeModelSet.add(model); - }); - }); - - return activeModelSet; - }, [forecasts, selectedDates, selectedTarget]); - - const defaultRange = useMemo(() => getDefaultRange(), [getDefaultRange]); - - useEffect(() => { - setXAxisRange(null); - }, [selectedTarget]); - - useEffect(() => { - const currentXRange = xAxisRange || defaultRange; - if (projectionsData.length > 0 && currentXRange) { - const initialYRange = calculateYRange(projectionsData, currentXRange); - setYAxisRange(initialYRange); - } else { - setYAxisRange(null); - } - }, [projectionsData, xAxisRange, defaultRange, calculateYRange]); - - const handlePlotUpdate = useCallback((figure) => { - if (isResettingRef.current) { - isResettingRef.current = false; - return; - } - - if (figure && 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: 'closest', - 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 => { - return { - 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, - showSendToCloud: false, - plotlyServerURL: "", - scrollZoom: false, - doubleClick: 'reset', - modeBarButtonsToRemove: ['select2d', 'lasso2d', 'resetScale2d'], - toImageButtonOptions: { - format: 'png', - filename: 'forecast_plot' - }, - modeBarButtonsToAdd: [{ - name: 'Reset view', - icon: Plotly.Icons.home, - click: function(gd) { - const currentGetDefaultRange = getDefaultRangeRef.current; - const currentProjectionsData = projectionsDataRef.current; - - const range = currentGetDefaultRange(); - if (!range) return; - - const newYRange = currentProjectionsData.length > 0 ? calculateYRange(currentProjectionsData, range) : null; - - isResettingRef.current = true; - - setXAxisRange(null); - setYAxisRange(newYRange); - - Plotly.relayout(gd, { - 'xaxis.range': range, - 'yaxis.range': newYRange, - 'yaxis.autorange': newYRange === null - }); - } - }] - }), [calculateYRange]); - - if (!selectedTarget) { - return ( - - Please select a target to view data. - - ); - } - - return ( - - -
- handlePlotUpdate(figure)} - /> -
- - {stateName} - -
-

- 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]; - }} - /> -
- ); -}; - -export default RSVView; \ No newline at end of file +export default RSVView; From 6d57ffb954a3a1e2c1d3038bc51eb340ce707b47 Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Wed, 4 Feb 2026 22:00:27 +0100 Subject: [PATCH 02/15] consolidation --- app/README.md | 30 ++ app/src/components/FluView.jsx | 489 ------------------ app/src/components/ForecastPlotView.jsx | 337 ++++++++++++ app/src/components/NHSNOverviewGraph.jsx | 121 ++--- app/src/components/OverviewGraphCard.jsx | 66 +++ app/src/components/PathogenOverviewGraph.jsx | 83 +-- app/src/components/ViewSwitchboard.jsx | 10 +- .../components/{ => views}/COVID19View.jsx | 2 +- app/src/components/views/FluView.jsx | 211 ++++++++ .../components/{ => views}/MetroCastView.jsx | 208 +++----- app/src/components/{ => views}/NHSNView.jsx | 12 +- app/src/components/{ => views}/RSVView.jsx | 2 +- app/src/hooks/useOverviewPlot.js | 104 ++++ app/src/hooks/useQuantileForecastTraces.js | 200 +++++++ 14 files changed, 1095 insertions(+), 780 deletions(-) delete mode 100644 app/src/components/FluView.jsx create mode 100644 app/src/components/ForecastPlotView.jsx create mode 100644 app/src/components/OverviewGraphCard.jsx rename app/src/components/{ => views}/COVID19View.jsx (72%) create mode 100644 app/src/components/views/FluView.jsx rename app/src/components/{ => views}/MetroCastView.jsx (62%) rename app/src/components/{ => views}/NHSNView.jsx (98%) rename app/src/components/{ => views}/RSVView.jsx (72%) create mode 100644 app/src/hooks/useOverviewPlot.js create mode 100644 app/src/hooks/useQuantileForecastTraces.js diff --git a/app/README.md b/app/README.md index f768e33..5f5e7a9 100644 --- a/app/README.md +++ b/app/README.md @@ -6,3 +6,33 @@ Currently, two official plugins are available: - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## RespiLens App Structure + +The app is organized around view-level components and shared UI primitives. + +- `app/src/components/views/`: top-level view components rendered by `ViewSwitchboard`. +- `app/src/components/`: shared components used across multiple views. +- `app/src/hooks/`: shared hooks for common data+plot logic. + +### Shared Overview Plot Logic + +`app/src/hooks/useOverviewPlot.js` consolidates the Plotly overview card mechanics: + +- `buildTraces(data)`: caller-supplied function that returns traces. +- `xRange`: optional `[startDate, endDate]` used to compute y-range. +- `yPaddingTopRatio` / `yPaddingBottomRatio`: asymmetric padding around min/max. +- `yMinFloor`: optional hard floor for the y-axis (`null` disables). +- `layoutDefaults` / `layoutOverrides`: customize Plotly layout safely. + +This keeps `PathogenOverviewGraph` and `NHSNOverviewGraph` consistent while still +allowing each to define its own trace logic. + +### Shared Quantile Forecast Logic + +`app/src/hooks/useQuantileForecastTraces.js` builds the quantile-based Plotly traces +used across forecast views: + +- Ground truth + median + 50%/95% interval bands. +- Formatting and styling controls (line widths, marker sizes, value suffixes). +- Handles missing quantiles when requested (e.g., MetroCast cards). diff --git a/app/src/components/FluView.jsx b/app/src/components/FluView.jsx deleted file mode 100644 index 344169c..0000000 --- a/app/src/components/FluView.jsx +++ /dev/null @@ -1,489 +0,0 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { useMantineColorScheme, Stack, Text } from '@mantine/core'; -import Plot from 'react-plotly.js'; -import Plotly from 'plotly.js/dist/plotly'; -import ModelSelector from './ModelSelector'; -import FluPeak from './FluPeak'; -import LastFetched from './LastFetched'; -import { MODEL_COLORS } from '../config/datasets'; -import { CHART_CONSTANTS, RATE_CHANGE_CATEGORIES } from '../constants/chart'; -import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils'; - -const FluView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, viewType, windowSize, getDefaultRange, selectedTarget, peaks, availablePeakDates, availablePeakModels, peakLocation }) => { - const [yAxisRange, setYAxisRange] = useState(null); - const [xAxisRange, setXAxisRange] = useState(null); - const plotRef = useRef(null); - const isResettingRef = useRef(false); - const debounceTimerRef = useRef(null); - const stateName = data?.metadata?.location_name; - - const getDefaultRangeRef = useRef(getDefaultRange); - const projectionsDataRef = useRef([]); - - const { colorScheme } = useMantineColorScheme(); - const groundTruth = data?.ground_truth; - const forecasts = data?.forecasts; - - const lastSelectedDate = useMemo(() => { - if (selectedDates.length === 0) return null; - return selectedDates.slice().sort().pop(); - }, [selectedDates]); - - const defaultRange = useMemo(() => getDefaultRange(), [getDefaultRange]); - const calculateYRange = useCallback((data, xRange) => { - if (!data || !xRange || !Array.isArray(data) || data.length === 0) return null; - let minY = Infinity; - let maxY = -Infinity; - const [startX, endX] = xRange; - const startDate = new Date(startX); - const endDate = new Date(endX); - - data.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); - } - } - } - }); - - if (minY !== Infinity && maxY !== -Infinity) { - const padding = maxY * (CHART_CONSTANTS.Y_AXIS_PADDING_PERCENT / 100); - return [0, maxY + padding]; - } - return null; - }, []); - - const projectionsData = useMemo(() => { - const targetForProjections = (viewType === 'flu' || viewType === 'flu_forecasts') - ? selectedTarget - : 'wk inc flu hosp'; - - if (!groundTruth || !forecasts || selectedDates.length === 0 || !targetForProjections) { - return []; - } - - const groundTruthValues = groundTruth[targetForProjections]; - if (!groundTruthValues) { - console.warn(`Ground truth data not found for target: ${targetForProjections}`); - 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' }, - hovertemplate: 'Observed Data
Date: %{x}
Value: %{y}' - }; - - const modelTraces = selectedModels.flatMap(model => - selectedDates.flatMap((date, dateIndex) => { - const forecastsForDate = forecasts[date] || {}; - const forecast = forecastsForDate[targetForProjections]?.[model]; - - 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]) => { - const pointDate = pred.date; - forecastDates.push(pointDate); - if (forecast.type !== 'quantile') return; - const { quantiles = [], values = [] } = pred; - - const findValue = (q) => { - const idx = quantiles.indexOf(q); - return idx !== -1 ? values[idx] : 0; - }; - - 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, 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]); - - useEffect(() => { - getDefaultRangeRef.current = getDefaultRange; - projectionsDataRef.current = projectionsData; - }, [getDefaultRange, projectionsData]); - - const rateChangeData = useMemo(() => { - if (!forecasts || selectedDates.length === 0) return []; - const categoryOrder = RATE_CHANGE_CATEGORIES; - return selectedModels.map(model => { - const forecast = forecasts[lastSelectedDate]?.['wk flu hosp rate change']?.[model]; - if (!forecast) return null; - const horizon0 = forecast.predictions['0']; - if (!horizon0) return null; - const modelColor = MODEL_COLORS[selectedModels.indexOf(model) % MODEL_COLORS.length]; - const orderedData = categoryOrder.map(cat => ({ - 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', - hovertemplate: '%{fullData.name}
%{y}: %{x:.1f}%' - }; - }).filter(Boolean); - }, [forecasts, selectedDates, selectedModels, lastSelectedDate]); - - const finalPlotData = useMemo(() => { - const histogramTraces = viewType === 'fludetailed' - ? rateChangeData.map(trace => ({ - ...trace, - orientation: 'h', - xaxis: 'x2', - yaxis: 'y2' - })) - : []; - - return [...projectionsData, ...histogramTraces]; - }, [projectionsData, rateChangeData, viewType]); - - const activeModels = useMemo(() => { - const activeModelSet = new Set(); - if (viewType === 'flu_peak' || !forecasts || !selectedDates.length) { - return activeModelSet; - } - - const targetForProjections = (viewType === 'flu' || viewType === 'flu_forecasts') - ? selectedTarget - : 'wk inc flu hosp'; - - if ((viewType === 'flu' || viewType === 'flu_forecasts') && !targetForProjections) return activeModelSet; - - selectedDates.forEach(date => { - const forecastsForDate = forecasts[date]; - if (!forecastsForDate) return; - - if (targetForProjections) { - const targetData = forecastsForDate[targetForProjections]; - if (targetData) { - Object.keys(targetData).forEach(model => activeModelSet.add(model)); - } - } - - if (viewType === 'fludetailed') { - const rateChangeData = forecastsForDate['wk flu hosp rate change']; - if (rateChangeData) { - Object.keys(rateChangeData).forEach(model => activeModelSet.add(model)); - } - } - }); - - return activeModelSet; - }, [forecasts, selectedDates, selectedTarget, viewType]); - - useEffect(() => { - setXAxisRange(null); - }, [viewType, selectedTarget]); - - useEffect(() => { - const currentXRange = xAxisRange || defaultRange; - if (projectionsData.length > 0 && currentXRange) { - const initialYRange = calculateYRange(projectionsData, currentXRange); - if (initialYRange) { - setYAxisRange(initialYRange); - } - } else { - setYAxisRange(null); - } - }, [projectionsData, xAxisRange, defaultRange, calculateYRange]); - - const handlePlotUpdate = useCallback((figure) => { - if (isResettingRef.current) { - isResettingRef.current = false; - return; - } - - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - if (figure && figure['xaxis.range']) { - const newXRange = figure['xaxis.range']; - - debounceTimerRef.current = setTimeout(() => { - if (JSON.stringify(newXRange) !== JSON.stringify(xAxisRange)) { - setXAxisRange(newXRange); - } - }, 100); - } - }, [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' - }, - grid: viewType === 'fludetailed' ? { - columns: 1, - rows: 1, - pattern: 'independent', - subplots: [['xy'], ['x2y2']], - xgap: 0.15 - } : undefined, - 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: 'closest', - dragmode: false, - margin: { l: 60, r: 30, t: 30, b: 30 }, - xaxis: { - domain: viewType === 'fludetailed' ? [0, 0.8] : [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 - }, - yaxis: { - title: (() => { - if (viewType === 'flu_peak') return 'Flu Hospitalizations'; - const longName = targetDisplayNameMap[selectedTarget]; - return targetYAxisLabelMap[longName] || longName || selectedTarget || 'Value'; - })(), - range: yAxisRange, - autorange: yAxisRange === null, - }, - shapes: selectedDates.map(date => { - return { - type: 'line', - x0: date, - x1: date, - y0: 0, - y1: 1, - yref: 'paper', - line: { - color: 'red', - width: 1, - dash: 'dash' - } - }; - }), - hoverlabel: { namelength: -1 }, - ...(viewType === 'fludetailed' ? { - xaxis2: { - title: { - text: `displaying date ${lastSelectedDate || 'N/A'}`, - font: { - family: 'Arial, sans-serif', - size: 13, - color: '#1f77b4' - }, - standoff: 10 - }, - domain: [0.85, 1], - showgrid: false - }, - yaxis2: { - title: '', - showticklabels: true, - type: 'category', - side: 'right', - automargin: true, - tickfont: { align: 'right' } - } - } : {}) - }), [colorScheme, windowSize, defaultRange, selectedTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange, viewType, lastSelectedDate]); - - const config = useMemo(() => ({ - responsive: true, - displayModeBar: true, - displaylogo: false, - modeBarPosition: 'left', - showSendToCloud: false, - plotlyServerURL: "", - scrollZoom: false, - doubleClick: 'reset', - modeBarButtonsToRemove: ['select2d', 'lasso2d', 'resetScale2d'], - toImageButtonOptions: { - format: 'png', - filename: 'forecast_plot' - }, - modeBarButtonsToAdd: [{ - name: 'Reset view', - icon: Plotly.Icons.home, - click: function(gd) { - const currentGetDefaultRange = getDefaultRangeRef.current; - const currentProjectionsData = projectionsDataRef.current; - - const range = currentGetDefaultRange(); - if (!range) return; - - const newYRange = currentProjectionsData.length > 0 ? calculateYRange(currentProjectionsData, range) : null; - - isResettingRef.current = true; - - setXAxisRange(null); - setYAxisRange(newYRange); - - Plotly.relayout(gd, { - 'xaxis.range': range, - 'yaxis.range': newYRange, - 'yaxis.autorange': newYRange === null - }); - } - }] - }), [calculateYRange]); - - if (viewType === 'flu' && !selectedTarget) { - return ( - - Please select a target to view data. - - ); - } - - if (viewType === 'flu_peak') { - return ( - - - - - ); - } - - return ( - - -
- handlePlotUpdate(figure)} - /> -
- - {stateName} - -
-

- 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]; - }} - /> -
- ); -}; - -export default FluView; \ No newline at end of file diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx new file mode 100644 index 0000000..c0ce75a --- /dev/null +++ b/app/src/components/ForecastPlotView.jsx @@ -0,0 +1,337 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useMantineColorScheme, Stack, Text } 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 useQuantileForecastTraces from '../hooks/useQuantileForecastTraces'; + +const ForecastPlotView = ({ + data, + metadata, + selectedDates, + selectedModels, + models, + setSelectedModels, + windowSize, + getDefaultRange, + selectedTarget, + forecastTarget = null, + displayTarget = null, + requireTarget = true, + activeModels: activeModelsOverride = null, + extraTraces = null, + layoutOverrides = null, + configOverrides = null, + groundTruthValueFormat = '%{y}' +}) => { + const [yAxisRange, setYAxisRange] = useState(null); + const [xAxisRange, setXAxisRange] = useState(null); + const plotRef = useRef(null); + const isResettingRef = useRef(false); + const stateName = data?.metadata?.location_name; + + const getDefaultRangeRef = useRef(getDefaultRange); + const projectionsDataRef = useRef([]); + + const { colorScheme } = useMantineColorScheme(); + const groundTruth = data?.ground_truth; + const forecasts = data?.forecasts; + + const resolvedForecastTarget = forecastTarget || selectedTarget; + const resolvedDisplayTarget = displayTarget || selectedTarget || resolvedForecastTarget; + + const calculateYRange = useCallback((chartData, xRange) => { + if (!chartData || !xRange || !Array.isArray(chartData) || chartData.length === 0 || !resolvedForecastTarget) return null; + let minY = Infinity; + let maxY = -Infinity; + const [startX, endX] = xRange; + const startDate = new Date(startX); + const endDate = new Date(endX); + + chartData.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); + } + } + } + }); + if (minY !== Infinity && maxY !== -Infinity) { + const padding = maxY * (CHART_CONSTANTS.Y_AXIS_PADDING_PERCENT / 100); + const rangeMin = Math.max(0, minY - padding); + return [rangeMin, maxY + padding]; + } + return null; + }, [resolvedForecastTarget]); + + const projectionsData = useQuantileForecastTraces({ + groundTruth, + forecasts, + selectedDates, + selectedModels, + target: resolvedForecastTarget, + groundTruthLabel: 'Observed', + groundTruthValueFormat, + valueSuffix: '', + modelLineWidth: 2, + modelMarkerSize: 6, + groundTruthLineWidth: 2, + groundTruthMarkerSize: 4, + showLegendForFirstDate: true, + fillMissingQuantiles: false + }); + + const appendedTraces = useMemo(() => { + if (!extraTraces) return []; + if (typeof extraTraces === 'function') { + return extraTraces({ baseTraces: projectionsData }) || []; + } + return Array.isArray(extraTraces) ? extraTraces : []; + }, [extraTraces, projectionsData]); + + const finalTraces = useMemo(() => { + if (!appendedTraces.length) return projectionsData; + return [...projectionsData, ...appendedTraces]; + }, [projectionsData, appendedTraces]); + + useEffect(() => { + getDefaultRangeRef.current = getDefaultRange; + projectionsDataRef.current = projectionsData; + }, [getDefaultRange, projectionsData]); + + const activeModels = useMemo(() => { + if (activeModelsOverride) { + return activeModelsOverride; + } + const activeModelSet = new Set(); + if (!forecasts || !resolvedForecastTarget || !selectedDates.length) { + return activeModelSet; + } + + selectedDates.forEach(date => { + const forecastsForDate = forecasts[date]; + if (!forecastsForDate) return; + + const targetData = forecastsForDate[resolvedForecastTarget]; + if (!targetData) return; + + Object.keys(targetData).forEach(model => { + activeModelSet.add(model); + }); + }); + + return activeModelSet; + }, [activeModelsOverride, forecasts, selectedDates, resolvedForecastTarget]); + + const defaultRange = useMemo(() => getDefaultRange(), [getDefaultRange]); + + useEffect(() => { + setXAxisRange(null); + }, [selectedTarget, resolvedForecastTarget]); + + useEffect(() => { + const currentXRange = xAxisRange || defaultRange; + if (projectionsData.length > 0 && currentXRange) { + const initialYRange = calculateYRange(projectionsData, currentXRange); + setYAxisRange(initialYRange); + } else { + setYAxisRange(null); + } + }, [projectionsData, xAxisRange, defaultRange, calculateYRange]); + + const handlePlotUpdate = useCallback((figure) => { + if (isResettingRef.current) { + isResettingRef.current = false; + return; + } + if (figure && figure['xaxis.range']) { + const newXRange = figure['xaxis.range']; + if (JSON.stringify(newXRange) !== JSON.stringify(xAxisRange)) { + setXAxisRange(newXRange); + } + } + }, [xAxisRange]); + + const layout = useMemo(() => { + const baseLayout = { + 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: 'closest', + 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[resolvedDisplayTarget]; + return targetYAxisLabelMap[longName] || longName || resolvedDisplayTarget || 'Value'; + })(), + range: yAxisRange, + autorange: yAxisRange === null, + }, + shapes: selectedDates.map(date => { + return { + type: 'line', + x0: date, + x1: date, + y0: 0, + y1: 1, + yref: 'paper', + line: { + color: 'red', + width: 1, + dash: 'dash' + } + }; + }) + }; + + if (layoutOverrides) { + return layoutOverrides(baseLayout); + } + + return baseLayout; + }, [colorScheme, windowSize, defaultRange, resolvedDisplayTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides]); + + const config = useMemo(() => { + const baseConfig = { + responsive: true, + displayModeBar: true, + displaylogo: false, + showSendToCloud: false, + plotlyServerURL: "", + scrollZoom: false, + doubleClick: 'reset', + toImageButtonOptions: { + format: 'png', + filename: 'forecast_plot' + }, + modeBarButtonsToRemove: ['resetScale2d', 'select2d', 'lasso2d'], + modeBarButtonsToAdd: [{ + name: 'Reset view', + icon: Plotly.Icons.home, + click: function(gd) { + const currentGetDefaultRange = getDefaultRangeRef.current; + const currentProjectionsData = projectionsDataRef.current; + + const range = currentGetDefaultRange(); + if (!range) return; + + const newYRange = currentProjectionsData.length > 0 ? calculateYRange(currentProjectionsData, range) : null; + + isResettingRef.current = true; + + setXAxisRange(null); + setYAxisRange(newYRange); + + Plotly.relayout(gd, { + 'xaxis.range': range, + 'yaxis.range': newYRange, + 'yaxis.autorange': newYRange === null + }); + } + }] + }; + + if (configOverrides) { + return configOverrides(baseConfig); + } + + return baseConfig; + }, [calculateYRange, configOverrides]); + + if (requireTarget && !selectedTarget) { + return ( + + Please select a target to view data. + + ); + } + + return ( + + +
+ handlePlotUpdate(figure)} + /> +
+ + {stateName} + +
+

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

+
+ { + const index = currentSelected.indexOf(model); + return MODEL_COLORS[index % MODEL_COLORS.length]; + }} + /> +
+ ); +}; + +export default ForecastPlotView; diff --git a/app/src/components/NHSNOverviewGraph.jsx b/app/src/components/NHSNOverviewGraph.jsx index 7f56066..0d5d8ba 100644 --- a/app/src/components/NHSNOverviewGraph.jsx +++ b/app/src/components/NHSNOverviewGraph.jsx @@ -1,19 +1,20 @@ -import { useMemo, useState, useEffect } from 'react'; +import { useMemo, useState, useEffect, useCallback } from 'react'; import { IconChevronRight } from '@tabler/icons-react'; import { getDataPath } from '../utils/paths'; import { useView } from '../hooks/useView'; import OverviewGraphCard from './OverviewGraphCard'; +import useOverviewPlot from '../hooks/useOverviewPlot'; 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' + 'Total COVID-19 Admissions': '#e377c2', + 'Total Influenza Admissions': '#1f77b4', + 'Total RSV Admissions': '#7f7f7f' }; -const NHSNOverviewGraph = ( {location} ) => { - const { setViewType, 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); @@ -27,9 +28,9 @@ const NHSNOverviewGraph = ( {location} ) => { setLoading(true); setError(null); const response = await fetch(getDataPath(`nhsn/${resolvedLocation}_nhsn.json`)); - + if (!response.ok) { - throw new Error('Data not available'); + throw new Error('Data not available'); } const json = await response.json(); @@ -46,93 +47,67 @@ const NHSNOverviewGraph = ( {location} ) => { fetchData(); }, [resolvedLocation]); - const { traces, layout } = useMemo(() => { - if (!data || !data.series || !data.series.dates) return { traces: [], layout: {} }; + const { buildTraces, xRange } = useMemo(() => { + if (!data?.series?.dates) { + return { buildTraces: () => [], xRange: null }; + } const dates = data.series.dates; const lastDateStr = dates[dates.length - 1]; const lastDate = new Date(lastDateStr); const twoMonthsAgo = new Date(lastDate); twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); - - const xRange = [twoMonthsAgo.toISOString().split('T')[0], lastDateStr]; - const activeTraces = DEFAULT_COLS.map((col) => { - const yData = data.series[col]; + const range = [twoMonthsAgo.toISOString().split('T')[0], lastDateStr]; + + const tracesBuilder = (snapshot) => DEFAULT_COLS.map((col) => { + const yData = snapshot.series?.[col]; if (!yData) return null; return { - x: dates, + x: snapshot.series.dates, y: yData, name: col.replace('Total ', '').replace(' Admissions', ''), type: 'scatter', mode: 'lines', - line: { - color: PATHOGEN_COLORS[col], - width: 2 + line: { + color: PATHOGEN_COLORS[col], + width: 2 }, hovertemplate: '%{y}' }; }).filter(Boolean); - let minY = Infinity; - let maxY = -Infinity; - - activeTraces.forEach((trace) => { - trace.x.forEach((dateVal, i) => { - const currentPointDate = new Date(dateVal); - if (currentPointDate >= twoMonthsAgo && currentPointDate <= lastDate) { - const val = trace.y[i]; - if (val !== null && val !== undefined && !Number.isNaN(val)) { - minY = Math.min(minY, val); - maxY = Math.max(maxY, val); - } - } - }); - }); - - if (minY === Infinity || maxY === -Infinity) { - minY = 0; - maxY = 100; - } - - - const diff = maxY - minY; - const paddingTop = diff * 0.15; // 15% headroom - const paddingBottom = diff * 0.05; - - const dynamicYRange = [ - Math.max(0, minY - paddingBottom), // Maintain 0 as a hard floor for admissions - maxY + paddingTop - ]; + return { buildTraces: tracesBuilder, xRange: range }; + }, [data]); - const layoutConfig = { - height: 280, + const { traces, layout } = useOverviewPlot({ + data, + buildTraces, + xRange, + yPaddingTopRatio: 0.15, + yPaddingBottomRatio: 0.05, + yMinFloor: 0, + layoutDefaults: { margin: { l: 45, r: 20, t: 10, b: 40 }, - xaxis: { - range: xRange, - showgrid: false, - tickfont: { size: 10 } - }, - yaxis: { - range: dynamicYRange, - 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' - }; + legend: { + orientation: 'h', + y: -0.2, + x: 0.5, + xanchor: 'center', + font: { size: 9 } + } + } + }); - return { traces: activeTraces, layout: layoutConfig }; - }, [data]); + const layoutWithFloor = useMemo(() => ({ + ...layout, + yaxis: { + ...layout.yaxis, + fixedrange: true + } + }), [layout]); const locationLabel = resolvedLocation === 'US' ? 'US national view' : resolvedLocation; @@ -144,7 +119,7 @@ const NHSNOverviewGraph = ( {location} ) => { error={error} errorLabel={`No NHSN data for ${resolvedLocation}`} traces={traces} - layout={layout} + layout={layoutWithFloor} emptyLabel={null} actionLabel={isActive ? 'Viewing' : 'View NHSN data'} actionActive={isActive} diff --git a/app/src/components/OverviewGraphCard.jsx b/app/src/components/OverviewGraphCard.jsx new file mode 100644 index 0000000..01f1f38 --- /dev/null +++ b/app/src/components/OverviewGraphCard.jsx @@ -0,0 +1,66 @@ +import { Button, Card, Group, Loader, Stack, Text, Title } from '@mantine/core'; +import Plot from 'react-plotly.js'; + +const OverviewGraphCard = ({ + title, + meta = null, + loading, + loadingLabel = 'Loading data...', + error, + errorLabel, + traces, + layout, + emptyLabel = 'No data available.', + actionLabel, + actionActive = false, + onAction, + actionIcon, + locationLabel +}) => { + const hasTraces = Array.isArray(traces) && traces.length > 0; + const showEmpty = !loading && !error && !hasTraces && emptyLabel; + + return ( + + + + {title} + {meta} + + {loading && ( + + + {loadingLabel} + + )} + {!loading && error && ( + {errorLabel || error} + )} + {!loading && !error && hasTraces && ( + + )} + {showEmpty && ( + {emptyLabel} + )} + + + {locationLabel} + + + + ); +}; + +export default OverviewGraphCard; diff --git a/app/src/components/PathogenOverviewGraph.jsx b/app/src/components/PathogenOverviewGraph.jsx index cf6e20c..2271aad 100644 --- a/app/src/components/PathogenOverviewGraph.jsx +++ b/app/src/components/PathogenOverviewGraph.jsx @@ -1,10 +1,11 @@ -import { useMemo } from 'react'; +import { useMemo, useCallback } from 'react'; import { Text } from '@mantine/core'; import { IconChevronRight } from '@tabler/icons-react'; import { useForecastData } from '../hooks/useForecastData'; import { DATASETS } from '../config'; import { useView } from '../hooks/useView'; import OverviewGraphCard from './OverviewGraphCard'; +import useOverviewPlot from '../hooks/useOverviewPlot'; const DEFAULT_TARGETS = { covid_forecasts: 'wk inc covid hosp', @@ -145,12 +146,9 @@ const PathogenOverviewGraph = ({ viewType, title, location }) => { const chartRange = useMemo(() => getRangeAroundDate(selectedDate), [selectedDate]); const isActive = datasetConfig?.views?.some((view) => view.value === activeViewType) ?? false; - const { traces, yRange } = useMemo(() => { - if (!data || !selectedTarget) { - return { traces: [], yRange: undefined }; - } - - const groundTruth = data.ground_truth; + const buildTraces = useCallback((forecastData) => { + if (!forecastData || !selectedTarget) return []; + const groundTruth = forecastData.ground_truth; const groundTruthValues = groundTruth?.[selectedTarget]; const groundTruthTrace = groundTruthValues ? { @@ -165,72 +163,25 @@ const PathogenOverviewGraph = ({ viewType, title, location }) => { : null; const forecast = selectedDate && selectedTarget && selectedModel - ? data.forecasts?.[selectedDate]?.[selectedTarget]?.[selectedModel] + ? forecastData.forecasts?.[selectedDate]?.[selectedTarget]?.[selectedModel] : null; const intervalTraces = buildIntervalTraces(forecast, selectedModel); - const combinedTraces = [ + return [ groundTruthTrace, ...(intervalTraces || []) ].filter(Boolean); - - if (!chartRange) { - return { traces: combinedTraces, yRange: undefined }; - } - - const [rangeStart, rangeEnd] = chartRange; - const startDate = new Date(rangeStart); - const endDate = new Date(rangeEnd); - let minY = Infinity; - let maxY = -Infinity; - - combinedTraces.forEach((trace) => { - if (!trace?.x || !trace?.y) return; - trace.x.forEach((xValue, index) => { - const pointDate = new Date(xValue); - if (pointDate < startDate || pointDate > endDate) return; - const value = Number(trace.y[index]); - if (Number.isNaN(value)) return; - minY = Math.min(minY, value); - maxY = Math.max(maxY, value); - }); - }); - - if (minY === Infinity || maxY === -Infinity) { - return { traces: combinedTraces, yRange: undefined }; - } - - const padding = (maxY - minY) * 0.1; - const paddedMin = Math.max(0, minY - padding); - const paddedMax = maxY + padding; - - return { - traces: combinedTraces, - yRange: [paddedMin, paddedMax] - }; - }, [data, selectedDate, selectedTarget, selectedModel, chartRange]); - - const layout = useMemo(() => ({ - height: 280, - margin: { l: 40, r: 20, t: 40, b: 40 }, - title: { - text: '', - font: { size: 13 } - }, - xaxis: { - range: chartRange, - showgrid: false, - tickfont: { size: 10 } - }, - yaxis: { - automargin: true, - tickfont: { size: 10 }, - range: yRange - }, - showlegend: false, - hovermode: 'x unified' - }), [chartRange, yRange]); + }, [selectedDate, selectedTarget, selectedModel]); + + const { traces, layout } = useOverviewPlot({ + data, + buildTraces, + xRange: chartRange, + yPaddingTopRatio: 0.1, + yPaddingBottomRatio: 0.1, + yMinFloor: 0 + }); const locationLabel = resolvedLocation === 'US' ? 'US national view' : resolvedLocation; diff --git a/app/src/components/ViewSwitchboard.jsx b/app/src/components/ViewSwitchboard.jsx index f3431b9..e115fc7 100644 --- a/app/src/components/ViewSwitchboard.jsx +++ b/app/src/components/ViewSwitchboard.jsx @@ -1,10 +1,10 @@ import { Center, Stack, Loader, Text, Alert, Button } from '@mantine/core'; import { IconAlertTriangle, IconRefresh } from '@tabler/icons-react'; -import FluView from './FluView'; -import MetroCastView from './MetroCastView'; -import RSVView from './RSVView'; -import COVID19View from './COVID19View'; -import NHSNView from './NHSNView'; +import FluView from './views/FluView'; +import MetroCastView from './views/MetroCastView'; +import RSVView from './views/RSVView'; +import COVID19View from './views/COVID19View'; +import NHSNView from './views/NHSNView'; import { CHART_CONSTANTS } from '../constants/chart'; /** diff --git a/app/src/components/COVID19View.jsx b/app/src/components/views/COVID19View.jsx similarity index 72% rename from app/src/components/COVID19View.jsx rename to app/src/components/views/COVID19View.jsx index 4ba6982..20e0eea 100644 --- a/app/src/components/COVID19View.jsx +++ b/app/src/components/views/COVID19View.jsx @@ -1,4 +1,4 @@ -import ForecastPlotView from './ForecastPlotView'; +import ForecastPlotView from '../ForecastPlotView'; const COVID19View = (props) => ( { + const forecasts = data?.forecasts; + + const lastSelectedDate = useMemo(() => { + if (selectedDates.length === 0) return null; + return selectedDates.slice().sort().pop(); + }, [selectedDates]); + + const rateChangeData = useMemo(() => { + if (!forecasts || selectedDates.length === 0) return []; + const categoryOrder = RATE_CHANGE_CATEGORIES; + return selectedModels.map(model => { + const forecast = forecasts[lastSelectedDate]?.['wk flu hosp rate change']?.[model]; + if (!forecast) return null; + const horizon0 = forecast.predictions['0']; + if (!horizon0) return null; + const modelColor = MODEL_COLORS[selectedModels.indexOf(model) % MODEL_COLORS.length]; + const orderedData = categoryOrder.map(cat => ({ + 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', + hovertemplate: '%{fullData.name}
%{y}: %{x:.1f}%' + }; + }).filter(Boolean); + }, [forecasts, selectedDates, selectedModels, lastSelectedDate]); + + const extraTraces = useMemo(() => { + if (viewType !== 'fludetailed') return []; + return rateChangeData.map(trace => ({ + ...trace, + orientation: 'h', + xaxis: 'x2', + yaxis: 'y2' + })); + }, [rateChangeData, viewType]); + + const activeModels = useMemo(() => { + const activeModelSet = new Set(); + if (viewType === 'flu_peak' || !forecasts || !selectedDates.length) { + return activeModelSet; + } + + const targetForProjections = (viewType === 'flu' || viewType === 'flu_forecasts') + ? selectedTarget + : 'wk inc flu hosp'; + + if ((viewType === 'flu' || viewType === 'flu_forecasts') && !targetForProjections) return activeModelSet; + + selectedDates.forEach(date => { + const forecastsForDate = forecasts[date]; + if (!forecastsForDate) return; + + if (targetForProjections) { + const targetData = forecastsForDate[targetForProjections]; + if (targetData) { + Object.keys(targetData).forEach(model => activeModelSet.add(model)); + } + } + + if (viewType === 'fludetailed') { + const rateChangeSet = forecastsForDate['wk flu hosp rate change']; + if (rateChangeSet) { + Object.keys(rateChangeSet).forEach(model => activeModelSet.add(model)); + } + } + }); + + return activeModelSet; + }, [forecasts, selectedDates, selectedTarget, viewType]); + + const forecastTarget = (viewType === 'flu' || viewType === 'flu_forecasts') + ? selectedTarget + : 'wk inc flu hosp'; + + const displayTarget = selectedTarget || forecastTarget; + const requireTarget = viewType === 'flu'; + + const layoutOverrides = useCallback((baseLayout) => { + const baseXAxis = { + ...baseLayout.xaxis, + showline: false, + linewidth: undefined, + linecolor: undefined, + domain: viewType === 'fludetailed' ? [0, 0.8] : baseLayout.xaxis.domain + }; + + const nextLayout = { + ...baseLayout, + hoverlabel: { namelength: -1 }, + xaxis: baseXAxis + }; + + if (viewType !== 'fludetailed') { + return nextLayout; + } + + return { + ...nextLayout, + grid: { + columns: 1, + rows: 1, + pattern: 'independent', + subplots: [['xy'], ['x2y2']], + xgap: 0.15 + }, + xaxis2: { + title: { + text: `displaying date ${lastSelectedDate || 'N/A'}`, + font: { + family: 'Arial, sans-serif', + size: 13, + color: '#1f77b4' + }, + standoff: 10 + }, + domain: [0.85, 1], + showgrid: false + }, + yaxis2: { + title: '', + showticklabels: true, + type: 'category', + side: 'right', + automargin: true, + tickfont: { align: 'right' } + } + }; + }, [viewType, lastSelectedDate]); + + const configOverrides = useCallback((baseConfig) => ({ + ...baseConfig, + modeBarPosition: 'left', + modeBarButtonsToRemove: ['select2d', 'lasso2d', 'resetScale2d'] + }), []); + + if (viewType === 'flu_peak') { + return ( + <> + + + + ); + } + + return ( + + ); +}; + +export default FluView; diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/views/MetroCastView.jsx similarity index 62% rename from app/src/components/MetroCastView.jsx rename to app/src/components/views/MetroCastView.jsx index d6632dc..c157e2d 100644 --- a/app/src/components/MetroCastView.jsx +++ b/app/src/components/views/MetroCastView.jsx @@ -1,28 +1,29 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; 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'; -import { getDataPath } from '../utils/paths'; +import ModelSelector from '../ModelSelector'; +import LastFetched from '../LastFetched'; +import { useView } from '../../hooks/useView'; +import { MODEL_COLORS } from '../../config/datasets'; +import { CHART_CONSTANTS } from '../../constants/chart'; +import { targetDisplayNameMap, targetYAxisLabelMap } from '../../utils/mapUtils'; +import { getDataPath } from '../../utils/paths'; +import useQuantileForecastTraces from '../../hooks/useQuantileForecastTraces'; 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', + '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 MetroPlotCard = ({ - locationData, - title, - isSmall = false, - colorScheme, - selectedTarget, - selectedModels, +const MetroPlotCard = ({ + locationData, + title, + isSmall = false, + colorScheme, + selectedTarget, + selectedModels, selectedDates, getDefaultRange, xAxisRange, @@ -53,94 +54,23 @@ const MetroPlotCard = ({ return [Math.max(0, minY - pad), maxY + pad]; }, [selectedTarget]); - const projectionsData = useMemo(() => { - if (!groundTruth || !forecasts || !selectedTarget) return []; - const gtValues = groundTruth[selectedTarget]; - if (!gtValues) 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' }, - 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)); - - 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) { - 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}` + - `` - ); - } - }); - - 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 projectionsData = useQuantileForecastTraces({ + groundTruth, + forecasts, + selectedDates, + selectedModels, + target: selectedTarget, + groundTruthLabel: 'Ground Truth Data', + groundTruthValueFormat: '%{y:.2f}', + valueSuffix: '%', + formatValue: (value) => value.toFixed(2), + modelLineWidth: isSmall ? 1 : 2, + modelMarkerSize: isSmall ? 3 : 6, + groundTruthLineWidth: isSmall ? 1 : 2, + groundTruthMarkerSize: isSmall ? 2 : 4, + showLegendForFirstDate: !isSmall, + fillMissingQuantiles: true + }); const defRange = useMemo(() => getDefaultRange(), [getDefaultRange]); @@ -154,10 +84,10 @@ const MetroPlotCard = ({ const PlotContent = ( <> {title} - + {!hasForecasts && ( -
No forecast data for selection
+
No forecast data for selection
)} @@ -177,14 +107,14 @@ const MetroPlotCard = ({ 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 } }, - xaxis: { - range: xAxisRange || defRange, - showticklabels: !isSmall, + xaxis: { + range: xAxisRange || defRange, + showticklabels: !isSmall, rangeslider: { visible: !isSmall, range: getDefaultRange(true) }, showline: true, linewidth: 1, linecolor: colorScheme === 'dark' ? '#aaa' : '#444' }, - yaxis: { + yaxis: { title: !isSmall ? { text: (() => { const longName = targetDisplayNameMap[selectedTarget]; @@ -192,21 +122,21 @@ const MetroPlotCard = ({ })(), font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000', size: 12 } } : undefined, - range: yAxisRange, - autorange: yAxisRange === null, + range: yAxisRange, + autorange: yAxisRange === null, tickfont: { size: 9, color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' }, tickformat: '.2f', ticksuffix: '%' }, - hovermode: isSmall ? false : 'closest', - hoverlabel: { + 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']); } + if (e['xaxis.range']) { setXAxisRange(e['xaxis.range']); } else if (e['xaxis.autorange']) { setXAxisRange(null); } }} /> @@ -214,19 +144,19 @@ const MetroPlotCard = ({ ); return isSmall ? ( - {PlotContent} - { const { colorScheme } = useMantineColorScheme(); - const { handleLocationSelect } = useView(); + const { handleLocationSelect } = useView(); const [childData, setChildData] = useState({}); const [loadingChildren, setLoadingChildren] = useState(false); - const [xAxisRange, setXAxisRange] = useState(null); + const [xAxisRange, setXAxisRange] = useState(null); const stateName = data?.metadata?.location_name; const stateCode = METRO_STATE_MAP[stateName]; @@ -308,9 +238,9 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, return ( - - {Object.entries(childData).map(([abbr, cityData]) => ( - handleLocationSelect(abbr)} + handleLocationSelect(abbr)} style={{ width: '100%' }} > - )}
-

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

@@ -371,11 +301,11 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, models={models} selectedModels={selectedModels} setSelectedModels={setSelectedModels} - activeModels={activeModels} + activeModels={activeModels} getModelColor={(m, sel) => MODEL_COLORS[sel.indexOf(m) % MODEL_COLORS.length]} /> ); }; -export default MetroCastView; \ No newline at end of file +export default MetroCastView; diff --git a/app/src/components/NHSNView.jsx b/app/src/components/views/NHSNView.jsx similarity index 98% rename from app/src/components/NHSNView.jsx rename to app/src/components/views/NHSNView.jsx index 6dc4f1a..0a0713f 100644 --- a/app/src/components/NHSNView.jsx +++ b/app/src/components/views/NHSNView.jsx @@ -3,16 +3,16 @@ import { useSearchParams } from 'react-router-dom'; import { Stack, Alert, Text, Center, useMantineColorScheme, Loader } from '@mantine/core'; import Plot from 'react-plotly.js'; import Plotly from 'plotly.js/dist/plotly'; -import { getDataPath } from '../utils/paths'; -import NHSNColumnSelector from './NHSNColumnSelector'; -import LastFetched from './LastFetched'; -import { MODEL_COLORS } from '../config/datasets'; +import { getDataPath } from '../../utils/paths'; +import NHSNColumnSelector from '../NHSNColumnSelector'; +import LastFetched from '../LastFetched'; +import { MODEL_COLORS } from '../../config/datasets'; import { nhsnTargetsToColumnsMap, // groupings nhsnNameToSlugMap, // { longform: shortform } map nhsnSlugToNameMap, // { shortform: longform } map nhsnNameToPrettyNameMap // { longform: presentable name } map -} from '../utils/mapUtils'; +} from '../../utils/mapUtils'; const nhsnYAxisLabelMap = { @@ -466,4 +466,4 @@ const NHSNView = ({ location }) => { ); }; -export default NHSNView; \ No newline at end of file +export default NHSNView; diff --git a/app/src/components/RSVView.jsx b/app/src/components/views/RSVView.jsx similarity index 72% rename from app/src/components/RSVView.jsx rename to app/src/components/views/RSVView.jsx index 44c9653..b1c1ead 100644 --- a/app/src/components/RSVView.jsx +++ b/app/src/components/views/RSVView.jsx @@ -1,4 +1,4 @@ -import ForecastPlotView from './ForecastPlotView'; +import ForecastPlotView from '../ForecastPlotView'; const RSVView = (props) => ( { + const date = new Date(dateValue); + return !Number.isNaN(date.getTime()); +}; + +/** + * Shared Plotly overview helper for card-sized charts. + * + * Responsibilities: + * - Run a caller-provided `buildTraces(data)` to produce Plotly traces. + * - Compute a y-axis range based on the visible x-axis window. + * - Apply consistent layout defaults for overview cards. + * + * Customization points: + * - `xRange`: restrict y-range computation to a time window. + * - `yPaddingTopRatio` / `yPaddingBottomRatio`: asymmetric padding around min/max. + * - `yMinFloor`: hard floor for the y-axis (set null to disable). + * - `layoutDefaults` and `layoutOverrides` for layout customization. + */ +const useOverviewPlot = ({ + data, + buildTraces, + xRange = null, + yPaddingTopRatio = 0.1, + yPaddingBottomRatio = 0.1, + yMinFloor = 0, + layoutOverrides = null, + layoutDefaults = null, +}) => { + const traces = useMemo(() => { + if (!data || typeof buildTraces !== 'function') return []; + return buildTraces(data) || []; + }, [data, buildTraces]); + + const yRange = useMemo(() => { + if (!xRange || !Array.isArray(xRange) || xRange.length !== 2) return undefined; + const [rangeStart, rangeEnd] = xRange; + if (!isValidDate(rangeStart) || !isValidDate(rangeEnd)) return undefined; + + const startDate = new Date(rangeStart); + const endDate = new Date(rangeEnd); + let minY = Infinity; + let maxY = -Infinity; + + traces.forEach((trace) => { + if (!trace?.x || !trace?.y) return; + trace.x.forEach((xValue, index) => { + const pointDate = new Date(xValue); + if (Number.isNaN(pointDate.getTime())) return; + if (pointDate < startDate || pointDate > endDate) return; + const value = Number(trace.y[index]); + if (Number.isNaN(value)) return; + minY = Math.min(minY, value); + maxY = Math.max(maxY, value); + }); + }); + + if (minY === Infinity || maxY === -Infinity) return undefined; + + const spread = maxY - minY; + const paddingTop = spread * yPaddingTopRatio; + const paddingBottom = spread * yPaddingBottomRatio; + const paddedMin = yMinFloor === null ? minY - paddingBottom : Math.max(yMinFloor, minY - paddingBottom); + const paddedMax = maxY + paddingTop; + + return [paddedMin, paddedMax]; + }, [traces, xRange, yPaddingTopRatio, yPaddingBottomRatio, yMinFloor]); + + const layout = useMemo(() => { + const baseLayout = { + height: 280, + margin: DEFAULT_MARGIN, + title: { text: '', font: { size: 13 } }, + xaxis: { + range: xRange || undefined, + showgrid: false, + tickfont: { size: 10 } + }, + yaxis: { + automargin: true, + tickfont: { size: 10 }, + range: yRange + }, + showlegend: false, + hovermode: 'x unified' + }; + + const mergedLayout = layoutDefaults ? { ...baseLayout, ...layoutDefaults } : baseLayout; + + if (layoutOverrides) { + return layoutOverrides(mergedLayout, { traces, xRange, yRange }); + } + + return mergedLayout; + }, [xRange, yRange, layoutDefaults, layoutOverrides, traces]); + + return { traces, yRange, layout }; +}; + +export default useOverviewPlot; diff --git a/app/src/hooks/useQuantileForecastTraces.js b/app/src/hooks/useQuantileForecastTraces.js new file mode 100644 index 0000000..fdf258e --- /dev/null +++ b/app/src/hooks/useQuantileForecastTraces.js @@ -0,0 +1,200 @@ +import { useMemo } from 'react'; +import { MODEL_COLORS } from '../config/datasets'; + +const defaultFormatValue = (value) => value.toLocaleString(undefined, { maximumFractionDigits: 2 }); + +const buildDefaultModelHoverText = ({ + model, + pointDate, + formattedMedian, + formatted50, + formatted95, + issuedDate, + valueSuffix +}) => ( + `${model}
` + + `Date: ${pointDate}
` + + `Median: ${formattedMedian}${valueSuffix}
` + + `50% CI: [${formatted50}${valueSuffix}]
` + + `95% CI: [${formatted95}${valueSuffix}]
` + + `predicted as of ${issuedDate}` + + `` +); + +const resolveModelColor = (selectedModels, model) => { + const index = selectedModels.indexOf(model); + return MODEL_COLORS[index % MODEL_COLORS.length]; +}; + +const useQuantileForecastTraces = ({ + groundTruth, + forecasts, + selectedDates, + selectedModels, + target, + groundTruthLabel = 'Observed', + groundTruthValueFormat = '%{y}', + valueSuffix = '', + formatValue = defaultFormatValue, + modelHoverBuilder = null, + modelColorFn = null, + modelLineWidth = 2, + modelMarkerSize = 6, + groundTruthLineWidth = 2, + groundTruthMarkerSize = 4, + showLegendForFirstDate = true, + fillMissingQuantiles = false +}) => useMemo(() => { + if (!groundTruth || !forecasts || selectedDates.length === 0 || !target) { + return []; + } + + const groundTruthValues = groundTruth[target]; + if (!groundTruthValues) { + console.warn(`Ground truth data not found for target: ${target}`); + return []; + } + + const groundTruthTrace = { + x: groundTruth.dates || [], + y: groundTruthValues, + name: groundTruthLabel, + type: 'scatter', + mode: 'lines+markers', + line: { color: 'black', width: groundTruthLineWidth, dash: 'dash' }, + marker: { size: groundTruthMarkerSize, color: 'black' }, + hovertemplate: `${groundTruthLabel}
Date: %{x}
Value: ${groundTruthValueFormat}${valueSuffix}` + }; + + const modelTraces = selectedModels.flatMap(model => + selectedDates.flatMap((date, dateIndex) => { + const forecastsForDate = forecasts[date] || {}; + const forecast = forecastsForDate[target]?.[model]; + if (!forecast || forecast.type !== 'quantile') return []; + + const forecastDates = []; + const medianValues = []; + const ci95Upper = []; + const ci95Lower = []; + const ci50Upper = []; + const ci50Lower = []; + const hoverTexts = []; + + const sortedPredictions = Object.values(forecast.predictions || {}).sort((a, b) => new Date(a.date) - new Date(b.date)); + + sortedPredictions.forEach((pred) => { + const pointDate = pred.date; + const { quantiles = [], values = [] } = pred; + + const findValue = (q) => { + const index = quantiles.indexOf(q); + return index !== -1 ? values[index] : null; + }; + + const val_50 = findValue(0.5); + if (val_50 === null || val_50 === undefined) { + return; + } + + const val_025 = findValue(0.025); + const val_25 = findValue(0.25); + const val_75 = findValue(0.75); + const val_975 = findValue(0.975); + + const resolved025 = val_025 ?? (fillMissingQuantiles ? val_50 : null); + const resolved25 = val_25 ?? (fillMissingQuantiles ? val_50 : null); + const resolved75 = val_75 ?? (fillMissingQuantiles ? val_50 : null); + const resolved975 = val_975 ?? (fillMissingQuantiles ? val_50 : null); + + if (resolved025 === null || resolved25 === null || resolved75 === null || resolved975 === null) { + return; + } + + forecastDates.push(pointDate); + ci95Lower.push(resolved025); + ci50Lower.push(resolved25); + medianValues.push(val_50); + ci50Upper.push(resolved75); + ci95Upper.push(resolved975); + + const formattedMedian = formatValue(val_50); + const formatted50 = `${formatValue(resolved25)} - ${formatValue(resolved75)}`; + const formatted95 = `${formatValue(resolved025)} - ${formatValue(resolved975)}`; + + const hoverText = modelHoverBuilder + ? modelHoverBuilder({ + model, + pointDate, + formattedMedian, + formatted50, + formatted95, + issuedDate: date, + valueSuffix + }) + : buildDefaultModelHoverText({ + model, + pointDate, + formattedMedian, + formatted50, + formatted95, + issuedDate: date, + valueSuffix + }); + + hoverTexts.push(hoverText); + }); + + if (forecastDates.length === 0) return []; + + const modelColor = modelColorFn + ? modelColorFn(model, selectedModels) + : resolveModelColor(selectedModels, 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: modelLineWidth, dash: 'solid' }, + marker: { size: modelMarkerSize, color: modelColor }, + showlegend: showLegendForFirstDate ? isFirstDate : false, + legendgroup: model, + text: hoverTexts, + hovertemplate: '%{text}', + hoverlabel: { + bgcolor: modelColor, + font: { color: '#ffffff' }, + bordercolor: '#ffffff' + } + } + ]; + }) + ); + + return [groundTruthTrace, ...modelTraces]; +}, [ + groundTruth, + forecasts, + selectedDates, + selectedModels, + target, + groundTruthLabel, + groundTruthValueFormat, + valueSuffix, + formatValue, + modelHoverBuilder, + modelColorFn, + modelLineWidth, + modelMarkerSize, + groundTruthLineWidth, + groundTruthMarkerSize, + showLegendForFirstDate, + fillMissingQuantiles +]); + +export default useQuantileForecastTraces; From 87647d1207276d495587de6b52f16d8c23045fbc Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Wed, 4 Feb 2026 22:31:05 +0100 Subject: [PATCH 03/15] view control --- app/README.md | 10 ++ app/src/components/FluPeak.jsx | 114 +++++++++---- app/src/components/ForecastPlotView.jsx | 54 +++++- .../controls/ForecastChartControls.jsx | 61 +++++++ .../controls/ForecastControlsPanel.jsx | 48 ++++++ app/src/components/views/FluView.jsx | 6 + app/src/components/views/MetroCastView.jsx | 71 ++++++-- app/src/contexts/ViewContext.jsx | 12 +- app/src/hooks/useQuantileForecastTraces.js | 157 ++++++++++++++---- app/src/utils/scaleUtils.js | 58 +++++++ 10 files changed, 508 insertions(+), 83 deletions(-) create mode 100644 app/src/components/controls/ForecastChartControls.jsx create mode 100644 app/src/components/controls/ForecastControlsPanel.jsx create mode 100644 app/src/utils/scaleUtils.js diff --git a/app/README.md b/app/README.md index 5f5e7a9..cc4e27a 100644 --- a/app/README.md +++ b/app/README.md @@ -36,3 +36,13 @@ used across forecast views: - Ground truth + median + 50%/95% interval bands. - Formatting and styling controls (line widths, marker sizes, value suffixes). - Handles missing quantiles when requested (e.g., MetroCast cards). + +### Forecast Chart Controls + +Forecast views share a single control panel (`ForecastChartControls`) that manages: + +- Y-axis scale: `linear`, `log`, `sqrt`. +- Visible intervals: `median`, `50%`, `95%`. + +State is stored in `ViewContext` so the settings stay in sync across views +(including MetroCast cards and Flu Peak). diff --git a/app/src/components/FluPeak.jsx b/app/src/components/FluPeak.jsx index e9b7036..6669565 100644 --- a/app/src/components/FluPeak.jsx +++ b/app/src/components/FluPeak.jsx @@ -1,10 +1,12 @@ import { useState, useEffect, useMemo } from 'react'; -import { Stack, useMantineColorScheme, Switch, Group, Text } from '@mantine/core'; +import { Stack, useMantineColorScheme, Text } from '@mantine/core'; import Plot from 'react-plotly.js'; import ModelSelector from './ModelSelector'; import { MODEL_COLORS } from '../config/datasets'; import { CHART_CONSTANTS } from '../constants/chart'; import { getDataPath } from '../utils/paths'; +import ForecastControlsPanel from './controls/ForecastControlsPanel'; +import { buildSqrtTicks, getYRangeFromTraces } from '../utils/scaleUtils'; // helper to convert Hex to RGBA for opacity control const hexToRgba = (hex, alpha) => { @@ -29,13 +31,19 @@ const FluPeak = ({ windowSize, selectedModels, setSelectedModels, - selectedDates + selectedDates, + chartScale = 'linear', + intervalVisibility = { median: true, ci50: true, ci95: true }, + setChartScale, + setIntervalVisibility }) => { const { colorScheme } = useMantineColorScheme(); const groundTruth = data?.ground_truth; const [nhsnData, setNhsnData] = useState(null); - const [showUncertainty, setShowUncertainty] = useState(true); const stateName = data?.metadata?.location_name; + const showMedian = intervalVisibility?.median ?? true; + const show50 = intervalVisibility?.ci50 ?? true; + const show95 = intervalVisibility?.ci95 ?? true; const getNormalizedDate = (dateStr) => { const d = new Date(dateStr); @@ -87,7 +95,7 @@ const FluPeak = ({ return activeModelSet; }, [peaks, selectedDates, peakDates]); - const plotData = useMemo(() => { + const { plotData, rawYRange } = useMemo(() => { const traces = []; // Historic data (NHSN) @@ -253,9 +261,9 @@ const FluPeak = ({ const dynamicColor = hexToRgba(baseColorHex, alpha); - if (showUncertainty) { + if (show50 || show95) { // 95% vertical whisker (hosp) - if (low95 !== null && high95 !== null) { + if (show95 && low95 !== null && high95 !== null) { traces.push({ x: [normalizedDate, normalizedDate], y: [low95, high95], @@ -281,7 +289,7 @@ const FluPeak = ({ } // 50% vertical whisker (hosp) - if (low50 !== null && high50 !== null) { + if (show50 && low50 !== null && high50 !== null) { traces.push({ x: [normalizedDate, normalizedDate], y: [low50, high50], @@ -298,7 +306,7 @@ const FluPeak = ({ } // 95% horizontal whisker (dates) - if (lowDate95 && highDate95) { + if (show95 && lowDate95 && highDate95) { traces.push({ x: [getNormalizedDate(lowDate95), getNormalizedDate(highDate95)], y: [medianVal, medianVal], @@ -321,7 +329,7 @@ const FluPeak = ({ } // 50% horizontal whisker (dates) - if (lowDate50 && highDate50) { + if (show50 && lowDate50 && highDate50) { traces.push({ x: [getNormalizedDate(lowDate50), getNormalizedDate(highDate50)], y: [medianVal, medianVal], @@ -337,9 +345,11 @@ const FluPeak = ({ }); } } - xValues.push(getNormalizedDate(bestDateStr)); - yValues.push(medianVal); - pointColors.push(dynamicColor); + if (showMedian) { + xValues.push(getNormalizedDate(bestDateStr)); + yValues.push(medianVal); + pointColors.push(dynamicColor); + } const timing50 = `${lowDate50} - ${highDate50}`; const timing95 = `${lowDate95} - ${highDate95}`; @@ -347,10 +357,10 @@ const FluPeak = ({ const formatted50 = `${Math.round(low50).toLocaleString()} - ${Math.round(high50).toLocaleString()}`; const formatted95 = `${Math.round(low95).toLocaleString()} - ${Math.round(high95).toLocaleString()}`; - const timing50Row = showUncertainty ? `50% CI: [${timing50}]
` : ''; - const timing95Row = showUncertainty ? `95% CI: [${timing95}]
` : ''; - const burden50Row = showUncertainty ? `50% CI: [${formatted50}]
` : ''; - const burden95Row = showUncertainty ? `95% CI: [${formatted95}]
` : ''; + const timing50Row = show50 ? `50% CI: [${timing50}]
` : ''; + const timing95Row = show95 ? `95% CI: [${timing95}]
` : ''; + const burden50Row = show50 ? `50% CI: [${formatted50}]
` : ''; + const burden95Row = show95 ? `95% CI: [${formatted95}]
` : ''; hoverTexts.push( `${model}
` + @@ -368,7 +378,7 @@ const FluPeak = ({ }); // actual trace - if (xValues.length > 0) { + if (showMedian && xValues.length > 0) { traces.push({ x: xValues, y: yValues, @@ -411,8 +421,39 @@ const FluPeak = ({ }); } - return traces; - }, [groundTruth, nhsnData, peaks, selectedModels, selectedDates, peakDates, showUncertainty]); + const rawRange = getYRangeFromTraces(traces); + + if (chartScale !== 'sqrt') { + return { plotData: traces, rawYRange: rawRange }; + } + + const scaledTraces = traces.map((trace) => { + if (!Array.isArray(trace.y)) return trace; + const originalY = trace.y; + const scaledY = originalY.map((value) => Math.sqrt(Math.max(0, value))); + const nextTrace = { ...trace, y: scaledY }; + + if (trace.hovertemplate && trace.hovertemplate.includes('%{y}')) { + nextTrace.text = originalY.map((value) => Number(value).toLocaleString()); + nextTrace.hovertemplate = trace.hovertemplate.replace('%{y}', '%{text}'); + } else if (trace.hoverinfo && trace.hoverinfo.includes('y')) { + nextTrace.text = originalY.map((value) => `${trace.name}: ${Number(value).toLocaleString()}`); + nextTrace.hoverinfo = 'text'; + } + + return nextTrace; + }); + + return { plotData: scaledTraces, rawYRange: rawRange }; + }, [groundTruth, nhsnData, peaks, selectedModels, selectedDates, peakDates, showMedian, show50, show95, chartScale]); + + const sqrtTicks = useMemo(() => { + if (chartScale !== 'sqrt') return null; + return buildSqrtTicks({ + rawRange: rawYRange, + formatValue: (value) => Number(value).toLocaleString() + }); + }, [chartScale, rawYRange]); const layout = useMemo(() => ({ width: windowSize ? Math.min(CHART_CONSTANTS.MAX_WIDTH, windowSize.width * CHART_CONSTANTS.WIDTH_RATIO) : undefined, @@ -436,7 +477,19 @@ const FluPeak = ({ xaxis: { tickformat: '%b' }, - yaxis: { title: 'Flu Hospitalizations', rangemode: 'tozero' }, + yaxis: { + title: (() => { + const baseTitle = 'Flu Hospitalizations'; + if (chartScale === 'log') return `${baseTitle} (log)`; + if (chartScale === 'sqrt') return `${baseTitle} (sqrt)`; + return baseTitle; + })(), + rangemode: 'tozero', + type: chartScale === 'log' ? 'log' : 'linear', + tickmode: chartScale === 'sqrt' && sqrtTicks ? 'array' : undefined, + tickvals: chartScale === 'sqrt' && sqrtTicks ? sqrtTicks.tickvals : undefined, + ticktext: chartScale === 'sqrt' && sqrtTicks ? sqrtTicks.ticktext : undefined + }, // dynamic gray shading section shapes: selectedDates.flatMap(dateStr => { @@ -469,7 +522,7 @@ const FluPeak = ({ } ]; }), - }), [colorScheme, windowSize, selectedDates]); + }), [colorScheme, windowSize, selectedDates, chartScale, sqrtTicks]); const config = useMemo(() => ({ responsive: true, @@ -498,6 +551,14 @@ const FluPeak = ({ useResizeHandler={true} />
+ {setChartScale && setIntervalVisibility && ( + + )} {stateName} @@ -512,15 +573,6 @@ const FluPeak = ({ Note that forecasts should be interpreted with great caution and may not reliably predict rapid changes in disease trends.

- - - setShowUncertainty(event.currentTarget.checked)} - size="sm" - /> - { + if (chartScale !== 'sqrt') return null; + return (value) => Math.sqrt(Math.max(0, value)); + }, [chartScale]); const calculateYRange = useCallback((chartData, xRange) => { if (!chartData || !xRange || !Array.isArray(chartData) || chartData.length === 0 || !resolvedForecastTarget) return null; @@ -74,7 +86,7 @@ const ForecastPlotView = ({ return null; }, [resolvedForecastTarget]); - const projectionsData = useQuantileForecastTraces({ + const { traces: projectionsData, rawYRange } = useQuantileForecastTraces({ groundTruth, forecasts, selectedDates, @@ -88,9 +100,21 @@ const ForecastPlotView = ({ groundTruthLineWidth: 2, groundTruthMarkerSize: 4, showLegendForFirstDate: true, - fillMissingQuantiles: false + fillMissingQuantiles: false, + showMedian, + show50, + show95, + transformY: sqrtTransform, + groundTruthHoverFormatter: sqrtTransform + ? (value) => ( + groundTruthValueFormat.includes(':.2f') + ? Number(value).toFixed(2) + : Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ) + : null }); + const appendedTraces = useMemo(() => { if (!extraTraces) return []; if (typeof extraTraces === 'function') { @@ -162,6 +186,11 @@ const ForecastPlotView = ({ } }, [xAxisRange]); + const sqrtTicks = useMemo(() => { + if (chartScale !== 'sqrt') return null; + return buildSqrtTicks({ rawRange: rawYRange }); + }, [chartScale, rawYRange]); + const layout = useMemo(() => { const baseLayout = { width: Math.min(CHART_CONSTANTS.MAX_WIDTH, windowSize.width * CHART_CONSTANTS.WIDTH_RATIO), @@ -209,10 +238,17 @@ const ForecastPlotView = ({ yaxis: { title: (() => { const longName = targetDisplayNameMap[resolvedDisplayTarget]; - return targetYAxisLabelMap[longName] || longName || resolvedDisplayTarget || 'Value'; + const baseTitle = targetYAxisLabelMap[longName] || longName || resolvedDisplayTarget || 'Value'; + if (chartScale === 'log') return `${baseTitle} (log)`; + if (chartScale === 'sqrt') return `${baseTitle} (sqrt)`; + return baseTitle; })(), - range: yAxisRange, - autorange: yAxisRange === null, + range: chartScale === 'log' ? undefined : yAxisRange, + autorange: chartScale === 'log' ? true : yAxisRange === null, + type: chartScale === 'log' ? 'log' : 'linear', + tickmode: chartScale === 'sqrt' && sqrtTicks ? 'array' : undefined, + tickvals: chartScale === 'sqrt' && sqrtTicks ? sqrtTicks.tickvals : undefined, + ticktext: chartScale === 'sqrt' && sqrtTicks ? sqrtTicks.ticktext : undefined }, shapes: selectedDates.map(date => { return { @@ -236,7 +272,7 @@ const ForecastPlotView = ({ } return baseLayout; - }, [colorScheme, windowSize, defaultRange, resolvedDisplayTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides]); + }, [colorScheme, windowSize, defaultRange, resolvedDisplayTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides, chartScale, sqrtTicks]); const config = useMemo(() => { const baseConfig = { @@ -306,6 +342,12 @@ const ForecastPlotView = ({ onRelayout={(figure) => handlePlotUpdate(figure)} /> + {stateName} diff --git a/app/src/components/controls/ForecastChartControls.jsx b/app/src/components/controls/ForecastChartControls.jsx new file mode 100644 index 0000000..a6f2675 --- /dev/null +++ b/app/src/components/controls/ForecastChartControls.jsx @@ -0,0 +1,61 @@ +import { Paper, Stack, Text, SegmentedControl, Checkbox, Group } from '@mantine/core'; + +const INTERVAL_OPTIONS = [ + { value: 'median', label: 'Median' }, + { value: 'ci50', label: '50% interval' }, + { value: 'ci95', label: '95% interval' } +]; + +const SCALE_OPTIONS = [ + { value: 'linear', label: 'Linear' }, + { value: 'log', label: 'Log' }, + { value: 'sqrt', label: 'Sqrt' } +]; + +const ForecastChartControls = ({ + chartScale, + setChartScale, + intervalVisibility, + setIntervalVisibility +}) => { + const selectedIntervals = INTERVAL_OPTIONS + .filter((option) => intervalVisibility?.[option.value]) + .map((option) => option.value); + + const handleIntervalChange = (values) => { + setIntervalVisibility({ + median: values.includes('median'), + ci50: values.includes('ci50'), + ci95: values.includes('ci95') + }); + }; + + return ( + + + Graph controls + + Y-scale + + + + Intervals + + + {INTERVAL_OPTIONS.map((option) => ( + + ))} + + + + + + ); +}; + +export default ForecastChartControls; diff --git a/app/src/components/controls/ForecastControlsPanel.jsx b/app/src/components/controls/ForecastControlsPanel.jsx new file mode 100644 index 0000000..e9460b2 --- /dev/null +++ b/app/src/components/controls/ForecastControlsPanel.jsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { Drawer, Button, Stack } from '@mantine/core'; +import { IconAdjustmentsHorizontal } from '@tabler/icons-react'; +import ForecastChartControls from './ForecastChartControls'; + +const ForecastControlsPanel = ({ + chartScale, + setChartScale, + intervalVisibility, + setIntervalVisibility, + defaultOpen = false, + label = 'Advanced controls' +}) => { + const [open, setOpen] = useState(defaultOpen); + + return ( + <> + + setOpen(false)} + title={label} + position="left" + size="sm" + padding="md" + > + + + + + + ); +}; + +export default ForecastControlsPanel; diff --git a/app/src/components/views/FluView.jsx b/app/src/components/views/FluView.jsx index 89ebe88..3267d67 100644 --- a/app/src/components/views/FluView.jsx +++ b/app/src/components/views/FluView.jsx @@ -4,6 +4,7 @@ import FluPeak from '../FluPeak'; import LastFetched from '../LastFetched'; import { MODEL_COLORS } from '../../config/datasets'; import { RATE_CHANGE_CATEGORIES } from '../../constants/chart'; +import { useView } from '../../hooks/useView'; const FluView = ({ data, @@ -21,6 +22,7 @@ const FluView = ({ availablePeakModels, peakLocation }) => { + const { chartScale, setChartScale, intervalVisibility, setIntervalVisibility } = useView(); const forecasts = data?.forecasts; const lastSelectedDate = useMemo(() => { @@ -180,6 +182,10 @@ const FluView = ({ selectedDates={selectedDates} windowSize={windowSize} peakLocation={peakLocation} + chartScale={chartScale} + intervalVisibility={intervalVisibility} + setChartScale={setChartScale} + setIntervalVisibility={setIntervalVisibility} /> ); diff --git a/app/src/components/views/MetroCastView.jsx b/app/src/components/views/MetroCastView.jsx index c157e2d..c9c7cde 100644 --- a/app/src/components/views/MetroCastView.jsx +++ b/app/src/components/views/MetroCastView.jsx @@ -9,6 +9,8 @@ import { CHART_CONSTANTS } from '../../constants/chart'; import { targetDisplayNameMap, targetYAxisLabelMap } from '../../utils/mapUtils'; import { getDataPath } from '../../utils/paths'; import useQuantileForecastTraces from '../../hooks/useQuantileForecastTraces'; +import ForecastControlsPanel from '../controls/ForecastControlsPanel'; +import { buildSqrtTicks } from '../../utils/scaleUtils'; const METRO_STATE_MAP = { 'Colorado': 'CO', 'Georgia': 'GA', 'Indiana': 'IN', 'Maine': 'ME', @@ -27,7 +29,9 @@ const MetroPlotCard = ({ selectedDates, getDefaultRange, xAxisRange, - setXAxisRange + setXAxisRange, + chartScale, + intervalVisibility }) => { const [yAxisRange, setYAxisRange] = useState(null); const groundTruth = locationData?.ground_truth; @@ -54,7 +58,16 @@ const MetroPlotCard = ({ return [Math.max(0, minY - pad), maxY + pad]; }, [selectedTarget]); - const projectionsData = useQuantileForecastTraces({ + const showMedian = intervalVisibility?.median ?? true; + const show50 = intervalVisibility?.ci50 ?? true; + const show95 = intervalVisibility?.ci95 ?? true; + + const sqrtTransform = useMemo(() => { + if (chartScale !== 'sqrt') return null; + return (value) => Math.sqrt(Math.max(0, value)); + }, [chartScale]); + + const { traces: projectionsData, rawYRange } = useQuantileForecastTraces({ groundTruth, forecasts, selectedDates, @@ -69,10 +82,23 @@ const MetroPlotCard = ({ groundTruthLineWidth: isSmall ? 1 : 2, groundTruthMarkerSize: isSmall ? 2 : 4, showLegendForFirstDate: !isSmall, - fillMissingQuantiles: true + fillMissingQuantiles: true, + showMedian, + show50, + show95, + transformY: sqrtTransform, + groundTruthHoverFormatter: sqrtTransform ? (value) => Number(value).toFixed(2) : null }); + const defRange = useMemo(() => getDefaultRange(), [getDefaultRange]); + const sqrtTicks = useMemo(() => { + if (chartScale !== 'sqrt') return null; + return buildSqrtTicks({ + rawRange: rawYRange, + formatValue: (value) => `${value.toFixed(2)}%` + }); + }, [chartScale, rawYRange]); useEffect(() => { const range = xAxisRange || defRange; @@ -114,19 +140,26 @@ const MetroPlotCard = ({ showline: true, linewidth: 1, linecolor: colorScheme === 'dark' ? '#aaa' : '#444' }, - yaxis: { + yaxis: { title: !isSmall ? { text: (() => { const longName = targetDisplayNameMap[selectedTarget]; - return targetYAxisLabelMap[longName] || longName || selectedTarget || 'Value'; + const baseTitle = targetYAxisLabelMap[longName] || longName || selectedTarget || 'Value'; + if (chartScale === 'log') return `${baseTitle} (log)`; + if (chartScale === 'sqrt') return `${baseTitle} (sqrt)`; + return baseTitle; })(), font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000', size: 12 } } : undefined, - range: yAxisRange, - autorange: yAxisRange === null, + range: chartScale === 'log' ? undefined : yAxisRange, + autorange: chartScale === 'log' ? true : yAxisRange === null, + type: chartScale === 'log' ? 'log' : 'linear', tickfont: { size: 9, color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' }, - tickformat: '.2f', - ticksuffix: '%' + tickformat: chartScale === 'sqrt' ? undefined : '.2f', + ticksuffix: chartScale === 'sqrt' ? undefined : '%', + tickmode: chartScale === 'sqrt' && sqrtTicks ? 'array' : undefined, + tickvals: chartScale === 'sqrt' && sqrtTicks ? sqrtTicks.tickvals : undefined, + ticktext: chartScale === 'sqrt' && sqrtTicks ? sqrtTicks.ticktext : undefined }, hovermode: isSmall ? false : 'closest', hoverlabel: { @@ -187,7 +220,7 @@ const MetroPlotCard = ({ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, windowSize, getDefaultRange, selectedTarget }) => { const { colorScheme } = useMantineColorScheme(); - const { handleLocationSelect } = useView(); + const { handleLocationSelect, chartScale, setChartScale, intervalVisibility, setIntervalVisibility } = useView(); const [childData, setChildData] = useState({}); const [loadingChildren, setLoadingChildren] = useState(false); const [xAxisRange, setXAxisRange] = useState(null); @@ -238,9 +271,9 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, return ( - - + {stateCode && ( @@ -266,7 +307,7 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, onClick={() => handleLocationSelect(abbr)} style={{ width: '100%' }} > -
))} diff --git a/app/src/contexts/ViewContext.jsx b/app/src/contexts/ViewContext.jsx index 420b54f..2c325fd 100644 --- a/app/src/contexts/ViewContext.jsx +++ b/app/src/contexts/ViewContext.jsx @@ -27,6 +27,12 @@ export const ViewProvider = ({ children }) => { const [selectedDates, setSelectedDates] = useState([]); const [activeDate, setActiveDate] = useState(null); const [selectedTarget, setSelectedTarget] = useState(null); + const [chartScale, setChartScale] = useState('linear'); + const [intervalVisibility, setIntervalVisibility] = useState({ + median: true, + ci50: true, + ci95: true + }); const CURRENT_FLU_SEASON_START = '2025-11-01'; // !! CRITICAL !!: need to change this manually based on the season (for flu peak view) const { data, metadata, loading, error, availableDates, models, availableTargets, modelsByTarget, peaks, availablePeakDates, availablePeakModels } = useForecastData(selectedLocation, viewType); @@ -270,7 +276,11 @@ export const ViewProvider = ({ children }) => { handleTargetSelect, peaks, availablePeakDates: (availablePeakDates || []).filter(date => date >= CURRENT_FLU_SEASON_START), - availablePeakModels + availablePeakModels, + chartScale, + setChartScale, + intervalVisibility, + setIntervalVisibility }; return ( diff --git a/app/src/hooks/useQuantileForecastTraces.js b/app/src/hooks/useQuantileForecastTraces.js index fdf258e..d56ce5d 100644 --- a/app/src/hooks/useQuantileForecastTraces.js +++ b/app/src/hooks/useQuantileForecastTraces.js @@ -10,16 +10,31 @@ const buildDefaultModelHoverText = ({ formatted50, formatted95, issuedDate, - valueSuffix -}) => ( - `${model}
` + - `Date: ${pointDate}
` + - `Median: ${formattedMedian}${valueSuffix}
` + - `50% CI: [${formatted50}${valueSuffix}]
` + - `95% CI: [${formatted95}${valueSuffix}]
` + - `predicted as of ${issuedDate}` + - `` -); + valueSuffix, + show50, + show95 +}) => { + const rows = [ + `${model}
` + + `Date: ${pointDate}
` + + `Median: ${formattedMedian}${valueSuffix}
` + ]; + + if (show50) { + rows.push(`50% CI: [${formatted50}${valueSuffix}]
`); + } + + if (show95) { + rows.push(`95% CI: [${formatted95}${valueSuffix}]
`); + } + + rows.push( + `predicted as of ${issuedDate}` + + `` + ); + + return rows.join(''); +}; const resolveModelColor = (selectedModels, model) => { const index = selectedModels.indexOf(model); @@ -43,29 +58,56 @@ const useQuantileForecastTraces = ({ groundTruthLineWidth = 2, groundTruthMarkerSize = 4, showLegendForFirstDate = true, - fillMissingQuantiles = false + fillMissingQuantiles = false, + showMedian = true, + show50 = true, + show95 = true, + transformY = null, + groundTruthHoverFormatter = null }) => useMemo(() => { if (!groundTruth || !forecasts || selectedDates.length === 0 || !target) { - return []; + return { traces: [], rawYRange: null }; } const groundTruthValues = groundTruth[target]; if (!groundTruthValues) { console.warn(`Ground truth data not found for target: ${target}`); - return []; + return { traces: [], rawYRange: null }; } + let rawMin = Infinity; + let rawMax = -Infinity; + const updateRange = (value) => { + if (value === null || value === undefined) return; + const numeric = Number(value); + if (Number.isNaN(numeric)) return; + rawMin = Math.min(rawMin, numeric); + rawMax = Math.max(rawMax, numeric); + }; + + groundTruthValues.forEach((value) => updateRange(value)); + + const groundTruthY = transformY + ? groundTruthValues.map((value) => transformY(value)) + : groundTruthValues; + const groundTruthTrace = { x: groundTruth.dates || [], - y: groundTruthValues, + y: groundTruthY, name: groundTruthLabel, type: 'scatter', - mode: 'lines+markers', + mode: showMedian ? 'lines+markers' : 'lines', line: { color: 'black', width: groundTruthLineWidth, dash: 'dash' }, - marker: { size: groundTruthMarkerSize, color: 'black' }, - hovertemplate: `${groundTruthLabel}
Date: %{x}
Value: ${groundTruthValueFormat}${valueSuffix}` + marker: { size: groundTruthMarkerSize, color: 'black' } }; + if (groundTruthHoverFormatter) { + groundTruthTrace.text = groundTruthValues.map((value) => groundTruthHoverFormatter(value)); + groundTruthTrace.hovertemplate = `${groundTruthLabel}
Date: %{x}
Value: %{text}${valueSuffix}`; + } else { + groundTruthTrace.hovertemplate = `${groundTruthLabel}
Date: %{x}
Value: ${groundTruthValueFormat}${valueSuffix}`; + } + const modelTraces = selectedModels.flatMap(model => selectedDates.flatMap((date, dateIndex) => { const forecastsForDate = forecasts[date] || {}; @@ -111,11 +153,23 @@ const useQuantileForecastTraces = ({ } forecastDates.push(pointDate); - ci95Lower.push(resolved025); - ci50Lower.push(resolved25); - medianValues.push(val_50); - ci50Upper.push(resolved75); - ci95Upper.push(resolved975); + + if (showMedian) { + medianValues.push(transformY ? transformY(val_50) : val_50); + updateRange(val_50); + } + if (show50) { + ci50Lower.push(transformY ? transformY(resolved25) : resolved25); + ci50Upper.push(transformY ? transformY(resolved75) : resolved75); + updateRange(resolved25); + updateRange(resolved75); + } + if (show95) { + ci95Lower.push(transformY ? transformY(resolved025) : resolved025); + ci95Upper.push(transformY ? transformY(resolved975) : resolved975); + updateRange(resolved025); + updateRange(resolved975); + } const formattedMedian = formatValue(val_50); const formatted50 = `${formatValue(resolved25)} - ${formatValue(resolved75)}`; @@ -138,7 +192,9 @@ const useQuantileForecastTraces = ({ formatted50, formatted95, issuedDate: date, - valueSuffix + valueSuffix, + show50, + show95 }); hoverTexts.push(hoverText); @@ -151,10 +207,40 @@ const useQuantileForecastTraces = ({ : resolveModelColor(selectedModels, 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 }, - { + const traces = []; + + if (show95) { + traces.push({ + 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 + }); + } + + if (show50) { + traces.push({ + 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 + }); + } + + if (showMedian) { + traces.push({ x: forecastDates, y: medianValues, name: model, @@ -171,12 +257,16 @@ const useQuantileForecastTraces = ({ font: { color: '#ffffff' }, bordercolor: '#ffffff' } - } - ]; + }); + } + + return traces; }) ); - return [groundTruthTrace, ...modelTraces]; + const rawYRange = rawMin === Infinity || rawMax === -Infinity ? null : [rawMin, rawMax]; + + return { traces: [groundTruthTrace, ...modelTraces], rawYRange }; }, [ groundTruth, forecasts, @@ -194,7 +284,12 @@ const useQuantileForecastTraces = ({ groundTruthLineWidth, groundTruthMarkerSize, showLegendForFirstDate, - fillMissingQuantiles + fillMissingQuantiles, + showMedian, + show50, + show95, + transformY, + groundTruthHoverFormatter ]); export default useQuantileForecastTraces; diff --git a/app/src/utils/scaleUtils.js b/app/src/utils/scaleUtils.js new file mode 100644 index 0000000..1b38c51 --- /dev/null +++ b/app/src/utils/scaleUtils.js @@ -0,0 +1,58 @@ +const getYRangeFromTraces = (traces) => { + if (!Array.isArray(traces)) return null; + let minY = Infinity; + let maxY = -Infinity; + + traces.forEach((trace) => { + if (!Array.isArray(trace?.y)) return; + trace.y.forEach((value) => { + const numeric = Number(value); + if (Number.isNaN(numeric)) return; + minY = Math.min(minY, numeric); + maxY = Math.max(maxY, numeric); + }); + }); + + if (minY === Infinity || maxY === -Infinity) return null; + return [minY, maxY]; +}; + +const buildSqrtTicks = ({ + rawRange, + tickCount = 5, + formatValue = (value) => value.toLocaleString(undefined, { maximumFractionDigits: 2 }) +}) => { + if (!rawRange || rawRange.length !== 2) return null; + const [rawMin, rawMax] = rawRange; + if (rawMax <= 0) return null; + + const minValue = Math.max(0, rawMin); + const maxValue = Math.max(minValue, rawMax); + const sqrtMin = Math.sqrt(minValue); + const sqrtMax = Math.sqrt(maxValue); + + if (sqrtMax === sqrtMin) { + const tickValue = sqrtMax; + const rawTick = tickValue ** 2; + return { + tickvals: [tickValue], + ticktext: [formatValue(rawTick)] + }; + } + + const steps = Math.max(2, tickCount); + const step = (sqrtMax - sqrtMin) / (steps - 1); + const tickvals = []; + const ticktext = []; + + for (let i = 0; i < steps; i += 1) { + const tickValue = sqrtMin + step * i; + const rawTick = tickValue ** 2; + tickvals.push(tickValue); + ticktext.push(formatValue(rawTick)); + } + + return { tickvals, ticktext }; +}; + +export { getYRangeFromTraces, buildSqrtTicks }; From 2f24c6ea326886558499f185f5ca1a36b4782496 Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Wed, 4 Feb 2026 22:35:09 +0100 Subject: [PATCH 04/15] show legend button --- app/src/components/FluPeak.jsx | 9 +++++++-- app/src/components/ForecastPlotView.jsx | 8 +++++--- .../controls/ForecastChartControls.jsx | 16 ++++++++++++++-- .../controls/ForecastControlsPanel.jsx | 4 ++++ app/src/components/views/FluView.jsx | 4 +++- app/src/components/views/MetroCastView.jsx | 8 +++++--- app/src/contexts/ViewContext.jsx | 5 ++++- app/src/hooks/useQuantileForecastTraces.js | 14 ++++++++++++++ 8 files changed, 56 insertions(+), 12 deletions(-) diff --git a/app/src/components/FluPeak.jsx b/app/src/components/FluPeak.jsx index 6669565..7417817 100644 --- a/app/src/components/FluPeak.jsx +++ b/app/src/components/FluPeak.jsx @@ -35,7 +35,9 @@ const FluPeak = ({ chartScale = 'linear', intervalVisibility = { median: true, ci50: true, ci95: true }, setChartScale, - setIntervalVisibility + setIntervalVisibility, + showLegend = true, + setShowLegend }) => { const { colorScheme } = useMantineColorScheme(); const groundTruth = data?.ground_truth; @@ -464,6 +466,7 @@ const FluPeak = ({ plot_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' }, margin: { l: 60, r: 30, t: 30, b: 50 }, + showlegend: showLegend, legend: { x: 0, y: 1, xanchor: 'left', yanchor: 'top', bgcolor: colorScheme === 'dark' ? 'rgba(26, 27, 30, 0.8)' : 'rgba(255, 255, 255, 0.8)', @@ -522,7 +525,7 @@ const FluPeak = ({ } ]; }), - }), [colorScheme, windowSize, selectedDates, chartScale, sqrtTicks]); + }), [colorScheme, windowSize, selectedDates, chartScale, sqrtTicks, showLegend]); const config = useMemo(() => ({ responsive: true, @@ -557,6 +560,8 @@ const FluPeak = ({ setChartScale={setChartScale} intervalVisibility={intervalVisibility} setIntervalVisibility={setIntervalVisibility} + showLegend={showLegend} + setShowLegend={setShowLegend} /> )} diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx index b2c2223..2832055 100644 --- a/app/src/components/ForecastPlotView.jsx +++ b/app/src/components/ForecastPlotView.jsx @@ -41,7 +41,7 @@ const ForecastPlotView = ({ const projectionsDataRef = useRef([]); const { colorScheme } = useMantineColorScheme(); - const { chartScale, setChartScale, intervalVisibility, setIntervalVisibility } = useView(); + const { chartScale, setChartScale, intervalVisibility, setIntervalVisibility, showLegend, setShowLegend } = useView(); const groundTruth = data?.ground_truth; const forecasts = data?.forecasts; @@ -99,7 +99,7 @@ const ForecastPlotView = ({ modelMarkerSize: 6, groundTruthLineWidth: 2, groundTruthMarkerSize: 4, - showLegendForFirstDate: true, + showLegendForFirstDate: showLegend, fillMissingQuantiles: false, showMedian, show50, @@ -202,7 +202,7 @@ const ForecastPlotView = ({ font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' }, - showlegend: selectedModels.length < 15, + showlegend: showLegend, legend: { x: 0, y: 1, @@ -347,6 +347,8 @@ const ForecastPlotView = ({ setChartScale={setChartScale} intervalVisibility={intervalVisibility} setIntervalVisibility={setIntervalVisibility} + showLegend={showLegend} + setShowLegend={setShowLegend} /> {stateName} diff --git a/app/src/components/controls/ForecastChartControls.jsx b/app/src/components/controls/ForecastChartControls.jsx index a6f2675..466a1de 100644 --- a/app/src/components/controls/ForecastChartControls.jsx +++ b/app/src/components/controls/ForecastChartControls.jsx @@ -1,4 +1,4 @@ -import { Paper, Stack, Text, SegmentedControl, Checkbox, Group } from '@mantine/core'; +import { Paper, Stack, Text, SegmentedControl, Checkbox, Group, Switch } from '@mantine/core'; const INTERVAL_OPTIONS = [ { value: 'median', label: 'Median' }, @@ -16,7 +16,9 @@ const ForecastChartControls = ({ chartScale, setChartScale, intervalVisibility, - setIntervalVisibility + setIntervalVisibility, + showLegend, + setShowLegend }) => { const selectedIntervals = INTERVAL_OPTIONS .filter((option) => intervalVisibility?.[option.value]) @@ -53,6 +55,16 @@ const ForecastChartControls = ({ + + Legend + setShowLegend(event.currentTarget.checked)} + size="sm" + onLabel="On" + offLabel="Off" + /> +
); diff --git a/app/src/components/controls/ForecastControlsPanel.jsx b/app/src/components/controls/ForecastControlsPanel.jsx index e9460b2..0aaff97 100644 --- a/app/src/components/controls/ForecastControlsPanel.jsx +++ b/app/src/components/controls/ForecastControlsPanel.jsx @@ -8,6 +8,8 @@ const ForecastControlsPanel = ({ setChartScale, intervalVisibility, setIntervalVisibility, + showLegend, + setShowLegend, defaultOpen = false, label = 'Advanced controls' }) => { @@ -38,6 +40,8 @@ const ForecastControlsPanel = ({ setChartScale={setChartScale} intervalVisibility={intervalVisibility} setIntervalVisibility={setIntervalVisibility} + showLegend={showLegend} + setShowLegend={setShowLegend} /> diff --git a/app/src/components/views/FluView.jsx b/app/src/components/views/FluView.jsx index 3267d67..21d7830 100644 --- a/app/src/components/views/FluView.jsx +++ b/app/src/components/views/FluView.jsx @@ -22,7 +22,7 @@ const FluView = ({ availablePeakModels, peakLocation }) => { - const { chartScale, setChartScale, intervalVisibility, setIntervalVisibility } = useView(); + const { chartScale, setChartScale, intervalVisibility, setIntervalVisibility, showLegend, setShowLegend } = useView(); const forecasts = data?.forecasts; const lastSelectedDate = useMemo(() => { @@ -186,6 +186,8 @@ const FluView = ({ intervalVisibility={intervalVisibility} setChartScale={setChartScale} setIntervalVisibility={setIntervalVisibility} + showLegend={showLegend} + setShowLegend={setShowLegend} /> ); diff --git a/app/src/components/views/MetroCastView.jsx b/app/src/components/views/MetroCastView.jsx index c9c7cde..f341731 100644 --- a/app/src/components/views/MetroCastView.jsx +++ b/app/src/components/views/MetroCastView.jsx @@ -81,7 +81,7 @@ const MetroPlotCard = ({ modelMarkerSize: isSmall ? 3 : 6, groundTruthLineWidth: isSmall ? 1 : 2, groundTruthMarkerSize: isSmall ? 2 : 4, - showLegendForFirstDate: !isSmall, + showLegendForFirstDate: showLegend && !isSmall, fillMissingQuantiles: true, showMedian, show50, @@ -127,7 +127,7 @@ const MetroPlotCard = ({ 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, + showlegend: showLegend && !isSmall, legend: { x: 0, y: 1, xanchor: 'left', yanchor: 'top', bgcolor: colorScheme === 'dark' ? 'rgba(26, 27, 30, 0.8)' : 'rgba(255, 255, 255, 0.8)', @@ -220,7 +220,7 @@ const MetroPlotCard = ({ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, windowSize, getDefaultRange, selectedTarget }) => { const { colorScheme } = useMantineColorScheme(); - const { handleLocationSelect, chartScale, setChartScale, intervalVisibility, setIntervalVisibility } = useView(); + const { handleLocationSelect, chartScale, setChartScale, intervalVisibility, setIntervalVisibility, showLegend, setShowLegend } = useView(); const [childData, setChildData] = useState({}); const [loadingChildren, setLoadingChildren] = useState(false); const [xAxisRange, setXAxisRange] = useState(null); @@ -292,6 +292,8 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, setChartScale={setChartScale} intervalVisibility={intervalVisibility} setIntervalVisibility={setIntervalVisibility} + showLegend={showLegend} + setShowLegend={setShowLegend} /> {stateCode && ( diff --git a/app/src/contexts/ViewContext.jsx b/app/src/contexts/ViewContext.jsx index 2c325fd..fec21f0 100644 --- a/app/src/contexts/ViewContext.jsx +++ b/app/src/contexts/ViewContext.jsx @@ -33,6 +33,7 @@ export const ViewProvider = ({ children }) => { ci50: true, ci95: true }); + const [showLegend, setShowLegend] = useState(true); const CURRENT_FLU_SEASON_START = '2025-11-01'; // !! CRITICAL !!: need to change this manually based on the season (for flu peak view) const { data, metadata, loading, error, availableDates, models, availableTargets, modelsByTarget, peaks, availablePeakDates, availablePeakModels } = useForecastData(selectedLocation, viewType); @@ -280,7 +281,9 @@ export const ViewProvider = ({ children }) => { chartScale, setChartScale, intervalVisibility, - setIntervalVisibility + setIntervalVisibility, + showLegend, + setShowLegend }; return ( diff --git a/app/src/hooks/useQuantileForecastTraces.js b/app/src/hooks/useQuantileForecastTraces.js index d56ce5d..df3d9fc 100644 --- a/app/src/hooks/useQuantileForecastTraces.js +++ b/app/src/hooks/useQuantileForecastTraces.js @@ -260,6 +260,20 @@ const useQuantileForecastTraces = ({ }); } + if (!showMedian && (show50 || show95)) { + traces.push({ + x: [null], + y: [null], + name: model, + type: 'scatter', + mode: 'lines', + line: { color: modelColor, width: modelLineWidth }, + showlegend: showLegendForFirstDate ? isFirstDate : false, + legendgroup: model, + hoverinfo: 'skip' + }); + } + return traces; }) ); From 4271581b8172c64e1f7d7818a32b376d2b61bd5a Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Wed, 4 Feb 2026 22:50:13 +0100 Subject: [PATCH 05/15] control pannel --- app/src/components/ForecastPlotView.jsx | 6 +++--- app/src/components/views/MetroCastView.jsx | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx index 2832055..0e5511a 100644 --- a/app/src/components/ForecastPlotView.jsx +++ b/app/src/components/ForecastPlotView.jsx @@ -332,6 +332,9 @@ const ForecastPlotView = ({ return ( + + {stateName} +
- - {stateName} -

{ const [yAxisRange, setYAxisRange] = useState(null); const groundTruth = locationData?.ground_truth; @@ -286,6 +287,7 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, isSmall={false} chartScale={chartScale} intervalVisibility={intervalVisibility} + showLegend={showLegend} /> ))} From e8024a5bf0ec71cf64d6708557ba994584e67fde Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Wed, 4 Feb 2026 22:55:17 +0100 Subject: [PATCH 06/15] reize behaviour address issue --- app/src/components/ForecastPlotView.jsx | 8 +++----- app/src/components/OverviewGraphCard.jsx | 15 +++++++++------ app/src/hooks/useOverviewPlot.js | 2 +- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx index 0e5511a..7ccc32a 100644 --- a/app/src/components/ForecastPlotView.jsx +++ b/app/src/components/ForecastPlotView.jsx @@ -19,7 +19,6 @@ const ForecastPlotView = ({ selectedModels, models, setSelectedModels, - windowSize, getDefaultRange, selectedTarget, forecastTarget = null, @@ -193,8 +192,6 @@ const ForecastPlotView = ({ const layout = useMemo(() => { const baseLayout = { - 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', @@ -272,7 +269,7 @@ const ForecastPlotView = ({ } return baseLayout; - }, [colorScheme, windowSize, defaultRange, resolvedDisplayTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides, chartScale, sqrtTicks]); + }, [colorScheme, defaultRange, resolvedDisplayTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides, chartScale, sqrtTicks]); const config = useMemo(() => { const baseConfig = { @@ -335,9 +332,10 @@ const ForecastPlotView = ({ {stateName} -

+
{errorLabel || error} )} {!loading && !error && hasTraces && ( - +
+ +
)} {showEmpty && ( {emptyLabel} diff --git a/app/src/hooks/useOverviewPlot.js b/app/src/hooks/useOverviewPlot.js index ab9ad0f..5be1271 100644 --- a/app/src/hooks/useOverviewPlot.js +++ b/app/src/hooks/useOverviewPlot.js @@ -72,7 +72,7 @@ const useOverviewPlot = ({ const layout = useMemo(() => { const baseLayout = { - height: 280, + autosize: true, margin: DEFAULT_MARGIN, title: { text: '', font: { size: 13 } }, xaxis: { From d9d99aca54fa738b90250b5f1ab6dbd238ce37af Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Thu, 5 Feb 2026 00:02:28 +0100 Subject: [PATCH 07/15] ux improvement --- app/src/utils/datasetUtils.js | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 app/src/utils/datasetUtils.js diff --git a/app/src/utils/datasetUtils.js b/app/src/utils/datasetUtils.js new file mode 100644 index 0000000..89a7c0e --- /dev/null +++ b/app/src/utils/datasetUtils.js @@ -0,0 +1,11 @@ +import { DATASETS } from '../config/datasets'; + +export const getDatasetNameFromView = (viewType) => { + if (!viewType) return null; + for (const dataset of Object.values(DATASETS)) { + if (dataset.views?.some((view) => view.value === viewType)) { + return dataset.fullName || null; + } + } + return null; +}; From b3eca0fc2b40f0c7bfab63a1397068acd0e51596 Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Thu, 5 Feb 2026 09:13:34 +0100 Subject: [PATCH 08/15] sync --- app/src/components/FluPeak.jsx | 62 +++++------- app/src/components/ForecastPlotView.jsx | 54 +++++----- app/src/components/ModelSelector.jsx | 99 +++++++++---------- app/src/components/StateSelector.jsx | 52 ++++++++-- .../controls/ForecastChartControls.jsx | 67 ++++++------- .../controls/ForecastControlsPanel.jsx | 52 ---------- app/src/components/views/FluView.jsx | 18 +++- app/src/components/views/MetroCastView.jsx | 49 ++++----- app/src/config/datasets.js | 2 +- 9 files changed, 212 insertions(+), 243 deletions(-) delete mode 100644 app/src/components/controls/ForecastControlsPanel.jsx diff --git a/app/src/components/FluPeak.jsx b/app/src/components/FluPeak.jsx index 7417817..fe14cc1 100644 --- a/app/src/components/FluPeak.jsx +++ b/app/src/components/FluPeak.jsx @@ -1,11 +1,10 @@ import { useState, useEffect, useMemo } from 'react'; -import { Stack, useMantineColorScheme, Text } from '@mantine/core'; +import { Stack, useMantineColorScheme } from '@mantine/core'; import Plot from 'react-plotly.js'; import ModelSelector from './ModelSelector'; import { MODEL_COLORS } from '../config/datasets'; import { CHART_CONSTANTS } from '../constants/chart'; import { getDataPath } from '../utils/paths'; -import ForecastControlsPanel from './controls/ForecastControlsPanel'; import { buildSqrtTicks, getYRangeFromTraces } from '../utils/scaleUtils'; // helper to convert Hex to RGBA for opacity control @@ -34,15 +33,11 @@ const FluPeak = ({ selectedDates, chartScale = 'linear', intervalVisibility = { median: true, ci50: true, ci95: true }, - setChartScale, - setIntervalVisibility, - showLegend = true, - setShowLegend + showLegend = true }) => { const { colorScheme } = useMantineColorScheme(); const groundTruth = data?.ground_truth; const [nhsnData, setNhsnData] = useState(null); - const stateName = data?.metadata?.location_name; const showMedian = intervalVisibility?.median ?? true; const show50 = intervalVisibility?.ci50 ?? true; const show95 = intervalVisibility?.ci95 ?? true; @@ -554,40 +549,27 @@ const FluPeak = ({ useResizeHandler={true} />
- {setChartScale && setIntervalVisibility && ( - +

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

+ { + const index = currentSelected.indexOf(model); + return MODEL_COLORS[index % MODEL_COLORS.length]; + }} /> - )} - - {stateName} - -
-

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

-
- { - const index = currentSelected.indexOf(model); - return MODEL_COLORS[index % MODEL_COLORS.length]; - }} - /> + ); }; diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx index 7ccc32a..09a7d25 100644 --- a/app/src/components/ForecastPlotView.jsx +++ b/app/src/components/ForecastPlotView.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { useMantineColorScheme, Stack, Text } from '@mantine/core'; +import { useMantineColorScheme, Stack, Text, Box } from '@mantine/core'; import Plot from 'react-plotly.js'; import Plotly from 'plotly.js/dist/plotly'; import ModelSelector from './ModelSelector'; @@ -10,7 +10,7 @@ import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils'; import useQuantileForecastTraces from '../hooks/useQuantileForecastTraces'; import { buildSqrtTicks } from '../utils/scaleUtils'; import { useView } from '../hooks/useView'; -import ForecastControlsPanel from './controls/ForecastControlsPanel'; +import { getDatasetNameFromView } from '../utils/datasetUtils'; const ForecastPlotView = ({ data, @@ -34,13 +34,13 @@ const ForecastPlotView = ({ const [xAxisRange, setXAxisRange] = useState(null); const plotRef = useRef(null); const isResettingRef = useRef(false); + const { colorScheme } = useMantineColorScheme(); + const { chartScale, intervalVisibility, showLegend, viewType } = useView(); const stateName = data?.metadata?.location_name; + const hubName = getDatasetNameFromView(viewType) || data?.metadata?.dataset; const getDefaultRangeRef = useRef(getDefaultRange); const projectionsDataRef = useRef([]); - - const { colorScheme } = useMantineColorScheme(); - const { chartScale, setChartScale, intervalVisibility, setIntervalVisibility, showLegend, setShowLegend } = useView(); const groundTruth = data?.ground_truth; const forecasts = data?.forecasts; @@ -328,10 +328,14 @@ const ForecastPlotView = ({ return ( - - - {stateName} - + + + {hubName ? `${stateName} — ${hubName}` : stateName} + + + + +
handlePlotUpdate(figure)} />
- -
+

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

-
- { - const index = currentSelected.indexOf(model); - return MODEL_COLORS[index % MODEL_COLORS.length]; - }} - /> + { + const index = currentSelected.indexOf(model); + return MODEL_COLORS[index % MODEL_COLORS.length]; + }} + /> +
); }; diff --git a/app/src/components/ModelSelector.jsx b/app/src/components/ModelSelector.jsx index 16f3e97..c9b0e9b 100644 --- a/app/src/components/ModelSelector.jsx +++ b/app/src/components/ModelSelector.jsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Stack, Group, Button, Text, Tooltip, Divider, Switch, Card, SimpleGrid, PillsInput, Pill, Combobox, useCombobox } from '@mantine/core'; +import { Stack, Group, Button, Text, Tooltip, Switch, Card, SimpleGrid, PillsInput, Pill, Combobox, useCombobox, Paper } from '@mantine/core'; import { IconCircleCheck, IconCircle, IconEye, IconEyeOff } from '@tabler/icons-react'; import { MODEL_COLORS } from '../config/datasets'; @@ -64,12 +64,51 @@ const ModelSelector = ({ } return ( - - - - - Models ({selectedModels.length}/{models.length}) - + + + + + Model selection ({selectedModels.length}/{models.length}) + + {allowMultiple && ( + <> + + + + + + + + )} + setShowAllAvailable(event.currentTarget.checked)} + size="sm" + disabled={disabled} + thumbIcon={ + showAllAvailable ? ( + + ) : ( + + ) + } + /> + - - {allowMultiple && ( - - - - - - - - - )} - setShowAllAvailable(event.currentTarget.checked)} - size="sm" - disabled={disabled} - thumbIcon={ - showAllAvailable ? ( - - ) : ( - - ) - } - /> - - {allowMultiple && ( {selectedModels.length > 0 && `${selectedModels.length} selected`} @@ -278,8 +276,9 @@ const ModelSelector = ({ })} )} - + + ); }; -export default ModelSelector; \ No newline at end of file +export default ModelSelector; diff --git a/app/src/components/StateSelector.jsx b/app/src/components/StateSelector.jsx index 58a55ed..19d7a5d 100644 --- a/app/src/components/StateSelector.jsx +++ b/app/src/components/StateSelector.jsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; -import { Stack, ScrollArea, Button, TextInput, Text, Divider, Loader, Center, Alert } from '@mantine/core'; -import { IconSearch, IconAlertTriangle } from '@tabler/icons-react'; +import { Stack, ScrollArea, Button, TextInput, Text, Divider, Loader, Center, Alert, Accordion } from '@mantine/core'; +import { IconSearch, IconAlertTriangle, IconAdjustmentsHorizontal } from '@tabler/icons-react'; import { useView } from '../hooks/useView'; import ViewSelector from './ViewSelector'; import TargetSelector from './TargetSelector'; +import ForecastChartControls from './controls/ForecastChartControls'; import { getDataPath } from '../utils/paths'; const METRO_STATE_MAP = { @@ -14,7 +15,17 @@ const METRO_STATE_MAP = { }; const StateSelector = () => { - const { selectedLocation, handleLocationSelect, viewType} = useView(); + const { + selectedLocation, + handleLocationSelect, + viewType, + chartScale, + setChartScale, + intervalVisibility, + setIntervalVisibility, + showLegend, + setShowLegend + } = useView(); const [states, setStates] = useState([]); const [loading, setLoading] = useState(true); @@ -149,22 +160,47 @@ const StateSelector = () => { return }>{error}; } + const maxLocationScrollHeight = 360; + return ( - View - Target + + + }> + Advanced controls + + + + + + + - Location { autoFocus aria-label="Search locations" /> - + {filteredStates.map((state, index) => { const isSelected = selectedLocation === state.abbreviation; @@ -239,4 +275,4 @@ const StateSelector = () => { ); }; -export default StateSelector; \ No newline at end of file +export default StateSelector; diff --git a/app/src/components/controls/ForecastChartControls.jsx b/app/src/components/controls/ForecastChartControls.jsx index 466a1de..31c0d57 100644 --- a/app/src/components/controls/ForecastChartControls.jsx +++ b/app/src/components/controls/ForecastChartControls.jsx @@ -1,4 +1,4 @@ -import { Paper, Stack, Text, SegmentedControl, Checkbox, Group, Switch } from '@mantine/core'; +import { Stack, Text, SegmentedControl, Checkbox, Group, Switch } from '@mantine/core'; const INTERVAL_OPTIONS = [ { value: 'median', label: 'Median' }, @@ -33,40 +33,37 @@ const ForecastChartControls = ({ }; return ( - - - Graph controls - - Y-scale - - - - Intervals - - - {INTERVAL_OPTIONS.map((option) => ( - - ))} - - - - - Legend - setShowLegend(event.currentTarget.checked)} - size="sm" - onLabel="On" - offLabel="Off" - /> - - - + + + Y-scale + + + + Intervals + + + {INTERVAL_OPTIONS.map((option) => ( + + ))} + + + + + Legend + setShowLegend(event.currentTarget.checked)} + size="sm" + onLabel="On" + offLabel="Off" + /> + + ); }; diff --git a/app/src/components/controls/ForecastControlsPanel.jsx b/app/src/components/controls/ForecastControlsPanel.jsx deleted file mode 100644 index 0aaff97..0000000 --- a/app/src/components/controls/ForecastControlsPanel.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useState } from 'react'; -import { Drawer, Button, Stack } from '@mantine/core'; -import { IconAdjustmentsHorizontal } from '@tabler/icons-react'; -import ForecastChartControls from './ForecastChartControls'; - -const ForecastControlsPanel = ({ - chartScale, - setChartScale, - intervalVisibility, - setIntervalVisibility, - showLegend, - setShowLegend, - defaultOpen = false, - label = 'Advanced controls' -}) => { - const [open, setOpen] = useState(defaultOpen); - - return ( - <> - - setOpen(false)} - title={label} - position="left" - size="sm" - padding="md" - > - - - - - - ); -}; - -export default ForecastControlsPanel; diff --git a/app/src/components/views/FluView.jsx b/app/src/components/views/FluView.jsx index 21d7830..c9a532b 100644 --- a/app/src/components/views/FluView.jsx +++ b/app/src/components/views/FluView.jsx @@ -2,9 +2,11 @@ import { useMemo, useCallback } from 'react'; import ForecastPlotView from '../ForecastPlotView'; import FluPeak from '../FluPeak'; import LastFetched from '../LastFetched'; +import { Text, Box } from '@mantine/core'; import { MODEL_COLORS } from '../../config/datasets'; import { RATE_CHANGE_CATEGORIES } from '../../constants/chart'; import { useView } from '../../hooks/useView'; +import { getDatasetNameFromView } from '../../utils/datasetUtils'; const FluView = ({ data, @@ -22,7 +24,7 @@ const FluView = ({ availablePeakModels, peakLocation }) => { - const { chartScale, setChartScale, intervalVisibility, setIntervalVisibility, showLegend, setShowLegend } = useView(); + const { chartScale, intervalVisibility, showLegend } = useView(); const forecasts = data?.forecasts; const lastSelectedDate = useMemo(() => { @@ -169,9 +171,18 @@ const FluView = ({ }), []); if (viewType === 'flu_peak') { + const stateName = data?.metadata?.location_name; + const hubName = getDatasetNameFromView(viewType) || data?.metadata?.dataset; return ( <> - + + + {hubName ? `${stateName} — ${hubName}` : stateName} + + + + + ); diff --git a/app/src/components/views/MetroCastView.jsx b/app/src/components/views/MetroCastView.jsx index 650bb02..5a7ff74 100644 --- a/app/src/components/views/MetroCastView.jsx +++ b/app/src/components/views/MetroCastView.jsx @@ -9,8 +9,8 @@ import { CHART_CONSTANTS } from '../../constants/chart'; import { targetDisplayNameMap, targetYAxisLabelMap } from '../../utils/mapUtils'; import { getDataPath } from '../../utils/paths'; import useQuantileForecastTraces from '../../hooks/useQuantileForecastTraces'; -import ForecastControlsPanel from '../controls/ForecastControlsPanel'; import { buildSqrtTicks } from '../../utils/scaleUtils'; +import { getDatasetNameFromView } from '../../utils/datasetUtils'; const METRO_STATE_MAP = { 'Colorado': 'CO', 'Georgia': 'GA', 'Indiana': 'IN', 'Maine': 'ME', @@ -110,7 +110,11 @@ const MetroPlotCard = ({ const PlotContent = ( <> - {title} + {title && ( + + {title} + + )} {!hasForecasts && ( @@ -221,12 +225,13 @@ const MetroPlotCard = ({ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, windowSize, getDefaultRange, selectedTarget }) => { const { colorScheme } = useMantineColorScheme(); - const { handleLocationSelect, chartScale, setChartScale, intervalVisibility, setIntervalVisibility, showLegend, setShowLegend } = useView(); + const { handleLocationSelect, chartScale, intervalVisibility, showLegend, viewType } = useView(); const [childData, setChildData] = useState({}); const [loadingChildren, setLoadingChildren] = useState(false); const [xAxisRange, setXAxisRange] = useState(null); const stateName = data?.metadata?.location_name; + const hubName = getDatasetNameFromView(viewType) || data?.metadata?.dataset; const stateCode = METRO_STATE_MAP[stateName]; const forecasts = data?.forecasts; @@ -271,11 +276,18 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, return ( - + + + {hubName ? `${stateName} — ${hubName}` : stateName} + + + + + - - {stateCode && ( {loadingChildren ? ( @@ -334,7 +337,7 @@ 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.

-
- MODEL_COLORS[sel.indexOf(m) % MODEL_COLORS.length]} - /> + MODEL_COLORS[sel.indexOf(m) % MODEL_COLORS.length]} + /> +
); }; diff --git a/app/src/config/datasets.js b/app/src/config/datasets.js index 066e759..338d373 100644 --- a/app/src/config/datasets.js +++ b/app/src/config/datasets.js @@ -31,7 +31,7 @@ export const DATASETS = { }, covid: { shortName: 'covid', - fullName: 'COVID-19 Forecasts', + fullName: 'covid19 forecast hub', views: [ { key: 'forecasts', label: 'Forecasts', value: 'covid_forecasts' } ], From 042440cb4f3dd46524e2b1d5fb8c09589f5285f2 Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Thu, 5 Feb 2026 09:17:56 +0100 Subject: [PATCH 09/15] fix lint warning --- app/src/components/ForecastPlotView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx index 09a7d25..21f5527 100644 --- a/app/src/components/ForecastPlotView.jsx +++ b/app/src/components/ForecastPlotView.jsx @@ -269,7 +269,7 @@ const ForecastPlotView = ({ } return baseLayout; - }, [colorScheme, defaultRange, resolvedDisplayTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides, chartScale, sqrtTicks]); + }, [colorScheme, defaultRange, resolvedDisplayTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides, chartScale, sqrtTicks, showLegend]); const config = useMemo(() => { const baseConfig = { From cc1521313e4a174655b3384e937b50a4e54e8bcd Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Thu, 5 Feb 2026 09:18:09 +0100 Subject: [PATCH 10/15] fix lint warning --- app/src/components/ForecastPlotView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx index 21f5527..aeb7fe5 100644 --- a/app/src/components/ForecastPlotView.jsx +++ b/app/src/components/ForecastPlotView.jsx @@ -269,7 +269,7 @@ const ForecastPlotView = ({ } return baseLayout; - }, [colorScheme, defaultRange, resolvedDisplayTarget, selectedDates, selectedModels, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides, chartScale, sqrtTicks, showLegend]); + }, [colorScheme, defaultRange, resolvedDisplayTarget, selectedDates, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides, chartScale, sqrtTicks, showLegend]); const config = useMemo(() => { const baseConfig = { From b8f71ec57b895e096dc2b163e22dbe1fcbb33056 Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Thu, 5 Feb 2026 09:25:23 +0100 Subject: [PATCH 11/15] Fix NHSN plot resizing --- app/src/components/views/NHSNView.jsx | 23 +++++++++++++---------- baselinenowcast | 1 + export_rda_to_csv.R | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 10 deletions(-) create mode 160000 baselinenowcast create mode 100644 export_rda_to_csv.R diff --git a/app/src/components/views/NHSNView.jsx b/app/src/components/views/NHSNView.jsx index 0a0713f..ac2a4a5 100644 --- a/app/src/components/views/NHSNView.jsx +++ b/app/src/components/views/NHSNView.jsx @@ -336,6 +336,7 @@ const NHSNView = ({ location }) => { }, [data, selectedTarget, selectedColumns, filteredAvailableColumns]); const layout = useMemo(() => ({ + autosize: true, template: colorScheme === 'dark' ? 'plotly_dark' : 'plotly_white', paper_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', plot_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff', @@ -365,7 +366,6 @@ const NHSNView = ({ location }) => { range: yAxisRange, autorange: yAxisRange === null }, - height: 600, showlegend: selectedColumns.length < 15, legend: { x: 0, @@ -442,15 +442,18 @@ const NHSNView = ({ location }) => { {stateName} - +
+ +
Date: Thu, 5 Feb 2026 09:43:51 +0100 Subject: [PATCH 12/15] fix nhsn titles --- app/src/components/ForecastPlotView.jsx | 4 ++-- app/src/components/views/FluView.jsx | 4 ++-- app/src/components/views/MetroCastView.jsx | 4 ++-- app/src/components/views/NHSNView.jsx | 18 +++++++++++++----- app/src/config/datasets.js | 11 ++++++++--- app/src/utils/datasetUtils.js | 4 ++-- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx index aeb7fe5..6efde38 100644 --- a/app/src/components/ForecastPlotView.jsx +++ b/app/src/components/ForecastPlotView.jsx @@ -10,7 +10,7 @@ import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils'; import useQuantileForecastTraces from '../hooks/useQuantileForecastTraces'; import { buildSqrtTicks } from '../utils/scaleUtils'; import { useView } from '../hooks/useView'; -import { getDatasetNameFromView } from '../utils/datasetUtils'; +import { getDatasetTitleFromView } from '../utils/datasetUtils'; const ForecastPlotView = ({ data, @@ -37,7 +37,7 @@ const ForecastPlotView = ({ const { colorScheme } = useMantineColorScheme(); const { chartScale, intervalVisibility, showLegend, viewType } = useView(); const stateName = data?.metadata?.location_name; - const hubName = getDatasetNameFromView(viewType) || data?.metadata?.dataset; + const hubName = getDatasetTitleFromView(viewType) || data?.metadata?.dataset; const getDefaultRangeRef = useRef(getDefaultRange); const projectionsDataRef = useRef([]); diff --git a/app/src/components/views/FluView.jsx b/app/src/components/views/FluView.jsx index c9a532b..19a995e 100644 --- a/app/src/components/views/FluView.jsx +++ b/app/src/components/views/FluView.jsx @@ -6,7 +6,7 @@ import { Text, Box } from '@mantine/core'; import { MODEL_COLORS } from '../../config/datasets'; import { RATE_CHANGE_CATEGORIES } from '../../constants/chart'; import { useView } from '../../hooks/useView'; -import { getDatasetNameFromView } from '../../utils/datasetUtils'; +import { getDatasetTitleFromView } from '../../utils/datasetUtils'; const FluView = ({ data, @@ -172,7 +172,7 @@ const FluView = ({ if (viewType === 'flu_peak') { const stateName = data?.metadata?.location_name; - const hubName = getDatasetNameFromView(viewType) || data?.metadata?.dataset; + const hubName = getDatasetTitleFromView(viewType) || data?.metadata?.dataset; return ( <> diff --git a/app/src/components/views/MetroCastView.jsx b/app/src/components/views/MetroCastView.jsx index 5a7ff74..452980e 100644 --- a/app/src/components/views/MetroCastView.jsx +++ b/app/src/components/views/MetroCastView.jsx @@ -10,7 +10,7 @@ import { targetDisplayNameMap, targetYAxisLabelMap } from '../../utils/mapUtils' import { getDataPath } from '../../utils/paths'; import useQuantileForecastTraces from '../../hooks/useQuantileForecastTraces'; import { buildSqrtTicks } from '../../utils/scaleUtils'; -import { getDatasetNameFromView } from '../../utils/datasetUtils'; +import { getDatasetTitleFromView } from '../../utils/datasetUtils'; const METRO_STATE_MAP = { 'Colorado': 'CO', 'Georgia': 'GA', 'Indiana': 'IN', 'Maine': 'ME', @@ -231,7 +231,7 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models, const [xAxisRange, setXAxisRange] = useState(null); const stateName = data?.metadata?.location_name; - const hubName = getDatasetNameFromView(viewType) || data?.metadata?.dataset; + const hubName = getDatasetTitleFromView(viewType) || data?.metadata?.dataset; const stateCode = METRO_STATE_MAP[stateName]; const forecasts = data?.forecasts; diff --git a/app/src/components/views/NHSNView.jsx b/app/src/components/views/NHSNView.jsx index ac2a4a5..982c37c 100644 --- a/app/src/components/views/NHSNView.jsx +++ b/app/src/components/views/NHSNView.jsx @@ -1,12 +1,14 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { Stack, Alert, Text, Center, useMantineColorScheme, Loader } from '@mantine/core'; +import { Stack, Alert, Text, Center, useMantineColorScheme, Loader, Box } from '@mantine/core'; import Plot from 'react-plotly.js'; import Plotly from 'plotly.js/dist/plotly'; import { getDataPath } from '../../utils/paths'; import NHSNColumnSelector from '../NHSNColumnSelector'; import LastFetched from '../LastFetched'; import { MODEL_COLORS } from '../../config/datasets'; +import { useView } from '../../hooks/useView'; +import { getDatasetTitleFromView } from '../../utils/datasetUtils'; import { nhsnTargetsToColumnsMap, // groupings nhsnNameToSlugMap, // { longform: shortform } map @@ -41,7 +43,9 @@ const NHSNView = ({ location }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const { colorScheme } = useMantineColorScheme(); + const { viewType } = useView(); const stateName = data?.metadata?.location_name; + const hubName = getDatasetTitleFromView(viewType) || metadata?.dataset; const [allDataColumns, setAllDataColumns] = useState([]); // All columns from JSON const [filteredAvailableColumns, setFilteredAvailableColumns] = useState([]); // Columns for the selected target @@ -438,10 +442,14 @@ const NHSNView = ({ location }) => { return ( - - - {stateName} - + + + {hubName ? `${stateName} — ${hubName}` : stateName} + + + + +
{ +export const getDatasetTitleFromView = (viewType) => { if (!viewType) return null; for (const dataset of Object.values(DATASETS)) { if (dataset.views?.some((view) => view.value === viewType)) { - return dataset.fullName || null; + return dataset.titleName || dataset.fullName || null; } } return null; From 3f63e21756b7737d179059d6c7ccfb906eba4b8c Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Thu, 5 Feb 2026 10:04:07 +0100 Subject: [PATCH 13/15] fixes to nhsn --- app/src/components/ForecastPlotView.jsx | 16 ++-- app/src/components/StateSelector.jsx | 53 +++++++------ .../controls/ForecastChartControls.jsx | 25 +++--- app/src/components/views/FluView.jsx | 15 ++-- app/src/components/views/MetroCastView.jsx | 14 ++-- app/src/components/views/NHSNView.jsx | 79 +++++++++++++------ 6 files changed, 114 insertions(+), 88 deletions(-) diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx index 6efde38..b58d428 100644 --- a/app/src/components/ForecastPlotView.jsx +++ b/app/src/components/ForecastPlotView.jsx @@ -1,9 +1,9 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { useMantineColorScheme, Stack, Text, Box } from '@mantine/core'; +import { useMantineColorScheme, Stack, Text } 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 TitleRow from './TitleRow'; import { MODEL_COLORS } from '../config/datasets'; import { CHART_CONSTANTS } from '../constants/chart'; import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils'; @@ -328,14 +328,10 @@ const ForecastPlotView = ({ return ( - - - {hubName ? `${stateName} — ${hubName}` : stateName} - - - - - +
{ - - - }> - Advanced controls - - - - - - + {viewType !== 'frontpage' && ( + + + }> + Advanced controls + + + + + + + )} { const selectedIntervals = INTERVAL_OPTIONS .filter((option) => intervalVisibility?.[option.value]) @@ -43,16 +44,18 @@ const ForecastChartControls = ({ size="xs" /> - - Intervals - - - {INTERVAL_OPTIONS.map((option) => ( - - ))} - - - + {showIntervals && ( + + Intervals + + + {INTERVAL_OPTIONS.map((option) => ( + + ))} + + + + )} Legend - - - {hubName ? `${stateName} — ${hubName}` : stateName} - - - - - + - - - {hubName ? `${stateName} — ${hubName}` : stateName} - - - - - + { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const { colorScheme } = useMantineColorScheme(); - const { viewType } = useView(); + const { viewType, chartScale, showLegend } = useView(); const stateName = data?.metadata?.location_name; const hubName = getDatasetTitleFromView(viewType) || metadata?.dataset; @@ -297,9 +298,16 @@ const NHSNView = ({ location }) => { } const newYRange = calculateYRange(traces, currentXRange); - setYAxisRange(newYRange); + if (chartScale === 'sqrt' && newYRange) { + const [minY, maxY] = newYRange; + const sqrtMin = Math.sqrt(Math.max(0, minY)); + const sqrtMax = Math.sqrt(Math.max(0, maxY)); + setYAxisRange([sqrtMin, sqrtMax]); + } else { + setYAxisRange(newYRange); + } - }, [data, selectedColumns, xAxisRange, selectedTarget, defaultRange, calculateYRange]); + }, [data, selectedColumns, xAxisRange, selectedTarget, defaultRange, calculateYRange, chartScale]); const handleRelayout = useCallback((figure) => { if (isResettingRef.current) { @@ -315,19 +323,41 @@ const NHSNView = ({ location }) => { }, [xAxisRange]); - const traces = useMemo(() => { + const rawTraces = useMemo(() => { if (!data) return []; - const isPercentage = selectedTarget && selectedTarget.includes('%'); return selectedColumns.map((column) => { - const columnIndex = filteredAvailableColumns.indexOf(column); const yValues = data.series[column]; const processedYValues = isPercentage ? yValues.map(val => val !== null && val !== undefined ? val * 100 : val) : yValues; - return { x: data.series.dates, y: processedYValues, - name: column, + name: column + }; + }); + }, [data, selectedTarget, selectedColumns]); + + const rawYRange = useMemo(() => getYRangeFromTraces(rawTraces), [rawTraces]); + + const sqrtTicks = useMemo(() => { + if (chartScale !== 'sqrt') return null; + return buildSqrtTicks({ rawRange: rawYRange }); + }, [chartScale, rawYRange]); + + const traces = useMemo(() => { + if (!data) return []; + const applySqrt = chartScale === 'sqrt'; + + return rawTraces.map((trace) => { + const columnIndex = filteredAvailableColumns.indexOf(trace.name); + const transformedY = applySqrt + ? trace.y.map(val => (val === null || val === undefined ? val : Math.sqrt(Math.max(0, val)))) + : trace.y; + + return { + x: trace.x, + y: transformedY, + name: trace.name, type: 'scatter', mode: 'lines+markers', line: { @@ -337,7 +367,7 @@ const NHSNView = ({ location }) => { marker: { size: 6 } }; }); - }, [data, selectedTarget, selectedColumns, filteredAvailableColumns]); + }, [data, rawTraces, filteredAvailableColumns, chartScale]); const layout = useMemo(() => ({ autosize: true, @@ -367,10 +397,14 @@ const NHSNView = ({ location }) => { }, yaxis: { title: nhsnYAxisLabelMap[selectedTarget] || 'Value', - range: yAxisRange, - autorange: yAxisRange === null + range: chartScale === 'log' ? undefined : yAxisRange, + autorange: chartScale === 'log' ? true : yAxisRange === null, + type: chartScale === 'log' ? 'log' : 'linear', + tickmode: chartScale === 'sqrt' && sqrtTicks ? 'array' : undefined, + tickvals: chartScale === 'sqrt' && sqrtTicks ? sqrtTicks.tickvals : undefined, + ticktext: chartScale === 'sqrt' && sqrtTicks ? sqrtTicks.ticktext : undefined }, - showlegend: selectedColumns.length < 15, + showlegend: showLegend ?? selectedColumns.length < 15, legend: { x: 0, y: 1, @@ -389,9 +423,12 @@ const NHSNView = ({ location }) => { defaultRange, xAxisRange, yAxisRange, + chartScale, + showLegend, selectedTarget, selectedColumns.length, - plotRevision + plotRevision, + sqrtTicks ]); const config = useMemo(() => ({ @@ -442,14 +479,10 @@ const NHSNView = ({ location }) => { return ( - - - {hubName ? `${stateName} — ${hubName}` : stateName} - - - - - +
Date: Thu, 5 Feb 2026 10:12:10 +0100 Subject: [PATCH 14/15] f --- app/src/components/TitleRow.jsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 app/src/components/TitleRow.jsx diff --git a/app/src/components/TitleRow.jsx b/app/src/components/TitleRow.jsx new file mode 100644 index 0000000..1f50405 --- /dev/null +++ b/app/src/components/TitleRow.jsx @@ -0,0 +1,23 @@ +import { Box, Text } from '@mantine/core'; +import LastFetched from './LastFetched'; + +const TitleRow = ({ title, timestamp }) => { + if (!title && !timestamp) return null; + + return ( + + {title && ( + + {title} + + )} + {timestamp && ( + + + + )} + + ); +}; + +export default TitleRow; From db8b7b9e925d0106f5746f6913b87e8edea76908 Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Thu, 5 Feb 2026 17:22:19 +0100 Subject: [PATCH 15/15] address emily comments --- app/src/components/StateSelector.jsx | 4 +- app/src/components/TitleRow.jsx | 6 +-- app/src/contexts/ViewContext.jsx | 56 ++++++++++++++++---- app/src/utils/urlManager.js | 76 ++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 16 deletions(-) diff --git a/app/src/components/StateSelector.jsx b/app/src/components/StateSelector.jsx index b86d120..c212f99 100644 --- a/app/src/components/StateSelector.jsx +++ b/app/src/components/StateSelector.jsx @@ -160,8 +160,6 @@ const StateSelector = () => { return }>{error}; } - const maxLocationScrollHeight = 360; - return ( @@ -214,7 +212,7 @@ const StateSelector = () => { autoFocus aria-label="Search locations" /> - + {filteredStates.map((state, index) => { const isSelected = selectedLocation === state.abbreviation; diff --git a/app/src/components/TitleRow.jsx b/app/src/components/TitleRow.jsx index 1f50405..1fa67ee 100644 --- a/app/src/components/TitleRow.jsx +++ b/app/src/components/TitleRow.jsx @@ -1,4 +1,4 @@ -import { Box, Text } from '@mantine/core'; +import { Box, Title } from '@mantine/core'; import LastFetched from './LastFetched'; const TitleRow = ({ title, timestamp }) => { @@ -7,9 +7,9 @@ const TitleRow = ({ title, timestamp }) => { return ( {title && ( - + {title} - </Text> + )} {timestamp && ( diff --git a/app/src/contexts/ViewContext.jsx b/app/src/contexts/ViewContext.jsx index fec21f0..26fc344 100644 --- a/app/src/contexts/ViewContext.jsx +++ b/app/src/contexts/ViewContext.jsx @@ -27,13 +27,9 @@ export const ViewProvider = ({ children }) => { const [selectedDates, setSelectedDates] = useState([]); const [activeDate, setActiveDate] = useState(null); const [selectedTarget, setSelectedTarget] = useState(null); - const [chartScale, setChartScale] = useState('linear'); - const [intervalVisibility, setIntervalVisibility] = useState({ - median: true, - ci50: true, - ci95: true - }); - const [showLegend, setShowLegend] = useState(true); + const [chartScale, setChartScale] = useState(() => urlManager.getAdvancedParams().chartScale); + const [intervalVisibility, setIntervalVisibility] = useState(() => urlManager.getAdvancedParams().intervalVisibility); + const [showLegend, setShowLegend] = useState(() => urlManager.getAdvancedParams().showLegend); const CURRENT_FLU_SEASON_START = '2025-11-01'; // !! CRITICAL !!: need to change this manually based on the season (for flu peak view) const { data, metadata, loading, error, availableDates, models, availableTargets, modelsByTarget, peaks, availablePeakDates, availablePeakModels } = useForecastData(selectedLocation, viewType); @@ -243,6 +239,46 @@ export const ViewProvider = ({ children }) => { } }, [searchParams, urlManager, viewType]); + useEffect(() => { + if (!isForecastPage) { + return; + } + const { chartScale: urlScale, intervalVisibility: urlIntervals, showLegend: urlLegend } = urlManager.getAdvancedParams(); + if (urlScale !== chartScale) { + setChartScale(urlScale); + } + if (JSON.stringify(urlIntervals) !== JSON.stringify(intervalVisibility)) { + setIntervalVisibility(urlIntervals); + } + if (urlLegend !== showLegend) { + setShowLegend(urlLegend); + } + }, [searchParams, urlManager, isForecastPage, chartScale, intervalVisibility, showLegend]); + + const setChartScaleWithUrl = useCallback((nextScale) => { + setChartScale(nextScale); + if (isForecastPage) { + urlManager.updateAdvancedParams({ chartScale: nextScale }); + } + }, [urlManager, isForecastPage]); + + const setIntervalVisibilityWithUrl = useCallback((updater) => { + setIntervalVisibility(prev => { + const next = typeof updater === 'function' ? updater(prev) : updater; + if (isForecastPage) { + urlManager.updateAdvancedParams({ intervalVisibility: next }); + } + return next; + }); + }, [urlManager, isForecastPage]); + + const setShowLegendWithUrl = useCallback((nextShowLegend) => { + setShowLegend(nextShowLegend); + if (isForecastPage) { + urlManager.updateAdvancedParams({ showLegend: nextShowLegend }); + } + }, [urlManager, isForecastPage]); + const contextValue = { selectedLocation, handleLocationSelect, data, metadata, loading, error, @@ -279,11 +315,11 @@ export const ViewProvider = ({ children }) => { availablePeakDates: (availablePeakDates || []).filter(date => date >= CURRENT_FLU_SEASON_START), availablePeakModels, chartScale, - setChartScale, + setChartScale: setChartScaleWithUrl, intervalVisibility, - setIntervalVisibility, + setIntervalVisibility: setIntervalVisibilityWithUrl, showLegend, - setShowLegend + setShowLegend: setShowLegendWithUrl }; return ( diff --git a/app/src/utils/urlManager.js b/app/src/utils/urlManager.js index b3a40c3..6618bbf 100644 --- a/app/src/utils/urlManager.js +++ b/app/src/utils/urlManager.js @@ -1,5 +1,13 @@ import { DATASETS, APP_CONFIG } from '../config'; +const DEFAULT_CHART_SCALE = 'linear'; +const DEFAULT_INTERVAL_VISIBILITY = { + median: true, + ci50: true, + ci95: true +}; +const DEFAULT_SHOW_LEGEND = true; + export class URLParameterManager { constructor(searchParams, setSearchParams) { this.searchParams = searchParams; @@ -50,6 +58,39 @@ export class URLParameterManager { return params; } + getAdvancedParams() { + const scaleParam = this.searchParams.get('scale'); + const intervalsParam = this.searchParams.get('intervals'); + const legendParam = this.searchParams.get('legend'); + + const chartScale = scaleParam || DEFAULT_CHART_SCALE; + let intervalVisibility = { ...DEFAULT_INTERVAL_VISIBILITY }; + + if (intervalsParam === 'none') { + intervalVisibility = { + median: false, + ci50: false, + ci95: false + }; + } else if (intervalsParam) { + const enabled = new Set(intervalsParam.split(',').filter(Boolean)); + intervalVisibility = { + median: enabled.has('median'), + ci50: enabled.has('ci50'), + ci95: enabled.has('ci95') + }; + } + + let showLegend = DEFAULT_SHOW_LEGEND; + if (legendParam === '0' || legendParam === 'false') { + showLegend = false; + } else if (legendParam === '1' || legendParam === 'true') { + showLegend = true; + } + + return { chartScale, intervalVisibility, showLegend }; + } + // Clear parameters for a specific dataset clearDatasetParams(dataset) { if (!dataset) { @@ -74,6 +115,41 @@ export class URLParameterManager { this.setSearchParams(newParams, { replace: true }); } + updateAdvancedParams({ chartScale, intervalVisibility, showLegend }) { + const updatedParams = new URLSearchParams(this.searchParams); + + if (chartScale) { + if (chartScale !== DEFAULT_CHART_SCALE) { + updatedParams.set('scale', chartScale); + } else { + updatedParams.delete('scale'); + } + } + + if (intervalVisibility) { + const enabled = ['median', 'ci50', 'ci95'].filter(key => intervalVisibility[key]); + if (enabled.length === 0) { + updatedParams.set('intervals', 'none'); + } else if (enabled.length === 3) { + updatedParams.delete('intervals'); + } else { + updatedParams.set('intervals', enabled.join(',')); + } + } + + if (typeof showLegend === 'boolean') { + if (showLegend !== DEFAULT_SHOW_LEGEND) { + updatedParams.set('legend', showLegend ? '1' : '0'); + } else { + updatedParams.delete('legend'); + } + } + + if (updatedParams.toString() !== this.searchParams.toString()) { + this.setSearchParams(updatedParams, { replace: true }); + } + } + updateDatasetParams(dataset, newParams) { if (!dataset) { return;