diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml index 64274902..16a0e2a7 100644 --- a/.github/workflows/build-deploy.yml +++ b/.github/workflows/build-deploy.yml @@ -44,7 +44,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - pip install pandas==2.2.3 numpy pyarrow tqdm requests hubdata jsonschema + pip install pandas numpy pyarrow tqdm requests hubdata jsonschema - name: Process All Datasets (Hubs + NHSN) run: | diff --git a/.github/workflows/format-lint.yml b/.github/workflows/format-lint.yml new file mode 100644 index 00000000..0c373bd1 --- /dev/null +++ b/.github/workflows/format-lint.yml @@ -0,0 +1,32 @@ +name: Formatting and lint compliance + +on: + pull_request: + branches: [ main ] + types: [opened, synchronize, reopened] + +jobs: + quality-check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: app + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + cache: 'npm' + cache-dependency-path: app/package-lock.json + + - name: Install Dependencies + run: npm ci + + - name: Run Linter + run: npm run lint + + - name: Verify Formatting + run: npm run format:check \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index ccc25ebf..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Lint - -on: - push: - branches: - - main - pull_request: - -jobs: - frontend-lint: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - - - name: Install dependencies - working-directory: app - run: npm ci - - - name: Run lint - working-directory: app - run: npm run lint diff --git a/.github/workflows/parity-test.yml b/.github/workflows/parity-test.yml index 5fbf0466..c533c0e0 100644 --- a/.github/workflows/parity-test.yml +++ b/.github/workflows/parity-test.yml @@ -27,7 +27,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - pip install pandas==2.2.3 jsonschema + pip install pandas jsonschema - name: Set up R uses: r-lib/actions/setup-r@v2 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 56b957d3..ab54ed43 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -22,7 +22,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pandas==2.2.3 pytest jsonschema + pip install pandas pytest jsonschema - name: Run processor unit tests run: python -m pytest tests/test_processors.py diff --git a/app/.husky/pre-commit b/app/.husky/pre-commit new file mode 100755 index 00000000..157c6d7c --- /dev/null +++ b/app/.husky/pre-commit @@ -0,0 +1,10 @@ +#!/bin/sh + +# Help GUI apps (like GitHub Desktop) find Node and npx +export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH" + +# Move into the app directory +cd app + +# Run lint-staged +npx lint-staged \ No newline at end of file diff --git a/app/.prettierignore b/app/.prettierignore new file mode 100644 index 00000000..87b6bdea --- /dev/null +++ b/app/.prettierignore @@ -0,0 +1,32 @@ +# build outputs +dist/ +build/ +out/ +.next/ + +# dependencies +node_modules/ +vendor/ + +# environment variables +.env +.env.* + +# any potential testing and logs +coverage/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# assets + data (corrupts images, takes too long for data) +public/ +*.svg +*.ico +*.png +*.jpg + +# locks +package-lock.json +yarn.lock +pnpm-lock.yaml \ No newline at end of file diff --git a/app/.prettierrc b/app/.prettierrc new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/app/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/app/eslint.config.js b/app/eslint.config.js index 00538660..76fc05ae 100644 --- a/app/eslint.config.js +++ b/app/eslint.config.js @@ -1,41 +1,43 @@ -import js from '@eslint/js' -import globals from 'globals' -import react from 'eslint-plugin-react' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' +import js from "@eslint/js"; +import globals from "globals"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import eslintConfigPrettier from "eslint-config-prettier"; export default [ - { ignores: ['dist'] }, + { ignores: ["dist"] }, { - files: ['**/*.{js,jsx}'], + files: ["**/*.{js,jsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, parserOptions: { - ecmaVersion: 'latest', + ecmaVersion: "latest", ecmaFeatures: { jsx: true }, - sourceType: 'module', + sourceType: "module", }, }, - settings: { react: { version: '18.3' } }, + settings: { react: { version: "18.3" } }, plugins: { react, - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, }, rules: { ...js.configs.recommended.rules, ...react.configs.recommended.rules, - ...react.configs['jsx-runtime'].rules, + ...react.configs["jsx-runtime"].rules, ...reactHooks.configs.recommended.rules, - 'react/jsx-no-target-blank': 'off', - 'react-refresh/only-export-components': [ - 'warn', + "react/jsx-no-target-blank": "off", + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], - 'react/prop-types': 'off', - 'react/no-unescaped-entities': 'off', + "react/prop-types": "off", + "react/no-unescaped-entities": "off", "no-irregular-whitespace": "off", }, }, -] + eslintConfigPrettier, +]; diff --git a/app/index.html b/app/index.html index f2009be5..3b4ee41b 100644 --- a/app/index.html +++ b/app/index.html @@ -6,36 +6,48 @@
- Data for the RespiLens COVID-19 Forecasts view is retrieved from the COVID-19 Forecast Hub, which is an open challenge organized by the US CDC designed to collect forecasts for the following two targets: + Data for the RespiLens COVID-19 Forecasts view is retrieved from the + COVID-19 Forecast Hub, which is an open challenge organized by the{" "} + + US CDC + {" "} + designed to collect forecasts for the following two targets:
- Forecasting teams submit a probabilistic forecasts of these targets every Wednesday. RespiLens displays the 50% and 95% confidence intervals for each model's forecast for a chosen date, shown on the plot with a shadow. + Forecasting teams submit a probabilistic forecasts of these + targets every Wednesday. RespiLens displays the 50% and 95% + confidence intervals for each model's forecast for a chosen date, + shown on the plot with a shadow.
- Data for the RespiLens RSV Forecasts view is retrieved from the RSV Forecast Hub, which is an open challenge organized by the US CDC designed to collect forecasts for the following two targets: + Data for the RespiLens RSV Forecasts view is retrieved from the RSV + Forecast Hub, which is an open challenge organized by the US CDC + designed to collect forecasts for the following two targets:
- Forecasting teams submit a probabilistic forecasts of these targets every Wednesday of the RSV season. RespiLens displays the 50% and 95% confidence intervals for each model's forecast for a chosen date, shown on the plot with a shadow. + Forecasting teams submit a probabilistic forecasts of these + targets every Wednesday of the RSV season. RespiLens displays the + 50% and 95% confidence intervals for each model's forecast for a + chosen date, shown on the plot with a shadow.
- Data for the RespiLens Flu Peaks view is retrieved from FluSight, which is an open challenge organized by the US CDC designed to collect influenza forecasts during the flu season. RespiLens displays forecasts for all models, dates and targets. For attribution and more information, please visit the FluSight GitHub repository. + Data for the RespiLens Flu Peaks view is retrieved from FluSight, + which is an open challenge organized by the US CDC designed to + collect{" "} + + influenza forecasts + {" "} + during the flu season. RespiLens displays forecasts for all models, + dates and targets. For attribution and more information, please + visit the FluSight{" "} + + GitHub repository + + .
-- Forecasting teams submit a probabilistic forecasts of these targets every Wednesday of the flu season. RespiLens displays the 50% and 95% confidence intervals for each model's forecast for a chosen date, shown on the plot with a shadow. + Forecasting teams submit a probabilistic forecasts of these + targets every Wednesday of the flu season. RespiLens displays the + 50% and 95% confidence intervals for each model's forecast for a + chosen date, shown on the plot with a shadow.
-- Some teams elect to submit predictions for peak influenza burden (for which week the peak is expected, and what the hospitalization burden is projected to be). This data is displayed in our Flu Peaks view, where you can view participating models' median forecast for peak flu burden during the current season. + Some teams elect to submit predictions for peak influenza burden + (for which week the peak is expected, and what the hospitalization + burden is projected to be). This data is displayed in our Flu + Peaks view, where you can view participating models' median + forecast for peak flu burden during the current season.
- Data for the RespiLens Flu Projections view is retrieved from FluSight, which is an open challenge organized by the US CDC designed to collect influenza forecasts during the flu season. RespiLens displays forecasts for all models, dates and targets. For attribution and more information, please visit the FluSight GitHub repository. + Data for the RespiLens Flu Projections view is retrieved from + FluSight, which is an open challenge organized by the US CDC + designed to collect{" "} + + influenza forecasts + {" "} + during the flu season. RespiLens displays forecasts for all models, + dates and targets. For attribution and more information, please + visit the FluSight{" "} + + GitHub repository + + .
-- Forecasting teams submit a probabilistic forecasts of these targets every Wednesday of the flu season. RespiLens displays the 50% and 95% confidence intervals for each model's forecast for a chosen date, shown on the plot with a shadow. + Forecasting teams submit a probabilistic forecasts of these + targets every Wednesday of the flu season. RespiLens displays the + 50% and 95% confidence intervals for each model's forecast for a + chosen date, shown on the plot with a shadow.
-- Some teams elect to submit predictions for peak influenza burden (for which week the peak is expected, and what the hospitalization burden is projected to be). This data is displayed in our Flu Peaks view, where you can view participating models' median forecast for peak flu burden during the current season. + Some teams elect to submit predictions for peak influenza burden + (for which week the peak is expected, and what the hospitalization + burden is projected to be). This data is displayed in our Flu + Peaks view, where you can view participating models' median + forecast for peak flu burden during the current season.
- Data for the RespiLens Flu Detailed View is retrieved from FluSight, which is an open challenge organized by the US CDC designed to collect influenza forecasts during the flu season. RespiLens displays forecasts for all models, dates and targets. For attribution and more information, please visit the FluSight GitHub repository. + Data for the RespiLens Flu Detailed View is retrieved from FluSight, + which is an open challenge organized by the US CDC designed to + collect{" "} + + influenza forecasts + {" "} + during the flu season. RespiLens displays forecasts for all models, + dates and targets. For attribution and more information, please + visit the FluSight{" "} + + GitHub repository + + .
-- Forecasting teams submit a probabilistic forecasts of these targets every Wednesday of the flu season. RespiLens displays the 50% and 95% confidence intervals for each model's forecast for a chosen date, shown on the plot with a shadow. + Forecasting teams submit a probabilistic forecasts of these + targets every Wednesday of the flu season. RespiLens displays the + 50% and 95% confidence intervals for each model's forecast for a + chosen date, shown on the plot with a shadow.
-- Some teams elect to submit predictions for peak influenza burden (for which week the peak is expected, and what the hospitalization burden is projected to be). This data is displayed in our Flu Peaks view, where you can view participating models' median forecast for peak flu burden during the current season. + Some teams elect to submit predictions for peak influenza burden + (for which week the peak is expected, and what the hospitalization + burden is projected to be). This data is displayed in our Flu + Peaks view, where you can view participating models' median + forecast for peak flu burden during the current season.
- Data for the RespiLens Flu Metrocast view is retrieved from the Flu MetroCast Hub, which is a collaborative modeling project that collects and shares weekly probabilistic forecasts of influenza activity at the metropolitan level in the United States. The hub is run by epiENGAGE – an Insight Net Center for Implementation within the U.S. Centers for Disease Control and Prevention (CDC)’s Center for Forecasting and Outbreak Analytics (CFA). + Data for the RespiLens Flu Metrocast view is retrieved from the Flu + MetroCast Hub, which is a collaborative modeling project that + collects and shares weekly probabilistic forecasts of influenza + activity at the metropolitan level in the United States. The hub is + run by{" "} + + epiENGAGE + {" "} + – an{" "} + + Insight Net + {" "} + Center for Implementation within the U.S. Centers for Disease + Control and Prevention (CDC)’s{" "} + + Center for Forecasting and Outbreak Analytics + {" "} + (CFA). +
++ For more info and attribution on the Flu MetroCast Hub, please visit + their{" "} + + site + + , or visit their{" "} + + visualization dashboard + {" "} + to engage with their original visualization scheme.
-For more info and attribution on the Flu MetroCast Hub, please visit their site, or visit their visualization dashboard to engage with their original visualization scheme.
- Forecasting teams submit a probabilistic forecasts of targets every week of the flu season. RespiLens displays the 50% and 95% confidence intervals for each model's forecast for a chosen date, shown on the plot with a shadow. + Forecasting teams submit a probabilistic forecasts of targets + every week of the flu season. RespiLens displays the 50% and 95% + confidence intervals for each model's forecast for a chosen date, + shown on the plot with a shadow.
- Data for the RespiLens NHSN view comes from the CDC's National Healthcare Safety Network weekly "Hospital Respiratory Data" (HRD) dataset. - This dataset represents metrics aggregated to national and state/territory levels beginning in August 2020. + Data for the RespiLens NHSN view comes from the CDC's{" "} + + National Healthcare Safety Network + {" "} + weekly "Hospital Respiratory Data" (HRD) dataset. This dataset + represents metrics aggregated to national and state/territory levels + beginning in August 2020.
- The NHSN dataset contains ~300 columns for plotting data with a variety of scales, including hospitalization admission counts, percent of - admissions by pathogen, hospitalization rates per 100k, raw bed capacity numbers, bed capacity percents, and absolute - percentage of change. On RespiLens, you can use the timeseries unit selector to switch between data scales and view similar columns on the same plot. + The NHSN dataset contains ~300 columns for plotting data with a + variety of scales, including hospitalization admission counts, + percent of admissions by pathogen, hospitalization rates per 100k, + raw bed capacity numbers, bed capacity percents, and absolute + percentage of change. On RespiLens, you can use the timeseries + unit selector to switch between data scales and view similar + columns on the same plot.
@@ -77,10 +93,10 @@ class ErrorBoundary extends Component {
componentDidCatch(error, errorInfo) {
this.setState({
error: error,
- errorInfo: errorInfo
+ errorInfo: errorInfo,
});
-
- console.error('Error caught by boundary:', error, errorInfo);
+
+ console.error("Error caught by boundary:", error, errorInfo);
}
render() {
diff --git a/app/src/components/FluPeak.jsx b/app/src/components/FluPeak.jsx
index fe14cc11..22f1b604 100644
--- a/app/src/components/FluPeak.jsx
+++ b/app/src/components/FluPeak.jsx
@@ -1,577 +1,654 @@
-import { useState, useEffect, useMemo } from 'react';
-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';
+import { useState, useEffect, useMemo } from "react";
+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";
+import { buildPlotDownloadName } from "../utils/plotDownloadName";
// helper to convert Hex to RGBA for opacity control
const hexToRgba = (hex, alpha) => {
- let c;
- if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) {
- c = hex.substring(1).split('');
- if (c.length === 3) {
- c = [c[0], c[0], c[1], c[1], c[2], c[2]];
- }
- c = '0x' + c.join('');
- return 'rgba(' + [(c >> 16) & 255, (c >> 8) & 255, c & 255].join(',') + ',' + alpha + ')';
+ let c;
+ if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) {
+ c = hex.substring(1).split("");
+ if (c.length === 3) {
+ c = [c[0], c[0], c[1], c[1], c[2], c[2]];
}
- return hex;
+ c = "0x" + c.join("");
+ return (
+ "rgba(" +
+ [(c >> 16) & 255, (c >> 8) & 255, c & 255].join(",") +
+ "," +
+ alpha +
+ ")"
+ );
+ }
+ return hex;
};
-const FluPeak = ({
- data,
- peaks,
- peakDates,
- peakModels,
- peakLocation,
- windowSize,
- selectedModels,
- setSelectedModels,
- selectedDates,
- chartScale = 'linear',
- intervalVisibility = { median: true, ci50: true, ci95: true },
- showLegend = true
+const FluPeak = ({
+ data,
+ peaks,
+ peakDates,
+ peakModels,
+ peakLocation,
+ windowSize,
+ selectedModels,
+ setSelectedModels,
+ 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 showMedian = intervalVisibility?.median ?? true;
- const show50 = intervalVisibility?.ci50 ?? true;
- const show95 = intervalVisibility?.ci95 ?? true;
-
- const getNormalizedDate = (dateStr) => {
- const d = new Date(dateStr);
- const month = d.getUTCMonth();
- const baseYear = month >= 7 ? 2000 : 2001;
- d.setUTCFullYear(baseYear);
- return d;
- };
-
- useEffect(() => {
- if (!peakLocation) return;
- const fetchNhsnData = async () => {
- try {
- const dataUrl = getDataPath(`nhsn/${peakLocation}_nhsn.json`);
- const response = await fetch(dataUrl);
- if (!response.ok) {
- setNhsnData(null);
- return;
- }
- const json = await response.json();
- const dates = json.series?.dates || [];
- const admissions = json.series?.['Total Influenza Admissions'] || [];
-
- if (dates.length > 0 && admissions.length > 0) {
- setNhsnData({ dates, admissions });
- }
- } catch (err) {
- console.error(err);
- }
- };
- fetchNhsnData();
- }, [peakLocation]);
-
- const activePeakModels = useMemo(() => {
- const activeModelSet = new Set();
- const datesToCheck = (selectedDates && selectedDates.length > 0)
- ? selectedDates : (peakDates || []);
-
- if (!peaks || !datesToCheck.length) return activeModelSet;
-
- datesToCheck.forEach(date => {
- const dateData = peaks[date];
- if (!dateData) return;
- Object.values(dateData).forEach(metricData => {
- if (!metricData) return;
- Object.keys(metricData).forEach(model => activeModelSet.add(model));
- });
- });
- return activeModelSet;
- }, [peaks, selectedDates, peakDates]);
-
- const { plotData, rawYRange } = useMemo(() => {
- const traces = [];
-
- // Historic data (NHSN)
- if (nhsnData && nhsnData.dates && nhsnData.admissions) {
- const seasons = {};
- nhsnData.dates.forEach((dateStr, index) => {
- const date = new Date(dateStr);
- const year = date.getUTCFullYear();
- const month = date.getUTCMonth() + 1;
- const seasonStartYear = month >= 8 ? year : year - 1;
- const seasonKey = `${seasonStartYear}-${seasonStartYear + 1}`;
-
- if (!seasons[seasonKey]) seasons[seasonKey] = { x: [], y: [] };
- seasons[seasonKey].x.push(getNormalizedDate(dateStr));
- seasons[seasonKey].y.push(nhsnData.admissions[index]);
- });
- const currentSeasonKey = '2025-2026';
- const sortedKeys = Object.keys(seasons)
- .filter(key => key !== currentSeasonKey)
- .sort();
-
- // Dummy data for legend
- if (sortedKeys.length > 0) {
- const firstKey = sortedKeys[0];
- traces.push({
- x: [seasons[firstKey].x[0]],
- y: [seasons[firstKey].y[0]],
- name: 'Historical Seasons',
- legendgroup: 'history',
- showlegend: true,
- mode: 'lines',
- line: { color: '#d3d3d3', width: 1.5 },
- hoverinfo: 'skip'
- });
- }
-
- sortedKeys.forEach(seasonKey => {
- traces.push({
- x: seasons[seasonKey].x,
- y: seasons[seasonKey].y,
- name: `${seasonKey} Season`,
- legendgroup: 'history',
- type: 'scatter',
- mode: 'lines',
- line: { color: '#d3d3d3', width: 1.5 },
- connectgaps: true,
- showlegend: false,
- hoverinfo: 'name+y'
- });
- });
+ const { colorScheme } = useMantineColorScheme();
+ const groundTruth = data?.ground_truth;
+ const [nhsnData, setNhsnData] = useState(null);
+ const showMedian = intervalVisibility?.median ?? true;
+ const show50 = intervalVisibility?.ci50 ?? true;
+ const show95 = intervalVisibility?.ci95 ?? true;
+
+ const getNormalizedDate = (dateStr) => {
+ const d = new Date(dateStr);
+ const month = d.getUTCMonth();
+ const baseYear = month >= 7 ? 2000 : 2001;
+ d.setUTCFullYear(baseYear);
+ return d;
+ };
+
+ useEffect(() => {
+ if (!peakLocation) return;
+ const fetchNhsnData = async () => {
+ try {
+ const dataUrl = getDataPath(`nhsn/${peakLocation}_nhsn.json`);
+ const response = await fetch(dataUrl);
+ if (!response.ok) {
+ setNhsnData(null);
+ return;
}
+ const json = await response.json();
+ const dates = json.series?.dates || [];
+ const admissions = json.series?.["Total Influenza Admissions"] || [];
- // Current season (gt data)
- const targetKey = 'wk inc flu hosp';
- const SEASON_START_DATE = '2025-08-01';
- if (groundTruth && groundTruth[targetKey] && groundTruth.dates) {
- const { dates, values } = groundTruth.dates.reduce((acc, date, index) => {
- if (date >= SEASON_START_DATE) {
- acc.dates.push(getNormalizedDate(date));
- acc.values.push(groundTruth[targetKey][index]);
- }
- return acc;
- }, { dates: [], values: [] });
-
- if (dates.length > 0) {
- traces.push({
- x: dates,
- y: values,
- name: 'Current season',
- type: 'scatter',
- mode: 'lines+markers',
- line: { color: 'black', width: 2, dash: 'dash' },
- showlegend: true,
- marker: { size: 4, color: 'black' },
- hovertemplate:
- 'Current Season
' +
- 'Hospitalizations: %{y}
' +
- 'Date: %{x|%b %d} '
- });
- }
+ if (dates.length > 0 && admissions.length > 0) {
+ setNhsnData({ dates, admissions });
}
+ } catch (err) {
+ console.error(err);
+ }
+ };
+ fetchNhsnData();
+ }, [peakLocation]);
+
+ const activePeakModels = useMemo(() => {
+ const activeModelSet = new Set();
+ const datesToCheck =
+ selectedDates && selectedDates.length > 0
+ ? selectedDates
+ : peakDates || [];
+
+ if (!peaks || !datesToCheck.length) return activeModelSet;
+
+ datesToCheck.forEach((date) => {
+ const dateData = peaks[date];
+ if (!dateData) return;
+ Object.values(dateData).forEach((metricData) => {
+ if (!metricData) return;
+ Object.keys(metricData).forEach((model) => activeModelSet.add(model));
+ });
+ });
+ return activeModelSet;
+ }, [peaks, selectedDates, peakDates]);
+
+ const { plotData, rawYRange } = useMemo(() => {
+ const traces = [];
+
+ // Historic data (NHSN)
+ if (nhsnData && nhsnData.dates && nhsnData.admissions) {
+ const seasons = {};
+ nhsnData.dates.forEach((dateStr, index) => {
+ const date = new Date(dateStr);
+ const year = date.getUTCFullYear();
+ const month = date.getUTCMonth() + 1;
+ const seasonStartYear = month >= 8 ? year : year - 1;
+ const seasonKey = `${seasonStartYear}-${seasonStartYear + 1}`;
+
+ if (!seasons[seasonKey]) seasons[seasonKey] = { x: [], y: [] };
+ seasons[seasonKey].x.push(getNormalizedDate(dateStr));
+ seasons[seasonKey].y.push(nhsnData.admissions[index]);
+ });
+ const currentSeasonKey = "2025-2026";
+ const sortedKeys = Object.keys(seasons)
+ .filter((key) => key !== currentSeasonKey)
+ .sort();
+
+ // Dummy data for legend
+ if (sortedKeys.length > 0) {
+ const firstKey = sortedKeys[0];
+ traces.push({
+ x: [seasons[firstKey].x[0]],
+ y: [seasons[firstKey].y[0]],
+ name: "Historical Seasons",
+ legendgroup: "history",
+ showlegend: true,
+ mode: "lines",
+ line: { color: "#d3d3d3", width: 1.5 },
+ hoverinfo: "skip",
+ });
+ }
+
+ sortedKeys.forEach((seasonKey) => {
+ traces.push({
+ x: seasons[seasonKey].x,
+ y: seasons[seasonKey].y,
+ name: `${seasonKey} Season`,
+ legendgroup: "history",
+ type: "scatter",
+ mode: "lines",
+ line: { color: "#d3d3d3", width: 1.5 },
+ connectgaps: true,
+ showlegend: false,
+ hoverinfo: "name+y",
+ });
+ });
+ }
- // Model peak predictions data
- if (peaks && selectedModels.length > 0) {
- const rawDates = (selectedDates && selectedDates.length > 0)
- ? selectedDates : (peakDates || []);
- const datesToCheck = [...rawDates].sort(); // Sort chronological
-
- selectedModels.forEach(model => {
- const xValues = [];
- const yValues = [];
- const hoverTexts = [];
- const pointColors = [];
-
- // Base color for this model (Solid, used for Legend)
- const baseColorHex = MODEL_COLORS[selectedModels.indexOf(model) % MODEL_COLORS.length];
-
- datesToCheck.forEach((refDate, index) => {
- const dateData = peaks[refDate];
- if (!dateData) return;
-
- const intensityData = dateData['peak inc flu hosp']?.[model];
- if (!intensityData || !intensityData.predictions) return;
-
- // extract confidence intervals
- const iPreds = intensityData.predictions;
- const getVal = (q) => {
- const idx = iPreds.quantiles.indexOf(q);
- return idx !== -1 ? iPreds.values[idx] : null;
- };
-
- const medianVal = getVal(0.5);
- const low95 = getVal(0.025);
- const high95 = getVal(0.975);
- const low50 = getVal(0.25);
- const high50 = getVal(0.75);
-
- if (medianVal === null) return;
-
- const timingData = dateData['peak week inc flu hosp']?.[model];
- if (!timingData || !timingData.predictions) return;
-
- const tPreds = timingData.predictions;
- const dateArray = tPreds['peak week'] || tPreds['values'];
- const probArray = tPreds['probabilities'];
-
- let bestDateStr = null;
- let lowDate95 = null, highDate95 = null;
- let lowDate50 = null, highDate50 = null;
-
- if (dateArray && probArray) {
- let cumulativeProb = 0;
- let medianIdx = -1, q025Idx = -1, q975Idx = -1, q25Idx = -1, q75Idx = -1;
- for (let i = 0; i < probArray.length; i++) {
- cumulativeProb += probArray[i];
-
- if (q025Idx === -1 && cumulativeProb >= 0.025) q025Idx = i;
- if (q25Idx === -1 && cumulativeProb >= 0.25) q25Idx = i;
- if (medianIdx === -1 && cumulativeProb >= 0.5) medianIdx = i;
- if (q75Idx === -1 && cumulativeProb >= 0.75) q75Idx = i;
- if (q975Idx === -1 && cumulativeProb >= 0.975) q975Idx = i;
- }
- if (medianIdx === -1) medianIdx = probArray.length - 1;
- if (q975Idx === -1) q975Idx = probArray.length - 1;
- if (q75Idx === -1) q75Idx = probArray.length - 1;
-
- bestDateStr = dateArray[medianIdx];
- lowDate95 = dateArray[q025Idx !== -1 ? q025Idx : 0];
- highDate95 = dateArray[q975Idx];
- lowDate50 = dateArray[q25Idx !== -1 ? q25Idx : 0];
- highDate50 = dateArray[q75Idx];
- } else if (dateArray && dateArray.length > 0) {
- bestDateStr = dateArray[Math.floor(dateArray.length / 2)];
- }
- if (!bestDateStr) return;
-
- const normalizedDate = getNormalizedDate(bestDateStr);
- // Gradient Opacity Calculation
- const minOpacity = 0.4;
- const alpha = datesToCheck.length === 1
- ? 1.0
- : minOpacity + ((index / (datesToCheck.length - 1)) * (1 - minOpacity));
-
- const dynamicColor = hexToRgba(baseColorHex, alpha);
-
- if (show50 || show95) {
- // 95% vertical whisker (hosp)
- if (show95 && low95 !== null && high95 !== null) {
- traces.push({
- x: [normalizedDate, normalizedDate],
- y: [low95, high95],
- mode: 'lines+markers',
- line: {
- color: dynamicColor,
- width: 1,
- dash: 'dash'
- },
- marker: {
- symbol: 'line-ew',
- color: dynamicColor,
- size: 10,
- line: {
- width: 1,
- color: dynamicColor
- }
- },
- legendgroup: model,
- showlegend: false,
- hoverinfo: 'skip'
- });
- }
-
- // 50% vertical whisker (hosp)
- if (show50 && low50 !== null && high50 !== null) {
- traces.push({
- x: [normalizedDate, normalizedDate],
- y: [low50, high50],
- mode: 'lines',
- line: {
- color: dynamicColor,
- width: 4,
- dash: '6px, 3px'
- },
- legendgroup: model,
- showlegend: false,
- hoverinfo: 'skip'
- });
- }
-
- // 95% horizontal whisker (dates)
- if (show95 && lowDate95 && highDate95) {
- traces.push({
- x: [getNormalizedDate(lowDate95), getNormalizedDate(highDate95)],
- y: [medianVal, medianVal],
- mode: 'lines+markers',
- line: {
- color: dynamicColor,
- width: 1,
- dash: 'dash'
- },
- marker: {
- symbol: 'line-ns',
- color: dynamicColor,
- size: 10,
- line: { width: 1, color: dynamicColor }
- },
- legendgroup: model,
- showlegend: false,
- hoverinfo: 'skip'
- });
- }
-
- // 50% horizontal whisker (dates)
- if (show50 && lowDate50 && highDate50) {
- traces.push({
- x: [getNormalizedDate(lowDate50), getNormalizedDate(highDate50)],
- y: [medianVal, medianVal],
- mode: 'lines',
- line: {
- color: dynamicColor,
- width: 4,
- dash: '6px, 3px'
- },
- legendgroup: model,
- showlegend: false,
- hoverinfo: 'skip'
- });
- }
- }
- if (showMedian) {
- xValues.push(getNormalizedDate(bestDateStr));
- yValues.push(medianVal);
- pointColors.push(dynamicColor);
- }
-
- const timing50 = `${lowDate50} - ${highDate50}`;
- const timing95 = `${lowDate95} - ${highDate95}`;
- const formattedMedian = Math.round(medianVal).toLocaleString();
- const formatted50 = `${Math.round(low50).toLocaleString()} - ${Math.round(high50).toLocaleString()}`;
- const formatted95 = `${Math.round(low95).toLocaleString()} - ${Math.round(high95).toLocaleString()}`;
-
- 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}
` +
- `Peak timing:
` +
- `Median Week: ${bestDateStr}
` +
- timing50Row +
- timing95Row +
- `` +
- `Peak hospitalization:
` +
- `Median: ${formattedMedian}
` +
- burden50Row +
- burden95Row +
- `predicted as of ${refDate}`
- );
- });
-
- // actual trace
- if (showMedian && xValues.length > 0) {
- traces.push({
- x: xValues,
- y: yValues,
- name: model,
- type: 'scatter',
- mode: 'markers',
- marker: {
- color: pointColors,
- size: 12,
- symbol: 'circle',
- line: { width: 1, color: 'white' }
- },
- hoverlabel: {
- font: { color: '#ffffff' },
- bordercolor: '#ffffff' // maakes border white
- },
- hovertemplate: '%{text} ',
- text: hoverTexts,
- showlegend: false,
- legendgroup: model
- });
-
- // dummy legend
- traces.push({
- x: [null],
- y: [null],
- name: model,
- type: 'scatter',
- mode: 'markers',
- marker: {
- color: baseColorHex,
- size: 12,
- symbol: 'circle',
- line: { width: 1, color: 'white' }
- },
- showlegend: true,
- legendgroup: model
- });
- }
- });
- }
+ // Current season (gt data)
+ const targetKey = "wk inc flu hosp";
+ const SEASON_START_DATE = "2025-08-01";
+ if (groundTruth && groundTruth[targetKey] && groundTruth.dates) {
+ const { dates, values } = groundTruth.dates.reduce(
+ (acc, date, index) => {
+ if (date >= SEASON_START_DATE) {
+ acc.dates.push(getNormalizedDate(date));
+ acc.values.push(groundTruth[targetKey][index]);
+ }
+ return acc;
+ },
+ { dates: [], values: [] },
+ );
+
+ if (dates.length > 0) {
+ traces.push({
+ x: dates,
+ y: values,
+ name: "Current season",
+ type: "scatter",
+ mode: "lines+markers",
+ line: { color: "black", width: 2, dash: "dash" },
+ showlegend: true,
+ marker: { size: 4, color: "black" },
+ hovertemplate:
+ "Current Season
" +
+ "Hospitalizations: %{y}
" +
+ "Date: %{x|%b %d} ",
+ });
+ }
+ }
- const rawRange = getYRangeFromTraces(traces);
+ // Model peak predictions data
+ if (peaks && selectedModels.length > 0) {
+ const rawDates =
+ selectedDates && selectedDates.length > 0
+ ? selectedDates
+ : peakDates || [];
+ const datesToCheck = [...rawDates].sort(); // Sort chronological
+
+ selectedModels.forEach((model) => {
+ const xValues = [];
+ const yValues = [];
+ const hoverTexts = [];
+ const pointColors = [];
+
+ // Base color for this model (Solid, used for Legend)
+ const baseColorHex =
+ MODEL_COLORS[selectedModels.indexOf(model) % MODEL_COLORS.length];
+
+ datesToCheck.forEach((refDate, index) => {
+ const dateData = peaks[refDate];
+ if (!dateData) return;
+
+ const intensityData = dateData["peak inc flu hosp"]?.[model];
+ if (!intensityData || !intensityData.predictions) return;
+
+ // extract confidence intervals
+ const iPreds = intensityData.predictions;
+ const getVal = (q) => {
+ const idx = iPreds.quantiles.indexOf(q);
+ return idx !== -1 ? iPreds.values[idx] : null;
+ };
+
+ const medianVal = getVal(0.5);
+ const low95 = getVal(0.025);
+ const high95 = getVal(0.975);
+ const low50 = getVal(0.25);
+ const high50 = getVal(0.75);
+
+ if (medianVal === null) return;
+
+ const timingData = dateData["peak week inc flu hosp"]?.[model];
+ if (!timingData || !timingData.predictions) return;
+
+ const tPreds = timingData.predictions;
+ const dateArray = tPreds["peak week"] || tPreds["values"];
+ const probArray = tPreds["probabilities"];
+
+ let bestDateStr = null;
+ let lowDate95 = null,
+ highDate95 = null;
+ let lowDate50 = null,
+ highDate50 = null;
+
+ if (dateArray && probArray) {
+ let cumulativeProb = 0;
+ let medianIdx = -1,
+ q025Idx = -1,
+ q975Idx = -1,
+ q25Idx = -1,
+ q75Idx = -1;
+ for (let i = 0; i < probArray.length; i++) {
+ cumulativeProb += probArray[i];
+
+ if (q025Idx === -1 && cumulativeProb >= 0.025) q025Idx = i;
+ if (q25Idx === -1 && cumulativeProb >= 0.25) q25Idx = i;
+ if (medianIdx === -1 && cumulativeProb >= 0.5) medianIdx = i;
+ if (q75Idx === -1 && cumulativeProb >= 0.75) q75Idx = i;
+ if (q975Idx === -1 && cumulativeProb >= 0.975) q975Idx = i;
+ }
+ if (medianIdx === -1) medianIdx = probArray.length - 1;
+ if (q975Idx === -1) q975Idx = probArray.length - 1;
+ if (q75Idx === -1) q75Idx = probArray.length - 1;
+
+ bestDateStr = dateArray[medianIdx];
+ lowDate95 = dateArray[q025Idx !== -1 ? q025Idx : 0];
+ highDate95 = dateArray[q975Idx];
+ lowDate50 = dateArray[q25Idx !== -1 ? q25Idx : 0];
+ highDate50 = dateArray[q75Idx];
+ } else if (dateArray && dateArray.length > 0) {
+ bestDateStr = dateArray[Math.floor(dateArray.length / 2)];
+ }
+ if (!bestDateStr) return;
+
+ const normalizedDate = getNormalizedDate(bestDateStr);
+ // Gradient Opacity Calculation
+ const minOpacity = 0.4;
+ const alpha =
+ datesToCheck.length === 1
+ ? 1.0
+ : minOpacity +
+ (index / (datesToCheck.length - 1)) * (1 - minOpacity);
+
+ const dynamicColor = hexToRgba(baseColorHex, alpha);
+
+ if (show50 || show95) {
+ // 95% vertical whisker (hosp)
+ if (show95 && low95 !== null && high95 !== null) {
+ traces.push({
+ x: [normalizedDate, normalizedDate],
+ y: [low95, high95],
+ mode: "lines+markers",
+ line: {
+ color: dynamicColor,
+ width: 1,
+ dash: "dash",
+ },
+ marker: {
+ symbol: "line-ew",
+ color: dynamicColor,
+ size: 10,
+ line: {
+ width: 1,
+ color: dynamicColor,
+ },
+ },
+ legendgroup: model,
+ showlegend: false,
+ hoverinfo: "skip",
+ });
+ }
- if (chartScale !== 'sqrt') {
- return { plotData: traces, rawYRange: rawRange };
- }
+ // 50% vertical whisker (hosp)
+ if (show50 && low50 !== null && high50 !== null) {
+ traces.push({
+ x: [normalizedDate, normalizedDate],
+ y: [low50, high50],
+ mode: "lines",
+ line: {
+ color: dynamicColor,
+ width: 4,
+ dash: "6px, 3px",
+ },
+ legendgroup: model,
+ showlegend: false,
+ hoverinfo: "skip",
+ });
+ }
- 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';
+ // 95% horizontal whisker (dates)
+ if (show95 && lowDate95 && highDate95) {
+ traces.push({
+ x: [
+ getNormalizedDate(lowDate95),
+ getNormalizedDate(highDate95),
+ ],
+ y: [medianVal, medianVal],
+ mode: "lines+markers",
+ line: {
+ color: dynamicColor,
+ width: 1,
+ dash: "dash",
+ },
+ marker: {
+ symbol: "line-ns",
+ color: dynamicColor,
+ size: 10,
+ line: { width: 1, color: dynamicColor },
+ },
+ legendgroup: model,
+ showlegend: false,
+ hoverinfo: "skip",
+ });
}
- return nextTrace;
+ // 50% horizontal whisker (dates)
+ if (show50 && lowDate50 && highDate50) {
+ traces.push({
+ x: [
+ getNormalizedDate(lowDate50),
+ getNormalizedDate(highDate50),
+ ],
+ y: [medianVal, medianVal],
+ mode: "lines",
+ line: {
+ color: dynamicColor,
+ width: 4,
+ dash: "6px, 3px",
+ },
+ legendgroup: model,
+ showlegend: false,
+ hoverinfo: "skip",
+ });
+ }
+ }
+ if (showMedian) {
+ xValues.push(getNormalizedDate(bestDateStr));
+ yValues.push(medianVal);
+ pointColors.push(dynamicColor);
+ }
+
+ const timing50 = `${lowDate50} - ${highDate50}`;
+ const timing95 = `${lowDate95} - ${highDate95}`;
+ const formattedMedian = Math.round(medianVal).toLocaleString();
+ const formatted50 = `${Math.round(low50).toLocaleString()} - ${Math.round(high50).toLocaleString()}`;
+ const formatted95 = `${Math.round(low95).toLocaleString()} - ${Math.round(high95).toLocaleString()}`;
+
+ 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}
` +
+ `Peak timing:
` +
+ `Median Week: ${bestDateStr}
` +
+ timing50Row +
+ timing95Row +
+ `` +
+ `Peak hospitalization:
` +
+ `Median: ${formattedMedian}
` +
+ burden50Row +
+ burden95Row +
+ `predicted as of ${refDate}`,
+ );
});
- return { plotData: scaledTraces, rawYRange: rawRange };
- }, [groundTruth, nhsnData, peaks, selectedModels, selectedDates, peakDates, showMedian, show50, show95, chartScale]);
+ // actual trace
+ if (showMedian && xValues.length > 0) {
+ traces.push({
+ x: xValues,
+ y: yValues,
+ name: model,
+ type: "scatter",
+ mode: "markers",
+ marker: {
+ color: pointColors,
+ size: 12,
+ symbol: "circle",
+ line: { width: 1, color: "white" },
+ },
+ hoverlabel: {
+ font: { color: "#ffffff" },
+ bordercolor: "#ffffff", // maakes border white
+ },
+ hovertemplate: "%{text} ",
+ text: hoverTexts,
+ showlegend: false,
+ legendgroup: model,
+ });
+
+ // dummy legend
+ traces.push({
+ x: [null],
+ y: [null],
+ name: model,
+ type: "scatter",
+ mode: "markers",
+ marker: {
+ color: baseColorHex,
+ size: 12,
+ symbol: "circle",
+ line: { width: 1, color: "white" },
+ },
+ showlegend: true,
+ legendgroup: model,
+ });
+ }
+ });
+ }
- 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,
- height: windowSize ? Math.min(CHART_CONSTANTS.MAX_HEIGHT, windowSize.height * 0.5) : 500,
- 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' },
- 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)',
- bordercolor: colorScheme === 'dark' ? '#444' : '#ccc',
- borderwidth: 1,
- font: { size: 10 }
- },
- hovermode: 'closest',
- hoverlabel: { namelength: -1 },
- dragmode: false,
- xaxis: {
- tickformat: '%b'
- },
- 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
- },
+ const rawRange = getYRangeFromTraces(traces);
- // dynamic gray shading section
- shapes: selectedDates.flatMap(dateStr => {
- const normalizedRefDate = getNormalizedDate(dateStr);
- const seasonStart = new Date('2000-08-01');
- return [
- {
- type: 'rect',
- xref: 'x',
- yref: 'paper',
- x0: seasonStart,
- x1: normalizedRefDate,
- y0: 0,
- y1: 1,
- fillcolor: colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(128, 128, 128, 0.1)',
- line: { width: 0 },
- layer: 'below'
- },
- {
- type: 'line',
- x0: normalizedRefDate,
- x1: normalizedRefDate,
- y0: 0,
- y1: 1,
- yref: 'paper',
- line: {
- color: 'rgba(255, 255, 255, 0.05)',
- width: 2,
- }
- }
- ];
- }),
- }), [colorScheme, windowSize, selectedDates, chartScale, sqrtTicks, showLegend]);
-
- const config = useMemo(() => ({
- responsive: true,
- displayModeBar: true,
- displaylogo: false,
- modeBarPosition: 'left',
- scrollZoom: false,
- doubleClick: 'reset',
- modeBarButtonsToRemove: ['select2d', 'lasso2d'],
- toImageButtonOptions: { format: 'png', filename: 'peak_plot' },
- }), []);
+ if (chartScale !== "sqrt") {
+ return { plotData: traces, rawYRange: rawRange };
+ }
- return (
-
-
-
-
-
-
-
- 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];
- }}
- />
-
-
- );
+
+
+
+
+
+ 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 FluPeak;
diff --git a/app/src/components/ForecastPlotView.jsx b/app/src/components/ForecastPlotView.jsx
index b58d4285..03779c5c 100644
--- a/app/src/components/ForecastPlotView.jsx
+++ b/app/src/components/ForecastPlotView.jsx
@@ -1,16 +1,17 @@
-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';
+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";
+import { buildPlotDownloadName } from "../utils/plotDownloadName";
const ForecastPlotView = ({
data,
@@ -28,7 +29,7 @@ const ForecastPlotView = ({
extraTraces = null,
layoutOverrides = null,
configOverrides = null,
- groundTruthValueFormat = '%{y}'
+ groundTruthValueFormat = "%{y}",
}) => {
const [yAxisRange, setYAxisRange] = useState(null);
const [xAxisRange, setXAxisRange] = useState(null);
@@ -45,45 +46,56 @@ const ForecastPlotView = ({
const forecasts = data?.forecasts;
const resolvedForecastTarget = forecastTarget || selectedTarget;
- const resolvedDisplayTarget = displayTarget || selectedTarget || resolvedForecastTarget;
+ 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;
+ 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);
+ 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];
}
- });
- 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]);
+ return null;
+ },
+ [resolvedForecastTarget],
+ );
const { traces: projectionsData, rawYRange } = useQuantileForecastTraces({
groundTruth,
@@ -91,9 +103,9 @@ const ForecastPlotView = ({
selectedDates,
selectedModels,
target: resolvedForecastTarget,
- groundTruthLabel: 'Observed',
+ groundTruthLabel: "Observed",
groundTruthValueFormat,
- valueSuffix: '',
+ valueSuffix: "",
modelLineWidth: 2,
modelMarkerSize: 6,
groundTruthLineWidth: 2,
@@ -105,18 +117,18 @@ const ForecastPlotView = ({
show95,
transformY: sqrtTransform,
groundTruthHoverFormatter: sqrtTransform
- ? (value) => (
- groundTruthValueFormat.includes(':.2f')
- ? Number(value).toFixed(2)
- : Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })
- )
- : null
+ ? (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') {
+ if (typeof extraTraces === "function") {
return extraTraces({ baseTraces: projectionsData }) || [];
}
return Array.isArray(extraTraces) ? extraTraces : [];
@@ -141,14 +153,14 @@ const ForecastPlotView = ({
return activeModelSet;
}
- selectedDates.forEach(date => {
+ selectedDates.forEach((date) => {
const forecastsForDate = forecasts[date];
if (!forecastsForDate) return;
const targetData = forecastsForDate[resolvedForecastTarget];
if (!targetData) return;
- Object.keys(targetData).forEach(model => {
+ Object.keys(targetData).forEach((model) => {
activeModelSet.add(model);
});
});
@@ -172,96 +184,108 @@ const ForecastPlotView = ({
}
}, [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);
+ const handlePlotUpdate = useCallback(
+ (figure) => {
+ if (isResettingRef.current) {
+ isResettingRef.current = false;
+ return;
}
- }
- }, [xAxisRange]);
+ 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;
+ 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',
+ 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'
+ 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',
+ 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
- }
+ size: 10,
+ },
},
- hovermode: 'closest',
+ hovermode: "closest",
dragmode: false,
margin: { l: 60, r: 30, t: 30, b: 30 },
xaxis: {
domain: [0, 1],
rangeslider: {
- range: getDefaultRange(true)
+ 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'}
- ]
+ { 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'
+ 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)`;
+ 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
+ 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 => {
+ shapes: selectedDates.map((date) => {
return {
- type: 'line',
+ type: "line",
x0: date,
x1: date,
y0: 0,
y1: 1,
- yref: 'paper',
+ yref: "paper",
line: {
- color: 'red',
+ color: "red",
width: 1,
- dash: 'dash'
- }
+ dash: "dash",
+ },
};
- })
+ }),
};
if (layoutOverrides) {
@@ -269,7 +293,19 @@ const ForecastPlotView = ({
}
return baseLayout;
- }, [colorScheme, defaultRange, resolvedDisplayTarget, selectedDates, yAxisRange, xAxisRange, getDefaultRange, layoutOverrides, chartScale, sqrtTicks, showLegend]);
+ }, [
+ colorScheme,
+ defaultRange,
+ resolvedDisplayTarget,
+ selectedDates,
+ yAxisRange,
+ xAxisRange,
+ getDefaultRange,
+ layoutOverrides,
+ chartScale,
+ sqrtTicks,
+ showLegend,
+ ]);
const config = useMemo(() => {
const baseConfig = {
@@ -279,36 +315,41 @@ const ForecastPlotView = ({
showSendToCloud: false,
plotlyServerURL: "",
scrollZoom: false,
- doubleClick: 'reset',
+ doubleClick: "reset",
toImageButtonOptions: {
- format: 'png',
- filename: 'forecast_plot'
+ format: "png",
+ filename: buildPlotDownloadName("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
- });
- }
- }]
+ 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) {
@@ -320,7 +361,7 @@ const ForecastPlotView = ({
if (requireTarget && !selectedTarget) {
return (
-
+
Please select a target to view data.
);
@@ -332,11 +373,13 @@ const ForecastPlotView = ({
title={hubName ? `${stateName} — ${hubName}` : stateName}
timestamp={metadata?.last_updated}
/>
-
+
-
- Note that forecasts should be interpreted with great caution and may not reliably predict rapid changes in disease trends.
+
+ Note that forecasts should be interpreted with great caution and may
+ not reliably predict rapid changes in disease trends.
{
const { setViewType } = useView();
const handleClick = (e) => {
e.preventDefault();
- setViewType('flu_peak');
+ setViewType("flu_peak");
};
return (
- RespiLens now displays{' '}
+ RespiLens now displays{" "}
flu peak forecasts;
-
- {' '}forecasts for peak of the current influenza season.
+ {" "}
+ forecasts for peak of the current influenza season.
- )
-}
+ );
+};
const MetroCastLink = () => {
const { setViewType } = useView();
const handleClick = (e) => {
e.preventDefault();
- setViewType('metrocast_forecasts');
+ setViewType("metrocast_forecasts");
};
return (
- RespiLens now displays{' '}
-
flu MetroCast forecasts;
-
- {' '}metro area-level flu forecasts.
+ {" "}
+ metro area-level flu forecasts.
);
};
@@ -58,12 +58,12 @@ const FrontPage = () => {
return (
- }
+ text={ }
/>
{
announcementType={"update"}
text={ }
/>
-
Explore forecasts by pathogen
-
-
-
+
+
+
Explore surveillance data
-
-
+
+
diff --git a/app/src/components/InfoOverlay.jsx b/app/src/components/InfoOverlay.jsx
index 4d712fce..97a94280 100644
--- a/app/src/components/InfoOverlay.jsx
+++ b/app/src/components/InfoOverlay.jsx
@@ -1,6 +1,22 @@
-import { Modal, Button, Group, Text, List, Anchor, Image, Title, Stack, Badge, ActionIcon } from '@mantine/core';
-import { useDisclosure } from '@mantine/hooks';
-import { IconInfoCircle, IconBrandGithub, IconWorld } from '@tabler/icons-react';
+import {
+ Modal,
+ Button,
+ Group,
+ Text,
+ List,
+ Anchor,
+ Image,
+ Title,
+ Stack,
+ Badge,
+ ActionIcon,
+} from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
+import {
+ IconInfoCircle,
+ IconBrandGithub,
+ IconWorld,
+} from "@tabler/icons-react";
const InfoOverlay = () => {
const [opened, { open, close }] = useDisclosure(false);
@@ -37,74 +53,186 @@ const InfoOverlay = () => {
onClose={close}
title={
-
- RespiLens
+
+
+ RespiLens
+
}
size="lg"
scrollAreaComponent={Modal.NativeScrollArea}
>
-
- RespiLens is a responsive web app to visualize respiratory disease forecasts in the US, focused on
- accessibility for state health departments and the general public. Key features include:
+ RespiLens is a responsive web app to visualize respiratory disease
+ forecasts in the US, focused on accessibility for state health
+ departments and the general public. Key features include:
- URL-shareable views for specific forecast settings
+
+ URL-shareable views for specific forecast settings
+
Responsive and mobile-friendly site
Frequent and automatic site updates
Multi date, target, and model comparison
the Forecastle game!
- MyRespiLens, a safe visualization tool for your own data
+
+ MyRespiLens, a safe visualization tool for your own data
+
- Attribution
- RespiLens exists within a landscape of other respiratory illness data dashboards. We rely heavily on the{' '}
+
+ Attribution
+
+ RespiLens exists within a landscape of other respiratory illness
+ data dashboards. We rely heavily on the{" "}
Hubverse
- {' '}
- project which standardizes and consolidates forecast data formats. For each of the hub displayed on RespiLens, the data, organization and forecasts
- belong to their respective teams. RespiLens is only a visualization layer, and contains no original work.
+ {" "}
+ project which standardizes and consolidates forecast data formats.
+ For each of the hub displayed on RespiLens, the data, organization
+ and forecasts belong to their respective teams.{" "}
+
+ RespiLens is only a visualization layer, and contains no original
+ work.
+
- You can find information and alternative visualization for each pathogen at the following locations:
+ You can find information and alternative visualization for each
+ pathogen at the following locations:
- FluSight Forecast Hub: official CDC page – Hubverse dashboard – official GitHub repository
+ FluSight Forecast Hub:{" "}
+
+ official CDC page
+ {" "}
+ –{" "}
+
+ Hubverse dashboard
+ {" "}
+ –{" "}
+
+ official GitHub repository
+
- RSV Forecast Hub: official GitHub repository
+ RSV Forecast Hub:{" "}
+
+ official GitHub repository
+
- COVID-19 Forecast Hub: official CDC page – Hubverse dashboard – official GitHub repository
+ COVID-19 Forecast Hub:{" "}
+
+ official CDC page
+ {" "}
+ –{" "}
+
+ Hubverse dashboard
+ {" "}
+ –
+
+ official GitHub repository
+
- Flu MetroCast Hub: official dashboard – site – official GitHub repository
+ Flu MetroCast Hub:{" "}
+
+ official dashboard
+ {" "}
+ –{" "}
+
+ site
+ {" "}
+ –
+
+ official GitHub repository
+
- RespiLens is made by Emily Przykucki (UNC Chapel Hill), {' '}
-
+ RespiLens is made by Emily Przykucki (UNC Chapel Hill),{" "}
+
Joseph Lemaitre
- {' '}
- (UNC Chapel Hill) and others within ACCIDDA , the Atlantic Coast Center
- for Infectious Disease Dynamics and Analytics.
+ {" "}
+ (UNC Chapel Hill) and others within{" "}
+
+ ACCIDDA
+
+ , the Atlantic Coast Center for Infectious Disease Dynamics and
+ Analytics.
-
- Deployments
+
+
+ Deployments
+
- Stable
+
+ Stable
+
{
- Staging
+
+ Staging
+
{
-
>
diff --git a/app/src/components/LastFetched.jsx b/app/src/components/LastFetched.jsx
index 436f95f4..980a5f10 100644
--- a/app/src/components/LastFetched.jsx
+++ b/app/src/components/LastFetched.jsx
@@ -1,6 +1,6 @@
-import { Text, Tooltip } from '@mantine/core';
-import dayjs from 'dayjs';
-import relativeTime from 'dayjs/plugin/relativeTime';
+import { Text, Tooltip } from "@mantine/core";
+import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
@@ -10,20 +10,21 @@ const LastFetched = ({ timestamp }) => {
const date = new Date(timestamp);
const relativeTimeStr = dayjs(timestamp).fromNow();
const fullTimestamp = date.toLocaleString(undefined, {
- timeZone: 'America/New_York',
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- hour: 'numeric',
- minute: '2-digit',
- second: '2-digit',
- timeZoneName: 'short'
+ timeZone: "America/New_York",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ timeZoneName: "short",
});
return (
- last fetched:
-
+ last fetched:{" "}
+
+
{relativeTimeStr}
diff --git a/app/src/components/ModelSelector.jsx b/app/src/components/ModelSelector.jsx
index c9b0e9b7..4511f0c5 100644
--- a/app/src/components/ModelSelector.jsx
+++ b/app/src/components/ModelSelector.jsx
@@ -1,26 +1,47 @@
-import { useState } from 'react';
-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';
+import { useState } from "react";
+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";
-const ModelSelector = ({
+const ModelSelector = ({
models = [],
- selectedModels = [],
+ selectedModels = [],
setSelectedModels,
- activeModels = null,
+ activeModels = null,
allowMultiple = true,
- disabled = false
+ disabled = false,
}) => {
const [showAllAvailable, setShowAllAvailable] = useState(false);
- const [search, setSearch] = useState('');
+ const [search, setSearch] = useState("");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
- onDropdownOpen: () => combobox.updateSelectedOptionIndex('active', 0),
+ onDropdownOpen: () => combobox.updateSelectedOptionIndex("active", 0),
});
const handleSelectAll = () => {
// Only select models that are currently active
- const modelsToSelect = activeModels ? models.filter(m => activeModels.has(m)) : models;
+ const modelsToSelect = activeModels
+ ? models.filter((m) => activeModels.has(m))
+ : models;
setSelectedModels(modelsToSelect);
};
@@ -37,9 +58,9 @@ const ModelSelector = ({
const modelsToShow = showAllAvailable ? models : selectedModels;
const handleValueSelect = (val) => {
- setSearch('');
+ setSearch("");
if (selectedModels.includes(val)) {
- setSelectedModels(selectedModels.filter(v => v !== val));
+ setSelectedModels(selectedModels.filter((v) => v !== val));
} else if (allowMultiple) {
setSelectedModels([...selectedModels, val]);
} else {
@@ -48,11 +69,11 @@ const ModelSelector = ({
};
const handleValueRemove = (val) => {
- setSelectedModels(selectedModels.filter(v => v !== val));
+ setSelectedModels(selectedModels.filter((v) => v !== val));
};
- const filteredModels = models.filter(model =>
- model.toLowerCase().includes(search.toLowerCase().trim())
+ const filteredModels = models.filter((model) =>
+ model.toLowerCase().includes(search.toLowerCase().trim()),
);
if (!models.length) {
@@ -66,216 +87,238 @@ const ModelSelector = ({
return (
-
-
- Model selection ({selectedModels.length}/{models.length})
-
- {allowMultiple && (
- <>
-
-
- Select All
-
-
-
-
- Clear All
-
-
- >
- )}
- setShowAllAvailable(event.currentTarget.checked)}
- size="sm"
- disabled={disabled}
- thumbIcon={
- showAllAvailable ? (
-
- ) : (
-
- )
- }
- />
-
+
+
+ Model selection ({selectedModels.length}/{models.length})
+
+ {allowMultiple && (
+ <>
+
+
+ Select All
+
+
+
+
+ Clear All
+
+
+ >
+ )}
+
+ setShowAllAvailable(event.currentTarget.checked)
+ }
+ size="sm"
+ disabled={disabled}
+ thumbIcon={
+ showAllAvailable ? (
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+
+ combobox.openDropdown()}
+ size="sm"
+ label="Search and select models"
+ >
+
+ {selectedModels.map((model) => {
+ const modelColor = getModelColorByIndex(model);
+ const isActive = !activeModels || activeModels.has(model);
+ return (
+ handleValueRemove(model)}
+ style={{
+ backgroundColor: isActive
+ ? modelColor
+ : "var(--mantine-color-gray-6)",
+ color: "white",
+ padding: "2px 6px",
+ fontSize: "0.75rem",
+ }}
+ >
+ {model}
+
+ );
+ })}
-
-
- combobox.openDropdown()} size="sm" label="Search and select models">
-
- {selectedModels.map((model) => {
+
+ combobox.openDropdown()}
+ onBlur={() => combobox.closeDropdown()}
+ value={search}
+ placeholder="Quick search and select models..."
+ aria-label="Search and select forecasting models"
+ onChange={(event) => {
+ combobox.updateSelectedOptionIndex();
+ setSearch(event.currentTarget.value);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Backspace" && search.length === 0) {
+ event.preventDefault();
+ handleValueRemove(
+ selectedModels[selectedModels.length - 1],
+ );
+ }
+ }}
+ />
+
+
+
+
+
+
+
+ {filteredModels.map((model) => {
const modelColor = getModelColorByIndex(model);
+ const isSelected = selectedModels.includes(model);
const isActive = !activeModels || activeModels.has(model);
+
return (
- handleValueRemove(model)}
style={{
- backgroundColor: isActive ? modelColor : 'var(--mantine-color-gray-6)',
- color: 'white',
- padding: '2px 6px',
- fontSize: '0.75rem',
+ padding: "4px 8px",
}}
+ disabled={!isActive} // Disable selection if not active
>
- {model}
-
+
+
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+ {model}
+
+
+
+
);
})}
+
+
+
-
- combobox.openDropdown()}
- onBlur={() => combobox.closeDropdown()}
- value={search}
- placeholder="Quick search and select models..."
- aria-label="Search and select forecasting models"
- onChange={(event) => {
- combobox.updateSelectedOptionIndex();
- setSearch(event.currentTarget.value);
- }}
- onKeyDown={(event) => {
- if (event.key === 'Backspace' && search.length === 0) {
- event.preventDefault();
- handleValueRemove(selectedModels[selectedModels.length - 1]);
- }
- }}
- />
-
-
-
-
+ {allowMultiple && (
+
+ {selectedModels.length > 0 && `${selectedModels.length} selected`}
+
+ )}
-
-
- {filteredModels.map((model) => {
- const modelColor = getModelColorByIndex(model);
+ {modelsToShow.length > 0 && (
+
+ {modelsToShow.map((model) => {
const isSelected = selectedModels.includes(model);
+ const modelColor = getModelColorByIndex(model);
+ const inactiveColor = "var(--mantine-color-gray-5)";
const isActive = !activeModels || activeModels.has(model);
-
+ const isDisabled = disabled || !isActive; // Combine overall disabled with specific model active state
+
return (
- {
+ if (isDisabled) return; // Use combined disabled state
+
+ if (isSelected) {
+ setSelectedModels(
+ selectedModels.filter((m) => m !== model),
+ );
+ } else {
+ if (allowMultiple) {
+ setSelectedModels([...selectedModels, model]);
+ } else {
+ setSelectedModels([model]);
+ }
+ }
}}
- disabled={!isActive} // Disable selection if not active
>
-
-
+
+
{isSelected ? (
-
+
) : (
-
+
)}
-
{model}
-
+
-
+
);
})}
-
-
-
-
- {allowMultiple && (
-
- {selectedModels.length > 0 && `${selectedModels.length} selected`}
-
- )}
-
- {modelsToShow.length > 0 && (
-
- {modelsToShow.map((model) => {
- const isSelected = selectedModels.includes(model);
- const modelColor = getModelColorByIndex(model);
- const inactiveColor = 'var(--mantine-color-gray-5)';
- const isActive = !activeModels || activeModels.has(model);
- const isDisabled = disabled || !isActive; // Combine overall disabled with specific model active state
-
- return (
- {
- if (isDisabled) return; // Use combined disabled state
-
- if (isSelected) {
- setSelectedModels(selectedModels.filter(m => m !== model));
- } else {
- if (allowMultiple) {
- setSelectedModels([...selectedModels, model]);
- } else {
- setSelectedModels([model]);
- }
- }
- }}
- >
-
-
- {isSelected ? (
-
- ) : (
-
- )}
-
- {model}
-
-
-
-
- );
- })}
-
- )}
+
+ )}
);
diff --git a/app/src/components/NHSNColumnSelector.jsx b/app/src/components/NHSNColumnSelector.jsx
index 81cafd76..a710134a 100644
--- a/app/src/components/NHSNColumnSelector.jsx
+++ b/app/src/components/NHSNColumnSelector.jsx
@@ -1,30 +1,51 @@
-import { Stack, Group, Button, Text, SimpleGrid, Select } from '@mantine/core';
-import { MODEL_COLORS } from '../config/datasets';
+import { Stack, Group, Button, Text, SimpleGrid, Select } from "@mantine/core";
+import { MODEL_COLORS } from "../config/datasets";
// Function to organize columns by disease first, then by subcategory
const organizeByDisease = (columns) => {
const diseases = {
- covid: { total: [], icu: [], byAge: [], adult: [], pediatric: [], percent: [] },
- influenza: { total: [], icu: [], byAge: [], adult: [], pediatric: [], percent: [] },
- rsv: { total: [], icu: [], byAge: [], adult: [], pediatric: [], percent: [] }
+ covid: {
+ total: [],
+ icu: [],
+ byAge: [],
+ adult: [],
+ pediatric: [],
+ percent: [],
+ },
+ influenza: {
+ total: [],
+ icu: [],
+ byAge: [],
+ adult: [],
+ pediatric: [],
+ percent: [],
+ },
+ rsv: {
+ total: [],
+ icu: [],
+ byAge: [],
+ adult: [],
+ pediatric: [],
+ percent: [],
+ },
};
const other = { beds: [], bedPercent: [], other: [] };
const sortByAge = (a, b) => {
- const ageRanges = ['0-4', '5-17', '18-49', '50-64', '65-74', '75+'];
- const aAge = ageRanges.findIndex(age => a.includes(age));
- const bAge = ageRanges.findIndex(age => b.includes(age));
+ const ageRanges = ["0-4", "5-17", "18-49", "50-64", "65-74", "75+"];
+ const aAge = ageRanges.findIndex((age) => a.includes(age));
+ const bAge = ageRanges.findIndex((age) => b.includes(age));
if (aAge !== -1 && bAge !== -1) return aAge - bAge;
return a.localeCompare(b);
};
- columns.forEach(col => {
+ columns.forEach((col) => {
const colLower = col.toLowerCase();
// Bed capacity columns - prioritize these over disease classification
- if (colLower.includes('bed')) {
- if (colLower.startsWith('percent ')) {
+ if (colLower.includes("bed")) {
+ if (colLower.startsWith("percent ")) {
other.bedPercent.push(col);
} else {
other.beds.push(col);
@@ -34,32 +55,45 @@ const organizeByDisease = (columns) => {
// Determine disease
let disease = null;
- if (colLower.includes('covid')) disease = 'covid';
- else if (colLower.includes('influenza') || colLower.includes('flu')) disease = 'influenza';
- else if (colLower.includes('rsv')) disease = 'rsv';
+ if (colLower.includes("covid")) disease = "covid";
+ else if (colLower.includes("influenza") || colLower.includes("flu"))
+ disease = "influenza";
+ else if (colLower.includes("rsv")) disease = "rsv";
// Disease-specific columns
if (disease) {
const group = diseases[disease];
- if (colLower.startsWith('percent ')) {
+ if (colLower.startsWith("percent ")) {
group.percent.push(col);
- } else if (colLower.includes('icu patients')) {
+ } else if (colLower.includes("icu patients")) {
group.icu.push(col);
- } else if (colLower.includes('unknown age')) {
+ } else if (colLower.includes("unknown age")) {
// Put unknown age in the by age section
group.byAge.push(col);
- } else if (colLower.includes('pediatric') && (colLower.includes('0-4') || colLower.includes('5-17'))) {
+ } else if (
+ colLower.includes("pediatric") &&
+ (colLower.includes("0-4") || colLower.includes("5-17"))
+ ) {
group.byAge.push(col);
- } else if (colLower.includes('adult') && (colLower.includes('18-49') || colLower.includes('50-64') || colLower.includes('65-74') || colLower.includes('75+'))) {
+ } else if (
+ colLower.includes("adult") &&
+ (colLower.includes("18-49") ||
+ colLower.includes("50-64") ||
+ colLower.includes("65-74") ||
+ colLower.includes("75+"))
+ ) {
group.byAge.push(col);
- } else if (colLower.includes('pediatric') || colLower.includes('pedatric')) {
+ } else if (
+ colLower.includes("pediatric") ||
+ colLower.includes("pedatric")
+ ) {
// Pediatric without age ranges
group.pediatric.push(col);
- } else if (colLower.includes('adult')) {
+ } else if (colLower.includes("adult")) {
// Adult without age ranges
group.adult.push(col);
- } else if (colLower.startsWith('total ')) {
+ } else if (colLower.startsWith("total ")) {
group.total.push(col);
} else {
group.total.push(col);
@@ -70,13 +104,13 @@ const organizeByDisease = (columns) => {
});
// Sort within each subcategory
- Object.values(diseases).forEach(disease => {
- Object.keys(disease).forEach(key => {
+ Object.values(diseases).forEach((disease) => {
+ Object.keys(disease).forEach((key) => {
disease[key].sort(sortByAge);
});
});
- Object.keys(other).forEach(key => {
+ Object.keys(other).forEach((key) => {
other[key].sort();
});
@@ -91,11 +125,11 @@ const NHSNColumnSelector = ({
selectedTarget,
availableTargets,
onTargetChange,
- loading
+ loading,
}) => {
const toggleColumn = (column) => {
if (selectedColumns.includes(column)) {
- setSelectedColumns(selectedColumns.filter(c => c !== column));
+ setSelectedColumns(selectedColumns.filter((c) => c !== column));
} else {
setSelectedColumns([...selectedColumns, column]);
}
@@ -109,11 +143,15 @@ const NHSNColumnSelector = ({
toggleColumn(column)}
- variant={selectedColumns.includes(column) ? 'filled' : 'outline'}
+ variant={selectedColumns.includes(column) ? "filled" : "outline"}
size="xs"
style={
selectedColumns.includes(column)
- ? { backgroundColor: MODEL_COLORS[columnIndex % MODEL_COLORS.length], color: 'white' }
+ ? {
+ backgroundColor:
+ MODEL_COLORS[columnIndex % MODEL_COLORS.length],
+ color: "white",
+ }
: undefined
}
>
@@ -124,25 +162,34 @@ const NHSNColumnSelector = ({
const renderDiseaseSection = (diseaseName, diseaseData, colorScheme) => {
const subcategories = [
- { key: 'total', label: 'Total' },
- { key: 'icu', label: 'ICU' },
- { key: 'byAge', label: 'By Age' },
- { key: 'adult', label: 'Adult' },
- { key: 'pediatric', label: 'Pediatric' },
- { key: 'percent', label: 'Percent' }
+ { key: "total", label: "Total" },
+ { key: "icu", label: "ICU" },
+ { key: "byAge", label: "By Age" },
+ { key: "adult", label: "Adult" },
+ { key: "pediatric", label: "Pediatric" },
+ { key: "percent", label: "Percent" },
];
- const hasData = Object.values(diseaseData).some(arr => arr.length > 0);
+ const hasData = Object.values(diseaseData).some((arr) => arr.length > 0);
if (!hasData) return null;
return (
- {diseaseName}
+
+ {diseaseName}
+
{subcategories.map(({ key, label }) => {
if (diseaseData[key].length === 0) return null;
return (
- {label}:
+
+ {label}:
+
{diseaseData[key].map(renderButton)}
@@ -153,11 +200,14 @@ const NHSNColumnSelector = ({
);
};
- const hasDiseaseData = Object.values(diseases).some(disease =>
- Object.values(disease).some(arr => arr.length > 0)
+ const hasDiseaseData = Object.values(diseases).some((disease) =>
+ Object.values(disease).some((arr) => arr.length > 0),
);
- const hasOtherData = other.beds.length > 0 || other.bedPercent.length > 0 || other.other.length > 0;
+ const hasOtherData =
+ other.beds.length > 0 ||
+ other.bedPercent.length > 0 ||
+ other.other.length > 0;
return (
@@ -180,9 +230,9 @@ const NHSNColumnSelector = ({
{/* Disease-specific columns in 3-column layout */}
{hasDiseaseData && (
- {renderDiseaseSection('COVID-19', diseases.covid, 'black')}
- {renderDiseaseSection('Influenza', diseases.influenza, 'black')}
- {renderDiseaseSection('RSV', diseases.rsv, 'black')}
+ {renderDiseaseSection("COVID-19", diseases.covid, "black")}
+ {renderDiseaseSection("Influenza", diseases.influenza, "black")}
+ {renderDiseaseSection("RSV", diseases.rsv, "black")}
)}
@@ -191,7 +241,9 @@ const NHSNColumnSelector = ({
{(other.beds.length > 0 || other.bedPercent.length > 0) && (
<>
- Bed Capacity
+
+ Bed Capacity
+
{other.beds.length > 0 && (
{other.beds.map(renderButton)}
@@ -206,7 +258,9 @@ const NHSNColumnSelector = ({
)}
{other.other.length > 0 && (
<>
- Other
+
+ Other
+
{other.other.map(renderButton)}
@@ -218,4 +272,4 @@ const NHSNColumnSelector = ({
);
};
-export default NHSNColumnSelector;
\ No newline at end of file
+export default NHSNColumnSelector;
diff --git a/app/src/components/NHSNOverviewGraph.jsx b/app/src/components/NHSNOverviewGraph.jsx
index 1f9dcd41..71d9474b 100644
--- a/app/src/components/NHSNOverviewGraph.jsx
+++ b/app/src/components/NHSNOverviewGraph.jsx
@@ -1,16 +1,20 @@
-import { useMemo, useState, useEffect } from 'react';
-import { IconChevronRight } from '@tabler/icons-react';
-import { getDataPath } from '../utils/paths';
-import { useView } from '../hooks/useView';
-import OverviewGraphCard from './OverviewGraphCard';
-import useOverviewPlot from '../hooks/useOverviewPlot';
-
-const DEFAULT_COLS = ['Total COVID-19 Admissions', 'Total Influenza Admissions', 'Total RSV Admissions'];
+import { useMemo, useState, useEffect } from "react";
+import { IconChevronRight } from "@tabler/icons-react";
+import { getDataPath } from "../utils/paths";
+import { useView } from "../hooks/useView";
+import OverviewGraphCard from "./OverviewGraphCard";
+import useOverviewPlot from "../hooks/useOverviewPlot";
+
+const DEFAULT_COLS = [
+ "Total COVID-19 Admissions",
+ "Total Influenza Admissions",
+ "Total RSV Admissions",
+];
const PATHOGEN_COLORS = {
- 'Total COVID-19 Admissions': '#e377c2',
- 'Total Influenza Admissions': '#1f77b4',
- 'Total RSV Admissions': '#7f7f7f'
+ "Total COVID-19 Admissions": "#e377c2",
+ "Total Influenza Admissions": "#1f77b4",
+ "Total RSV Admissions": "#7f7f7f",
};
const NHSNOverviewGraph = ({ location }) => {
@@ -19,24 +23,26 @@ const NHSNOverviewGraph = ({ location }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const resolvedLocation = location || 'US';
- const isActive = activeViewType === 'nhsn';
+ const resolvedLocation = location || "US";
+ const isActive = activeViewType === "nhsn";
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
- const response = await fetch(getDataPath(`nhsn/${resolvedLocation}_nhsn.json`));
+ 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 {
@@ -58,25 +64,26 @@ const NHSNOverviewGraph = ({ location }) => {
const twoMonthsAgo = new Date(lastDate);
twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2);
- 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: snapshot.series.dates,
- y: yData,
- name: col.replace('Total ', '').replace(' Admissions', ''),
- type: 'scatter',
- mode: 'lines',
- line: {
- color: PATHOGEN_COLORS[col],
- width: 2
- },
- hovertemplate: '%{y} '
- };
- }).filter(Boolean);
+ const 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: snapshot.series.dates,
+ y: yData,
+ name: col.replace("Total ", "").replace(" Admissions", ""),
+ type: "scatter",
+ mode: "lines",
+ line: {
+ color: PATHOGEN_COLORS[col],
+ width: 2,
+ },
+ hovertemplate: "%{y} ",
+ };
+ }).filter(Boolean);
return { buildTraces: tracesBuilder, xRange: range };
}, [data]);
@@ -92,24 +99,28 @@ const NHSNOverviewGraph = ({ location }) => {
margin: { l: 45, r: 20, t: 10, b: 40 },
showlegend: true,
legend: {
- orientation: 'h',
+ orientation: "h",
y: -0.2,
x: 0.5,
- xanchor: 'center',
- font: { size: 9 }
- }
- }
+ xanchor: "center",
+ font: { size: 9 },
+ },
+ },
});
- const layoutWithFloor = useMemo(() => ({
- ...layout,
- yaxis: {
- ...layout.yaxis,
- fixedrange: true
- }
- }), [layout]);
+ const layoutWithFloor = useMemo(
+ () => ({
+ ...layout,
+ yaxis: {
+ ...layout.yaxis,
+ fixedrange: true,
+ },
+ }),
+ [layout],
+ );
- const locationLabel = resolvedLocation === 'US' ? 'US national view' : resolvedLocation;
+ const locationLabel =
+ resolvedLocation === "US" ? "US national view" : resolvedLocation;
return (
{
traces={traces}
layout={layoutWithFloor}
emptyLabel={null}
- actionLabel={isActive ? 'Viewing' : 'View NHSN data'}
+ actionLabel={isActive ? "Viewing" : "View NHSN data"}
actionActive={isActive}
- onAction={() => setViewType('nhsn')}
+ onAction={() => setViewType("nhsn")}
actionIcon={ }
locationLabel={locationLabel}
/>
diff --git a/app/src/components/OverviewGraphCard.jsx b/app/src/components/OverviewGraphCard.jsx
index 282926fa..859ec59a 100644
--- a/app/src/components/OverviewGraphCard.jsx
+++ b/app/src/components/OverviewGraphCard.jsx
@@ -1,21 +1,21 @@
-import { Button, Card, Group, Loader, Stack, Text, Title } from '@mantine/core';
-import Plot from 'react-plotly.js';
+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...',
+ loadingLabel = "Loading data...",
error,
errorLabel,
traces,
layout,
- emptyLabel = 'No data available.',
+ emptyLabel = "No data available.",
actionLabel,
actionActive = false,
onAction,
actionIcon,
- locationLabel
+ locationLabel,
}) => {
const hasTraces = Array.isArray(traces) && traces.length > 0;
const showEmpty = !loading && !error && !hasTraces && emptyLabel;
@@ -30,17 +30,21 @@ const OverviewGraphCard = ({
{loading && (
- {loadingLabel}
+
+ {loadingLabel}
+
)}
{!loading && error && (
- {errorLabel || error}
+
+ {errorLabel || error}
+
)}
{!loading && !error && hasTraces && (
-
+
)}
{showEmpty && (
- {emptyLabel}
+
+ {emptyLabel}
+
)}
{actionLabel}
- {locationLabel}
+
+ {locationLabel}
+
diff --git a/app/src/components/PathogenOverviewGraph.jsx b/app/src/components/PathogenOverviewGraph.jsx
index 2271aadb..fe14e6b9 100644
--- a/app/src/components/PathogenOverviewGraph.jsx
+++ b/app/src/components/PathogenOverviewGraph.jsx
@@ -1,22 +1,22 @@
-import { useMemo, useCallback } from 'react';
-import { Text } from '@mantine/core';
-import { IconChevronRight } from '@tabler/icons-react';
-import { useForecastData } from '../hooks/useForecastData';
-import { DATASETS } from '../config';
-import { useView } from '../hooks/useView';
-import OverviewGraphCard from './OverviewGraphCard';
-import useOverviewPlot from '../hooks/useOverviewPlot';
+import { useMemo, useCallback } from "react";
+import { Text } from "@mantine/core";
+import { IconChevronRight } from "@tabler/icons-react";
+import { useForecastData } from "../hooks/useForecastData";
+import { DATASETS } from "../config";
+import { useView } from "../hooks/useView";
+import OverviewGraphCard from "./OverviewGraphCard";
+import useOverviewPlot from "../hooks/useOverviewPlot";
const DEFAULT_TARGETS = {
- covid_forecasts: 'wk inc covid hosp',
- flu_forecasts: 'wk inc flu hosp',
- rsv_forecasts: 'wk inc rsv hosp'
+ covid_forecasts: "wk inc covid hosp",
+ flu_forecasts: "wk inc flu hosp",
+ rsv_forecasts: "wk inc rsv hosp",
};
const VIEW_TO_DATASET = {
- covid_forecasts: 'covid',
- flu_forecasts: 'flu',
- rsv_forecasts: 'rsv'
+ covid_forecasts: "covid",
+ flu_forecasts: "flu",
+ rsv_forecasts: "rsv",
};
const getRangeAroundDate = (dateStr, weeksBefore = 4, weeksAfter = 4) => {
@@ -29,17 +29,14 @@ const getRangeAroundDate = (dateStr, weeksBefore = 4, weeksAfter = 4) => {
const end = new Date(baseDate);
end.setDate(end.getDate() + weeksAfter * 7);
- return [
- start.toISOString().split('T')[0],
- end.toISOString().split('T')[0]
- ];
+ return [start.toISOString().split("T")[0], end.toISOString().split("T")[0]];
};
const buildIntervalTraces = (forecast, model) => {
- if (!forecast || forecast.type !== 'quantile') return null;
+ if (!forecast || forecast.type !== "quantile") return null;
const predictionEntries = Object.values(forecast.predictions || {}).sort(
- (a, b) => new Date(a.date) - new Date(b.date)
+ (a, b) => new Date(a.date) - new Date(b.date),
);
const x = [];
@@ -57,7 +54,13 @@ const buildIntervalTraces = (forecast, model) => {
const lower50Index = quantiles.indexOf(0.25);
const upper50Index = quantiles.indexOf(0.75);
- if (medianIndex !== -1 && lower95Index !== -1 && upper95Index !== -1 && lower50Index !== -1 && upper50Index !== -1) {
+ if (
+ medianIndex !== -1 &&
+ lower95Index !== -1 &&
+ upper95Index !== -1 &&
+ lower50Index !== -1 &&
+ upper50Index !== -1
+ ) {
x.push(pred.date);
median.push(values[medianIndex]);
lower95.push(values[lower95Index]);
@@ -74,105 +77,116 @@ const buildIntervalTraces = (forecast, model) => {
x,
y: upper95,
name: `${model} 95% interval`,
- type: 'scatter',
- mode: 'lines',
+ type: "scatter",
+ mode: "lines",
line: { width: 0 },
showlegend: false,
- hoverinfo: 'skip'
+ hoverinfo: "skip",
},
{
x,
y: lower95,
name: `${model} 95% interval`,
- type: 'scatter',
- mode: 'lines',
- fill: 'tonexty',
- fillcolor: 'rgba(34, 139, 230, 0.15)',
+ type: "scatter",
+ mode: "lines",
+ fill: "tonexty",
+ fillcolor: "rgba(34, 139, 230, 0.15)",
line: { width: 0 },
showlegend: false,
- hoverinfo: 'skip'
+ hoverinfo: "skip",
},
{
x,
y: upper50,
name: `${model} 50% interval`,
- type: 'scatter',
- mode: 'lines',
+ type: "scatter",
+ mode: "lines",
line: { width: 0 },
showlegend: false,
- hoverinfo: 'skip'
+ hoverinfo: "skip",
},
{
x,
y: lower50,
name: `${model} 50% interval`,
- type: 'scatter',
- mode: 'lines',
- fill: 'tonexty',
- fillcolor: 'rgba(34, 139, 230, 0.25)',
+ type: "scatter",
+ mode: "lines",
+ fill: "tonexty",
+ fillcolor: "rgba(34, 139, 230, 0.25)",
line: { width: 0 },
showlegend: false,
- hoverinfo: 'skip'
+ hoverinfo: "skip",
},
{
x,
y: median,
name: `${model} median`,
- type: 'scatter',
- mode: 'lines+markers',
- line: { width: 2, color: '#228be6' },
- marker: { size: 4 }
- }
+ 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 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 buildTraces = useCallback((forecastData) => {
- if (!forecastData || !selectedTarget) return [];
- const groundTruth = forecastData.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
- ? forecastData.forecasts?.[selectedDate]?.[selectedTarget]?.[selectedModel]
- : null;
-
- const intervalTraces = buildIntervalTraces(forecast, selectedModel);
-
- return [
- groundTruthTrace,
- ...(intervalTraces || [])
- ].filter(Boolean);
- }, [selectedDate, selectedTarget, selectedModel]);
+ 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 buildTraces = useCallback(
+ (forecastData) => {
+ if (!forecastData || !selectedTarget) return [];
+ const groundTruth = forecastData.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
+ ? forecastData.forecasts?.[selectedDate]?.[selectedTarget]?.[
+ selectedModel
+ ]
+ : null;
+
+ const intervalTraces = buildIntervalTraces(forecast, selectedModel);
+
+ return [groundTruthTrace, ...(intervalTraces || [])].filter(Boolean);
+ },
+ [selectedDate, selectedTarget, selectedModel],
+ );
const { traces, layout } = useOverviewPlot({
data,
@@ -180,22 +194,29 @@ const PathogenOverviewGraph = ({ viewType, title, location }) => {
xRange: chartRange,
yPaddingTopRatio: 0.1,
yPaddingBottomRatio: 0.1,
- yMinFloor: 0
+ yMinFloor: 0,
});
- const locationLabel = resolvedLocation === 'US' ? 'US national view' : resolvedLocation;
+ const locationLabel =
+ resolvedLocation === "US" ? "US national view" : resolvedLocation;
return (
{selectedDate} : null}
+ meta={
+ selectedDate ? (
+
+ {selectedDate}
+
+ ) : null
+ }
loading={loading}
loadingLabel="Loading data..."
error={error}
traces={traces}
layout={layout}
emptyLabel="No data available."
- actionLabel={isActive ? 'Viewing' : 'View forecasts'}
+ actionLabel={isActive ? "Viewing" : "View forecasts"}
actionActive={isActive}
onAction={() => setViewType(datasetConfig?.defaultView || viewType)}
actionIcon={ }
diff --git a/app/src/components/StateSelector.jsx b/app/src/components/StateSelector.jsx
index c212f994..96335d68 100644
--- a/app/src/components/StateSelector.jsx
+++ b/app/src/components/StateSelector.jsx
@@ -1,17 +1,41 @@
-import { useState, useEffect } from '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';
+import { useState, useEffect } from "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 = {
- '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'
+ 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 StateSelector = () => {
@@ -24,71 +48,80 @@ const StateSelector = () => {
intervalVisibility,
setIntervalVisibility,
showLegend,
- setShowLegend
+ setShowLegend,
} = useView();
const [states, setStates] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [searchTerm, setSearchTerm] = useState('');
-
- const [highlightedIndex, setHighlightedIndex] = useState(-1);
+ const [searchTerm, setSearchTerm] = useState("");
+
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
useEffect(() => {
const controller = new AbortController(); // controller prevents issues if you click away while locs are loading
-
- setStates([]);
+
+ setStates([]);
setLoading(true);
- const fetchStates = async () => { // different fetching/ordering if it is metrocast vs. other views
+ const fetchStates = async () => {
+ // different fetching/ordering if it is metrocast vs. other views
try {
- const isMetro = viewType === 'metrocast_forecasts';
- const directory = isMetro ? 'flumetrocast' : 'flusight';
-
+ const isMetro = viewType === "metrocast_forecasts";
+ const directory = isMetro ? "flumetrocast" : "flusight";
+
const manifestResponse = await fetch(
getDataPath(`${directory}/metadata.json`),
- { signal: controller.signal }
+ { signal: controller.signal },
);
-
- if (!manifestResponse.ok) throw new Error(`Failed: ${manifestResponse.statusText}`);
-
+
+ if (!manifestResponse.ok)
+ throw new Error(`Failed: ${manifestResponse.statusText}`);
+
const metadata = await manifestResponse.json();
let finalOrderedList = [];
if (isMetro) {
const locations = metadata.locations;
- const statesOnly = locations.filter(l => !l.location_name.includes(','));
- const citiesOnly = locations.filter(l => l.location_name.includes(','));
- statesOnly.sort((a, b) => a.location_name.localeCompare(b.location_name));
-
- statesOnly.forEach(stateObj => {
- finalOrderedList.push(stateObj)
+ const statesOnly = locations.filter(
+ (l) => !l.location_name.includes(","),
+ );
+ const citiesOnly = locations.filter((l) =>
+ l.location_name.includes(","),
+ );
+ statesOnly.sort((a, b) =>
+ a.location_name.localeCompare(b.location_name),
+ );
+
+ statesOnly.forEach((stateObj) => {
+ finalOrderedList.push(stateObj);
const code = METRO_STATE_MAP[stateObj.location_name];
-
+
const children = citiesOnly
- .filter(city => city.location_name.endsWith(`, ${code}`))
+ .filter((city) => city.location_name.endsWith(`, ${code}`))
.sort((a, b) => a.location_name.localeCompare(b.location_name));
finalOrderedList.push(...children);
});
- const handledIds = finalOrderedList.map(l => l.abbreviation);
- const leftovers = locations.filter(l => !handledIds.includes(l.abbreviation));
+ const handledIds = finalOrderedList.map((l) => l.abbreviation);
+ const leftovers = locations.filter(
+ (l) => !handledIds.includes(l.abbreviation),
+ );
finalOrderedList.push(...leftovers);
-
} else {
finalOrderedList = metadata.locations.sort((a, b) => {
- const isA_Default = a.abbreviation === 'US';
- const isB_Default = b.abbreviation === 'US';
+ const isA_Default = a.abbreviation === "US";
+ const isB_Default = b.abbreviation === "US";
if (isA_Default) return -1;
if (isB_Default) return 1;
- return (a.location_name || '').localeCompare(b.location_name || '');
+ return (a.location_name || "").localeCompare(b.location_name || "");
});
}
setStates(finalOrderedList);
} catch (err) {
- if (err.name === 'AbortError') return;
+ if (err.name === "AbortError") return;
setError(err.message);
} finally {
if (!controller.signal.aborted) setLoading(false);
@@ -102,26 +135,30 @@ const StateSelector = () => {
useEffect(() => {
if (states.length > 0) {
- const index = states.findIndex(state => state.abbreviation === selectedLocation);
+ const index = states.findIndex(
+ (state) => state.abbreviation === selectedLocation,
+ );
setHighlightedIndex(index >= 0 ? index : 0);
}
}, [states, selectedLocation]);
-
- const filteredStates = states.filter(state =>
- state.location_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
- state.abbreviation.toLowerCase().includes(searchTerm.toLowerCase())
+ const filteredStates = states.filter(
+ (state) =>
+ state.location_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ state.abbreviation.toLowerCase().includes(searchTerm.toLowerCase()),
);
const handleSearchChange = (e) => {
const newSearchTerm = e.currentTarget.value;
setSearchTerm(newSearchTerm);
-
+
if (newSearchTerm.length > 0 && filteredStates.length > 0) {
- setHighlightedIndex(0);
+ setHighlightedIndex(0);
} else if (newSearchTerm.length === 0) {
- const index = states.findIndex(state => state.abbreviation === selectedLocation);
- setHighlightedIndex(index >= 0 ? index : 0);
+ const index = states.findIndex(
+ (state) => state.abbreviation === selectedLocation,
+ );
+ setHighlightedIndex(index >= 0 ? index : 0);
}
};
@@ -130,38 +167,54 @@ const StateSelector = () => {
let newIndex = highlightedIndex;
- if (event.key === 'ArrowDown') {
+ if (event.key === "ArrowDown") {
event.preventDefault();
newIndex = (highlightedIndex + 1) % filteredStates.length;
- } else if (event.key === 'ArrowUp') {
+ } else if (event.key === "ArrowUp") {
event.preventDefault();
- newIndex = (highlightedIndex - 1 + filteredStates.length) % filteredStates.length;
- } else if (event.key === 'Enter') {
+ newIndex =
+ (highlightedIndex - 1 + filteredStates.length) % filteredStates.length;
+ } else if (event.key === "Enter") {
event.preventDefault();
const selectedState = filteredStates[highlightedIndex];
-
+
if (selectedState) {
handleLocationSelect(selectedState.abbreviation);
- setSearchTerm('');
- setHighlightedIndex(states.findIndex(s => s.abbreviation === selectedState.abbreviation));
+ setSearchTerm("");
+ setHighlightedIndex(
+ states.findIndex(
+ (s) => s.abbreviation === selectedState.abbreviation,
+ ),
+ );
event.currentTarget.blur();
}
return; // Exit early if Enter is pressed
}
-
+
setHighlightedIndex(newIndex);
};
if (loading) {
- return ;
+ return (
+
+
+
+ );
}
if (error) {
- return }>{error};
+ return (
+ }>
+ {error}
+
+ );
}
return (
-
+
@@ -172,14 +225,14 @@ const StateSelector = () => {
- {viewType !== 'frontpage' && (
+ {viewType !== "frontpage" && (
@@ -194,14 +247,22 @@ const StateSelector = () => {
setIntervalVisibility={setIntervalVisibility}
showLegend={showLegend}
setShowLegend={setShowLegend}
- showIntervals={viewType !== 'nhsnall'}
+ showIntervals={viewType !== "nhsnall"}
/>
)}
-
+
{
onChange={handleSearchChange}
onKeyDown={handleKeyDown}
leftSection={ }
- autoFocus
+ autoFocus
aria-label="Search locations"
/>
{filteredStates.map((state, index) => {
const isSelected = selectedLocation === state.abbreviation;
- const isKeyboardHighlighted = (searchTerm.length > 0 || index === highlightedIndex) &&
- index === highlightedIndex &&
- !isSelected;
+ const isKeyboardHighlighted =
+ (searchTerm.length > 0 || index === highlightedIndex) &&
+ index === highlightedIndex &&
+ !isSelected;
// Only apply nested styling in Metrocast view
- const isCity = viewType === 'metrocast_forecasts' && state.location_name.includes(',');
+ const isCity =
+ viewType === "metrocast_forecasts" &&
+ state.location_name.includes(",");
- let variant = 'subtle';
- let color = 'blue';
+ let variant = "subtle";
+ let color = "blue";
if (isSelected) {
- variant = 'filled';
- color = 'blue';
+ variant = "filled";
+ color = "blue";
} else if (isKeyboardHighlighted) {
- variant = 'light';
- color = 'blue';
+ variant = "light";
+ color = "blue";
}
return (
@@ -241,8 +305,12 @@ const StateSelector = () => {
color={color}
onClick={() => {
handleLocationSelect(state.abbreviation);
- setSearchTerm('');
- setHighlightedIndex(states.findIndex(s => s.abbreviation === state.abbreviation));
+ setSearchTerm("");
+ setHighlightedIndex(
+ states.findIndex(
+ (s) => s.abbreviation === state.abbreviation,
+ ),
+ );
}}
justify="start"
size="sm"
@@ -256,8 +324,8 @@ const StateSelector = () => {
styles={{
label: {
fontWeight: isCity ? 400 : 700,
- fontSize: isCity ? '13px' : '14px'
- }
+ fontSize: isCity ? "13px" : "14px",
+ },
}}
>
{state.location_name}
diff --git a/app/src/components/TargetSelector.jsx b/app/src/components/TargetSelector.jsx
index 8bfde0af..18c8801f 100644
--- a/app/src/components/TargetSelector.jsx
+++ b/app/src/components/TargetSelector.jsx
@@ -1,6 +1,6 @@
-import { Select, Stack } from '@mantine/core';
-import { useView } from '../hooks/useView';
-import { targetDisplayNameMap } from '../utils/mapUtils';
+import { Select, Stack } from "@mantine/core";
+import { useView } from "../hooks/useView";
+import { targetDisplayNameMap } from "../utils/mapUtils";
const TargetSelector = () => {
// Get the target-related state and functions from our central context
@@ -10,9 +10,9 @@ const TargetSelector = () => {
const isDisabled = !availableTargets || availableTargets.length < 1;
// Format the targets for the Select component's `data` prop
- const selectData = availableTargets.map(target => ({
+ const selectData = availableTargets.map((target) => ({
value: target,
- label: targetDisplayNameMap[target] || target
+ label: targetDisplayNameMap[target] || target,
}));
return (
@@ -31,4 +31,4 @@ const TargetSelector = () => {
);
};
-export default TargetSelector;
\ No newline at end of file
+export default TargetSelector;
diff --git a/app/src/components/TitleRow.jsx b/app/src/components/TitleRow.jsx
index 1fa67ee5..3e5333ff 100644
--- a/app/src/components/TitleRow.jsx
+++ b/app/src/components/TitleRow.jsx
@@ -1,18 +1,25 @@
-import { Box, Title } from '@mantine/core';
-import LastFetched from './LastFetched';
+import { Box, Title } from "@mantine/core";
+import LastFetched from "./LastFetched";
const TitleRow = ({ title, timestamp }) => {
if (!title && !timestamp) return null;
return (
-
+
{title && (
-
+
{title}
)}
{timestamp && (
-
+
)}
diff --git a/app/src/components/ViewSelector.jsx b/app/src/components/ViewSelector.jsx
index 7aa5e5af..af9e12a1 100644
--- a/app/src/components/ViewSelector.jsx
+++ b/app/src/components/ViewSelector.jsx
@@ -1,24 +1,25 @@
-import { useMemo } from 'react';
-import { Stack, Button, Menu, Paper } from '@mantine/core';
-import { IconChevronRight } from '@tabler/icons-react';
-import { useView } from '../hooks/useView';
-import { DATASETS, APP_CONFIG } from '../config';
+import { useMemo } from "react";
+import { Stack, Button, Menu, Paper } from "@mantine/core";
+import { IconChevronRight } from "@tabler/icons-react";
+import { useView } from "../hooks/useView";
+import { DATASETS, APP_CONFIG } from "../config";
const ViewSelector = () => {
const { viewType, setViewType } = useView();
const datasetOrder = useMemo(() => APP_CONFIG.datasetDisplayOrder, []);
const datasets = useMemo(
- () =>
- datasetOrder
- .map(key => DATASETS[key])
- .filter(Boolean),
- [datasetOrder]
+ () => datasetOrder.map((key) => DATASETS[key]).filter(Boolean),
+ [datasetOrder],
);
const getDefaultProjectionsView = (dataset) => {
- const projectionsView = dataset.views.find(view => view.key === 'projections');
- return projectionsView?.value || dataset.defaultView || dataset.views[0]?.value;
+ const projectionsView = dataset.views.find(
+ (view) => view.key === "projections",
+ );
+ return (
+ projectionsView?.value || dataset.defaultView || dataset.views[0]?.value
+ );
};
const handleDatasetSelect = (dataset) => {
@@ -33,10 +34,17 @@ const ViewSelector = () => {
};
return (
-
+
{datasets.map((dataset, index) => {
- const isActive = dataset.views.some(view => view.value === viewType);
+ const isActive = dataset.views.some(
+ (view) => view.value === viewType,
+ );
const isLast = index === datasets.length - 1;
return (
@@ -50,8 +58,8 @@ const ViewSelector = () => {
>
}
radius={0}
@@ -60,30 +68,36 @@ const ViewSelector = () => {
styles={{
root: {
height: 36,
- borderBottom: isLast ? 'none' : '1px solid var(--mantine-color-gray-3)'
+ borderBottom: isLast
+ ? "none"
+ : "1px solid var(--mantine-color-gray-3)",
},
inner: {
- width: '100%',
- justifyContent: 'space-between'
+ width: "100%",
+ justifyContent: "space-between",
},
label: {
- width: '100%',
- display: 'flex',
- justifyContent: 'space-between',
- alignItems: 'center'
- }
+ width: "100%",
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ },
}}
>
{dataset.fullName}
-
- {dataset.views.map(view => (
+
+ {dataset.views.map((view) => (
handleViewSelect(view.value)}
- color={view.value === viewType ? 'blue' : undefined}
- leftSection={view.value === viewType ? : null}
+ color={view.value === viewType ? "blue" : undefined}
+ leftSection={
+ view.value === viewType ? (
+
+ ) : null
+ }
>
{view.label}
diff --git a/app/src/components/ViewSwitchboard.jsx b/app/src/components/ViewSwitchboard.jsx
index e115fc7e..5785754f 100644
--- a/app/src/components/ViewSwitchboard.jsx
+++ b/app/src/components/ViewSwitchboard.jsx
@@ -1,11 +1,11 @@
-import { Center, Stack, Loader, Text, Alert, Button } from '@mantine/core';
-import { IconAlertTriangle, IconRefresh } from '@tabler/icons-react';
-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';
+import { Center, Stack, Loader, Text, Alert, Button } from "@mantine/core";
+import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
+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";
/**
* Component that handles rendering different data visualization types
@@ -22,10 +22,10 @@ const ViewSwitchboard = ({
selectedModels,
setSelectedModels,
windowSize,
- selectedTarget,
+ selectedTarget,
peaks,
- availablePeakDates,
- availablePeakModels
+ availablePeakDates,
+ availablePeakModels,
}) => {
// Show loading state
if (loading) {
@@ -70,17 +70,23 @@ const ViewSwitchboard = ({
const getDefaultRange = (forRangeslider = false) => {
if (!data?.ground_truth || !selectedDates.length) return undefined;
- const firstGroundTruthDate = new Date(data.ground_truth.dates?.[0] || selectedDates[0]);
- const lastGroundTruthDate = new Date(data.ground_truth.dates?.slice(-1)[0] || selectedDates[0]);
+ const firstGroundTruthDate = new Date(
+ data.ground_truth.dates?.[0] || selectedDates[0],
+ );
+ const lastGroundTruthDate = new Date(
+ data.ground_truth.dates?.slice(-1)[0] || selectedDates[0],
+ );
if (forRangeslider) {
const rangesliderEnd = new Date(lastGroundTruthDate);
- rangesliderEnd.setDate(rangesliderEnd.getDate() + (CHART_CONSTANTS.RANGESLIDER_WEEKS_AFTER * 7));
-
+ rangesliderEnd.setDate(
+ rangesliderEnd.getDate() + CHART_CONSTANTS.RANGESLIDER_WEEKS_AFTER * 7,
+ );
+
// Convert to YYYY-MM-DD strings
return [
- firstGroundTruthDate.toISOString().split('T')[0],
- rangesliderEnd.toISOString().split('T')[0]
+ firstGroundTruthDate.toISOString().split("T")[0],
+ rangesliderEnd.toISOString().split("T")[0],
];
}
@@ -90,21 +96,25 @@ const ViewSwitchboard = ({
const startDate = new Date(firstDate);
const endDate = new Date(lastDate);
- startDate.setDate(startDate.getDate() - (CHART_CONSTANTS.DEFAULT_WEEKS_BEFORE * 7));
- endDate.setDate(endDate.getDate() + (CHART_CONSTANTS.DEFAULT_WEEKS_AFTER * 7));
+ startDate.setDate(
+ startDate.getDate() - CHART_CONSTANTS.DEFAULT_WEEKS_BEFORE * 7,
+ );
+ endDate.setDate(
+ endDate.getDate() + CHART_CONSTANTS.DEFAULT_WEEKS_AFTER * 7,
+ );
// Convert to YYYY-MM-DD strings
return [
- startDate.toISOString().split('T')[0],
- endDate.toISOString().split('T')[0]
+ startDate.toISOString().split("T")[0],
+ endDate.toISOString().split("T")[0],
];
};
// Render appropriate view based on viewType
switch (viewType) {
- case 'fludetailed':
- case 'flu_forecasts':
- case 'flu_peak':
+ case "fludetailed":
+ case "flu_forecasts":
+ case "flu_peak":
return (
);
- case 'rsv_forecasts':
+ case "rsv_forecasts":
return (
);
- case 'covid_forecasts':
- return(
+ case "covid_forecasts":
+ return (
);
- case 'nhsnall':
+ case "nhsnall":
return (
);
- case 'metrocast_forecasts':
+ case "metrocast_forecasts":
return (
- Unknown View Type
+
+ Unknown View Type
+
The requested view type "{viewType}" is not supported.
diff --git a/app/src/components/controls/ForecastChartControls.jsx b/app/src/components/controls/ForecastChartControls.jsx
index 3b8f1285..0915fba5 100644
--- a/app/src/components/controls/ForecastChartControls.jsx
+++ b/app/src/components/controls/ForecastChartControls.jsx
@@ -1,15 +1,22 @@
-import { Stack, Text, SegmentedControl, Checkbox, Group, Switch } from '@mantine/core';
+import {
+ Stack,
+ Text,
+ SegmentedControl,
+ Checkbox,
+ Group,
+ Switch,
+} from "@mantine/core";
const INTERVAL_OPTIONS = [
- { value: 'median', label: 'Median' },
- { value: 'ci50', label: '50% interval' },
- { value: 'ci95', label: '95% interval' }
+ { 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' }
+ { value: "linear", label: "Linear" },
+ { value: "log", label: "Log" },
+ { value: "sqrt", label: "Sqrt" },
];
const ForecastChartControls = ({
@@ -19,24 +26,26 @@ const ForecastChartControls = ({
setIntervalVisibility,
showLegend,
setShowLegend,
- showIntervals = true
+ showIntervals = true,
}) => {
- const selectedIntervals = INTERVAL_OPTIONS
- .filter((option) => intervalVisibility?.[option.value])
- .map((option) => option.value);
+ 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')
+ median: values.includes("median"),
+ ci50: values.includes("ci50"),
+ ci95: values.includes("ci95"),
});
};
return (
- Y-scale
+
+ Y-scale
+
{showIntervals && (
- Intervals
-
+
+ Intervals
+
+
{INTERVAL_OPTIONS.map((option) => (
-
+
))}
)}
- Legend
+
+ Legend
+
setShowLegend(event.currentTarget.checked)}
diff --git a/app/src/components/forecastle/ForecastleChartCanvas.jsx b/app/src/components/forecastle/ForecastleChartCanvas.jsx
index f02debbc..5dec8f70 100644
--- a/app/src/components/forecastle/ForecastleChartCanvas.jsx
+++ b/app/src/components/forecastle/ForecastleChartCanvas.jsx
@@ -1,4 +1,4 @@
-import { memo, useEffect, useMemo, useRef, useState } from 'react';
+import { memo, useEffect, useMemo, useRef, useState } from "react";
import {
BarElement,
BarController,
@@ -12,9 +12,9 @@ import {
PointElement,
ScatterController,
Tooltip,
-} from 'chart.js';
-import { Chart } from 'react-chartjs-2';
-import { CHART_CONFIG } from '../../config';
+} from "chart.js";
+import { Chart } from "react-chartjs-2";
+import { CHART_CONFIG } from "../../config";
ChartJS.register(
CategoryScale,
@@ -32,9 +32,9 @@ ChartJS.register(
const INTERVAL95_COLOR = CHART_CONFIG.forecastleColors.interval95;
const INTERVAL50_COLOR = CHART_CONFIG.forecastleColors.interval50;
-const MEDIAN_COLOR = '#000000';
-const HANDLE_MEDIAN = '#dc143c'; // Crimson
-const HANDLE_OUTLINE = '#000000';
+const MEDIAN_COLOR = "#000000";
+const HANDLE_MEDIAN = "#dc143c"; // Crimson
+const HANDLE_OUTLINE = "#000000";
const buildLabels = (groundTruthSeries, horizonDates) => {
const observedLabels = groundTruthSeries.map((entry) => entry.date);
@@ -72,10 +72,15 @@ const ForecastleChartCanvasInner = ({
return groundTruthSeries.slice(-3);
}, [groundTruthSeries, zoomedView]);
- const labels = useMemo(() => buildLabels(visibleGroundTruth, horizonDates), [visibleGroundTruth, horizonDates]);
+ const labels = useMemo(
+ () => buildLabels(visibleGroundTruth, horizonDates),
+ [visibleGroundTruth, horizonDates],
+ );
const observedDataset = useMemo(() => {
- const valueMap = new Map(visibleGroundTruth.map((entry) => [entry.date, entry.value]));
+ const valueMap = new Map(
+ visibleGroundTruth.map((entry) => [entry.date, entry.value]),
+ );
return labels.map((label) => {
if (valueMap.has(label)) {
return { x: label, y: valueMap.get(label) ?? null };
@@ -90,7 +95,9 @@ const ForecastleChartCanvasInner = ({
() =>
horizonDates.map((date, idx) => ({
x: date,
- y: entries[idx]?.upper95 ?? ((entries[idx]?.median ?? 0) + (entries[idx]?.width95 ?? 0)),
+ y:
+ entries[idx]?.upper95 ??
+ (entries[idx]?.median ?? 0) + (entries[idx]?.width95 ?? 0),
})),
[entries, horizonDates],
);
@@ -99,7 +106,12 @@ const ForecastleChartCanvasInner = ({
() =>
horizonDates.map((date, idx) => ({
x: date,
- y: entries[idx]?.lower95 ?? Math.max(0, (entries[idx]?.median ?? 0) - (entries[idx]?.width95 ?? 0)),
+ y:
+ entries[idx]?.lower95 ??
+ Math.max(
+ 0,
+ (entries[idx]?.median ?? 0) - (entries[idx]?.width95 ?? 0),
+ ),
})),
[entries, horizonDates],
);
@@ -108,7 +120,9 @@ const ForecastleChartCanvasInner = ({
() =>
horizonDates.map((date, idx) => ({
x: date,
- y: entries[idx]?.upper50 ?? ((entries[idx]?.median ?? 0) + (entries[idx]?.width50 ?? 0)),
+ y:
+ entries[idx]?.upper50 ??
+ (entries[idx]?.median ?? 0) + (entries[idx]?.width50 ?? 0),
})),
[entries, horizonDates],
);
@@ -117,7 +131,12 @@ const ForecastleChartCanvasInner = ({
() =>
horizonDates.map((date, idx) => ({
x: date,
- y: entries[idx]?.lower50 ?? Math.max(0, (entries[idx]?.median ?? 0) - (entries[idx]?.width50 ?? 0)),
+ y:
+ entries[idx]?.lower50 ??
+ Math.max(
+ 0,
+ (entries[idx]?.median ?? 0) - (entries[idx]?.width50 ?? 0),
+ ),
})),
[entries, horizonDates],
);
@@ -148,7 +167,7 @@ const ForecastleChartCanvasInner = ({
return horizonDates.map((date, idx) => ({
x: date,
y: entries[idx]?.median ?? 0,
- meta: { index: idx, type: 'median' },
+ meta: { index: idx, type: "median" },
radius: 8,
}));
}, [entries, horizonDates]);
@@ -161,29 +180,43 @@ const ForecastleChartCanvasInner = ({
// 95% upper bound
handles.push({
x: date,
- y: entries[idx]?.upper95 ?? ((entries[idx]?.median ?? 0) + (entries[idx]?.width95 ?? 0)),
- meta: { index: idx, type: 'upper95' },
+ y:
+ entries[idx]?.upper95 ??
+ (entries[idx]?.median ?? 0) + (entries[idx]?.width95 ?? 0),
+ meta: { index: idx, type: "upper95" },
radius: 6,
});
// 95% lower bound
handles.push({
x: date,
- y: entries[idx]?.lower95 ?? Math.max(0, (entries[idx]?.median ?? 0) - (entries[idx]?.width95 ?? 0)),
- meta: { index: idx, type: 'lower95' },
+ y:
+ entries[idx]?.lower95 ??
+ Math.max(
+ 0,
+ (entries[idx]?.median ?? 0) - (entries[idx]?.width95 ?? 0),
+ ),
+ meta: { index: idx, type: "lower95" },
radius: 6,
});
// 50% upper bound
handles.push({
x: date,
- y: entries[idx]?.upper50 ?? ((entries[idx]?.median ?? 0) + (entries[idx]?.width50 ?? 0)),
- meta: { index: idx, type: 'upper50' },
+ y:
+ entries[idx]?.upper50 ??
+ (entries[idx]?.median ?? 0) + (entries[idx]?.width50 ?? 0),
+ meta: { index: idx, type: "upper50" },
radius: 5,
});
// 50% lower bound
handles.push({
x: date,
- y: entries[idx]?.lower50 ?? Math.max(0, (entries[idx]?.median ?? 0) - (entries[idx]?.width50 ?? 0)),
- meta: { index: idx, type: 'lower50' },
+ y:
+ entries[idx]?.lower50 ??
+ Math.max(
+ 0,
+ (entries[idx]?.median ?? 0) - (entries[idx]?.width50 ?? 0),
+ ),
+ meta: { index: idx, type: "lower50" },
radius: 5,
});
});
@@ -210,7 +243,9 @@ const ForecastleChartCanvasInner = ({
let groundTruthMax = 0;
if (showScoring && scores?.groundTruth) {
- groundTruthMax = Math.max(...scores.groundTruth.filter(v => v !== null));
+ groundTruthMax = Math.max(
+ ...scores.groundTruth.filter((v) => v !== null),
+ );
}
// Get the maximum of all visible values
@@ -241,9 +276,9 @@ const ForecastleChartCanvasInner = ({
if (!dataset) return null;
// Get the correct metadata based on which dataset was clicked
- if (dataset.label === 'Median Handles') {
+ if (dataset.label === "Median Handles") {
return medianHandles[activeElement.index]?.meta || null;
- } else if (dataset.label === 'Interval Handles') {
+ } else if (dataset.label === "Interval Handles") {
return intervalHandles[activeElement.index]?.meta || null;
}
@@ -264,20 +299,32 @@ const ForecastleChartCanvasInner = ({
const roundedValue = Math.round(nextValue);
// Handle different types of adjustments
- if (dragState.type === 'median') {
- onAdjust(dragState.index, 'median', roundedValue);
- } else if (dragState.type === 'upper95') {
+ if (dragState.type === "median") {
+ onAdjust(dragState.index, "median", roundedValue);
+ } else if (dragState.type === "upper95") {
const entry = entries[dragState.index];
- onAdjust(dragState.index, 'interval95', [entry.lower95, roundedValue]);
- } else if (dragState.type === 'lower95') {
+ onAdjust(dragState.index, "interval95", [
+ entry.lower95,
+ roundedValue,
+ ]);
+ } else if (dragState.type === "lower95") {
const entry = entries[dragState.index];
- onAdjust(dragState.index, 'interval95', [roundedValue, entry.upper95]);
- } else if (dragState.type === 'upper50') {
+ onAdjust(dragState.index, "interval95", [
+ roundedValue,
+ entry.upper95,
+ ]);
+ } else if (dragState.type === "upper50") {
const entry = entries[dragState.index];
- onAdjust(dragState.index, 'interval50', [entry.lower50, roundedValue]);
- } else if (dragState.type === 'lower50') {
+ onAdjust(dragState.index, "interval50", [
+ entry.lower50,
+ roundedValue,
+ ]);
+ } else if (dragState.type === "lower50") {
const entry = entries[dragState.index];
- onAdjust(dragState.index, 'interval50', [roundedValue, entry.upper50]);
+ onAdjust(dragState.index, "interval50", [
+ roundedValue,
+ entry.upper50,
+ ]);
}
});
};
@@ -288,7 +335,12 @@ const ForecastleChartCanvasInner = ({
};
const pointerDown = (event) => {
- const elements = chart.getElementsAtEventForMode(event, 'nearest', { intersect: true }, false);
+ const elements = chart.getElementsAtEventForMode(
+ event,
+ "nearest",
+ { intersect: true },
+ false,
+ );
if (!elements?.length) {
setDragState(null);
return;
@@ -304,26 +356,28 @@ const ForecastleChartCanvasInner = ({
setDragState(meta);
};
- canvas.addEventListener('pointerdown', pointerDown);
- window.addEventListener('pointermove', pointerMove);
- window.addEventListener('pointerup', pointerUp);
- window.addEventListener('pointercancel', pointerUp);
+ canvas.addEventListener("pointerdown", pointerDown);
+ window.addEventListener("pointermove", pointerMove);
+ window.addEventListener("pointerup", pointerUp);
+ window.addEventListener("pointercancel", pointerUp);
return () => {
- canvas.removeEventListener('pointerdown', pointerDown);
- window.removeEventListener('pointermove', pointerMove);
- window.removeEventListener('pointerup', pointerUp);
- window.removeEventListener('pointercancel', pointerUp);
+ canvas.removeEventListener("pointerdown", pointerDown);
+ window.removeEventListener("pointermove", pointerMove);
+ window.removeEventListener("pointerup", pointerUp);
+ window.removeEventListener("pointercancel", pointerUp);
};
}, [dragState, onAdjust, entries, medianHandles, intervalHandles]);
// Ground truth for forecast horizons (scoring mode only)
const groundTruthForecastPoints = useMemo(() => {
if (!showScoring || !scores?.groundTruth) return [];
- return horizonDates.map((date, idx) => ({
- x: date,
- y: scores.groundTruth[idx],
- })).filter(point => point.y !== null);
+ return horizonDates
+ .map((date, idx) => ({
+ x: date,
+ y: scores.groundTruth[idx],
+ }))
+ .filter((point) => point.y !== null);
}, [showScoring, scores, horizonDates]);
// Top model forecasts (scoring mode only)
@@ -331,252 +385,280 @@ const ForecastleChartCanvasInner = ({
if (!showScoring || !scores?.models || !modelForecasts) return [];
// Show top 3 models
- return scores.models.slice(0, 3).map((model, idx) => {
- const isHub = model.modelName.toLowerCase().includes('hub') ||
- model.modelName.toLowerCase().includes('ensemble');
-
- // Extract medians from model predictions
- const modelData = modelForecasts[model.modelName];
- if (!modelData?.predictions) {
- return null;
- }
+ return scores.models
+ .slice(0, 3)
+ .map((model, idx) => {
+ const isHub =
+ model.modelName.toLowerCase().includes("hub") ||
+ model.modelName.toLowerCase().includes("ensemble");
+
+ // Extract medians from model predictions
+ const modelData = modelForecasts[model.modelName];
+ if (!modelData?.predictions) {
+ return null;
+ }
- const modelMedians = horizons.map(horizon => {
- const horizonPrediction = modelData.predictions[String(horizon)];
- if (!horizonPrediction) return null;
+ const modelMedians = horizons.map((horizon) => {
+ const horizonPrediction = modelData.predictions[String(horizon)];
+ if (!horizonPrediction) return null;
- const quantiles = horizonPrediction.quantiles;
- const values = horizonPrediction.values;
+ const quantiles = horizonPrediction.quantiles;
+ const values = horizonPrediction.values;
- if (!quantiles || !values || quantiles.length !== values.length) return null;
+ if (!quantiles || !values || quantiles.length !== values.length)
+ return null;
- // Find the 0.5 quantile (median)
- const medianIndex = quantiles.findIndex(q => Math.abs(q - 0.5) < 0.001);
- if (medianIndex === -1) return null;
+ // Find the 0.5 quantile (median)
+ const medianIndex = quantiles.findIndex(
+ (q) => Math.abs(q - 0.5) < 0.001,
+ );
+ if (medianIndex === -1) return null;
- return values[medianIndex];
- });
+ return values[medianIndex];
+ });
- return {
- modelName: model.modelName,
- data: horizonDates.map((date, horizonIdx) => ({
- x: date,
- y: modelMedians[horizonIdx],
- })).filter(point => point.y !== null && Number.isFinite(point.y)),
- // Green for ensemble/hub, otherwise use other colors
- color: isHub ? 'rgba(34, 139, 34, 0.8)' : // Forest green for ensemble
- idx === 0 ? 'rgba(30, 144, 255, 0.8)' : // Blue for best
- idx === 1 ? 'rgba(255, 99, 71, 0.6)' : // Tomato for 2nd
- 'rgba(255, 165, 0, 0.6)', // Orange for 3rd
- };
- }).filter(model => model !== null);
+ return {
+ modelName: model.modelName,
+ data: horizonDates
+ .map((date, horizonIdx) => ({
+ x: date,
+ y: modelMedians[horizonIdx],
+ }))
+ .filter((point) => point.y !== null && Number.isFinite(point.y)),
+ // Green for ensemble/hub, otherwise use other colors
+ color: isHub
+ ? "rgba(34, 139, 34, 0.8)" // Forest green for ensemble
+ : idx === 0
+ ? "rgba(30, 144, 255, 0.8)" // Blue for best
+ : idx === 1
+ ? "rgba(255, 99, 71, 0.6)" // Tomato for 2nd
+ : "rgba(255, 165, 0, 0.6)", // Orange for 3rd
+ };
+ })
+ .filter((model) => model !== null);
}, [showScoring, scores, horizonDates, modelForecasts, horizons]);
- const chartData = useMemo(
- () => {
- const datasets = [
+ const chartData = useMemo(() => {
+ const datasets = [
+ {
+ type: "line",
+ label: "Observed",
+ data: observedDataset,
+ parsing: false,
+ tension: 0, // No smoothing - straight lines between points
+ spanGaps: true,
+ borderColor: MEDIAN_COLOR,
+ backgroundColor: MEDIAN_COLOR,
+ borderWidth: 2,
+ pointRadius: 4, // Show dots
+ pointHoverRadius: 6,
+ pointBackgroundColor: MEDIAN_COLOR,
+ pointBorderColor: "#ffffff",
+ pointBorderWidth: 1,
+ },
+ ];
+
+ // Only show intervals when in interval mode - use filled areas
+ if (showIntervals) {
+ datasets.push(
+ // 95% interval lower bound (invisible line, used for fill)
{
- type: 'line',
- label: 'Observed',
- data: observedDataset,
+ type: "line",
+ label: "95% lower",
+ data: interval95Lower,
parsing: false,
- tension: 0, // No smoothing - straight lines between points
- spanGaps: true,
- borderColor: MEDIAN_COLOR,
- backgroundColor: MEDIAN_COLOR,
- borderWidth: 2,
- pointRadius: 4, // Show dots
- pointHoverRadius: 6,
- pointBackgroundColor: MEDIAN_COLOR,
- pointBorderColor: '#ffffff',
- pointBorderWidth: 1,
+ tension: 0,
+ borderColor: "transparent",
+ backgroundColor: "transparent",
+ pointRadius: 0,
+ fill: false,
+ },
+ // 95% interval upper bound (fills down to previous dataset)
+ {
+ type: "line",
+ label: "95% interval",
+ data: interval95Upper,
+ parsing: false,
+ tension: 0,
+ borderColor: "transparent", // No border line
+ backgroundColor: INTERVAL95_COLOR,
+ borderWidth: 0,
+ pointRadius: 0,
+ fill: "-1", // Fill to previous dataset (95% lower)
+ },
+ // 50% interval lower bound (invisible line)
+ {
+ type: "line",
+ label: "50% lower",
+ data: interval50Lower,
+ parsing: false,
+ tension: 0,
+ borderColor: "transparent",
+ backgroundColor: "transparent",
+ pointRadius: 0,
+ fill: false,
+ },
+ // 50% interval upper bound (fills down to previous dataset)
+ {
+ type: "line",
+ label: "50% interval",
+ data: interval50Upper,
+ parsing: false,
+ tension: 0,
+ borderColor: "transparent", // No border line
+ backgroundColor: INTERVAL50_COLOR,
+ borderWidth: 0,
+ pointRadius: 0,
+ fill: "-1", // Fill to previous dataset (50% lower)
},
+ );
+ }
+
+ // In scoring mode, show ground truth as a connected line (like observed data)
+ if (showScoring && groundTruthForecastPoints.length > 0) {
+ // Combine observed data with ground truth for forecast period
+ const fullGroundTruthLine = [
+ ...observedDataset.filter((d) => d.y !== null),
+ ...groundTruthForecastPoints,
];
- // Only show intervals when in interval mode - use filled areas
- if (showIntervals) {
- datasets.push(
- // 95% interval lower bound (invisible line, used for fill)
- {
- type: 'line',
- label: '95% lower',
- data: interval95Lower,
- parsing: false,
- tension: 0,
- borderColor: 'transparent',
- backgroundColor: 'transparent',
- pointRadius: 0,
- fill: false,
- },
- // 95% interval upper bound (fills down to previous dataset)
- {
- type: 'line',
- label: '95% interval',
- data: interval95Upper,
- parsing: false,
- tension: 0,
- borderColor: 'transparent', // No border line
- backgroundColor: INTERVAL95_COLOR,
- borderWidth: 0,
- pointRadius: 0,
- fill: '-1', // Fill to previous dataset (95% lower)
- },
- // 50% interval lower bound (invisible line)
- {
- type: 'line',
- label: '50% lower',
- data: interval50Lower,
- parsing: false,
- tension: 0,
- borderColor: 'transparent',
- backgroundColor: 'transparent',
- pointRadius: 0,
- fill: false,
- },
- // 50% interval upper bound (fills down to previous dataset)
- {
- type: 'line',
- label: '50% interval',
- data: interval50Upper,
- parsing: false,
- tension: 0,
- borderColor: 'transparent', // No border line
- backgroundColor: INTERVAL50_COLOR,
- borderWidth: 0,
- pointRadius: 0,
- fill: '-1', // Fill to previous dataset (50% lower)
- }
- );
- }
+ datasets.push({
+ type: "line",
+ label: "Ground Truth",
+ data: fullGroundTruthLine,
+ parsing: false,
+ tension: 0,
+ spanGaps: false,
+ borderColor: MEDIAN_COLOR,
+ backgroundColor: MEDIAN_COLOR,
+ borderWidth: 2,
+ pointRadius: 4,
+ pointHoverRadius: 6,
+ pointBackgroundColor: MEDIAN_COLOR,
+ pointBorderColor: "#ffffff",
+ pointBorderWidth: 1,
+ });
+ }
- // In scoring mode, show ground truth as a connected line (like observed data)
- if (showScoring && groundTruthForecastPoints.length > 0) {
- // Combine observed data with ground truth for forecast period
- const fullGroundTruthLine = [...observedDataset.filter(d => d.y !== null), ...groundTruthForecastPoints];
+ // User's forecast
+ datasets.push({
+ type: "line",
+ label: showScoring ? "Your Forecast" : "Median",
+ data: medianData,
+ parsing: false,
+ tension: 0, // No smoothing - straight lines
+ borderColor: showScoring ? "#dc143c" : MEDIAN_COLOR,
+ backgroundColor: showScoring ? "#dc143c" : MEDIAN_COLOR,
+ borderWidth: showScoring ? 3 : 3,
+ pointRadius: showScoring ? 5 : 0,
+ pointBackgroundColor: showScoring ? "#dc143c" : HANDLE_MEDIAN,
+ pointBorderColor: showScoring ? "#ffffff" : "#000000",
+ pointBorderWidth: showScoring ? 1 : 2,
+ borderDash: [5, 5], // Always dashed for forecasts
+ });
+ // Top model forecasts (scoring mode only)
+ if (showScoring) {
+ topModelForecasts.forEach((model) => {
datasets.push({
- type: 'line',
- label: 'Ground Truth',
- data: fullGroundTruthLine,
+ type: "line",
+ label: model.modelName,
+ data: model.data,
parsing: false,
tension: 0,
- spanGaps: false,
- borderColor: MEDIAN_COLOR,
- backgroundColor: MEDIAN_COLOR,
+ borderColor: model.color,
+ backgroundColor: model.color,
borderWidth: 2,
pointRadius: 4,
- pointHoverRadius: 6,
- pointBackgroundColor: MEDIAN_COLOR,
- pointBorderColor: '#ffffff',
+ pointBackgroundColor: model.color,
+ pointBorderColor: "#ffffff",
pointBorderWidth: 1,
+ borderDash: [5, 5], // Dashed for all forecasts
});
- }
+ });
+ }
- // User's forecast
+ // Only show draggable handles when not in scoring mode
+ if (!showScoring) {
+ // Median handles (always visible when not scoring)
datasets.push({
- type: 'line',
- label: showScoring ? 'Your Forecast' : 'Median',
- data: medianData,
+ type: "scatter",
+ label: "Median Handles",
+ data: medianHandles,
parsing: false,
- tension: 0, // No smoothing - straight lines
- borderColor: showScoring ? '#dc143c' : MEDIAN_COLOR,
- backgroundColor: showScoring ? '#dc143c' : MEDIAN_COLOR,
- borderWidth: showScoring ? 3 : 3,
- pointRadius: showScoring ? 5 : 0,
- pointBackgroundColor: showScoring ? '#dc143c' : HANDLE_MEDIAN,
- pointBorderColor: showScoring ? '#ffffff' : '#000000',
- pointBorderWidth: showScoring ? 1 : 2,
- borderDash: [5, 5], // Always dashed for forecasts
+ pointBackgroundColor: HANDLE_MEDIAN,
+ pointBorderColor: HANDLE_OUTLINE,
+ pointBorderWidth: 2,
+ pointHoverRadius: 10,
+ pointRadius: 8,
+ showLine: false,
+ hitRadius: 15,
});
- // Top model forecasts (scoring mode only)
- if (showScoring) {
- topModelForecasts.forEach((model) => {
- datasets.push({
- type: 'line',
- label: model.modelName,
- data: model.data,
- parsing: false,
- tension: 0,
- borderColor: model.color,
- backgroundColor: model.color,
- borderWidth: 2,
- pointRadius: 4,
- pointBackgroundColor: model.color,
- pointBorderColor: '#ffffff',
- pointBorderWidth: 1,
- borderDash: [5, 5], // Dashed for all forecasts
- });
- });
- }
-
- // Only show draggable handles when not in scoring mode
- if (!showScoring) {
- // Median handles (always visible when not scoring)
+ // Interval bound handles (only in interval mode)
+ if (showIntervals && intervalHandles.length > 0) {
datasets.push({
- type: 'scatter',
- label: 'Median Handles',
- data: medianHandles,
+ type: "scatter",
+ label: "Interval Handles",
+ data: intervalHandles,
parsing: false,
- pointBackgroundColor: HANDLE_MEDIAN,
+ pointBackgroundColor: (context) => {
+ const meta = intervalHandles[context.dataIndex]?.meta;
+ if (!meta) return "rgba(220, 20, 60, 0.8)";
+ // Color-code by interval type - lighter for 95%
+ if (meta.type === "upper95" || meta.type === "lower95") {
+ return "rgba(255, 182, 193, 0.9)"; // Light pink for 95%
+ }
+ return "rgba(220, 20, 60, 0.9)"; // Crimson for 50%
+ },
pointBorderColor: HANDLE_OUTLINE,
pointBorderWidth: 2,
- pointHoverRadius: 10,
- pointRadius: 8,
+ pointHoverRadius: 8,
+ pointRadius: (context) => {
+ return intervalHandles[context.dataIndex]?.radius ?? 6;
+ },
showLine: false,
- hitRadius: 15,
+ hitRadius: 12,
});
-
- // Interval bound handles (only in interval mode)
- if (showIntervals && intervalHandles.length > 0) {
- datasets.push({
- type: 'scatter',
- label: 'Interval Handles',
- data: intervalHandles,
- parsing: false,
- pointBackgroundColor: (context) => {
- const meta = intervalHandles[context.dataIndex]?.meta;
- if (!meta) return 'rgba(220, 20, 60, 0.8)';
- // Color-code by interval type - lighter for 95%
- if (meta.type === 'upper95' || meta.type === 'lower95') {
- return 'rgba(255, 182, 193, 0.9)'; // Light pink for 95%
- }
- return 'rgba(220, 20, 60, 0.9)'; // Crimson for 50%
- },
- pointBorderColor: HANDLE_OUTLINE,
- pointBorderWidth: 2,
- pointHoverRadius: 8,
- pointRadius: (context) => {
- return intervalHandles[context.dataIndex]?.radius ?? 6;
- },
- showLine: false,
- hitRadius: 12,
- });
- }
}
+ }
- return { datasets, labels };
- },
- [medianHandles, intervalHandles, interval50Upper, interval50Lower, interval95Upper, interval95Lower, medianData, labels, observedDataset, showIntervals, showScoring, groundTruthForecastPoints, topModelForecasts],
- );
+ return { datasets, labels };
+ }, [
+ medianHandles,
+ intervalHandles,
+ interval50Upper,
+ interval50Lower,
+ interval95Upper,
+ interval95Lower,
+ medianData,
+ labels,
+ observedDataset,
+ showIntervals,
+ showScoring,
+ groundTruthForecastPoints,
+ topModelForecasts,
+ ]);
const options = useMemo(
() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
- mode: 'nearest',
+ mode: "nearest",
intersect: true,
},
plugins: {
legend: {
display: showScoring,
- position: 'bottom',
+ position: "bottom",
labels: {
filter: (legendItem) => {
// Hide helper datasets from legend
- return !legendItem.text.includes('lower') &&
- !legendItem.text.includes('Handles');
+ return (
+ !legendItem.text.includes("lower") &&
+ !legendItem.text.includes("Handles")
+ );
},
usePointStyle: true,
padding: 12,
@@ -588,18 +670,21 @@ const ForecastleChartCanvasInner = ({
tooltip: {
callbacks: {
label: (context) => {
- const datasetLabel = context.dataset.label || '';
- if (datasetLabel === 'Observed') {
- return `${datasetLabel}: ${context.parsed.y?.toLocaleString('en-US') ?? '—'}`;
+ const datasetLabel = context.dataset.label || "";
+ if (datasetLabel === "Observed") {
+ return `${datasetLabel}: ${context.parsed.y?.toLocaleString("en-US") ?? "—"}`;
}
- if (datasetLabel.includes('interval')) {
+ if (datasetLabel.includes("interval")) {
if (Array.isArray(context.raw?.y)) {
const [lower, upper] = context.raw.y;
return `${datasetLabel}: ${Math.round(lower)} – ${Math.round(upper)}`;
}
return datasetLabel;
}
- if (datasetLabel === 'Median' || datasetLabel === 'Median Handles') {
+ if (
+ datasetLabel === "Median" ||
+ datasetLabel === "Median Handles"
+ ) {
return `Median: ${Math.round(context.parsed.y)}`;
}
return datasetLabel;
@@ -609,9 +694,9 @@ const ForecastleChartCanvasInner = ({
},
scales: {
x: {
- type: 'category',
+ type: "category",
ticks: {
- color: '#000000',
+ color: "#000000",
autoSkip: true,
maxRotation: 45,
minRotation: 45,
@@ -624,11 +709,11 @@ const ForecastleChartCanvasInner = ({
beginAtZero: true,
suggestedMax: dynamicMax,
ticks: {
- color: '#000000',
- callback: (value) => Math.round(value).toLocaleString('en-US'),
+ color: "#000000",
+ callback: (value) => Math.round(value).toLocaleString("en-US"),
},
grid: {
- color: 'rgba(0, 0, 0, 0.08)',
+ color: "rgba(0, 0, 0, 0.08)",
},
},
},
@@ -645,13 +730,13 @@ const ForecastleChartCanvasInner = ({
return (
@@ -660,6 +745,6 @@ const ForecastleChartCanvasInner = ({
};
const ForecastleChartCanvas = memo(ForecastleChartCanvasInner);
-ForecastleChartCanvas.displayName = 'ForecastleChartCanvas';
+ForecastleChartCanvas.displayName = "ForecastleChartCanvas";
export default ForecastleChartCanvas;
diff --git a/app/src/components/forecastle/ForecastleGame.jsx b/app/src/components/forecastle/ForecastleGame.jsx
index 602d9423..b806811e 100644
--- a/app/src/components/forecastle/ForecastleGame.jsx
+++ b/app/src/components/forecastle/ForecastleGame.jsx
@@ -1,6 +1,6 @@
-import { useEffect, useMemo, useState } from 'react';
-import { useSearchParams } from 'react-router-dom';
-import { Helmet } from 'react-helmet-async';
+import { useEffect, useMemo, useState } from "react";
+import { useSearchParams } from "react-router-dom";
+import { Helmet } from "react-helmet-async";
import {
Alert,
Badge,
@@ -21,22 +21,36 @@ import {
Title,
ActionIcon,
Tooltip,
-} from '@mantine/core';
-import { IconAlertTriangle, IconTarget, IconTrophy, IconCopy, IconCheck, IconChartBar, IconRefresh } from '@tabler/icons-react';
-import { useForecastleScenario } from '../../hooks/useForecastleScenario';
-import { initialiseForecastInputs, convertToIntervals } from '../../utils/forecastleInputs';
-import { validateForecastSubmission } from '../../utils/forecastleValidation';
-import { FORECASTLE_CONFIG } from '../../config';
+} from "@mantine/core";
+import {
+ IconAlertTriangle,
+ IconTarget,
+ IconTrophy,
+ IconCopy,
+ IconCheck,
+ IconChartBar,
+ IconRefresh,
+} from "@tabler/icons-react";
+import { useForecastleScenario } from "../../hooks/useForecastleScenario";
+import {
+ initialiseForecastInputs,
+ convertToIntervals,
+} from "../../utils/forecastleInputs";
+import { validateForecastSubmission } from "../../utils/forecastleValidation";
+import { FORECASTLE_CONFIG } from "../../config";
import {
extractGroundTruthForHorizons,
scoreUserForecast,
scoreModels,
getOfficialModels,
-} from '../../utils/forecastleScoring';
-import { saveForecastleGame, getForecastleGame } from '../../utils/respilensStorage';
-import ForecastleChartCanvas from './ForecastleChartCanvas';
-import ForecastleInputControls from './ForecastleInputControls';
-import ForecastleStatsModal from './ForecastleStatsModal';
+} from "../../utils/forecastleScoring";
+import {
+ saveForecastleGame,
+ getForecastleGame,
+} from "../../utils/respilensStorage";
+import ForecastleChartCanvas from "./ForecastleChartCanvas";
+import ForecastleInputControls from "./ForecastleInputControls";
+import ForecastleStatsModal from "./ForecastleStatsModal";
const addWeeksToDate = (dateString, weeks) => {
const base = new Date(`${dateString}T00:00:00Z`);
@@ -51,15 +65,20 @@ const ForecastleGame = () => {
const [searchParams] = useSearchParams();
// Get play_date from URL parameter (secret feature for populating history)
- const playDate = searchParams.get('play_date') || null;
+ const playDate = searchParams.get("play_date") || null;
const { scenarios, loading, error } = useForecastleScenario(playDate);
const [currentChallengeIndex, setCurrentChallengeIndex] = useState(0);
const [completedChallenges, setCompletedChallenges] = useState(new Set()); // Track which challenges are completed
const scenario = scenarios[currentChallengeIndex] || null;
- const isCurrentChallengeCompleted = completedChallenges.has(currentChallengeIndex);
- const allChallengesCompleted = scenarios.length > 0 && completedChallenges.size === scenarios.length && !playDate;
+ const isCurrentChallengeCompleted = completedChallenges.has(
+ currentChallengeIndex,
+ );
+ const allChallengesCompleted =
+ scenarios.length > 0 &&
+ completedChallenges.size === scenarios.length &&
+ !playDate;
const latestObservationValue = useMemo(() => {
const series = scenario?.groundTruthSeries;
@@ -69,14 +88,18 @@ const ForecastleGame = () => {
}, [scenario?.groundTruthSeries]);
const initialInputs = useMemo(
- () => initialiseForecastInputs(scenario?.horizons || [], latestObservationValue),
+ () =>
+ initialiseForecastInputs(
+ scenario?.horizons || [],
+ latestObservationValue,
+ ),
[scenario?.horizons, latestObservationValue],
);
const [forecastEntries, setForecastEntries] = useState(initialInputs);
const [submissionErrors, setSubmissionErrors] = useState({});
const [submittedPayload, setSubmittedPayload] = useState(null);
const [scores, setScores] = useState(null);
- const [inputMode, setInputMode] = useState('median'); // 'median', 'intervals', or 'scoring'
+ const [inputMode, setInputMode] = useState("median"); // 'median', 'intervals', or 'scoring'
const [zoomedView, setZoomedView] = useState(true); // Start with zoomed view for easier input
const [visibleRankings, setVisibleRankings] = useState(0); // For animated reveal
const [copied, setCopied] = useState(false); // For copy button feedback
@@ -103,7 +126,7 @@ const ForecastleGame = () => {
setSubmissionErrors({});
setSubmittedPayload(null);
setScores(null);
- setInputMode('median');
+ setInputMode("median");
setVisibleRankings(0);
// If this challenge is already completed, load the saved data and show scoring
@@ -118,19 +141,22 @@ const ForecastleGame = () => {
// Recalculate scores
const horizonDates = scenario.horizons.map((horizon) =>
- addWeeksToDate(scenario.forecastDate, horizon)
+ addWeeksToDate(scenario.forecastDate, horizon),
);
const groundTruthValues = extractGroundTruthForHorizons(
scenario.fullGroundTruthSeries,
- horizonDates
+ horizonDates,
);
- const userScore = scoreUserForecast(savedGame.userForecasts, groundTruthValues);
+ const userScore = scoreUserForecast(
+ savedGame.userForecasts,
+ groundTruthValues,
+ );
const modelScores = scoreModels(
scenario.modelForecasts || {},
scenario.horizons,
- groundTruthValues
+ groundTruthValues,
);
setScores({
@@ -142,23 +168,34 @@ const ForecastleGame = () => {
// Show scoring immediately only if not using play_date
if (!playDate) {
- setInputMode('scoring');
+ setInputMode("scoring");
}
return; // Don't initialize with default values
}
}
// Only initialize with default values if no saved game was loaded
- setForecastEntries(initialiseForecastInputs(scenario?.horizons || [], latestObservationValue));
- }, [scenario?.horizons, latestObservationValue, isCurrentChallengeCompleted, scenario, playDate]);
+ setForecastEntries(
+ initialiseForecastInputs(
+ scenario?.horizons || [],
+ latestObservationValue,
+ ),
+ );
+ }, [
+ scenario?.horizons,
+ latestObservationValue,
+ isCurrentChallengeCompleted,
+ scenario,
+ playDate,
+ ]);
// Animated reveal of leaderboard when entering scoring mode
useEffect(() => {
- if (inputMode === 'scoring' && scores) {
+ if (inputMode === "scoring" && scores) {
setVisibleRankings(0);
const totalEntries = scores.models.length + 1; // models + user
const interval = setInterval(() => {
- setVisibleRankings(prev => {
+ setVisibleRankings((prev) => {
if (prev >= totalEntries) {
clearInterval(interval);
return prev;
@@ -176,51 +213,64 @@ const ForecastleGame = () => {
const id = `${scenario.challengeDate}_${scenario.forecastDate}_${scenario.dataset.key}_${scenario.location.abbreviation}_${scenario.dataset.targetKey}`;
const existingGame = getForecastleGame(id);
if (existingGame) {
- setSubmissionErrors({ general: 'This forecast has already been submitted. You cannot resubmit when using play_date.' });
+ setSubmissionErrors({
+ general:
+ "This forecast has already been submitted. You cannot resubmit when using play_date.",
+ });
return;
}
}
// Validate that forecastEntries is properly populated
if (!forecastEntries || forecastEntries.length === 0) {
- console.error('No forecast entries to submit');
+ console.error("No forecast entries to submit");
return;
}
// Check if all entries have valid median values
- const hasInvalidEntries = forecastEntries.some(entry =>
- !entry || entry.median === null || entry.median === undefined || !Number.isFinite(entry.median)
+ const hasInvalidEntries = forecastEntries.some(
+ (entry) =>
+ !entry ||
+ entry.median === null ||
+ entry.median === undefined ||
+ !Number.isFinite(entry.median),
);
if (hasInvalidEntries) {
- console.error('Some forecast entries have invalid median values');
- setSubmissionErrors({ general: 'Invalid forecast data. Please reset and try again.' });
+ console.error("Some forecast entries have invalid median values");
+ setSubmissionErrors({
+ general: "Invalid forecast data. Please reset and try again.",
+ });
return;
}
// Convert to intervals for validation
const intervalsForValidation = convertToIntervals(forecastEntries);
- const { valid, errors } = validateForecastSubmission(intervalsForValidation);
+ const { valid, errors } = validateForecastSubmission(
+ intervalsForValidation,
+ );
setSubmissionErrors(errors);
if (!valid) {
setSubmittedPayload(null);
return;
}
- const payload = intervalsForValidation.map(({ horizon, interval50, interval95 }) => ({
- horizon,
- interval50: [interval50.lower, interval50.upper],
- interval95: [interval95.lower, interval95.upper],
- }));
+ const payload = intervalsForValidation.map(
+ ({ horizon, interval50, interval95 }) => ({
+ horizon,
+ interval50: [interval50.lower, interval50.upper],
+ interval95: [interval95.lower, interval95.upper],
+ }),
+ );
setSubmittedPayload({ submittedAt: new Date().toISOString(), payload });
// Calculate scores if ground truth is available
if (scenario?.fullGroundTruthSeries) {
const horizonDates = scenario.horizons.map((horizon) =>
- addWeeksToDate(scenario.forecastDate, horizon)
+ addWeeksToDate(scenario.forecastDate, horizon),
);
const groundTruthValues = extractGroundTruthForHorizons(
scenario.fullGroundTruthSeries,
- horizonDates
+ horizonDates,
);
// Score user forecast
@@ -230,7 +280,7 @@ const ForecastleGame = () => {
const modelScores = scoreModels(
scenario.modelForecasts || {},
scenario.horizons,
- groundTruthValues
+ groundTruthValues,
);
setScores({
@@ -241,21 +291,34 @@ const ForecastleGame = () => {
});
// Calculate ranking information
- const { ensemble: ensembleKey, baseline: baselineKey } = getOfficialModels(scenario.dataset.key);
+ const { ensemble: ensembleKey, baseline: baselineKey } =
+ getOfficialModels(scenario.dataset.key);
// Find ensemble and baseline in the model scores
- const ensembleScore = modelScores.find(m => m.modelName === ensembleKey);
- const baselineScore = modelScores.find(m => m.modelName === baselineKey);
+ const ensembleScore = modelScores.find(
+ (m) => m.modelName === ensembleKey,
+ );
+ const baselineScore = modelScores.find(
+ (m) => m.modelName === baselineKey,
+ );
// Create unified ranking list
const allRanked = [
- { name: 'user', wis: userScore.wis, isUser: true },
- ...modelScores.map(m => ({ name: m.modelName, wis: m.wis, isUser: false }))
+ { name: "user", wis: userScore.wis, isUser: true },
+ ...modelScores.map((m) => ({
+ name: m.modelName,
+ wis: m.wis,
+ isUser: false,
+ })),
].sort((a, b) => a.wis - b.wis);
- const userRank = allRanked.findIndex(e => e.isUser) + 1;
- const ensembleRank = ensembleScore ? allRanked.findIndex(e => e.name === ensembleKey) + 1 : null;
- const baselineRank = baselineScore ? allRanked.findIndex(e => e.name === baselineKey) + 1 : null;
+ const userRank = allRanked.findIndex((e) => e.isUser) + 1;
+ const ensembleRank = ensembleScore
+ ? allRanked.findIndex((e) => e.name === ensembleKey) + 1
+ : null;
+ const baselineRank = baselineScore
+ ? allRanked.findIndex((e) => e.name === baselineKey) + 1
+ : null;
const totalModels = modelScores.length;
// Save game to storage
@@ -292,18 +355,20 @@ const ForecastleGame = () => {
});
setSaveError(null);
// Mark this challenge as completed
- setCompletedChallenges(prev => new Set([...prev, currentChallengeIndex]));
+ setCompletedChallenges(
+ (prev) => new Set([...prev, currentChallengeIndex]),
+ );
} catch (error) {
- console.error('Failed to save game:', error);
- setSaveError(error.message || 'Failed to save game to storage');
+ console.error("Failed to save game:", error);
+ setSaveError(error.message || "Failed to save game to storage");
}
}
};
const handleNextChallenge = () => {
if (currentChallengeIndex < scenarios.length - 1) {
- setCurrentChallengeIndex(prev => prev + 1);
- setInputMode('median');
+ setCurrentChallengeIndex((prev) => prev + 1);
+ setInputMode("median");
setSubmittedPayload(null);
setScores(null);
setSubmissionErrors({});
@@ -314,16 +379,23 @@ const ForecastleGame = () => {
};
const handleResetMedians = () => {
- setForecastEntries(initialiseForecastInputs(scenario?.horizons || [], latestObservationValue));
+ setForecastEntries(
+ initialiseForecastInputs(
+ scenario?.horizons || [],
+ latestObservationValue,
+ ),
+ );
setSubmissionErrors({});
};
const handleResetIntervals = () => {
- const resetEntries = forecastEntries.map(entry => {
+ const resetEntries = forecastEntries.map((entry) => {
const median = entry.median;
// Reset to default symmetric intervals
- const width95 = median * FORECASTLE_CONFIG.defaultIntervals.width95Percent;
- const width50 = median * FORECASTLE_CONFIG.defaultIntervals.width50Percent;
+ const width95 =
+ median * FORECASTLE_CONFIG.defaultIntervals.width95Percent;
+ const width50 =
+ median * FORECASTLE_CONFIG.defaultIntervals.width50Percent;
return {
...entry,
lower95: Math.max(0, median - width95),
@@ -341,7 +413,7 @@ const ForecastleGame = () => {
const renderContent = () => {
if (loading) {
return (
-
+
);
@@ -349,7 +421,11 @@ const ForecastleGame = () => {
if (error) {
return (
- } title="Unable to load Forecastle" color="red">
+ }
+ title="Unable to load Forecastle"
+ color="red"
+ >
{error.message}
);
@@ -357,23 +433,38 @@ const ForecastleGame = () => {
if (!scenario) {
return (
- } title="No challenge available" color="yellow">
+ }
+ title="No challenge available"
+ color="yellow"
+ >
Please check back later for the next Forecastle challenge.
);
}
// const latestObservation =
- // scenario.groundTruthSeries[scenario.groundTruthSeries.length - 1] ?? null; // remove unused var!!
- const latestValue = Number.isFinite(latestObservationValue) ? latestObservationValue : 0;
+ // scenario.groundTruthSeries[scenario.groundTruthSeries.length - 1] ?? null; // remove unused var!!
+ const latestValue = Number.isFinite(latestObservationValue)
+ ? latestObservationValue
+ : 0;
const baseMax = latestValue > 0 ? latestValue * 5 : 1;
const userMaxCandidate = Math.max(
- ...forecastEntries.map((entry) => (entry.median ?? 0) + (entry.width95 ?? 0)),
+ ...forecastEntries.map(
+ (entry) => (entry.median ?? 0) + (entry.width95 ?? 0),
+ ),
0,
);
- const yAxisMax = Math.max(baseMax, userMaxCandidate * 1.1 || 0, latestObservationValue, 1);
+ const yAxisMax = Math.max(
+ baseMax,
+ userMaxCandidate * 1.1 || 0,
+ latestObservationValue,
+ 1,
+ );
- const horizonDates = scenario.horizons.map((horizon) => addWeeksToDate(scenario.forecastDate, horizon));
+ const horizonDates = scenario.horizons.map((horizon) =>
+ addWeeksToDate(scenario.forecastDate, horizon),
+ );
const handleMedianAdjust = (index, field, value) => {
setForecastEntries((prevEntries) =>
@@ -382,7 +473,7 @@ const ForecastleGame = () => {
const nextEntry = { ...entry };
- if (field === 'median') {
+ if (field === "median") {
const oldMedian = entry.median;
const newMedian = Math.max(0, value);
const medianShift = newMedian - oldMedian;
@@ -398,23 +489,34 @@ const ForecastleGame = () => {
nextEntry.lower50 = Math.max(0, entry.lower50 + medianShift);
nextEntry.upper50 = entry.upper50 + medianShift;
}
- } else if (field === 'interval95') {
+ } else if (field === "interval95") {
// Handle two-point interval adjustment
const [lower, upper] = value;
nextEntry.lower95 = Math.max(0, lower);
nextEntry.upper95 = Math.max(lower, upper);
// Ensure 50% interval stays within 95% bounds
- if (nextEntry.lower50 < nextEntry.lower95) nextEntry.lower50 = nextEntry.lower95;
- if (nextEntry.upper50 > nextEntry.upper95) nextEntry.upper50 = nextEntry.upper95;
+ if (nextEntry.lower50 < nextEntry.lower95)
+ nextEntry.lower50 = nextEntry.lower95;
+ if (nextEntry.upper50 > nextEntry.upper95)
+ nextEntry.upper50 = nextEntry.upper95;
// Update widths for backward compatibility
- nextEntry.width95 = Math.max(nextEntry.upper95 - entry.median, entry.median - nextEntry.lower95);
- } else if (field === 'interval50') {
+ nextEntry.width95 = Math.max(
+ nextEntry.upper95 - entry.median,
+ entry.median - nextEntry.lower95,
+ );
+ } else if (field === "interval50") {
// Handle two-point interval adjustment
const [lower, upper] = value;
nextEntry.lower50 = Math.max(nextEntry.lower95 || 0, lower);
- nextEntry.upper50 = Math.min(nextEntry.upper95 || 99999, Math.max(lower, upper));
+ nextEntry.upper50 = Math.min(
+ nextEntry.upper95 || 99999,
+ Math.max(lower, upper),
+ );
// Update widths for backward compatibility
- nextEntry.width50 = Math.max(nextEntry.upper50 - entry.median, entry.median - nextEntry.lower50);
+ nextEntry.width50 = Math.max(
+ nextEntry.upper50 - entry.median,
+ entry.median - nextEntry.lower50,
+ );
} else {
// Legacy field support
nextEntry[field] = Math.max(0, value);
@@ -452,20 +554,36 @@ const ForecastleGame = () => {
{scenarios.map((_, index) => (
{
setCurrentChallengeIndex(index);
- setInputMode('median');
+ setInputMode("median");
setSubmittedPayload(null);
setScores(null);
setSubmissionErrors({});
@@ -477,7 +595,9 @@ const ForecastleGame = () => {
{completedChallenges.has(index) ? (
) : (
- {index + 1}
+
+ {index + 1}
+
)}
@@ -499,9 +619,14 @@ const ForecastleGame = () => {
{/* All Challenges Complete Message */}
{allChallengesCompleted && (
-
+
- You've completed all {scenarios.length} challenges for today. Come back tomorrow for new challenges!
+ You've completed all {scenarios.length} challenges for today.
+ Come back tomorrow for new challenges!
)}
@@ -510,7 +635,9 @@ const ForecastleGame = () => {
{scenarios.length > 0 && !allChallengesCompleted && (
- Inspired by wordle, make predictions on up to three challenges everyday. Each challenge are score against models, and results and statistics are stored locally in your browser. Good luck!
+ Inspired by wordle, make predictions on up to three challenges
+ everyday. Each challenge are score against models, and results
+ and statistics are stored locally in your browser. Good luck!
@@ -520,13 +647,14 @@ const ForecastleGame = () => {
Predict
- {scenario?.dataset?.label || 'hospitalization'}
+ {scenario?.dataset?.label || "hospitalization"}
in
- {scenario?.location?.name} ({scenario?.location?.abbreviation})
+ {scenario?.location?.name} (
+ {scenario?.location?.abbreviation})
at
@@ -542,11 +670,13 @@ const ForecastleGame = () => {
{
- if (step === 0) setInputMode('median');
- else if (step === 1) setInputMode('intervals');
- else if (step === 2 && scores) setInputMode('scoring');
+ if (step === 0) setInputMode("median");
+ else if (step === 1) setInputMode("intervals");
+ else if (step === 2 && scores) setInputMode("scoring");
}}
allowNextStepsSelect={false}
size="sm"
@@ -568,20 +698,26 @@ const ForecastleGame = () => {
completedIcon={ }
/>
-
- {inputMode === 'scoring' && scores ? (
+ {inputMode === "scoring" && scores ? (
{saveError && (
- } color="yellow" onClose={() => setSaveError(null)} withCloseButton>
+ }
+ color="yellow"
+ onClose={() => setSaveError(null)}
+ withCloseButton
+ >
{saveError}
)}
{scores.user.wis !== null ? (
<>
- Based on {scores.user.validCount} of {scores.user.totalHorizons} horizons with available ground truth
+ Based on {scores.user.validCount} of{" "}
+ {scores.user.totalHorizons} horizons with available ground
+ truth
@@ -593,25 +729,29 @@ const ForecastleGame = () => {
{(() => {
// Get official ensemble model for this dataset
- const { ensemble: ensembleKey } = getOfficialModels(scenario.dataset.key);
+ const { ensemble: ensembleKey } =
+ getOfficialModels(scenario.dataset.key);
// Create unified leaderboard with user and models
const allEntries = [
{
- name: 'You',
+ name: "You",
wis: scores.user.wis,
isUser: true,
},
- ...scores.models.map(m => ({
+ ...scores.models.map((m) => ({
name: m.modelName,
wis: m.wis,
isUser: false,
isHub: m.modelName === ensembleKey,
- }))
+ })),
].sort((a, b) => a.wis - b.wis);
- const userRank = allEntries.findIndex(e => e.isUser) + 1;
- const hubRankIdx = allEntries.findIndex(e => e.isHub);
+ const userRank =
+ allEntries.findIndex((e) => e.isUser) + 1;
+ const hubRankIdx = allEntries.findIndex(
+ (e) => e.isHub,
+ );
const totalEntries = allEntries.length;
// Smart filtering: always show first place, consensus, and user
@@ -620,13 +760,18 @@ const ForecastleGame = () => {
// If all entries fit, show them all
if (allEntries.length <= maxDisplay) {
- return allEntries.map((entry, idx) => ({ entry, actualRank: idx + 1, isEllipsis: false }));
+ return allEntries.map((entry, idx) => ({
+ entry,
+ actualRank: idx + 1,
+ isEllipsis: false,
+ }));
}
// Track which indices to include
const mustInclude = new Set();
mustInclude.add(0); // First place
- if (hubRankIdx >= 0) mustInclude.add(hubRankIdx); // Consensus
+ if (hubRankIdx >= 0)
+ mustInclude.add(hubRankIdx); // Consensus
mustInclude.add(userRank - 1); // User (convert to 0-indexed)
// Include top 3 for medal display
@@ -635,12 +780,20 @@ const ForecastleGame = () => {
// Add entries around user and consensus for context (±1)
if (userRank > 1) mustInclude.add(userRank - 2);
- if (userRank < allEntries.length) mustInclude.add(userRank);
- if (hubRankIdx > 0) mustInclude.add(hubRankIdx - 1);
- if (hubRankIdx >= 0 && hubRankIdx < allEntries.length - 1) mustInclude.add(hubRankIdx + 1);
+ if (userRank < allEntries.length)
+ mustInclude.add(userRank);
+ if (hubRankIdx > 0)
+ mustInclude.add(hubRankIdx - 1);
+ if (
+ hubRankIdx >= 0 &&
+ hubRankIdx < allEntries.length - 1
+ )
+ mustInclude.add(hubRankIdx + 1);
// Sort the indices
- const sortedIndices = Array.from(mustInclude).sort((a, b) => a - b);
+ const sortedIndices = Array.from(
+ mustInclude,
+ ).sort((a, b) => a - b);
// Build display list with ellipsis indicators
const displayList = [];
@@ -675,7 +828,8 @@ const ForecastleGame = () => {
return (
<>
{displayEntries.map((item, displayIdx) => {
- if (displayIdx >= visibleRankings) return null;
+ if (displayIdx >= visibleRankings)
+ return null;
// Render ellipsis indicator
if (item.isEllipsis) {
@@ -685,15 +839,26 @@ const ForecastleGame = () => {
p="xs"
withBorder
style={{
- backgroundColor: '#f8f9fa',
- borderStyle: 'dashed',
+ backgroundColor: "#f8f9fa",
+ borderStyle: "dashed",
transform: `translateY(${visibleRankings > displayIdx ? 0 : 20}px)`,
- opacity: visibleRankings > displayIdx ? 1 : 0,
- transition: 'all 0.3s ease-out',
+ opacity:
+ visibleRankings > displayIdx
+ ? 1
+ : 0,
+ transition: "all 0.3s ease-out",
}}
>
-
- ⋯ {item.skippedCount} model{item.skippedCount !== 1 ? 's' : ''} hidden ⋯
+
+ ⋯ {item.skippedCount} model
+ {item.skippedCount !== 1
+ ? "s"
+ : ""}{" "}
+ hidden ⋯
);
@@ -711,43 +876,86 @@ const ForecastleGame = () => {
withBorder
style={{
backgroundColor: entry.isUser
- ? '#ffe0e6'
+ ? "#ffe0e6"
: entry.isHub
- ? '#e8f5e9'
- : undefined,
+ ? "#e8f5e9"
+ : undefined,
borderColor: entry.isUser
- ? '#dc143c'
+ ? "#dc143c"
: entry.isHub
- ? '#228b22'
- : undefined,
- borderWidth: entry.isUser || entry.isHub ? 2 : 1,
+ ? "#228b22"
+ : undefined,
+ borderWidth:
+ entry.isUser || entry.isHub ? 2 : 1,
transform: `translateY(${visibleRankings > displayIdx ? 0 : 20}px)`,
- opacity: visibleRankings > displayIdx ? 1 : 0,
- transition: 'all 0.3s ease-out',
+ opacity:
+ visibleRankings > displayIdx
+ ? 1
+ : 0,
+ transition: "all 0.3s ease-out",
}}
>
-
+
-
- {idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : `#${actualRank}`}
+
+ {idx === 0
+ ? "🥇"
+ : idx === 1
+ ? "🥈"
+ : idx === 2
+ ? "🥉"
+ : `#${actualRank}`}
-
+
{entry.name}
- {entry.isUser && ' 👤'}
- {entry.isHub && ' 🏆'}
+ {entry.isUser && " 👤"}
+ {entry.isHub && " 🏆"}
{entry.isUser && (
- Rank {userRank} of {totalEntries}
+ Rank {userRank} of{" "}
+ {totalEntries}
)}
WIS: {entry.wis.toFixed(3)}
@@ -757,7 +965,12 @@ const ForecastleGame = () => {
})}
{allEntries.length > 15 && (
- {displayEntries.filter(e => !e.isEllipsis).length} of {allEntries.length} entries shown
+ {
+ displayEntries.filter(
+ (e) => !e.isEllipsis,
+ ).length
+ }{" "}
+ of {allEntries.length} entries shown
)}
>
@@ -775,7 +988,9 @@ const ForecastleGame = () => {
setZoomedView(!event.currentTarget.checked)}
+ onChange={(event) =>
+ setZoomedView(!event.currentTarget.checked)
+ }
color="red"
size="md"
/>
@@ -784,36 +999,45 @@ const ForecastleGame = () => {
{/* Shareable Ranking Summary Card */}
{(() => {
// Get official ensemble model for this dataset
- const { ensemble: ensembleKey } = getOfficialModels(scenario.dataset.key);
+ const { ensemble: ensembleKey } = getOfficialModels(
+ scenario.dataset.key,
+ );
const allEntries = [
- { name: 'You', wis: scores.user.wis, isUser: true },
- ...scores.models.map(m => ({
+ {
+ name: "You",
+ wis: scores.user.wis,
+ isUser: true,
+ },
+ ...scores.models.map((m) => ({
name: m.modelName,
wis: m.wis,
isUser: false,
isHub: m.modelName === ensembleKey,
- }))
+ })),
].sort((a, b) => a.wis - b.wis);
- const userRank = allEntries.findIndex(e => e.isUser) + 1;
+ const userRank =
+ allEntries.findIndex((e) => e.isUser) + 1;
const totalModels = scores.models.length;
- const hubEntry = allEntries.find(e => e.isHub);
- const hubRank = hubEntry ? allEntries.findIndex(e => e.isHub) + 1 : null;
+ const hubEntry = allEntries.find((e) => e.isHub);
+ const hubRank = hubEntry
+ ? allEntries.findIndex((e) => e.isHub) + 1
+ : null;
- let comparisonText = '';
- let emojiIndicator = '';
+ let comparisonText = "";
+ let emojiIndicator = "";
if (hubRank !== null) {
const spotsDiff = Math.abs(userRank - hubRank);
if (userRank < hubRank) {
- comparisonText = `${spotsDiff} spot${spotsDiff !== 1 ? 's' : ''} above the ensemble`;
- emojiIndicator = '🟢';
+ comparisonText = `${spotsDiff} spot${spotsDiff !== 1 ? "s" : ""} above the ensemble`;
+ emojiIndicator = "🟢";
} else if (userRank > hubRank) {
- comparisonText = `${spotsDiff} spot${spotsDiff !== 1 ? 's' : ''} below the ensemble`;
- emojiIndicator = '🔴';
+ comparisonText = `${spotsDiff} spot${spotsDiff !== 1 ? "s" : ""} below the ensemble`;
+ emojiIndicator = "🔴";
} else {
- comparisonText = 'tied with the ensemble';
- emojiIndicator = '🟡';
+ comparisonText = "tied with the ensemble";
+ emojiIndicator = "🟡";
}
}
@@ -821,15 +1045,18 @@ const ForecastleGame = () => {
const generateEmojiSummary = () => {
const topN = 15;
const displayEntries = allEntries.slice(0, topN);
- const emojis = displayEntries.map(entry => {
- if (entry.isUser) return '🟩'; // User in green
- if (entry.isHub) return '🟦'; // Hub in blue
- return '⬜'; // Other models in gray
+ const emojis = displayEntries.map((entry) => {
+ if (entry.isUser) return "🟩"; // User in green
+ if (entry.isHub) return "🟦"; // Hub in blue
+ return "⬜"; // Other models in gray
});
// Simplify dataset label for copy
let datasetLabel = scenario.dataset.label;
- if (datasetLabel.includes('(') && datasetLabel.includes(')')) {
+ if (
+ datasetLabel.includes("(") &&
+ datasetLabel.includes(")")
+ ) {
// Extract text within parentheses
const match = datasetLabel.match(/\(([^)]+)\)/);
if (match) {
@@ -837,7 +1064,7 @@ const ForecastleGame = () => {
}
}
- return `Forecastle ${scenario.challengeDate}\n${emojis.join('')}\nRank #${userRank}/${totalModels} • WIS: ${scores.user.wis.toFixed(3)}\n${comparisonText}\n${datasetLabel} • ${scenario.location.abbreviation}`;
+ return `Forecastle ${scenario.challengeDate}\n${emojis.join("")}\nRank #${userRank}/${totalModels} • WIS: ${scores.user.wis.toFixed(3)}\n${comparisonText}\n${datasetLabel} • ${scenario.location.abbreviation}`;
};
const handleCopy = async () => {
@@ -847,7 +1074,7 @@ const ForecastleGame = () => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
- console.error('Failed to copy:', err);
+ console.error("Failed to copy:", err);
}
};
@@ -857,30 +1084,42 @@ const ForecastleGame = () => {
withBorder
shadow="md"
style={{
- background: 'linear-gradient(135deg, #f9d77e 0%, #f5c842 25%, #e6b800 50%, #f5c842 75%, #f9d77e 100%)',
+ background:
+ "linear-gradient(135deg, #f9d77e 0%, #f5c842 25%, #e6b800 50%, #f5c842 75%, #f9d77e 100%)",
borderWidth: 2,
- borderColor: '#d4af37',
- position: 'relative',
- boxShadow: '0 4px 12px rgba(212, 175, 55, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.4)',
- backdropFilter: 'blur(10px)',
+ borderColor: "#d4af37",
+ position: "relative",
+ boxShadow:
+ "0 4px 12px rgba(212, 175, 55, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.4)",
+ backdropFilter: "blur(10px)",
}}
>
-
+
- {emojiIndicator} You ranked #{userRank} across {totalModels} models
+ {emojiIndicator} You ranked #{userRank}{" "}
+ across {totalModels} models
-
+
{
onClick={handleCopy}
style={{ flexShrink: 0 }}
>
- {copied ? : }
+ {copied ? (
+
+ ) : (
+
+ )}
@@ -898,8 +1141,9 @@ const ForecastleGame = () => {
fw={600}
ta="center"
style={{
- color: '#2d2d2d',
- textShadow: '0 1px 2px rgba(255, 255, 255, 0.7), 0 -1px 1px rgba(0, 0, 0, 0.2)',
+ color: "#2d2d2d",
+ textShadow:
+ "0 1px 2px rgba(255, 255, 255, 0.7), 0 -1px 1px rgba(0, 0, 0, 0.2)",
}}
>
{comparisonText}
@@ -910,18 +1154,21 @@ const ForecastleGame = () => {
fw={600}
ta="center"
style={{
- color: '#3d3d3d',
- textShadow: '0 1px 1px rgba(255, 255, 255, 0.6)',
+ color: "#3d3d3d",
+ textShadow:
+ "0 1px 1px rgba(255, 255, 255, 0.6)",
}}
>
- WIS: {scores.user.wis.toFixed(3)} • {scenario.dataset.label} • {scenario.location.abbreviation}
+ WIS: {scores.user.wis.toFixed(3)} •{" "}
+ {scenario.dataset.label} •{" "}
+ {scenario.location.abbreviation}
);
})()}
-
+
{
zoomedView={zoomedView}
scores={scores}
showScoring={true}
- fullGroundTruthSeries={scenario.fullGroundTruthSeries}
+ fullGroundTruthSeries={
+ scenario.fullGroundTruthSeries
+ }
modelForecasts={scenario.modelForecasts || {}}
horizons={scenario.horizons}
/>
@@ -944,13 +1193,14 @@ const ForecastleGame = () => {
>
) : (
- Ground truth data is not yet available for these forecast horizons.
+ Ground truth data is not yet available for these forecast
+ horizons.
)}
setInputMode('intervals')}
+ onClick={() => setInputMode("intervals")}
variant="default"
leftSection="←"
>
@@ -983,28 +1233,34 @@ const ForecastleGame = () => {
setZoomedView(!event.currentTarget.checked)}
+ onChange={(event) =>
+ setZoomedView(!event.currentTarget.checked)
+ }
color="red"
size="md"
/>
-
+
{} : handleMedianAdjust}
+ onAdjust={
+ isCurrentChallengeCompleted && !playDate
+ ? () => {}
+ : handleMedianAdjust
+ }
height={380}
- showIntervals={inputMode === 'intervals'}
+ showIntervals={inputMode === "intervals"}
zoomedView={zoomedView}
/>
{!isCurrentChallengeCompleted && (
- {inputMode === 'median'
- ? 'Drag the handles to set your median forecast for each week ahead.'
- : 'Drag the handles to adjust interval bounds, or use the sliders for precise control.'}
+ {inputMode === "median"
+ ? "Drag the handles to set your median forecast for each week ahead."
+ : "Drag the handles to adjust interval bounds, or use the sliders for precise control."}
)}
@@ -1014,7 +1270,9 @@ const ForecastleGame = () => {
- {inputMode === 'median' ? 'Median Forecasts' : 'Uncertainty Intervals'}
+ {inputMode === "median"
+ ? "Median Forecasts"
+ : "Uncertainty Intervals"}
{
rightSection="→"
color="green"
>
- Next Challenge ({currentChallengeIndex + 2}/{scenarios.length})
+ Next Challenge ({currentChallengeIndex + 2}/
+ {scenarios.length})
) : (
-
+
All Challenges Complete! 🎉
)}
- ) : inputMode === 'median' ? (
+ ) : inputMode === "median" ? (
{
Reset to Default
setInputMode('intervals')}
+ onClick={() => setInputMode("intervals")}
size="md"
fullWidth
rightSection="→"
@@ -1080,17 +1344,19 @@ const ForecastleGame = () => {
onClick={() => {
handleSubmit();
if (scenario?.fullGroundTruthSeries) {
- setTimeout(() => setInputMode('scoring'), 100);
+ setTimeout(() => setInputMode("scoring"), 100);
}
}}
size="md"
fullWidth
- disabled={inputMode === 'scoring'}
+ disabled={inputMode === "scoring"}
>
- {submittedPayload ? 'Resubmit & View Scores' : 'Submit & View Scores'}
+ {submittedPayload
+ ? "Resubmit & View Scores"
+ : "Submit & View Scores"}
setInputMode('median')}
+ onClick={() => setInputMode("median")}
variant="default"
size="sm"
fullWidth
@@ -1099,9 +1365,19 @@ const ForecastleGame = () => {
Back to Median
{Object.keys(submissionErrors).length > 0 && (
-
+
- {submissionErrors.general || 'Please adjust your intervals to continue.'}
+ {submissionErrors.general ||
+ "Please adjust your intervals to continue."}
)}
@@ -1112,7 +1388,6 @@ const ForecastleGame = () => {
)}
-
@@ -1124,7 +1399,7 @@ const ForecastleGame = () => {
RespiLens | Forecastle
-
+
{renderContent()}
{
- if (horizon === 1) return '1 week ahead';
+ if (horizon === 1) return "1 week ahead";
return `${horizon} weeks ahead`;
};
-const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'intervals', disabled = false }) => {
+const ForecastleInputControls = ({
+ entries,
+ onChange,
+ maxValue,
+ mode = "intervals",
+ disabled = false,
+}) => {
const sliderMax = useMemo(() => Math.max(maxValue, 1), [maxValue]);
// Calculate initial auto interval values from current entries
// This must be called before any conditional returns to follow Rules of Hooks
const calculateAutoIntervalParams = useCallback(() => {
- if (entries.length === 0) return { width50: 0, growth50: 0, additionalWidth95: 0, additionalGrowth95: 0 };
+ if (entries.length === 0)
+ return {
+ width50: 0,
+ growth50: 0,
+ additionalWidth95: 0,
+ additionalGrowth95: 0,
+ };
// For first horizon
const firstEntry = entries[0];
@@ -28,8 +48,12 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
const lastAdditionalWidth95 = Math.max(0, lastWidth95 - lastWidth50);
const horizonDiff = lastEntry.horizon - firstEntry.horizon;
- const growth50 = horizonDiff > 0 ? (lastWidth50 - width50) / horizonDiff : 0;
- const additionalGrowth95 = horizonDiff > 0 ? (lastAdditionalWidth95 - additionalWidth95) / horizonDiff : 0;
+ const growth50 =
+ horizonDiff > 0 ? (lastWidth50 - width50) / horizonDiff : 0;
+ const additionalGrowth95 =
+ horizonDiff > 0
+ ? (lastAdditionalWidth95 - additionalWidth95) / horizonDiff
+ : 0;
return { width50, growth50, additionalWidth95, additionalGrowth95 };
}
@@ -37,17 +61,24 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
return { width50, growth50: 0, additionalWidth95, additionalGrowth95: 0 };
}, [entries]);
- const autoParams = useMemo(() => calculateAutoIntervalParams(), [calculateAutoIntervalParams]);
- const [intervalMode, setIntervalMode] = useState('auto'); // 'auto' or 'manual'
+ const autoParams = useMemo(
+ () => calculateAutoIntervalParams(),
+ [calculateAutoIntervalParams],
+ );
+ const [intervalMode, setIntervalMode] = useState("auto"); // 'auto' or 'manual'
const [width50, setWidth50] = useState(autoParams.width50);
const [growth50, setGrowth50] = useState(autoParams.growth50);
- const [additionalWidth95, setAdditionalWidth95] = useState(autoParams.additionalWidth95);
- const [additionalGrowth95, setAdditionalGrowth95] = useState(autoParams.additionalGrowth95);
+ const [additionalWidth95, setAdditionalWidth95] = useState(
+ autoParams.additionalWidth95,
+ );
+ const [additionalGrowth95, setAdditionalGrowth95] = useState(
+ autoParams.additionalGrowth95,
+ );
const [isSliding, setIsSliding] = useState(false); // Track if user is actively sliding
// Update state when entries change, but only if not currently sliding and in auto mode
useEffect(() => {
- if (!isSliding && intervalMode === 'auto') {
+ if (!isSliding && intervalMode === "auto") {
const params = calculateAutoIntervalParams();
setWidth50(params.width50);
setGrowth50(params.growth50);
@@ -62,26 +93,37 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
const nextEntry = { ...entry };
- if (field === 'median') {
+ if (field === "median") {
nextEntry.median = Math.max(0, value);
- } else if (field === 'interval95') {
+ } else if (field === "interval95") {
// Two-point slider for 95% interval
const [lower, upper] = value;
nextEntry.lower95 = Math.max(0, lower);
nextEntry.upper95 = Math.max(lower, upper);
// Ensure 50% interval stays within 95% bounds
- if (nextEntry.lower50 < nextEntry.lower95) nextEntry.lower50 = nextEntry.lower95;
- if (nextEntry.upper50 > nextEntry.upper95) nextEntry.upper50 = nextEntry.upper95;
+ if (nextEntry.lower50 < nextEntry.lower95)
+ nextEntry.lower50 = nextEntry.lower95;
+ if (nextEntry.upper50 > nextEntry.upper95)
+ nextEntry.upper50 = nextEntry.upper95;
// Update widths for backward compatibility
- nextEntry.width95 = Math.max(nextEntry.upper95 - entry.median, entry.median - nextEntry.lower95);
- } else if (field === 'interval50') {
+ nextEntry.width95 = Math.max(
+ nextEntry.upper95 - entry.median,
+ entry.median - nextEntry.lower95,
+ );
+ } else if (field === "interval50") {
// Two-point slider for 50% interval
const [lower, upper] = value;
nextEntry.lower50 = Math.max(nextEntry.lower95 || 0, lower);
- nextEntry.upper50 = Math.min(nextEntry.upper95 || sliderMax, Math.max(lower, upper));
+ nextEntry.upper50 = Math.min(
+ nextEntry.upper95 || sliderMax,
+ Math.max(lower, upper),
+ );
// Update widths for backward compatibility
- nextEntry.width50 = Math.max(nextEntry.upper50 - entry.median, entry.median - nextEntry.lower50);
- } else if (field === 'width95') {
+ nextEntry.width50 = Math.max(
+ nextEntry.upper50 - entry.median,
+ entry.median - nextEntry.lower50,
+ );
+ } else if (field === "width95") {
// Legacy symmetric width support
nextEntry.width95 = Math.max(0, value);
nextEntry.lower95 = Math.max(0, entry.median - value);
@@ -91,7 +133,7 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
nextEntry.lower50 = Math.max(0, entry.median - nextEntry.width50);
nextEntry.upper50 = entry.median + nextEntry.width50;
}
- } else if (field === 'width50') {
+ } else if (field === "width50") {
// Legacy symmetric width support
nextEntry.width50 = Math.min(Math.max(0, value), entry.width95);
nextEntry.lower50 = Math.max(0, entry.median - nextEntry.width50);
@@ -105,7 +147,7 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
};
// In median mode, show only median controls
- if (mode === 'median') {
+ if (mode === "median") {
return (
{entries.map((entry, index) => (
@@ -116,10 +158,12 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
{/* Median */}
- Median Forecast
+
+ Median Forecast
+
updateEntry(index, 'median', val)}
+ onChange={(val) => updateEntry(index, "median", val)}
min={0}
max={sliderMax}
step={10}
@@ -134,11 +178,22 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
}
// Apply auto interval to all horizons
- const applyAutoInterval = (baseWidth50, baseGrowth50, addWidth95, addGrowth95) => {
+ const applyAutoInterval = (
+ baseWidth50,
+ baseGrowth50,
+ addWidth95,
+ addGrowth95,
+ ) => {
const nextEntries = entries.map((entry) => {
const horizonIndex = entry.horizon - (entries[0]?.horizon || 1);
- const currentWidth50 = Math.max(0, baseWidth50 + (baseGrowth50 * horizonIndex));
- const currentAdditionalWidth95 = Math.max(0, addWidth95 + (addGrowth95 * horizonIndex));
+ const currentWidth50 = Math.max(
+ 0,
+ baseWidth50 + baseGrowth50 * horizonIndex,
+ );
+ const currentAdditionalWidth95 = Math.max(
+ 0,
+ addWidth95 + addGrowth95 * horizonIndex,
+ );
const currentWidth95 = currentWidth50 + currentAdditionalWidth95;
return {
@@ -163,30 +218,41 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
value={intervalMode}
onChange={setIntervalMode}
data={[
- { label: 'Auto Interval', value: 'auto' },
- { label: 'Manual Controls', value: 'manual' },
+ { label: "Auto Interval", value: "auto" },
+ { label: "Manual Controls", value: "manual" },
]}
fullWidth
color="blue"
size="sm"
/>
- {intervalMode === 'auto' ? (
+ {intervalMode === "auto" ? (
/* Auto Interval Controls */
- Auto Interval
+
+ Auto Interval
+
{/* 50% Interval Auto Controls */}
- 50% Interval Width
- {Math.round(width50)}
+
+ 50% Interval Width
+
+
+ {Math.round(width50)}
+
{
setWidth50(val);
- applyAutoInterval(val, growth50, additionalWidth95, additionalGrowth95);
+ applyAutoInterval(
+ val,
+ growth50,
+ additionalWidth95,
+ additionalGrowth95,
+ );
}}
onChangeEnd={() => setIsSliding(false)}
onMouseDown={() => setIsSliding(true)}
@@ -198,7 +264,7 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
size="md"
disabled={disabled}
marks={[
- { value: 0, label: '0' },
+ { value: 0, label: "0" },
{ value: sliderMax / 4, label: `${Math.round(sliderMax / 4)}` },
{ value: sliderMax / 2, label: `${Math.round(sliderMax / 2)}` },
]}
@@ -207,14 +273,23 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
- 50% Growth per Week
- {Math.round(growth50 * 10) / 10}
+
+ 50% Growth per Week
+
+
+ {Math.round(growth50 * 10) / 10}
+
{
setGrowth50(val);
- applyAutoInterval(width50, val, additionalWidth95, additionalGrowth95);
+ applyAutoInterval(
+ width50,
+ val,
+ additionalWidth95,
+ additionalGrowth95,
+ );
}}
onChangeEnd={() => setIsSliding(false)}
onMouseDown={() => setIsSliding(true)}
@@ -226,10 +301,10 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
size="md"
disabled={disabled}
marks={[
- { value: -50, label: '-50' },
- { value: 0, label: '0' },
- { value: 50, label: '50' },
- { value: 100, label: '100' },
+ { value: -50, label: "-50" },
+ { value: 0, label: "0" },
+ { value: 50, label: "50" },
+ { value: 100, label: "100" },
]}
/>
@@ -237,8 +312,12 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
{/* 95% Additional Width (beyond 50%) */}
- 95% Additional Width (beyond 50%)
- {Math.round(additionalWidth95)}
+
+ 95% Additional Width (beyond 50%)
+
+
+ {Math.round(additionalWidth95)}
+
{/* Preview of applied intervals */}
- Preview
+
+ Preview
+
{entries.map((entry) => (
@@ -302,10 +387,12 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
{formatHorizonLabel(entry.horizon)}
- 50%: [{Math.round(entry.lower50)}, {Math.round(entry.upper50)}]
+ 50%: [{Math.round(entry.lower50)},{" "}
+ {Math.round(entry.upper50)}]
- 95%: [{Math.round(entry.lower95)}, {Math.round(entry.upper95)}]
+ 95%: [{Math.round(entry.lower95)},{" "}
+ {Math.round(entry.upper95)}]
))}
@@ -315,7 +402,9 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
) : (
/* Manual - Detailed Sliders */
- Manual Controls
+
+ Manual Controls
+
{entries.map((entry, index) => (
@@ -324,20 +413,25 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
- Median: {Math.round(entry.median)}
+ Median:{" "}
+
+ {Math.round(entry.median)}
+
{/* 95% Interval - Two-point range slider */}
- 95% Interval
+
+ 95% Interval
+
[{Math.round(entry.lower95)}, {Math.round(entry.upper95)}]
updateEntry(index, 'interval95', val)}
+ onChange={(val) => updateEntry(index, "interval95", val)}
min={0}
max={sliderMax}
step={1}
@@ -346,8 +440,11 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
minRange={0}
disabled={disabled}
marks={[
- { value: 0, label: '0' },
- { value: entry.median, label: `${Math.round(entry.median)}` },
+ { value: 0, label: "0" },
+ {
+ value: entry.median,
+ label: `${Math.round(entry.median)}`,
+ },
{ value: sliderMax, label: `${Math.round(sliderMax)}` },
]}
/>
@@ -359,14 +456,16 @@ const ForecastleInputControls = ({ entries, onChange, maxValue, mode = 'interval
{/* 50% Interval - Two-point range slider */}
- 50% Interval
+
+ 50% Interval
+
[{Math.round(entry.lower50)}, {Math.round(entry.upper50)}]
updateEntry(index, 'interval50', val)}
+ onChange={(val) => updateEntry(index, "interval50", val)}
min={entry.lower95}
max={entry.upper95}
step={1}
diff --git a/app/src/components/forecastle/ForecastleStatsModal.jsx b/app/src/components/forecastle/ForecastleStatsModal.jsx
index b3789e63..9d9895e2 100644
--- a/app/src/components/forecastle/ForecastleStatsModal.jsx
+++ b/app/src/components/forecastle/ForecastleStatsModal.jsx
@@ -1,4 +1,4 @@
-import { useMemo, useState, useEffect } from 'react';
+import { useMemo, useState, useEffect } from "react";
import {
Modal,
Stack,
@@ -14,7 +14,7 @@ import {
Divider,
ScrollArea,
Tooltip,
-} from '@mantine/core';
+} from "@mantine/core";
import {
IconChartBar,
IconTarget,
@@ -25,11 +25,14 @@ import {
IconAlertCircle,
IconCopy,
IconCheck as IconCheckCircle,
-} from '@tabler/icons-react';
-import { useRespilensStats } from '../../hooks/useRespilensStats';
-import { clearForecastleGames, exportForecastleData } from '../../utils/respilensStorage';
+} from "@tabler/icons-react";
+import { useRespilensStats } from "../../hooks/useRespilensStats";
+import {
+ clearForecastleGames,
+ exportForecastleData,
+} from "../../utils/respilensStorage";
-const StatCard = ({ icon, label, value, color = 'blue' }) => (
+const StatCard = ({ icon, label, value, color = "blue" }) => (
@@ -57,7 +60,7 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
// Refresh stats when modal opens
useEffect(() => {
if (opened) {
- setRefreshKey(prev => prev + 1);
+ setRefreshKey((prev) => prev + 1);
setExportError(null);
setShowClearConfirm(false);
}
@@ -67,9 +70,9 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
try {
setExportError(null);
const data = exportForecastleData();
- const blob = new Blob([data], { type: 'application/json' });
+ const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
+ const a = document.createElement("a");
a.href = url;
a.download = `respilens-forecastle-history-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
@@ -77,29 +80,33 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
- console.error('Export failed:', error);
- setExportError('Failed to export data. Please try again.');
+ console.error("Export failed:", error);
+ setExportError("Failed to export data. Please try again.");
}
};
const handleClear = () => {
clearForecastleGames();
- setRefreshKey(prev => prev + 1);
+ setRefreshKey((prev) => prev + 1);
setShowClearConfirm(false);
};
// Format date for display
const formatDate = (dateString) => {
const date = new Date(dateString);
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
};
// Get dataset label
const getDatasetLabel = (key) => {
const labels = {
- flusight: 'Flu',
- rsv: 'RSV',
- covid19: 'COVID-19',
+ flusight: "Flu",
+ rsv: "RSV",
+ covid19: "COVID-19",
};
return labels[key] || key;
};
@@ -107,7 +114,7 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
// Sort game history by date (most recent first)
const sortedHistory = useMemo(() => {
return [...stats.gameHistory].sort(
- (a, b) => new Date(b.challengeDate) - new Date(a.challengeDate)
+ (a, b) => new Date(b.challengeDate) - new Date(a.challengeDate),
);
}, [stats.gameHistory]);
@@ -115,7 +122,7 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
const pathogenStats = useMemo(() => {
const groups = {};
- stats.gameHistory.forEach(game => {
+ stats.gameHistory.forEach((game) => {
const pathogen = game.dataset;
if (!groups[pathogen]) {
groups[pathogen] = {
@@ -161,16 +168,28 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
if (Number.isFinite(truthValue) && forecast) {
// Check 95% coverage
- if (Number.isFinite(forecast.lower95) && Number.isFinite(forecast.upper95)) {
+ if (
+ Number.isFinite(forecast.lower95) &&
+ Number.isFinite(forecast.upper95)
+ ) {
groups[pathogen].coverage95Total += 1;
- if (truthValue >= forecast.lower95 && truthValue <= forecast.upper95) {
+ if (
+ truthValue >= forecast.lower95 &&
+ truthValue <= forecast.upper95
+ ) {
groups[pathogen].coverage95Count += 1;
}
}
// Check 50% coverage
- if (Number.isFinite(forecast.lower50) && Number.isFinite(forecast.upper50)) {
+ if (
+ Number.isFinite(forecast.lower50) &&
+ Number.isFinite(forecast.upper50)
+ ) {
groups[pathogen].coverage50Total += 1;
- if (truthValue >= forecast.lower50 && truthValue <= forecast.upper50) {
+ if (
+ truthValue >= forecast.lower50 &&
+ truthValue <= forecast.upper50
+ ) {
groups[pathogen].coverage50Count += 1;
}
}
@@ -181,9 +200,12 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
// Track ensemble and baseline scores
if (Number.isFinite(game.ensembleWIS)) {
groups[pathogen].totalEnsembleWIS += game.ensembleWIS;
- groups[pathogen].totalEnsembleDispersion += game.ensembleDispersion || 0;
- groups[pathogen].totalEnsembleUnderprediction += game.ensembleUnderprediction || 0;
- groups[pathogen].totalEnsembleOverprediction += game.ensembleOverprediction || 0;
+ groups[pathogen].totalEnsembleDispersion +=
+ game.ensembleDispersion || 0;
+ groups[pathogen].totalEnsembleUnderprediction +=
+ game.ensembleUnderprediction || 0;
+ groups[pathogen].totalEnsembleOverprediction +=
+ game.ensembleOverprediction || 0;
groups[pathogen].ensembleCount += 1;
// Count if user beat ensemble
@@ -202,17 +224,26 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
}
// Track rank difference from ensemble
- if (Number.isFinite(game.userRank) && Number.isFinite(game.ensembleRank)) {
+ if (
+ Number.isFinite(game.userRank) &&
+ Number.isFinite(game.ensembleRank)
+ ) {
const rankDiff = game.ensembleRank - game.userRank; // Positive if user is better
groups[pathogen].totalRankDiff += rankDiff;
groups[pathogen].rankDiffCount += 1;
}
// Track rank percentile (what % of models the user beat)
- if (Number.isFinite(game.userRank) && Number.isFinite(game.totalModels) && game.totalModels > 0) {
+ if (
+ Number.isFinite(game.userRank) &&
+ Number.isFinite(game.totalModels) &&
+ game.totalModels > 0
+ ) {
// Calculate percentile: (total - rank + 1) / (total + 1) * 100
// This gives the % of the field the user beat (including themselves)
- const percentile = ((game.totalModels - game.userRank + 1) / (game.totalModels + 1)) * 100;
+ const percentile =
+ ((game.totalModels - game.userRank + 1) / (game.totalModels + 1)) *
+ 100;
groups[pathogen].totalRankPercentile += percentile;
groups[pathogen].rankPercentileCount += 1;
}
@@ -224,22 +255,50 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
pathogen,
count: data.count,
averageWIS: data.count > 0 ? data.totalWIS / data.count : null,
- averageDispersion: data.count > 0 ? data.totalDispersion / data.count : null,
- averageUnderprediction: data.count > 0 ? data.totalUnderprediction / data.count : null,
- averageOverprediction: data.count > 0 ? data.totalOverprediction / data.count : null,
- averageEnsembleWIS: data.ensembleCount > 0 ? data.totalEnsembleWIS / data.ensembleCount : null,
- averageEnsembleDispersion: data.ensembleCount > 0 ? data.totalEnsembleDispersion / data.ensembleCount : null,
- averageEnsembleUnderprediction: data.ensembleCount > 0 ? data.totalEnsembleUnderprediction / data.ensembleCount : null,
- averageEnsembleOverprediction: data.ensembleCount > 0 ? data.totalEnsembleOverprediction / data.ensembleCount : null,
- averageBaselineWIS: data.baselineCount > 0 ? data.totalBaselineWIS / data.baselineCount : null,
+ averageDispersion:
+ data.count > 0 ? data.totalDispersion / data.count : null,
+ averageUnderprediction:
+ data.count > 0 ? data.totalUnderprediction / data.count : null,
+ averageOverprediction:
+ data.count > 0 ? data.totalOverprediction / data.count : null,
+ averageEnsembleWIS:
+ data.ensembleCount > 0
+ ? data.totalEnsembleWIS / data.ensembleCount
+ : null,
+ averageEnsembleDispersion:
+ data.ensembleCount > 0
+ ? data.totalEnsembleDispersion / data.ensembleCount
+ : null,
+ averageEnsembleUnderprediction:
+ data.ensembleCount > 0
+ ? data.totalEnsembleUnderprediction / data.ensembleCount
+ : null,
+ averageEnsembleOverprediction:
+ data.ensembleCount > 0
+ ? data.totalEnsembleOverprediction / data.ensembleCount
+ : null,
+ averageBaselineWIS:
+ data.baselineCount > 0
+ ? data.totalBaselineWIS / data.baselineCount
+ : null,
beatEnsembleCount: data.beatEnsembleCount,
beatBaselineCount: data.beatBaselineCount,
ensembleGamesCount: data.ensembleCount,
baselineGamesCount: data.baselineCount,
- meanRankDiff: data.rankDiffCount > 0 ? data.totalRankDiff / data.rankDiffCount : null,
- averageRankPercentile: data.rankPercentileCount > 0 ? data.totalRankPercentile / data.rankPercentileCount : null,
- coverage95Percent: data.coverage95Total > 0 ? (data.coverage95Count / data.coverage95Total) * 100 : null,
- coverage50Percent: data.coverage50Total > 0 ? (data.coverage50Count / data.coverage50Total) * 100 : null,
+ meanRankDiff:
+ data.rankDiffCount > 0 ? data.totalRankDiff / data.rankDiffCount : null,
+ averageRankPercentile:
+ data.rankPercentileCount > 0
+ ? data.totalRankPercentile / data.rankPercentileCount
+ : null,
+ coverage95Percent:
+ data.coverage95Total > 0
+ ? (data.coverage95Count / data.coverage95Total) * 100
+ : null,
+ coverage50Percent:
+ data.coverage50Total > 0
+ ? (data.coverage50Count / data.coverage50Total) * 100
+ : null,
coverage95Count: data.coverage95Count,
coverage95Total: data.coverage95Total,
coverage50Count: data.coverage50Count,
@@ -247,82 +306,102 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
}));
// Sort by average WIS (best first)
- return results.sort((a, b) => (a.averageWIS || Infinity) - (b.averageWIS || Infinity));
+ return results.sort(
+ (a, b) => (a.averageWIS || Infinity) - (b.averageWIS || Infinity),
+ );
}, [stats.gameHistory]);
// Calculate coverage percentages with color coding
const getCoverageColor = (percent, expectedCoverage) => {
- if (percent === null) return 'gray';
+ if (percent === null) return "gray";
const diff = Math.abs(percent - expectedCoverage);
- if (diff < 5) return 'green'; // Well-calibrated
- if (diff < 15) return 'yellow'; // Somewhat calibrated
- return 'red'; // Poorly calibrated
+ if (diff < 5) return "green"; // Well-calibrated
+ if (diff < 15) return "yellow"; // Somewhat calibrated
+ return "red"; // Poorly calibrated
};
// Generate Wordle-style shareable summary
const generateShareSummary = () => {
- const lines = ['RespiLens.com/forecastle Stats 📊\n'];
+ const lines = ["RespiLens.com/forecastle Stats 📊\n"];
- pathogenStats.forEach(stat => {
+ pathogenStats.forEach((stat) => {
const pathogenLabel = getDatasetLabel(stat.pathogen);
// Coverage with emojis and numbers
- const coverage95 = stat.coverage95Percent !== null ? Math.round(stat.coverage95Percent) : null;
- const coverage50 = stat.coverage50Percent !== null ? Math.round(stat.coverage50Percent) : null;
+ const coverage95 =
+ stat.coverage95Percent !== null
+ ? Math.round(stat.coverage95Percent)
+ : null;
+ const coverage50 =
+ stat.coverage50Percent !== null
+ ? Math.round(stat.coverage50Percent)
+ : null;
- let coverage95Emoji = '⚪';
+ let coverage95Emoji = "⚪";
if (coverage95 !== null) {
const diff95 = Math.abs(coverage95 - 95);
- if (diff95 < 5) coverage95Emoji = '🟢'; // Excellent
- else if (diff95 < 15) coverage95Emoji = '🟡'; // Good
- else coverage95Emoji = '🔴'; // Poor
+ if (diff95 < 5)
+ coverage95Emoji = "🟢"; // Excellent
+ else if (diff95 < 15)
+ coverage95Emoji = "🟡"; // Good
+ else coverage95Emoji = "🔴"; // Poor
}
- let coverage50Emoji = '⚪';
+ let coverage50Emoji = "⚪";
if (coverage50 !== null) {
const diff50 = Math.abs(coverage50 - 50);
- if (diff50 < 5) coverage50Emoji = '🟢'; // Excellent
- else if (diff50 < 15) coverage50Emoji = '🟡'; // Good
- else coverage50Emoji = '🔴'; // Poor
+ if (diff50 < 5)
+ coverage50Emoji = "🟢"; // Excellent
+ else if (diff50 < 15)
+ coverage50Emoji = "🟡"; // Good
+ else coverage50Emoji = "🔴"; // Poor
}
lines.push(`\n${pathogenLabel} (${stat.count} games)`);
// Coverage - only show if not N/A
if (coverage95 !== null || coverage50 !== null) {
- const coverage95Text = coverage95 !== null
- ? `${coverage95Emoji} ${coverage95}% (${stat.coverage95Count}/${stat.coverage95Total})`
- : null;
- const coverage50Text = coverage50 !== null
- ? `${coverage50Emoji} ${coverage50}% (${stat.coverage50Count}/${stat.coverage50Total})`
- : null;
+ const coverage95Text =
+ coverage95 !== null
+ ? `${coverage95Emoji} ${coverage95}% (${stat.coverage95Count}/${stat.coverage95Total})`
+ : null;
+ const coverage50Text =
+ coverage50 !== null
+ ? `${coverage50Emoji} ${coverage50}% (${stat.coverage50Count}/${stat.coverage50Total})`
+ : null;
const coverageParts = [];
if (coverage95Text) coverageParts.push(`95%: ${coverage95Text}`);
if (coverage50Text) coverageParts.push(`50%: ${coverage50Text}`);
if (coverageParts.length > 0) {
- lines.push(`Coverage: ${coverageParts.join(' | ')}`);
+ lines.push(`Coverage: ${coverageParts.join(" | ")}`);
}
}
// Beat ensemble with emojis
- const beatPercent = stat.ensembleGamesCount > 0
- ? Math.round((stat.beatEnsembleCount / stat.ensembleGamesCount) * 100)
- : null;
+ const beatPercent =
+ stat.ensembleGamesCount > 0
+ ? Math.round((stat.beatEnsembleCount / stat.ensembleGamesCount) * 100)
+ : null;
- let ensembleEmoji = '😐';
+ let ensembleEmoji = "😐";
if (beatPercent !== null) {
- if (beatPercent >= 75) ensembleEmoji = '😎'; // Crushing it
- else if (beatPercent >= 50) ensembleEmoji = '😊'; // Beating ensemble
- else if (beatPercent >= 25) ensembleEmoji = '🙂'; // Competitive
- else ensembleEmoji = '😅'; // Room to grow
+ if (beatPercent >= 75)
+ ensembleEmoji = "😎"; // Crushing it
+ else if (beatPercent >= 50)
+ ensembleEmoji = "😊"; // Beating ensemble
+ else if (beatPercent >= 25)
+ ensembleEmoji = "🙂"; // Competitive
+ else ensembleEmoji = "😅"; // Room to grow
}
- lines.push(`Beat Ensemble: ${ensembleEmoji} ${stat.beatEnsembleCount}/${stat.ensembleGamesCount}`);
+ lines.push(
+ `Beat Ensemble: ${ensembleEmoji} ${stat.beatEnsembleCount}/${stat.ensembleGamesCount}`,
+ );
});
- return lines.join('\n');
+ return lines.join("\n");
};
const handleCopyStats = async () => {
@@ -332,7 +411,7 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
- console.error('Failed to copy:', err);
+ console.error("Failed to copy:", err);
}
};
@@ -344,7 +423,11 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
opened={opened}
onClose={onClose}
title={
-
+
@@ -355,10 +438,12 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
variant="light"
size="sm"
onClick={handleCopyStats}
- leftSection={copied ? : }
+ leftSection={
+ copied ? :
+ }
color={copied ? "teal" : "blue"}
>
- {copied ? 'Copied!' : 'Share'}
+ {copied ? "Copied!" : "Share"}
}
@@ -368,7 +453,8 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
{stats.gamesPlayed === 0 ? (
} color="blue">
- No games played yet. Complete your first Forecastle challenge to see your statistics!
+ No games played yet. Complete your first Forecastle challenge to see
+ your statistics!
) : (
<>
@@ -388,17 +474,22 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
label="Avg Rank vs Ensemble"
value={
stats.averageRankVsEnsemble !== null
- ? `${stats.averageRankVsEnsemble > 0 ? '+' : ''}${stats.averageRankVsEnsemble.toFixed(1)}`
- : 'N/A'
+ ? `${stats.averageRankVsEnsemble > 0 ? "+" : ""}${stats.averageRankVsEnsemble.toFixed(1)}`
+ : "N/A"
+ }
+ color={
+ stats.averageRankVsEnsemble !== null &&
+ stats.averageRankVsEnsemble > 0
+ ? "green"
+ : "cyan"
}
- color={stats.averageRankVsEnsemble !== null && stats.averageRankVsEnsemble > 0 ? 'green' : 'cyan'}
/>
}
label="Current Streak"
- value={`${stats.currentStreak} day${stats.currentStreak !== 1 ? 's' : ''}`}
+ value={`${stats.currentStreak} day${stats.currentStreak !== 1 ? "s" : ""}`}
color="orange"
/>
@@ -411,10 +502,22 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
Interval Coverage (Forecast Calibration)
- Shows how often the true value fell within your prediction intervals. Well-calibrated forecasts should have ~95% coverage for 95% intervals and ~50% for 50% intervals.
+ Shows how often the true value fell within your prediction
+ intervals. Well-calibrated forecasts should have ~95% coverage
+ for 95% intervals and ~50% for 50% intervals.
-
+
95% Interval Coverage
@@ -423,23 +526,33 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
{stats.coverage95Percent !== null
? `${stats.coverage95Percent.toFixed(1)}%`
- : 'N/A'}
+ : "N/A"}
{stats.coverage95Percent !== null
? Math.abs(stats.coverage95Percent - 95) < 5
- ? 'Excellent'
+ ? "Excellent"
: Math.abs(stats.coverage95Percent - 95) < 15
- ? 'Good'
- : stats.coverage95Percent < 95
- ? 'Too narrow'
- : 'Too wide'
- : 'N/A'}
+ ? "Good"
+ : stats.coverage95Percent < 95
+ ? "Too narrow"
+ : "Too wide"
+ : "N/A"}
-
+
50% Interval Coverage
@@ -448,18 +561,18 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
{stats.coverage50Percent !== null
? `${stats.coverage50Percent.toFixed(1)}%`
- : 'N/A'}
+ : "N/A"}
{stats.coverage50Percent !== null
? Math.abs(stats.coverage50Percent - 50) < 5
- ? 'Excellent'
+ ? "Excellent"
: Math.abs(stats.coverage50Percent - 50) < 15
- ? 'Good'
- : stats.coverage50Percent < 50
- ? 'Too narrow'
- : 'Too wide'
- : 'N/A'}
+ ? "Good"
+ : stats.coverage50Percent < 50
+ ? "Too narrow"
+ : "Too wide"
+ : "N/A"}
@@ -476,12 +589,13 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
Performance by Pathogen
- Average WIS scores grouped by disease type. Compare your performance against the hub ensemble and baseline. Lower scores indicate better forecasting.
+ Average WIS scores grouped by disease type. Compare your
+ performance against the hub ensemble and baseline. Lower
+ scores indicate better forecasting.
{/* Summary stats */}
{pathogenStats.map((stat) => {
-
return (
@@ -489,114 +603,206 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
{getDatasetLabel(stat.pathogen)}
- {stat.count} games
+
+ {stat.count} games
+
- 95% Coverage
-
- {stat.coverage95Percent !== null ? `${stat.coverage95Percent.toFixed(1)}%` : 'N/A'}
+
+ 95% Coverage
+
+
+ {stat.coverage95Percent !== null
+ ? `${stat.coverage95Percent.toFixed(1)}%`
+ : "N/A"}
- 50% Coverage
-
- {stat.coverage50Percent !== null ? `${stat.coverage50Percent.toFixed(1)}%` : 'N/A'}
+
+ 50% Coverage
+
+
+ {stat.coverage50Percent !== null
+ ? `${stat.coverage50Percent.toFixed(1)}%`
+ : "N/A"}
- Beat Ensemble
- stat.ensembleGamesCount / 2 ? 'green' : 'red'}>
- {stat.beatEnsembleCount}/{stat.ensembleGamesCount} times
+
+ Beat Ensemble
+
+
+ stat.ensembleGamesCount / 2
+ ? "green"
+ : "red"
+ }
+ >
+ {stat.beatEnsembleCount}/
+ {stat.ensembleGamesCount} times
- Beat Baseline
- stat.baselineGamesCount / 2 ? 'green' : 'red'}>
- {stat.beatBaselineCount}/{stat.baselineGamesCount} times
+
+ Beat Baseline
+
+
+ stat.baselineGamesCount / 2
+ ? "green"
+ : "red"
+ }
+ >
+ {stat.beatBaselineCount}/
+ {stat.baselineGamesCount} times
- Mean Rank vs Ensemble
- 0 ? 'green' : 'red'}>
+
+ Mean Rank vs Ensemble
+
+ 0
+ ? "green"
+ : "red"
+ }
+ >
{stat.meanRankDiff !== null
- ? `${stat.meanRankDiff > 0 ? '+' : ''}${stat.meanRankDiff.toFixed(1)} spots`
- : 'N/A'}
+ ? `${stat.meanRankDiff > 0 ? "+" : ""}${stat.meanRankDiff.toFixed(1)} spots`
+ : "N/A"}
- Average Rank Percentile
- = 50 ? 'green' : 'orange'}>
+
+ Average Rank Percentile
+
+ = 50
+ ? "green"
+ : "orange"
+ }
+ >
{stat.averageRankPercentile !== null
? `Top ${(100 - stat.averageRankPercentile).toFixed(1)}%`
- : 'N/A'}
+ : "N/A"}
{/* WIS Components Stacked Bar */}
- WIS Components Comparison
+
+ WIS Components Comparison
+
{/* User bar */}
- You
+
+ You
+
- {stat.averageUnderprediction !== null && stat.averageUnderprediction > 0 && (
-
- {stat.averageUnderprediction > 5 && (
- {stat.averageUnderprediction.toFixed(1)}
- )}
-
- )}
- {stat.averageOverprediction !== null && stat.averageOverprediction > 0 && (
-
- {stat.averageOverprediction > 5 && (
- {stat.averageOverprediction.toFixed(1)}
- )}
-
- )}
+ {stat.averageUnderprediction !== null &&
+ stat.averageUnderprediction > 0 && (
+
+ {stat.averageUnderprediction > 5 && (
+
+ {stat.averageUnderprediction.toFixed(
+ 1,
+ )}
+
+ )}
+
+ )}
+ {stat.averageOverprediction !== null &&
+ stat.averageOverprediction > 0 && (
+
+ {stat.averageOverprediction > 5 && (
+
+ {stat.averageOverprediction.toFixed(
+ 1,
+ )}
+
+ )}
+
+ )}
{stat.averageDispersion !== null && (
{stat.averageDispersion > 5 && (
- {stat.averageDispersion.toFixed(1)}
+
+ {stat.averageDispersion.toFixed(1)}
+
)}
)}
@@ -606,56 +812,79 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
{/* Ensemble bar */}
{stat.averageEnsembleWIS !== null && (
- Ensemble
+
+ Ensemble
+
- {stat.averageEnsembleUnderprediction !== null && stat.averageEnsembleUnderprediction > 0 && (
-
- {stat.averageEnsembleUnderprediction > 5 && (
- {stat.averageEnsembleUnderprediction.toFixed(1)}
- )}
-
- )}
- {stat.averageEnsembleOverprediction !== null && stat.averageEnsembleOverprediction > 0 && (
-
- {stat.averageEnsembleOverprediction > 5 && (
- {stat.averageEnsembleOverprediction.toFixed(1)}
- )}
-
- )}
- {stat.averageEnsembleDispersion !== null && (
+ {stat.averageEnsembleUnderprediction !==
+ null &&
+ stat.averageEnsembleUnderprediction >
+ 0 && (
+
+ {stat.averageEnsembleUnderprediction >
+ 5 && (
+
+ {stat.averageEnsembleUnderprediction.toFixed(
+ 1,
+ )}
+
+ )}
+
+ )}
+ {stat.averageEnsembleOverprediction !==
+ null &&
+ stat.averageEnsembleOverprediction >
+ 0 && (
+
+ {stat.averageEnsembleOverprediction >
+ 5 && (
+
+ {stat.averageEnsembleOverprediction.toFixed(
+ 1,
+ )}
+
+ )}
+
+ )}
+ {stat.averageEnsembleDispersion !==
+ null && (
{stat.averageEnsembleDispersion > 5 && (
- {stat.averageEnsembleDispersion.toFixed(1)}
+
+ {stat.averageEnsembleDispersion.toFixed(
+ 1,
+ )}
+
)}
)}
@@ -665,16 +894,40 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
-
- Dispersion
+
+
+ Dispersion
+
-
- Underprediction
+
+
+ Underprediction
+
-
- Overprediction
+
+
+ Overprediction
+
@@ -727,21 +980,26 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
WIS
- Rank
+
+ Rank
+
- vs Ensemble
+
+ vs Ensemble
+
{sortedHistory.map((game) => {
- const spotsDiffEnsemble = game.userRank && game.ensembleRank
- ? game.ensembleRank - game.userRank
- : null;
+ const spotsDiffEnsemble =
+ game.userRank && game.ensembleRank
+ ? game.ensembleRank - game.userRank
+ : null;
return (
@@ -758,27 +1016,39 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
- {game.wis !== null ? game.wis.toFixed(3) : 'N/A'}
+ {game.wis !== null ? game.wis.toFixed(3) : "N/A"}
{game.userRank && game.totalModels
? `#${game.userRank}/${game.totalModels}`
- : 'N/A'}
+ : "N/A"}
{spotsDiffEnsemble !== null ? (
0 ? 'green' : spotsDiffEnsemble < 0 ? 'red' : 'gray'}
+ color={
+ spotsDiffEnsemble > 0
+ ? "green"
+ : spotsDiffEnsemble < 0
+ ? "red"
+ : "gray"
+ }
variant="light"
>
- {spotsDiffEnsemble > 0 ? `+${spotsDiffEnsemble}` : spotsDiffEnsemble < 0 ? spotsDiffEnsemble : '0'}
+ {spotsDiffEnsemble > 0
+ ? `+${spotsDiffEnsemble}`
+ : spotsDiffEnsemble < 0
+ ? spotsDiffEnsemble
+ : "0"}
) : (
- N/A
+
+ N/A
+
)}
@@ -793,7 +1063,12 @@ const ForecastleStatsModal = ({ opened, onClose }) => {
{/* Export Error */}
{exportError && (
- } color="red" onClose={() => setExportError(null)} withCloseButton>
+ }
+ color="red"
+ onClose={() => setExportError(null)}
+ withCloseButton
+ >
{exportError}
)}
diff --git a/app/src/components/layout/DashboardNavigation.jsx b/app/src/components/layout/DashboardNavigation.jsx
index 63d6b981..5ad81d11 100644
--- a/app/src/components/layout/DashboardNavigation.jsx
+++ b/app/src/components/layout/DashboardNavigation.jsx
@@ -1,8 +1,34 @@
-import { Link } from 'react-router-dom';
-import { Group, ThemeIcon, Title, ActionIcon, Menu, Avatar, Text, Stack, Button, Divider } from '@mantine/core';
-import { IconDashboard, IconActivity, IconTarget, IconBookmark, IconSettings, IconBell, IconUser, IconLogout, IconChartLine } from '@tabler/icons-react';
+import { Link } from "react-router-dom";
+import {
+ Group,
+ ThemeIcon,
+ Title,
+ ActionIcon,
+ Menu,
+ Avatar,
+ Text,
+ Stack,
+ Button,
+ Divider,
+} from "@mantine/core";
+import {
+ IconDashboard,
+ IconActivity,
+ IconTarget,
+ IconBookmark,
+ IconSettings,
+ IconBell,
+ IconUser,
+ IconLogout,
+ IconChartLine,
+} from "@tabler/icons-react";
-const DashboardNavigation = ({ activeTab, setActiveTab, user, inHeader = false }) => {
+const DashboardNavigation = ({
+ activeTab,
+ setActiveTab,
+ user,
+ inHeader = false,
+}) => {
if (inHeader) {
return (
@@ -12,16 +38,18 @@ const DashboardNavigation = ({ activeTab, setActiveTab, user, inHeader = false }
MyRespiLens
-
+