diff --git a/app/README.md b/app/README.md
index f768e33..cc4e27a 100644
--- a/app/README.md
+++ b/app/README.md
@@ -6,3 +6,43 @@ 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).
+
+### 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/COVID19View.jsx b/app/src/components/COVID19View.jsx
deleted file mode 100644
index c9996ef..0000000
--- a/app/src/components/COVID19View.jsx
+++ /dev/null
@@ -1,368 +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 LastFetched from './LastFetched';
-import { MODEL_COLORS } from '../config/datasets';
-import { CHART_CONSTANTS } from '../constants/chart';
-import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils';
-
-
-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
diff --git a/app/src/components/FluPeak.jsx b/app/src/components/FluPeak.jsx
index e9b7036..fe14cc1 100644
--- a/app/src/components/FluPeak.jsx
+++ b/app/src/components/FluPeak.jsx
@@ -1,10 +1,11 @@
import { useState, useEffect, useMemo } from 'react';
-import { Stack, useMantineColorScheme, Switch, Group, 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 { buildSqrtTicks, getYRangeFromTraces } from '../utils/scaleUtils';
// helper to convert Hex to RGBA for opacity control
const hexToRgba = (hex, alpha) => {
@@ -29,13 +30,17 @@ const FluPeak = ({
windowSize,
selectedModels,
setSelectedModels,
- selectedDates
+ selectedDates,
+ chartScale = 'linear',
+ intervalVisibility = { median: true, ci50: true, ci95: true },
+ showLegend = true
}) => {
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 +92,7 @@ const FluPeak = ({
return activeModelSet;
}, [peaks, selectedDates, peakDates]);
- const plotData = useMemo(() => {
+ const { plotData, rawYRange } = useMemo(() => {
const traces = [];
// Historic data (NHSN)
@@ -253,9 +258,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 +286,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 +303,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 +326,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 +342,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 +354,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 +375,7 @@ const FluPeak = ({
});
// actual trace
- if (xValues.length > 0) {
+ if (showMedian && xValues.length > 0) {
traces.push({
x: xValues,
y: yValues,
@@ -411,8 +418,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,
@@ -423,6 +461,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)',
@@ -436,7 +475,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 +520,7 @@ const FluPeak = ({
}
];
}),
- }), [colorScheme, windowSize, selectedDates]);
+ }), [colorScheme, windowSize, selectedDates, chartScale, sqrtTicks, showLegend]);
const config = useMemo(() => ({
responsive: true,
@@ -498,41 +549,29 @@ const FluPeak = ({
useResizeHandler={true}
/>
-
- {stateName}
-
-
-
- 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"
+
+
+ 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];
- }}
- />
+
);
};
-export default FluPeak;
\ No newline at end of file
+export default FluPeak;
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..b58d428
--- /dev/null
+++ b/app/src/components/ForecastPlotView.jsx
@@ -0,0 +1,371 @@
+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 TitleRow from './TitleRow';
+import { MODEL_COLORS } from '../config/datasets';
+import { CHART_CONSTANTS } from '../constants/chart';
+import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils';
+import useQuantileForecastTraces from '../hooks/useQuantileForecastTraces';
+import { buildSqrtTicks } from '../utils/scaleUtils';
+import { useView } from '../hooks/useView';
+import { getDatasetTitleFromView } from '../utils/datasetUtils';
+
+const ForecastPlotView = ({
+ data,
+ metadata,
+ selectedDates,
+ selectedModels,
+ models,
+ setSelectedModels,
+ 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 { colorScheme } = useMantineColorScheme();
+ const { chartScale, intervalVisibility, showLegend, viewType } = useView();
+ const stateName = data?.metadata?.location_name;
+ const hubName = getDatasetTitleFromView(viewType) || data?.metadata?.dataset;
+
+ const getDefaultRangeRef = useRef(getDefaultRange);
+ const projectionsDataRef = useRef([]);
+ const groundTruth = data?.ground_truth;
+ const forecasts = data?.forecasts;
+
+ const resolvedForecastTarget = forecastTarget || selectedTarget;
+ const resolvedDisplayTarget = displayTarget || selectedTarget || resolvedForecastTarget;
+ 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 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 { traces: projectionsData, rawYRange } = useQuantileForecastTraces({
+ groundTruth,
+ forecasts,
+ selectedDates,
+ selectedModels,
+ target: resolvedForecastTarget,
+ groundTruthLabel: 'Observed',
+ groundTruthValueFormat,
+ valueSuffix: '',
+ modelLineWidth: 2,
+ modelMarkerSize: 6,
+ groundTruthLineWidth: 2,
+ groundTruthMarkerSize: 4,
+ showLegendForFirstDate: showLegend,
+ 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') {
+ 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 sqrtTicks = useMemo(() => {
+ if (chartScale !== 'sqrt') return null;
+ return buildSqrtTicks({ rawRange: rawYRange });
+ }, [chartScale, rawYRange]);
+
+ const layout = useMemo(() => {
+ const baseLayout = {
+ 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: 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)',
+ 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];
+ const baseTitle = targetYAxisLabelMap[longName] || longName || resolvedDisplayTarget || 'Value';
+ if (chartScale === 'log') return `${baseTitle} (log)`;
+ if (chartScale === 'sqrt') return `${baseTitle} (sqrt)`;
+ return baseTitle;
+ })(),
+ 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 {
+ 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, defaultRange, resolvedDisplayTarget, selectedDates, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides, chartScale, sqrtTicks, showLegend]);
+
+ 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)}
+ />
+
+
+
+ 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/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/NHSNOverviewGraph.jsx b/app/src/components/NHSNOverviewGraph.jsx
index 0bf84d3..1f9dcd4 100644
--- a/app/src/components/NHSNOverviewGraph.jsx
+++ b/app/src/components/NHSNOverviewGraph.jsx
@@ -1,20 +1,20 @@
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';
+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);
@@ -28,15 +28,15 @@ 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();
setData(json);
} catch (err) {
- console.error("Failed to fetch NHSN snapshot", err);
+ console.error('Failed to fetch NHSN snapshot', err);
setError(err.message);
setData(null);
} finally {
@@ -47,139 +47,87 @@ 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;
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/OverviewGraphCard.jsx b/app/src/components/OverviewGraphCard.jsx
new file mode 100644
index 0000000..282926f
--- /dev/null
+++ b/app/src/components/OverviewGraphCard.jsx
@@ -0,0 +1,69 @@
+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 e76f72c..2271aad 100644
--- a/app/src/components/PathogenOverviewGraph.jsx
+++ b/app/src/components/PathogenOverviewGraph.jsx
@@ -1,10 +1,11 @@
-import { useMemo } from 'react';
-import { Button, Card, Group, Loader, Stack, Text, Title } from '@mantine/core';
+import { useMemo, useCallback } from 'react';
+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';
+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,117 +163,44 @@ 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;
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
deleted file mode 100644
index 558b9e1..0000000
--- a/app/src/components/RSVView.jsx
+++ /dev/null
@@ -1,370 +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 LastFetched from './LastFetched';
-import { MODEL_COLORS } from '../config/datasets';
-import { CHART_CONSTANTS } from '../constants/chart';
-import { targetDisplayNameMap, targetYAxisLabelMap } from '../utils/mapUtils';
-
-
-const RSVView = ({ data, metadata, selectedDates, selectedModels, models, setSelectedModels, windowSize, getDefaultRange, selectedTarget }) => {
- const [yAxisRange, setYAxisRange] = useState(null);
- const [xAxisRange, setXAxisRange] = useState(null);
- 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
diff --git a/app/src/components/StateSelector.jsx b/app/src/components/StateSelector.jsx
index 58a55ed..c212f99 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);
@@ -152,19 +163,45 @@ const StateSelector = () => {
return (
- View
- Target
+ {viewType !== 'frontpage' && (
+
+
+ }>
+ Advanced controls
+
+
+
+
+
+
+ )}
+
- Location
{
autoFocus
aria-label="Search locations"
/>
-
+
{filteredStates.map((state, index) => {
const isSelected = selectedLocation === state.abbreviation;
@@ -239,4 +276,4 @@ const StateSelector = () => {
);
};
-export default StateSelector;
\ No newline at end of file
+export default StateSelector;
diff --git a/app/src/components/TitleRow.jsx b/app/src/components/TitleRow.jsx
new file mode 100644
index 0000000..1fa67ee
--- /dev/null
+++ b/app/src/components/TitleRow.jsx
@@ -0,0 +1,23 @@
+import { Box, Title } from '@mantine/core';
+import LastFetched from './LastFetched';
+
+const TitleRow = ({ title, timestamp }) => {
+ if (!title && !timestamp) return null;
+
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+ {timestamp && (
+
+
+
+ )}
+
+ );
+};
+
+export default TitleRow;
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/controls/ForecastChartControls.jsx b/app/src/components/controls/ForecastChartControls.jsx
new file mode 100644
index 0000000..3b8f128
--- /dev/null
+++ b/app/src/components/controls/ForecastChartControls.jsx
@@ -0,0 +1,73 @@
+import { Stack, Text, SegmentedControl, Checkbox, Group, Switch } 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,
+ showLegend,
+ setShowLegend,
+ showIntervals = true
+}) => {
+ 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 (
+
+
+ Y-scale
+
+
+ {showIntervals && (
+
+ Intervals
+
+
+ {INTERVAL_OPTIONS.map((option) => (
+
+ ))}
+
+
+
+ )}
+
+ Legend
+ setShowLegend(event.currentTarget.checked)}
+ size="sm"
+ onLabel="On"
+ offLabel="Off"
+ />
+
+
+ );
+};
+
+export default ForecastChartControls;
diff --git a/app/src/components/views/COVID19View.jsx b/app/src/components/views/COVID19View.jsx
new file mode 100644
index 0000000..20e0eea
--- /dev/null
+++ b/app/src/components/views/COVID19View.jsx
@@ -0,0 +1,10 @@
+import ForecastPlotView from '../ForecastPlotView';
+
+const COVID19View = (props) => (
+
+);
+
+export default COVID19View;
diff --git a/app/src/components/views/FluView.jsx b/app/src/components/views/FluView.jsx
new file mode 100644
index 0000000..1cc0900
--- /dev/null
+++ b/app/src/components/views/FluView.jsx
@@ -0,0 +1,222 @@
+import { useMemo, useCallback } from 'react';
+import ForecastPlotView from '../ForecastPlotView';
+import FluPeak from '../FluPeak';
+import TitleRow from '../TitleRow';
+import { MODEL_COLORS } from '../../config/datasets';
+import { RATE_CHANGE_CATEGORIES } from '../../constants/chart';
+import { useView } from '../../hooks/useView';
+import { getDatasetTitleFromView } from '../../utils/datasetUtils';
+
+const FluView = ({
+ data,
+ metadata,
+ selectedDates,
+ selectedModels,
+ models,
+ setSelectedModels,
+ viewType,
+ windowSize,
+ getDefaultRange,
+ selectedTarget,
+ peaks,
+ availablePeakDates,
+ availablePeakModels,
+ peakLocation
+}) => {
+ const { chartScale, intervalVisibility, showLegend } = useView();
+ 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') {
+ const stateName = data?.metadata?.location_name;
+ const hubName = getDatasetTitleFromView(viewType) || data?.metadata?.dataset;
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default FluView;
diff --git a/app/src/components/MetroCastView.jsx b/app/src/components/views/MetroCastView.jsx
similarity index 59%
rename from app/src/components/MetroCastView.jsx
rename to app/src/components/views/MetroCastView.jsx
index d6632dc..6027110 100644
--- a/app/src/components/MetroCastView.jsx
+++ b/app/src/components/views/MetroCastView.jsx
@@ -1,32 +1,38 @@
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 TitleRow from '../TitleRow';
+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';
+import { buildSqrtTicks } from '../../utils/scaleUtils';
+import { getDatasetTitleFromView } from '../../utils/datasetUtils';
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,
- setXAxisRange
+ setXAxisRange,
+ chartScale,
+ intervalVisibility,
+ showLegend = true
}) => {
const [yAxisRange, setYAxisRange] = useState(null);
const groundTruth = locationData?.ground_truth;
@@ -53,96 +59,47 @@ 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 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,
+ 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: showLegend && !isSmall,
+ fillMissingQuantiles: true,
+ showMedian,
+ show50,
+ show95,
+ transformY: sqrtTransform,
+ groundTruthHoverFormatter: sqrtTransform ? (value) => Number(value).toFixed(2) : null
+ });
- const traces = [{
- x: groundTruth.dates || [], y: gtValues, name: 'Observed', type: 'scatter',
- mode: 'lines+markers', line: { color: 'black', width: isSmall ? 1 : 2, dash: 'dash' },
- marker: { size: isSmall ? 2 : 4, color: 'black' },
- hovertemplate: 'Ground Truth Data
Date: %{x}
Value: %{y:.2f}%'
- }];
-
- selectedModels.forEach(model => {
- selectedDates.forEach((date, dateIdx) => {
- const forecast = forecasts[date]?.[selectedTarget]?.[model];
- if (forecast?.type !== 'quantile') return;
-
- const fDates = [], median = [], q95U = [], q95L = [], q50U = [], q50L = [];
- const hoverTexts = [];
-
- const sorted = Object.keys(forecast.predictions || {}).sort((a, b) => Number(a) - Number(b));
-
- 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 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;
@@ -153,11 +110,15 @@ const MetroPlotCard = ({
const PlotContent = (
<>
- {title}
-
+ {title && (
+
+ {title}
+
+ )}
+
{!hasForecasts && (
- No forecast data for selection
+ No forecast data for selection
)}
@@ -171,15 +132,15 @@ 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)',
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'
@@ -188,25 +149,32 @@ const MetroPlotCard = ({
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: {
+ 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 +182,19 @@ const MetroPlotCard = ({
);
return isSmall ? (
-
{PlotContent}
- {
const { colorScheme } = useMantineColorScheme();
- const { handleLocationSelect } = useView();
+ const { handleLocationSelect, chartScale, intervalVisibility, showLegend, viewType } = 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 hubName = getDatasetTitleFromView(viewType) || data?.metadata?.dataset;
const stateCode = METRO_STATE_MAP[stateName];
const forecasts = data?.forecasts;
@@ -307,11 +276,14 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models,
return (
-
+
-
{stateCode && (
{loadingChildren ? (
@@ -331,9 +305,9 @@ const MetroCastView = ({ data, metadata, selectedDates, selectedModels, models,
<>
{Object.entries(childData).map(([abbr, cityData]) => (
- handleLocationSelect(abbr)}
+ handleLocationSelect(abbr)}
style={{ width: '100%' }}
>
))}
@@ -356,26 +333,26 @@ 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]}
+ />
+
);
};
-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 84%
rename from app/src/components/NHSNView.jsx
rename to app/src/components/views/NHSNView.jsx
index 6dc4f1a..d432bae 100644
--- a/app/src/components/NHSNView.jsx
+++ b/app/src/components/views/NHSNView.jsx
@@ -3,16 +3,19 @@ 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 TitleRow from '../TitleRow';
+import { MODEL_COLORS } from '../../config/datasets';
+import { buildSqrtTicks, getYRangeFromTraces } from '../../utils/scaleUtils';
+import { useView } from '../../hooks/useView';
+import { getDatasetTitleFromView } from '../../utils/datasetUtils';
import {
nhsnTargetsToColumnsMap, // groupings
nhsnNameToSlugMap, // { longform: shortform } map
nhsnSlugToNameMap, // { shortform: longform } map
nhsnNameToPrettyNameMap // { longform: presentable name } map
-} from '../utils/mapUtils';
+} from '../../utils/mapUtils';
const nhsnYAxisLabelMap = {
@@ -41,7 +44,9 @@ const NHSNView = ({ location }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const { colorScheme } = useMantineColorScheme();
+ const { viewType, chartScale, showLegend } = 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
@@ -293,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) {
@@ -311,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: {
@@ -333,9 +367,10 @@ const NHSNView = ({ location }) => {
marker: { size: 6 }
};
});
- }, [data, selectedTarget, selectedColumns, filteredAvailableColumns]);
+ }, [data, rawTraces, filteredAvailableColumns, chartScale]);
const layout = useMemo(() => ({
+ autosize: true,
template: colorScheme === 'dark' ? 'plotly_dark' : 'plotly_white',
paper_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff',
plot_bgcolor: colorScheme === 'dark' ? '#1a1b1e' : '#ffffff',
@@ -362,11 +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
},
- height: 600,
- showlegend: selectedColumns.length < 15,
+ showlegend: showLegend ?? selectedColumns.length < 15,
legend: {
x: 0,
y: 1,
@@ -385,9 +423,12 @@ const NHSNView = ({ location }) => {
defaultRange,
xAxisRange,
yAxisRange,
+ chartScale,
+ showLegend,
selectedTarget,
selectedColumns.length,
- plotRevision
+ plotRevision,
+ sqrtTicks
]);
const config = useMemo(() => ({
@@ -438,19 +479,22 @@ const NHSNView = ({ location }) => {
return (
-
-
- {stateName}
-
-
+
{
);
};
-export default NHSNView;
\ No newline at end of file
+export default NHSNView;
diff --git a/app/src/components/views/RSVView.jsx b/app/src/components/views/RSVView.jsx
new file mode 100644
index 0000000..b1c1ead
--- /dev/null
+++ b/app/src/components/views/RSVView.jsx
@@ -0,0 +1,10 @@
+import ForecastPlotView from '../ForecastPlotView';
+
+const RSVView = (props) => (
+
+);
+
+export default RSVView;
diff --git a/app/src/config/datasets.js b/app/src/config/datasets.js
index 066e759..e00c852 100644
--- a/app/src/config/datasets.js
+++ b/app/src/config/datasets.js
@@ -1,7 +1,8 @@
export const DATASETS = {
flu: {
shortName: 'flu',
- fullName: 'Flu Forecasts',
+ fullName: 'Flu Forecast',
+ titleName: 'Flusight Forecasts',
views: [
{ key: 'detailed', label: 'Detailed Forecasts View', value: 'fludetailed' },
{ key: 'forecasts', label: 'Forecasts', value: 'flu_forecasts' },
@@ -17,7 +18,8 @@ export const DATASETS = {
},
rsv: {
shortName: 'rsv',
- fullName: 'RSV Forecasts',
+ fullName: 'RSV Forecast',
+ titleName: 'RSV Forecast Hub',
views: [
{ key: 'forecasts', label: 'Forecasts', value: 'rsv_forecasts' }
],
@@ -32,6 +34,7 @@ export const DATASETS = {
covid: {
shortName: 'covid',
fullName: 'COVID-19 Forecasts',
+ titleName: 'COVID-19 Forecast Hub',
views: [
{ key: 'forecasts', label: 'Forecasts', value: 'covid_forecasts' }
],
@@ -46,6 +49,7 @@ export const DATASETS = {
nhsn: {
shortName: 'nhsn',
fullName: 'NHSN Respiratory Data',
+ titleName: 'NHSN Respiratory Data',
views: [
{ key: 'all', label: 'All Data', value: 'nhsnall' }
],
@@ -59,6 +63,7 @@ export const DATASETS = {
metrocast: {
shortName: 'metrocast',
fullName: 'Flu MetroCast Forecasts',
+ titleName: 'Flu MetroCast Forecasts',
views: [
{ key: 'forecasts', label: 'Forecasts', value: 'metrocast_forecasts' }
],
diff --git a/app/src/contexts/ViewContext.jsx b/app/src/contexts/ViewContext.jsx
index 420b54f..26fc344 100644
--- a/app/src/contexts/ViewContext.jsx
+++ b/app/src/contexts/ViewContext.jsx
@@ -27,6 +27,9 @@ export const ViewProvider = ({ children }) => {
const [selectedDates, setSelectedDates] = useState([]);
const [activeDate, setActiveDate] = useState(null);
const [selectedTarget, setSelectedTarget] = useState(null);
+ 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);
@@ -236,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,
@@ -270,7 +313,13 @@ export const ViewProvider = ({ children }) => {
handleTargetSelect,
peaks,
availablePeakDates: (availablePeakDates || []).filter(date => date >= CURRENT_FLU_SEASON_START),
- availablePeakModels
+ availablePeakModels,
+ chartScale,
+ setChartScale: setChartScaleWithUrl,
+ intervalVisibility,
+ setIntervalVisibility: setIntervalVisibilityWithUrl,
+ showLegend,
+ setShowLegend: setShowLegendWithUrl
};
return (
diff --git a/app/src/hooks/useOverviewPlot.js b/app/src/hooks/useOverviewPlot.js
new file mode 100644
index 0000000..5be1271
--- /dev/null
+++ b/app/src/hooks/useOverviewPlot.js
@@ -0,0 +1,104 @@
+import { useMemo } from 'react';
+
+const DEFAULT_MARGIN = { l: 40, r: 20, t: 40, b: 40 };
+
+const isValidDate = (dateValue) => {
+ 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 = {
+ autosize: true,
+ 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..df3d9fc
--- /dev/null
+++ b/app/src/hooks/useQuantileForecastTraces.js
@@ -0,0 +1,309 @@
+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,
+ 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);
+ 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,
+ showMedian = true,
+ show50 = true,
+ show95 = true,
+ transformY = null,
+ groundTruthHoverFormatter = null
+}) => useMemo(() => {
+ if (!groundTruth || !forecasts || selectedDates.length === 0 || !target) {
+ return { traces: [], rawYRange: null };
+ }
+
+ const groundTruthValues = groundTruth[target];
+ if (!groundTruthValues) {
+ console.warn(`Ground truth data not found for target: ${target}`);
+ 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: groundTruthY,
+ name: groundTruthLabel,
+ type: 'scatter',
+ mode: showMedian ? 'lines+markers' : 'lines',
+ line: { color: 'black', width: groundTruthLineWidth, dash: 'dash' },
+ 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] || {};
+ 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);
+
+ 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)}`;
+ 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,
+ show50,
+ show95
+ });
+
+ hoverTexts.push(hoverText);
+ });
+
+ if (forecastDates.length === 0) return [];
+
+ const modelColor = modelColorFn
+ ? modelColorFn(model, selectedModels)
+ : resolveModelColor(selectedModels, model);
+ const isFirstDate = dateIndex === 0;
+
+ 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,
+ 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'
+ }
+ });
+ }
+
+ 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;
+ })
+ );
+
+ const rawYRange = rawMin === Infinity || rawMax === -Infinity ? null : [rawMin, rawMax];
+
+ return { traces: [groundTruthTrace, ...modelTraces], rawYRange };
+}, [
+ groundTruth,
+ forecasts,
+ selectedDates,
+ selectedModels,
+ target,
+ groundTruthLabel,
+ groundTruthValueFormat,
+ valueSuffix,
+ formatValue,
+ modelHoverBuilder,
+ modelColorFn,
+ modelLineWidth,
+ modelMarkerSize,
+ groundTruthLineWidth,
+ groundTruthMarkerSize,
+ showLegendForFirstDate,
+ fillMissingQuantiles,
+ showMedian,
+ show50,
+ show95,
+ transformY,
+ groundTruthHoverFormatter
+]);
+
+export default useQuantileForecastTraces;
diff --git a/app/src/utils/datasetUtils.js b/app/src/utils/datasetUtils.js
new file mode 100644
index 0000000..ecc010a
--- /dev/null
+++ b/app/src/utils/datasetUtils.js
@@ -0,0 +1,11 @@
+import { DATASETS } from '../config/datasets';
+
+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.titleName || dataset.fullName || null;
+ }
+ }
+ return null;
+};
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 };
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;
diff --git a/baselinenowcast b/baselinenowcast
new file mode 160000
index 0000000..65c7015
--- /dev/null
+++ b/baselinenowcast
@@ -0,0 +1 @@
+Subproject commit 65c70159b535ccd46b09e3363dc88fe2973f1f25
diff --git a/export_rda_to_csv.R b/export_rda_to_csv.R
new file mode 100644
index 0000000..b2e3254
--- /dev/null
+++ b/export_rda_to_csv.R
@@ -0,0 +1,16 @@
+files <- Sys.glob("baselinenowcast/data/*.rda")
+out_dir <- "baselinenowcast/data"
+
+for (f in files) {
+ env <- new.env()
+ objs <- load(f, envir = env)
+ for (o in objs) {
+ obj <- env[[o]]
+ out <- file.path(out_dir, paste0(tools::file_path_sans_ext(basename(f)), "_", o, ".csv"))
+ if (is.data.frame(obj)) {
+ write.csv(obj, out, row.names = FALSE)
+ } else if (is.matrix(obj)) {
+ write.csv(obj, out, row.names = TRUE)
+ }
+ }
+}