diff --git a/app/package-lock.json b/app/package-lock.json index d8451f17..77df6eb0 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -19,6 +19,7 @@ "@uiw/react-json-view": "^2.0.0-alpha.39", "chart.js": "^4.5.0", "dayjs": "^1.11.13", + "driver.js": "^1.4.0", "patch-package": "^8.0.1", "plotly.js": "^2.30.0", "react": "^18.3.1", @@ -45,6 +46,10 @@ "vite": "^6.0.1" } }, + "node_modules/driver.js": { + "version": "1.3.0", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -3438,6 +3443,12 @@ "normalize-svg-path": "~0.1.0" } }, + "node_modules/driver.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", + "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", + "license": "MIT" + }, "node_modules/dtype": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dtype/-/dtype-2.0.0.tgz", @@ -9160,4 +9171,4 @@ } } } -} +} \ No newline at end of file diff --git a/app/package.json b/app/package.json index 6772db0d..60a0b879 100644 --- a/app/package.json +++ b/app/package.json @@ -22,6 +22,7 @@ "@uiw/react-json-view": "^2.0.0-alpha.39", "chart.js": "^4.5.0", "dayjs": "^1.11.13", + "driver.js": "^1.4.0", "patch-package": "^8.0.1", "plotly.js": "^2.30.0", "react": "^18.3.1", @@ -47,4 +48,4 @@ "postcss-simple-vars": "^7.0.1", "vite": "^6.0.1" } -} +} \ No newline at end of file diff --git a/app/src/App.jsx b/app/src/App.jsx index 56af991e..87ef0ddb 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -10,6 +10,8 @@ import MyRespiLensDashboard from './components/myrespi/MyRespiLensDashboard'; import TournamentDashboard from './components/tournament/TournamentDashboard'; import UnifiedAppShell from './components/layout/UnifiedAppShell'; import Documentation from './components/Documentation' +import ReportingDelayPage from './components/reporting/ReportingDelayPage'; +import ToolsPage from './components/tools/ToolsPage'; import { Center, Text } from '@mantine/core'; // import ShutdownBanner from './components/ShutdownBanner';, no longer necessary @@ -42,6 +44,8 @@ const AppLayout = () => { } /> } /> } /> + } /> + } /> } /> diff --git a/app/src/components/DataVisualizationContainer.jsx b/app/src/components/DataVisualizationContainer.jsx index 502e1efa..796b3bcd 100644 --- a/app/src/components/DataVisualizationContainer.jsx +++ b/app/src/components/DataVisualizationContainer.jsx @@ -6,6 +6,7 @@ import DateSelector from './DateSelector'; import ViewSwitchboard from './ViewSwitchboard'; import ErrorBoundary from './ErrorBoundary'; import AboutHubOverlay from './AboutHubOverlay'; +import PathogenFrontPage from './PathogenFrontPage'; import { IconShare, IconBrandGithub } from '@tabler/icons-react'; import { useClipboard } from '@mantine/hooks'; @@ -253,6 +254,21 @@ const DataVisualizationContainer = () => { setSelectedDates([singleDate]); } }, [viewType, selectedDates, activeDate, setSelectedDates]); + + if (viewType === 'frontpage') { + return ( + window.location.reload()}> + + RespiLens | Forecasts + + + + + + + + ); + } return ( window.location.reload()}> @@ -260,8 +276,9 @@ const DataVisualizationContainer = () => { RespiLens | {currentDataset?.fullName || 'Forecasts'} - - + + +
800 ? 'auto 1fr auto' : '1fr', @@ -362,8 +379,9 @@ const DataVisualizationContainer = () => { availablePeakModels={availablePeakModels} />
-
-
+
+
+
); diff --git a/app/src/components/PathogenFrontPage.jsx b/app/src/components/PathogenFrontPage.jsx new file mode 100644 index 00000000..a0657c64 --- /dev/null +++ b/app/src/components/PathogenFrontPage.jsx @@ -0,0 +1,22 @@ +import { SimpleGrid, Stack, Title, Paper } from '@mantine/core'; +import PathogenOverviewGraph from './PathogenOverviewGraph'; +import { useView } from '../hooks/useView'; + +const PathogenFrontPage = () => { + const { selectedLocation } = useView(); + + return ( + + + Explore forecasts by pathogen + + + + + + + + ); +}; + +export default PathogenFrontPage; diff --git a/app/src/components/PathogenOverviewGraph.jsx b/app/src/components/PathogenOverviewGraph.jsx new file mode 100644 index 00000000..00995e32 --- /dev/null +++ b/app/src/components/PathogenOverviewGraph.jsx @@ -0,0 +1,282 @@ +import { useMemo } from 'react'; +import { Button, Card, Group, Loader, Stack, Text, Title } 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'; + +const DEFAULT_TARGETS = { + covid_projs: 'wk inc covid hosp', + flu_projs: 'wk inc flu hosp', + rsv_projs: 'wk inc rsv hosp' +}; + +const VIEW_TO_DATASET = { + covid_projs: 'covid', + flu_projs: 'flu', + rsv_projs: 'rsv' +}; + +const getRangeAroundDate = (dateStr, weeksBefore = 4, weeksAfter = 4) => { + if (!dateStr) return undefined; + const baseDate = new Date(dateStr); + if (Number.isNaN(baseDate.getTime())) return undefined; + + const start = new Date(baseDate); + start.setDate(start.getDate() - weeksBefore * 7); + const end = new Date(baseDate); + end.setDate(end.getDate() + weeksAfter * 7); + + return [ + start.toISOString().split('T')[0], + end.toISOString().split('T')[0] + ]; +}; + +const buildIntervalTraces = (forecast, model) => { + if (!forecast || forecast.type !== 'quantile') return null; + + const predictionEntries = Object.values(forecast.predictions || {}).sort( + (a, b) => new Date(a.date) - new Date(b.date) + ); + + const x = []; + const median = []; + const lower95 = []; + const upper95 = []; + const lower50 = []; + const upper50 = []; + + predictionEntries.forEach((pred) => { + const { quantiles = [], values = [] } = pred; + const medianIndex = quantiles.indexOf(0.5); + const lower95Index = quantiles.indexOf(0.025); + const upper95Index = quantiles.indexOf(0.975); + const lower50Index = quantiles.indexOf(0.25); + const upper50Index = quantiles.indexOf(0.75); + + if (medianIndex !== -1 && lower95Index !== -1 && upper95Index !== -1 && lower50Index !== -1 && upper50Index !== -1) { + x.push(pred.date); + median.push(values[medianIndex]); + lower95.push(values[lower95Index]); + upper95.push(values[upper95Index]); + lower50.push(values[lower50Index]); + upper50.push(values[upper50Index]); + } + }); + + if (x.length === 0) return null; + + return [ + { + x, + y: upper95, + name: `${model} 95% interval`, + type: 'scatter', + mode: 'lines', + line: { width: 0 }, + showlegend: false, + hoverinfo: 'skip' + }, + { + x, + y: lower95, + name: `${model} 95% interval`, + type: 'scatter', + mode: 'lines', + fill: 'tonexty', + fillcolor: 'rgba(34, 139, 230, 0.15)', + line: { width: 0 }, + showlegend: false, + hoverinfo: 'skip' + }, + { + x, + y: upper50, + name: `${model} 50% interval`, + type: 'scatter', + mode: 'lines', + line: { width: 0 }, + showlegend: false, + hoverinfo: 'skip' + }, + { + x, + y: lower50, + name: `${model} 50% interval`, + type: 'scatter', + mode: 'lines', + fill: 'tonexty', + fillcolor: 'rgba(34, 139, 230, 0.25)', + line: { width: 0 }, + showlegend: false, + hoverinfo: 'skip' + }, + { + x, + y: median, + name: `${model} median`, + type: 'scatter', + mode: 'lines+markers', + line: { width: 2, color: '#228be6' }, + marker: { size: 4 } + } + ]; +}; + +const PathogenOverviewGraph = ({ viewType, title, location }) => { + const { viewType: activeViewType, setViewType } = useView(); + const resolvedLocation = location || 'US'; + const { data, loading, error, availableDates, availableTargets, models } = useForecastData(resolvedLocation, viewType); + const datasetKey = VIEW_TO_DATASET[viewType]; + const datasetConfig = datasetKey ? DATASETS[datasetKey] : null; + + const selectedDate = availableDates[availableDates.length - 1]; + const preferredTarget = DEFAULT_TARGETS[viewType]; + const selectedTarget = preferredTarget && availableTargets.includes(preferredTarget) + ? preferredTarget + : availableTargets[0]; + + const selectedModel = datasetConfig?.defaultModel && models.includes(datasetConfig.defaultModel) + ? datasetConfig.defaultModel + : models[0]; + + 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 groundTruthValues = groundTruth?.[selectedTarget]; + const groundTruthTrace = groundTruthValues + ? { + x: groundTruth.dates || [], + y: groundTruthValues, + name: 'Observed', + type: 'scatter', + mode: 'lines+markers', + line: { color: '#1f1f1f', width: 2, dash: 'dash' }, + marker: { size: 3 } + } + : null; + + const forecast = selectedDate && selectedTarget && selectedModel + ? data.forecasts?.[selectedDate]?.[selectedTarget]?.[selectedModel] + : null; + + const intervalTraces = buildIntervalTraces(forecast, selectedModel); + + const combinedTraces = [ + 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]); + + 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} + + + + ); +}; + +export default PathogenOverviewGraph; diff --git a/app/src/components/layout/MainNavigation.jsx b/app/src/components/layout/MainNavigation.jsx index 8b6f1e64..c2b24492 100644 --- a/app/src/components/layout/MainNavigation.jsx +++ b/app/src/components/layout/MainNavigation.jsx @@ -1,10 +1,12 @@ import { useLocation, Link } from 'react-router-dom'; -import { Group, Button, Image, Title } from '@mantine/core'; +import { Group, Button, Image, Title, Anchor } from '@mantine/core'; import { IconChartLine, IconTarget, IconDashboard } from '@tabler/icons-react'; import InfoOverlay from '../InfoOverlay'; +import { useView } from '../../hooks/useView'; const MainNavigation = () => { const location = useLocation(); + const { setViewType } = useView(); const isActive = (path) => location.pathname.startsWith(path); @@ -19,12 +21,20 @@ const MainNavigation = () => { return ( {/* Logo */} - - RespiLens Logo - - RespiLens<sup style={{ color: 'var(--mantine-color-red-6)', fontSize: '0.75rem' }}></sup> - - + setViewType('frontpage')} + > + + RespiLens Logo + + RespiLens<sup style={{ color: 'var(--mantine-color-red-6)', fontSize: '0.75rem' }}></sup> + + + {/* Desktop Navigation - Full Buttons */} @@ -50,4 +60,4 @@ const MainNavigation = () => { ); }; -export default MainNavigation; \ No newline at end of file +export default MainNavigation; diff --git a/app/src/components/reporting/ReportingDelayPage.jsx b/app/src/components/reporting/ReportingDelayPage.jsx new file mode 100644 index 00000000..357891dd --- /dev/null +++ b/app/src/components/reporting/ReportingDelayPage.jsx @@ -0,0 +1,1003 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + Anchor, + ActionIcon, + Badge, + Box, + Button, + Card, + Container, + Divider, + Group, + List, + Modal, + NumberInput, + Paper, + RangeSlider, + ScrollArea, + Select, + SimpleGrid, + Stack, + Switch, + Table, + Text, + ThemeIcon, + Title, +} from '@mantine/core'; +import { + IconArrowsMaximize, + IconArrowsMinimize, + IconArrowRight, + IconClock, + IconDownload, + IconFileUpload, +} from '@tabler/icons-react'; +import { + BarElement, + CategoryScale, + Chart as ChartJS, + Legend, + LineElement, + LinearScale, + PointElement, + Tooltip, +} from 'chart.js'; +import { Chart } from 'react-chartjs-2'; +import Plot from 'react-plotly.js'; +import { driver } from 'driver.js'; +import 'driver.js/dist/driver.css'; + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend); + +const SAMPLE_CSV = `reference_date,report_date,value +2024-02-01,2024-02-02,31 +2024-02-01,2024-02-03,56 +2024-02-01,2024-02-04,71 +2024-02-01,2024-02-05,79 +2024-02-02,2024-02-03,28 +2024-02-02,2024-02-04,52 +2024-02-02,2024-02-05,68 +2024-02-02,2024-02-06,74 +2024-02-03,2024-02-04,26 +2024-02-03,2024-02-05,45 +2024-02-03,2024-02-06,62 +2024-02-03,2024-02-07,70 +2024-02-04,2024-02-05,24 +2024-02-04,2024-02-06,39 +2024-02-04,2024-02-07,55 +2024-02-04,2024-02-08,63 +2024-02-05,2024-02-06,23 +2024-02-05,2024-02-07,35 +2024-02-05,2024-02-08,49 +2024-02-05,2024-02-09,58`; + +const parseCsv = (text) => { + const rows = text.trim().split(/\r?\n/).filter(Boolean); + if (rows.length < 2) { + throw new Error('CSV appears to be empty.'); + } + + const headers = rows[0].split(',').map((header) => header.trim()); + const normalizedHeaders = headers.map((header) => header.toLowerCase()); + const dataRows = rows.slice(1).map((row) => row.split(',')); + + const records = dataRows.map((parts, index) => { + const entry = {}; + headers.forEach((header, headerIndex) => { + entry[header] = parts[headerIndex]?.trim() ?? ''; + }); + entry._rowIndex = index + 2; + return entry; + }); + + return { headers, normalizedHeaders, records }; +}; + +const formatDateLabel = (value) => new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + +const getDefaultReferenceRange = (dates) => { + if (!dates.length) return [0, 0]; + return [0, dates.length - 1]; +}; + +const getFrequencyUnit = (dates) => { + if (dates.length < 2) { + return { unit: 'day', unitDays: 1 }; + } + const diffs = dates + .slice(1) + .map((date, index) => new Date(date) - new Date(dates[index])) + .map((diff) => Math.max(1, Math.round(diff / (1000 * 60 * 60 * 24)))) + .sort((a, b) => a - b); + const median = diffs[Math.floor(diffs.length / 2)]; + if (median >= 28) { + return { unit: 'month', unitDays: 30 }; + } + if (median >= 6) { + return { unit: 'week', unitDays: 7 }; + } + return { unit: 'day', unitDays: 1 }; +}; + + +const buildTriangle = (records, { referenceDates, maxLagDays } = {}) => { + const allReferenceDates = Array.from(new Set(records.map((record) => record.referenceDate))).sort(); + const allReportDates = Array.from(new Set(records.map((record) => record.reportDate))).sort(); + const activeReferenceDates = referenceDates?.length ? referenceDates : allReferenceDates; + const activeReportDates = maxLagDays + ? allReportDates.filter((date) => { + return activeReferenceDates.some((referenceDate) => { + const diff = Math.round((new Date(date) - new Date(referenceDate)) / (1000 * 60 * 60 * 24)); + return diff >= 0 && diff <= maxLagDays; + }); + }) + : allReportDates; + const reportDatesWithDiagonal = Array.from(new Set([...activeReportDates, ...activeReferenceDates])).sort(); + const allowedReferenceDates = new Set(activeReferenceDates); + const allowedReportDates = new Set(reportDatesWithDiagonal); + const filteredRecords = records.filter( + (record) => allowedReferenceDates.has(record.referenceDate) && allowedReportDates.has(record.reportDate), + ); + const lagFilteredRecords = maxLagDays + ? filteredRecords.filter((record) => { + const delay = Math.round( + (new Date(record.reportDate) - new Date(record.referenceDate)) / (1000 * 60 * 60 * 24), + ); + return delay >= 0 && delay <= maxLagDays; + }) + : filteredRecords; + const valueMap = new Map(lagFilteredRecords.map((record) => [`${record.referenceDate}|${record.reportDate}`, record.value])); + + return { referenceDates: activeReferenceDates, reportDates: reportDatesWithDiagonal, valueMap, filteredRecords: lagFilteredRecords }; +}; + +const buildDelayDistribution = (records) => { + const delayMap = new Map(); + records.forEach((record) => { + const delayDays = Math.max( + 0, + Math.round((new Date(record.reportDate) - new Date(record.referenceDate)) / (1000 * 60 * 60 * 24)), + ); + delayMap.set(delayDays, (delayMap.get(delayDays) || 0) + record.value); + }); + + const delays = Array.from(delayMap.keys()).sort((a, b) => a - b); + const values = delays.map((delay) => delayMap.get(delay) || 0); + return { delays, values }; +}; + +const calculateQuantiles = (delays, values) => { + const total = values.reduce((sum, value) => sum + value, 0); + if (total === 0) { + return { medianDelay: 0, delay95: 0, total: 0 }; + } + + let cumulative = 0; + let medianDelay = delays[0]; + let delay95 = delays[0]; + + delays.forEach((delay, index) => { + cumulative += values[index]; + const proportion = cumulative / total; + if (proportion >= 0.5 && medianDelay === delays[0]) { + medianDelay = delay; + } + if (proportion >= 0.95 && delay95 === delays[0]) { + delay95 = delay; + } + }); + + return { medianDelay, delay95, total }; +}; + +const buildRecordsFromMapping = (rows, mapping) => { + const { referenceDate, reportDate, value } = mapping; + if (!referenceDate || !reportDate || !value) { + return []; + } + + return rows.map((row) => { + const referenceValue = row[referenceDate]; + const reportValue = row[reportDate]; + const numericValue = Number(row[value]); + if (!referenceValue || !reportValue || Number.isNaN(numericValue)) { + return null; + } + return { + referenceDate: referenceValue, + reportDate: reportValue, + value: numericValue, + }; + }).filter(Boolean); +}; + +const INITIAL_PARSED = parseCsv(SAMPLE_CSV); +const INITIAL_MAPPING = { + referenceDate: '', + reportDate: '', + value: '', +}; +const SAMPLE_MAPPING = { + referenceDate: + INITIAL_PARSED.headers[INITIAL_PARSED.normalizedHeaders.indexOf('reference_date')] ?? '', + reportDate: + INITIAL_PARSED.headers[INITIAL_PARSED.normalizedHeaders.indexOf('report_date')] ?? '', + value: INITIAL_PARSED.headers[INITIAL_PARSED.normalizedHeaders.indexOf('value')] ?? '', +}; + +const ReportingDelayPage = () => { + const inputRef = useRef(null); + const [csvRows, setCsvRows] = useState(() => INITIAL_PARSED.records); + const [csvHeaders, setCsvHeaders] = useState(() => INITIAL_PARSED.headers); + const [fileName, setFileName] = useState('sample-epinowcast.csv'); + const [error, setError] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [columnMapping, setColumnMapping] = useState(SAMPLE_MAPPING); + const [columnFilters, setColumnFilters] = useState({}); + const [isTriangleFullscreen, setIsTriangleFullscreen] = useState(false); + const [showHeatmap, setShowHeatmap] = useState(false); + const [analysisStarted, setAnalysisStarted] = useState(true); + + const headerOptions = useMemo( + () => csvHeaders.map((header) => ({ value: header, label: header })), + [csvHeaders], + ); + + const extraColumns = useMemo(() => { + const mappedColumns = new Set(Object.values(columnMapping).filter(Boolean)); + return csvHeaders.filter((header) => !mappedColumns.has(header)); + }, [csvHeaders, columnMapping]); + + const extraColumnOptions = useMemo(() => { + return extraColumns.map((column) => ({ + column, + options: Array.from(new Set(csvRows.map((row) => row[column]).filter(Boolean))).sort().map((value) => ({ + value, + label: value, + })), + })); + }, [csvRows, extraColumns]); + + const filteredRows = useMemo(() => { + return csvRows.filter((row) => { + return Object.entries(columnFilters).every(([column, value]) => { + if (!value) return true; + return row[column] === value; + }); + }); + }, [csvRows, columnFilters]); + + const records = useMemo( + () => buildRecordsFromMapping(filteredRows, columnMapping), + [filteredRows, columnMapping], + ); + + const mappingComplete = Boolean( + columnMapping.referenceDate && columnMapping.reportDate && columnMapping.value, + ); + + const allReferenceDates = useMemo( + () => Array.from(new Set(records.map((record) => record.referenceDate))).sort(), + [records], + ); + const [referenceRange, setReferenceRange] = useState(() => getDefaultReferenceRange(allReferenceDates)); + const { unit, unitDays } = useMemo(() => getFrequencyUnit(allReferenceDates), [allReferenceDates]); + const maxLagDays = useMemo(() => { + if (!records.length) return 0; + return Math.max( + ...records.map((record) => + Math.round((new Date(record.reportDate) - new Date(record.referenceDate)) / (1000 * 60 * 60 * 24)), + ), + ); + }, [records]); + const [maxLagUnits, setMaxLagUnits] = useState(() => Math.max(0, Math.ceil(maxLagDays / unitDays))); + + const activeReferenceDates = useMemo(() => { + const [start, end] = referenceRange; + return allReferenceDates.slice(start, end + 1); + }, [allReferenceDates, referenceRange]); + + const triangle = useMemo( + () => + buildTriangle(records, { + referenceDates: activeReferenceDates, + maxLagDays: maxLagUnits * unitDays, + }), + [records, activeReferenceDates, maxLagUnits, unitDays], + ); + const distribution = useMemo( + () => buildDelayDistribution(triangle.filteredRecords), + [triangle.filteredRecords], + ); + const summary = useMemo( + () => calculateQuantiles(distribution.delays, distribution.values), + [distribution.delays, distribution.values], + ); + + const hasLongDelay = summary.delay95 >= 7; + const hasShortDelay = summary.delay95 <= 3; + + const recommendation = hasLongDelay + ? 'Nowcasting is recommended to account for substantial reporting delays.' + : hasShortDelay + ? 'Nowcasting may be optional; delays resolve quickly.' + : 'Consider nowcasting when you need near-real-time situational awareness.'; + + const handleFile = async (file) => { + if (!file) return; + try { + const text = await file.text(); + const parsed = parseCsv(text); + setCsvRows(parsed.records); + setCsvHeaders(parsed.headers); + setFileName(file.name); + setError(null); + setColumnMapping(INITIAL_MAPPING); + setColumnFilters({}); + setAnalysisStarted(false); + setReferenceRange([0, 0]); + setMaxLagUnits(0); + } catch (err) { + setError(err.message); + } + }; + + const sampleDataUri = useMemo(() => { + const encoded = encodeURIComponent(SAMPLE_CSV); + return `data:text/csv;charset=utf-8,${encoded}`; + }, []); + + const startTour = () => { + setShowHeatmap(false); + const tour = driver({ + showProgress: true, + steps: [ + { + element: '#reporting-triangle-upload', + popover: { title: 'Import CSV', description: 'Drop your file or download the sample CSV to start.' }, + }, + { + element: '#reporting-triangle-mapping', + popover: { title: 'Map columns', description: 'Confirm which columns hold reference dates and reports.' }, + }, + { + element: '#reporting-triangle-trajectory', + popover: { title: 'Trajectories', description: 'Watch how reports revise over time.' }, + }, + { + element: '#reporting-triangle-distribution', + popover: { title: 'Delay distribution', description: 'Inspect how long delays typically are.' }, + }, + { + element: '#reporting-triangle-axis-cell', + popover: { title: 'Axes', description: 'Rows are reference dates and columns are report dates.' }, + }, + { + element: '#reporting-triangle-diagonal-cell', + popover: { title: 'Diagonal', description: 'Delay = 0 reports arrive on the same day as the reference.' }, + }, + ], + }); + tour.drive(); + }; + + useEffect(() => { + const maxIndex = Math.max(0, allReferenceDates.length - 1); + setReferenceRange((prev) => { + const nextStart = Math.min(prev[0], maxIndex); + const nextEnd = Math.min(prev[1], maxIndex); + if (nextStart === 0 && nextEnd === maxIndex) { + return getDefaultReferenceRange(allReferenceDates); + } + return [nextStart, nextEnd]; + }); + }, [allReferenceDates]); + + useEffect(() => { + if (!mappingComplete) { + setAnalysisStarted(false); + } + }, [mappingComplete]); + + useEffect(() => { + setMaxLagUnits(Math.max(0, Math.ceil(maxLagDays / unitDays))); + }, [maxLagDays, unitDays]); + + const sliderMarks = useMemo(() => { + if (allReferenceDates.length <= 1) { + return [{ value: 0, label: allReferenceDates[0] ? formatDateLabel(allReferenceDates[0]) : '' }]; + } + return [ + { value: 0, label: formatDateLabel(allReferenceDates[0]) }, + { + value: allReferenceDates.length - 1, + label: formatDateLabel(allReferenceDates[allReferenceDates.length - 1]), + }, + ]; + }, [allReferenceDates]); + + const maxDisplayRows = 80; + const maxDisplayCols = 80; + const displayReferenceDates = triangle.referenceDates.slice(-maxDisplayRows); + const displayReportDates = triangle.reportDates.slice(-maxDisplayCols); + const isTriangleTruncated = + displayReferenceDates.length < triangle.referenceDates.length || + displayReportDates.length < triangle.reportDates.length; + const diagonalDates = useMemo(() => { + const reportSet = new Set(displayReportDates); + return displayReferenceDates.filter((date) => reportSet.has(date)); + }, [displayReferenceDates, displayReportDates]); + const activeRangeLabel = activeReferenceDates.length + ? `${formatDateLabel(activeReferenceDates[0])}–${formatDateLabel(activeReferenceDates.at(-1))}` + : 'No data selected'; + const diagonalHighlightDate = diagonalDates[0] ?? null; + const triangleRows = displayReferenceDates.map((referenceDate) => ( + + {formatDateLabel(referenceDate)} + {displayReportDates.map((reportDate) => { + const value = triangle.valueMap.get(`${referenceDate}|${reportDate}`); + const intensity = value ? Math.min(1, value / 80) : 0; + const isDiagonal = referenceDate === reportDate; + const highlightDiagonal = + diagonalHighlightDate && + referenceDate === diagonalHighlightDate && + reportDate === diagonalHighlightDate; + return ( + + {value ?? '—'} + + ); + })} + + )); + + const heatmapData = useMemo(() => { + return [ + { + z: displayReferenceDates.map((referenceDate) => + displayReportDates.map((reportDate) => triangle.valueMap.get(`${referenceDate}|${reportDate}`) ?? null), + ), + x: displayReportDates.map(formatDateLabel), + y: displayReferenceDates.map(formatDateLabel), + type: 'heatmap', + colorscale: 'Blues', + showscale: false, + hovertemplate: 'Reference %{y}
Report %{x}
Value %{z}', + }, + ]; + }, [displayReferenceDates, displayReportDates, triangle.valueMap]); + + const heatmapLayout = useMemo(() => { + const diagonalLine = + diagonalDates.length > 1 + ? [ + { + type: 'line', + x0: formatDateLabel(diagonalDates[0]), + y0: formatDateLabel(diagonalDates[0]), + x1: formatDateLabel(diagonalDates[diagonalDates.length - 1]), + y1: formatDateLabel(diagonalDates[diagonalDates.length - 1]), + line: { color: '#1a1b1e', width: 2 }, + }, + ] + : []; + return { + margin: { l: 80, r: 20, t: 20, b: 60 }, + xaxis: { title: 'Report date', type: 'category' }, + yaxis: { title: 'Reference date', type: 'category', autorange: 'reversed' }, + shapes: diagonalLine, + height: 520, + }; + }, [diagonalDates]); + + const canAnalyze = mappingComplete; + const showFilters = extraColumnOptions.length > 0; + + const revisionChartData = useMemo(() => { + const referenceSeries = triangle.referenceDates.slice(0, 3); + const labels = triangle.reportDates.map(formatDateLabel); + return { + labels, + datasets: referenceSeries.map((referenceDate, index) => ({ + label: formatDateLabel(referenceDate), + data: triangle.reportDates.map((reportDate) => triangle.valueMap.get(`${referenceDate}|${reportDate}`) ?? null), + borderColor: ['#1c7ed6', '#2f9e44', '#f59f00'][index % 3], + backgroundColor: 'transparent', + tension: 0.3, + })), + }; + }, [triangle.referenceDates, triangle.reportDates, triangle.valueMap]); + + const delayChartData = useMemo(() => { + return { + labels: distribution.delays.map((delay) => `${delay}d`), + datasets: [ + { + label: 'Total reports', + data: distribution.values, + backgroundColor: '#4dabf7', + }, + ], + }; + }, [distribution.delays, distribution.values]); + + const chartOptions = { + responsive: true, + plugins: { + legend: { position: 'bottom' }, + tooltip: { mode: 'index', intersect: false }, + }, + scales: { + y: { beginAtZero: true }, + }, + }; + + return ( + + + + } w="fit-content"> + Reporting triangle explorer + + Do you need to nowcast? What is your reporting delay distribution? + + Analyze your reporting delay distribution with RespiLens and epinowcast. Everything runs locally in your browser. + + + + + + Introduction + + Do you need nowcasting ? What does your reporting delay distrubution look like ? Let's dive into that using this little app. Nothing leave your computer (say how to check). + So upload a data with some columns indicating the refrence date of an event, the report date when it was reported, and the value reported. Optionally you can have other columns like location, age group, or target type to filter the data. + You'll see your reporting distrubtion and the so called reporting triangle introduced by (probably Kaitlyn Johnson et al. but really i need to check this). This + For any deeper dive open the link below to epinowcast on which this work is based. + + + + EpiNowcast + + + Baselinenowcast + + + + + + + + { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(event) => { + event.preventDefault(); + setIsDragging(false); + handleFile(event.dataTransfer.files?.[0]); + }} + style={{ + borderStyle: 'dashed', + borderColor: isDragging ? 'var(--mantine-color-blue-6)' : undefined, + background: isDragging ? 'var(--mantine-color-blue-0)' : undefined, + }} + > + + + + + Drag & drop a CSV file here + + Expected columns: reference_date, report_date, value. + + + Each row should be a cumulative total for one reference date as reported on a later report date. + + + Optional columns like location, age, or target are supported and can be filtered after upload. + + + + + + + Current dataset: {fileName} + + {error && ( + + {error} Showing the last valid dataset. + + )} + {!mappingComplete && ( + + Please map the required columns to continue. + + )} + handleFile(event.target.files?.[0])} + /> + + + + + + + Column mapping + {csvHeaders.length} columns detected + + + Map your CSV columns to the required fields. Each upload resets the mapping. + + + { + setColumnMapping((prev) => ({ ...prev, reportDate: value ?? '' })); + setAnalysisStarted(false); + }} + size="sm" + /> + { + setColumnFilters((prev) => ({ ...prev, [column]: value })); + setAnalysisStarted(false); + }} + clearable + searchable + size="sm" + /> + ))} + + + + )} + {!mappingComplete && ( + + Please map reference date, report date, and value to continue. + + )} + + + + + + {analysisStarted && ( + <> + + + + Window & cutoff + {triangle.referenceDates.length} reference dates + + + Use the slider to focus on a subset of reference dates (rows), and set how far after reference dates to + include reports (columns). + + + + + Reference-date window + + formatDateLabel(allReferenceDates[value])} + /> + + Showing {activeRangeLabel} + + + + setMaxLagUnits(Number(value) || 0)} + min={0} + max={Math.max(0, Math.ceil(maxLagDays / unitDays))} + clampBehavior="strict" + size="sm" + /> + + Latest observed delay: {maxLagDays} days (~{Math.ceil(maxLagDays / unitDays)} {unit}s) + + + + + + + + + + + Revision trajectories + first 3 reference dates + + + View how reported totals evolve over successive reports. + + + + + + + + + Delay distribution + {summary.total} total reports + + + How long it takes for reports to arrive after the reference date. + + + + + + + + + + Reporting triangle + + {triangle.referenceDates.length} reference dates + setIsTriangleFullscreen((prev) => !prev)} + > + {isTriangleFullscreen ? : } + + + + + + + Rows = reference date, columns = report date. + + + Diagonal cells (delay = 0) represent reports received on the same day as the reference date. + + + setShowHeatmap(event.currentTarget.checked)} + size="sm" + /> + + + Darker cells are larger cumulative counts. + {isTriangleTruncated && ' Showing a recent subset to keep the table responsive.'} + + {showHeatmap ? ( + + ) : ( + + + + + Reference date + {displayReportDates.map((date) => ( + + {formatDateLabel(date)} + + ))} + + + {triangleRows} +
+
+ )} +
+
+ + + + + Nota Bene + + Late reports can be structurally different (e.g., lab corrections, backfills). + Holiday effects and reporting interruptions bias delay estimates. + Changes in case definitions or data pipelines break comparability over time. + + + + + + + Summary + + + Based on this dataset: + + The reported quantity is 95% complete after {summary.delay95} days, with a + median delay of {summary.medianDelay} days. + + + {recommendation} + + + + + + + + Decision tree for nowcasting + }> + Simple: use baselinenowcast + Better but more complex: use epinowcast (tood link) + (both use the same data format) + For more information about nowcasting (epinowcast, the forum) + + + + + + + + + Useful links + + + + Baseline nowcast toolkit + + + + + EpiNowcast documentation + + + + + + Want help operationalizing? Start with the baseline nowcast decision tree and upgrade to EpiNowcast + when you need probabilistic delay distributions. + + + + + + )} + + setIsTriangleFullscreen(false)} + fullScreen + title="Reporting triangle" + padding="md" + > + + + + Showing {activeRangeLabel} · Cutoff {maxLagUnits} {unit}(s) + + setShowHeatmap(event.currentTarget.checked)} + size="sm" + /> + + {showHeatmap ? ( + + ) : ( + + + + + Reference date + {displayReportDates.map((date) => ( + + {formatDateLabel(date)} + + ))} + + + {triangleRows} +
+
+ )} +
+
+
+ ); +}; + +export default ReportingDelayPage; diff --git a/app/src/components/tools/ToolsPage.jsx b/app/src/components/tools/ToolsPage.jsx new file mode 100644 index 00000000..d0d6eee0 --- /dev/null +++ b/app/src/components/tools/ToolsPage.jsx @@ -0,0 +1,54 @@ +import { Badge, Button, Card, Container, Group, SimpleGrid, Stack, Text, Title } from '@mantine/core'; +import { IconClock, IconTools } from '@tabler/icons-react'; +import { Link } from 'react-router-dom'; + +const tools = [ + { + title: 'Reporting delay explorer', + description: + 'Do you need to nowcast? What does your reporting delay distribution look like? Securely upload your reporting data to build your reporting triangle and answer these questions.', + icon: IconClock, + href: '/reporting-triangle', + badge: 'Nowcasting', + }, +]; + +const ToolsPage = () => { + return ( + + + + + RespiLens Toolboox + + + Browse lightweight utilities for data QA and perhaps more at some point. + + + + {tools.map((tool) => ( + + + + + + {tool.title} + + {tool.badge} + + + {tool.description} + + + + + ))} + + + + ); +}; + +export default ToolsPage; diff --git a/app/src/config/app.js b/app/src/config/app.js index 4587fe36..363c2400 100644 --- a/app/src/config/app.js +++ b/app/src/config/app.js @@ -12,7 +12,7 @@ export const APP_CONFIG = { * These determine what users see when they first visit the site */ defaultDataset: 'covid', - defaultView: 'covid_projs', + defaultView: 'frontpage', defaultLocation: 'US', /** diff --git a/app/src/hooks/useForecastData.js b/app/src/hooks/useForecastData.js index 2e0eec9e..904ae7fb 100644 --- a/app/src/hooks/useForecastData.js +++ b/app/src/hooks/useForecastData.js @@ -16,7 +16,17 @@ export const useForecastData = (location, viewType) => { const peaks = data?.peaks || null; useEffect(() => { - if (!location || !viewType) return; + if (!location || !viewType || viewType === 'frontpage') { + setLoading(false); + setError(null); + setData(null); + setMetadata(null); + setAvailableDates([]); + setModels([]); + setAvailableTargets([]); + setModelsByTarget({}); + return; + } const fetchData = async () => { setLoading(true); @@ -142,4 +152,4 @@ export const useForecastData = (location, viewType) => { }, [peaks]); return { data, metadata, loading, error, availableDates, models, availableTargets, modelsByTarget, peaks, availablePeakDates, availablePeakModels }; -}; \ No newline at end of file +}; diff --git a/app/src/utils/urlManager.js b/app/src/utils/urlManager.js index 1152d470..47c7b59b 100644 --- a/app/src/utils/urlManager.js +++ b/app/src/utils/urlManager.js @@ -167,12 +167,19 @@ export class URLParameterManager { // Use DATASETS config to find the default view if not in URL const viewParam = this.searchParams.get('view'); const allViews = Object.values(DATASETS).flatMap(ds => ds.views.map(v => v.value)); - if (viewParam && allViews.includes(viewParam)) { + if (viewParam) { + if (viewParam === APP_CONFIG.defaultView) { return viewParam; + } + if (allViews.includes(viewParam)) { + return viewParam; + } + } + if (APP_CONFIG.defaultView) { + return APP_CONFIG.defaultView; } - // Find the default view of the default dataset const defaultDatasetKey = APP_CONFIG.defaultDataset; - return DATASETS[defaultDatasetKey]?.defaultView || APP_CONFIG.defaultView; + return DATASETS[defaultDatasetKey]?.defaultView; } // Initialize URL with defaults if missing (Less critical now with context handling) @@ -180,4 +187,4 @@ export class URLParameterManager { initializeDefaults() { // Intentionally empty - URL initialization now handled by ViewContext } -} \ No newline at end of file +}