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 @@ RespiLens - + - + diff --git a/app/package-lock.json b/app/package-lock.json index 5c734a96..2199efb9 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -36,13 +36,17 @@ "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.17.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^16.4.0", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", "postcss": "^8.5.0", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", + "prettier": "3.8.1", "vite": "^6.0.1" } }, @@ -792,9 +796,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -875,9 +879,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -887,7 +891,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -912,9 +916,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -1412,9 +1416,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", - "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -2289,9 +2293,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "peer": true, "dependencies": { @@ -2318,6 +2322,35 @@ "integrity": "sha512-0V/PkoculFl5+0Lp47JoxUcO0xSxhIBvm+BxHdD/OgXNmdRpRHCFnKVuUoWyS9EzQP+otSGv0m9Lb4yVkQBn2A==", "license": "MIT" }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2557,12 +2590,15 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", - "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-search-bounds": { @@ -2617,9 +2653,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -2636,11 +2672,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -2723,9 +2759,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001757", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", - "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", "funding": [ { "type": "opencollective", @@ -2810,6 +2846,39 @@ "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==", "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2914,6 +2983,13 @@ "mumath": "^3.3.4" } }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -3493,9 +3569,9 @@ "license": "ISC" }, "node_modules/electron-to-chromium": { - "version": "1.5.261", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.261.tgz", - "integrity": "sha512-cmyHEWFqEt3ICUNF93ShneOF47DHoSDbLb7E/AonsWcbzg95N+kPXeLNfkdzgTT/vEUcoW76fxbLBkeYtfoM8A==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "license": "ISC" }, "node_modules/element-size": { @@ -3544,6 +3620,13 @@ "embla-carousel": "8.6.0" } }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -3554,19 +3637,32 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "license": "MIT", "peer": true, "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -3683,9 +3779,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "license": "MIT", "peer": true }, @@ -3886,9 +3982,9 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", "dependencies": { @@ -3898,7 +3994,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3945,6 +4041,22 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -3992,9 +4104,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", - "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4130,6 +4242,13 @@ "es5-ext": "~0.10.14" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -4445,6 +4564,19 @@ "integrity": "sha512-LnpfLf/TNzr9zVOGiIY6aKCz8EKuXmlYNV7CM2pUjBa/B+c2I15tS7KLySep75+FuerJdmArvJLcsAXWEy2H0A==", "license": "MIT" }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5040,6 +5172,22 @@ "integrity": "sha512-08iL2VyCRbkQKBySkSh6m8zMUa3sADAxGVWs3Z1aPcUkTJeK0ETG4Fc27tEmQBGUAXZjIsXOZqBvacuVNSC/fQ==", "license": "MIT" }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5362,6 +5510,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -5902,6 +6066,59 @@ "node": ">= 0.8.0" } }, + "node_modules/lint-staged": { + "version": "16.2.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", + "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.2", + "listr2": "^9.0.5", + "micromatch": "^4.0.8", + "nano-spawn": "^2.0.0", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/loader-runner": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", @@ -5938,6 +6155,26 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6165,6 +6402,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6241,6 +6491,19 @@ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", "license": "MIT" }, + "node_modules/nano-spawn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", + "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6450,6 +6713,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/open": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", @@ -6683,6 +6962,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/plotly.js": { "version": "2.35.3", "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-2.35.3.tgz", @@ -6999,6 +7291,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/probe-image-size": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", @@ -7210,12 +7518,12 @@ } }, "node_modules/react-router": { - "version": "6.30.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", - "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.1" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -7225,13 +7533,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", - "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.1", - "react-router": "6.30.2" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -7507,6 +7815,30 @@ "protocol-buffers-schema": "^3.3.1" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/right-now": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", @@ -7678,9 +8010,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "peer": true, "dependencies": { @@ -7891,6 +8223,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/signum": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/signum/-/signum-1.0.0.tgz", @@ -7906,6 +8251,36 @@ "node": ">=6" } }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8011,6 +8386,16 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-split-by": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", @@ -8020,6 +8405,23 @@ "parenthesis": "^3.1.5" } }, + "node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -8118,6 +8520,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8301,9 +8719,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "license": "MIT", "peer": true, "dependencies": { @@ -8628,9 +9046,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -8885,9 +9303,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", "peer": true, "dependencies": { @@ -8914,9 +9332,9 @@ } }, "node_modules/webpack": { - "version": "5.103.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", - "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", + "version": "5.105.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz", + "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "license": "MIT", "peer": true, "dependencies": { @@ -8928,10 +9346,10 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", @@ -8942,8 +9360,8 @@ "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.4", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { @@ -9119,6 +9537,55 @@ "object-assign": "^4.1.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/app/package.json b/app/package.json index 60a0b879..08fb6618 100644 --- a/app/package.json +++ b/app/package.json @@ -8,7 +8,10 @@ "build": "vite build", "build:staging": "vite build --mode staging", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "format": "prettier . --write", + "format:check": "prettier . --check --log-level warn", + "prepare": "cd .. && husky app/.husky" }, "dependencies": { "@mantine/carousel": "^8.1.1", @@ -39,13 +42,20 @@ "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.17.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^16.4.0", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", "postcss": "^8.5.0", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", + "prettier": "3.8.1", "vite": "^6.0.1" + }, + "lint-staged": { + "**/*.{js,jsx}": "prettier --write" } -} \ No newline at end of file +} diff --git a/app/postcss.config.js b/app/postcss.config.js index 5ada75c1..2cf28c2c 100644 --- a/app/postcss.config.js +++ b/app/postcss.config.js @@ -1,14 +1,14 @@ export default { plugins: { - 'postcss-preset-mantine': {}, - 'postcss-simple-vars': { + "postcss-preset-mantine": {}, + "postcss-simple-vars": { variables: { - 'mantine-breakpoint-xs': '36em', - 'mantine-breakpoint-sm': '48em', - 'mantine-breakpoint-md': '62em', - 'mantine-breakpoint-lg': '75em', - 'mantine-breakpoint-xl': '88em', + "mantine-breakpoint-xs": "36em", + "mantine-breakpoint-sm": "48em", + "mantine-breakpoint-md": "62em", + "mantine-breakpoint-lg": "75em", + "mantine-breakpoint-xl": "88em", }, }, }, -} +}; diff --git a/app/src/App.jsx b/app/src/App.jsx index 84f4c5b4..026be490 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -1,19 +1,25 @@ -import { BrowserRouter as Router, Routes, Route, useNavigate, useLocation } from 'react-router-dom'; -import { useEffect } from 'react'; -import { HelmetProvider } from 'react-helmet-async'; -import { ViewProvider } from './contexts/ViewContext'; -import { useView } from './hooks/useView'; -import DataVisualizationContainer from './components/DataVisualizationContainer'; -import NarrativeBrowser from './components/narratives/NarrativeBrowser'; -import SlideNarrativeViewer from './components/narratives/SlideNarrativeViewer'; -import ForecastleGame from './components/forecastle/ForecastleGame'; -import MyRespiLensDashboard from './components/myrespi/MyRespiLensDashboard'; -import TournamentDashboard from './components/tournament/TournamentDashboard'; -import UnifiedAppShell from './components/layout/UnifiedAppShell'; -import Documentation from './components/Documentation' -import ReportingDelayPage from './components/reporting/ReportingDelayPage'; -import ToolsPage from './components/tools/ToolsPage'; -import { Center, Text } from '@mantine/core'; +import { + BrowserRouter as Router, + Routes, + Route, + useNavigate, + useLocation, +} from "react-router-dom"; +import { useEffect } from "react"; +import { HelmetProvider } from "react-helmet-async"; +import { ViewProvider } from "./contexts/ViewContext"; +import { useView } from "./hooks/useView"; +import DataVisualizationContainer from "./components/DataVisualizationContainer"; +import NarrativeBrowser from "./components/narratives/NarrativeBrowser"; +import SlideNarrativeViewer from "./components/narratives/SlideNarrativeViewer"; +import ForecastleGame from "./components/forecastle/ForecastleGame"; +import MyRespiLensDashboard from "./components/myrespi/MyRespiLensDashboard"; +import TournamentDashboard from "./components/tournament/TournamentDashboard"; +import UnifiedAppShell from "./components/layout/UnifiedAppShell"; +import Documentation from "./components/Documentation"; +import ReportingDelayPage from "./components/reporting/ReportingDelayPage"; +import ToolsPage from "./components/tools/ToolsPage"; +import { Center, Text } from "@mantine/core"; // import ShutdownBanner from './components/ShutdownBanner';, no longer necessary const ForecastApp = () => { @@ -23,7 +29,9 @@ const ForecastApp = () => { if (!selectedLocation) { return (
- Select a state to view forecasts + + Select a state to view forecasts +
); } @@ -35,21 +43,28 @@ const ForecastApp = () => { const AppLayout = () => { const navigate = useNavigate(); // Safely used inside - // was below UnifiedAppShell, should we need it again + // was below UnifiedAppShell, should we need it again return ( - - - } /> - navigate(`/narratives/${id}`)} />} /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + } /> + navigate(`/narratives/${id}`)} + /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); }; @@ -58,8 +73,8 @@ const App = () => { const location = useLocation(); useEffect(() => { - if (typeof window !== 'undefined' && window.gtag) { - window.gtag('config', import.meta.env.VITE_GA_MEASUREMENT_ID, { + if (typeof window !== "undefined" && window.gtag) { + window.gtag("config", import.meta.env.VITE_GA_MEASUREMENT_ID, { page_path: location.pathname + location.search, }); } diff --git a/app/src/components/AboutHubOverlay.jsx b/app/src/components/AboutHubOverlay.jsx index 581bd863..0970c4fb 100644 --- a/app/src/components/AboutHubOverlay.jsx +++ b/app/src/components/AboutHubOverlay.jsx @@ -1,8 +1,12 @@ -import { useDisclosure } from '@mantine/hooks'; -import { Modal, Group, Button } from '@mantine/core'; -import { IconInfoCircle } from '@tabler/icons-react'; +import { useDisclosure } from "@mantine/hooks"; +import { Modal, Group, Button } from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; -const AboutHubOverlay = ({ title, children, buttonLabel = "About the Hub" }) => { +const AboutHubOverlay = ({ + title, + children, + buttonLabel = "About the Hub", +}) => { const [opened, { open, close }] = useDisclosure(false); return ( @@ -15,16 +19,16 @@ const AboutHubOverlay = ({ title, children, buttonLabel = "About the Hub" }) => + ); }; -export default AboutHubOverlay; \ No newline at end of file +export default AboutHubOverlay; diff --git a/app/src/components/Announcement.jsx b/app/src/components/Announcement.jsx index ff6c198a..8a9faa59 100644 --- a/app/src/components/Announcement.jsx +++ b/app/src/components/Announcement.jsx @@ -1,6 +1,13 @@ -import { useState } from 'react'; -import { Paper, Group, Text, ThemeIcon, Stack, CloseButton } from '@mantine/core'; -import { IconSpeakerphone, IconAlertSquareRounded } from '@tabler/icons-react'; +import { useState } from "react"; +import { + Paper, + Group, + Text, + ThemeIcon, + Stack, + CloseButton, +} from "@mantine/core"; +import { IconSpeakerphone, IconAlertSquareRounded } from "@tabler/icons-react"; // Announcement component params: // `id` | unique ID for the announcement @@ -10,17 +17,17 @@ import { IconSpeakerphone, IconAlertSquareRounded } from '@tabler/icons-react'; // `announcementType` | alert or update const Announcement = ({ id, startDate, endDate, text, announcementType }) => { const storageKey = `dismissed-announcement-${id}`; - + const [dismissed, setDismissed] = useState(() => { - if (typeof window === 'undefined') return false; - return localStorage.getItem(storageKey) === 'true'; + if (typeof window === "undefined") return false; + return localStorage.getItem(storageKey) === "true"; }); const currentDate = new Date(); const start = new Date(startDate); const end = new Date(endDate); - const validTypes = ['update', 'alert']; + const validTypes = ["update", "alert"]; if (!validTypes.includes(announcementType)) { console.error(`[Announcement Error]: Invalid type "${announcementType}".`); } @@ -29,11 +36,11 @@ const Announcement = ({ id, startDate, endDate, text, announcementType }) => { if (!isVisible || dismissed) return null; const handleDismiss = () => { - localStorage.setItem(storageKey, 'true'); + localStorage.setItem(storageKey, "true"); setDismissed(true); }; - const isAlert = announcementType === 'alert'; + const isAlert = announcementType === "alert"; return ( @@ -43,32 +50,36 @@ const Announcement = ({ id, startDate, endDate, text, announcementType }) => { radius="md" shadow="xs" style={{ - background: isAlert - ? 'linear-gradient(45deg, #fef3c7, #fffbeb)' - : 'linear-gradient(45deg, var(--mantine-color-blue-light), var(--mantine-color-cyan-light))', - borderColor: isAlert - ? '#f59e0b' - : 'var(--mantine-color-blue-outline)', + background: isAlert + ? "linear-gradient(45deg, #fef3c7, #fffbeb)" + : "linear-gradient(45deg, var(--mantine-color-blue-light), var(--mantine-color-cyan-light))", + borderColor: isAlert + ? "#f59e0b" + : "var(--mantine-color-blue-outline)", }} > - - {isAlert ? : } + {isAlert ? ( + + ) : ( + + )} - {isAlert ? 'Alert' : 'Update'}: {text} + {isAlert ? "Alert" : "Update"}: {text} - { ); }; -export default Announcement; \ No newline at end of file +export default Announcement; diff --git a/app/src/components/DataVisualizationContainer.jsx b/app/src/components/DataVisualizationContainer.jsx index 8193f43e..810831c9 100644 --- a/app/src/components/DataVisualizationContainer.jsx +++ b/app/src/components/DataVisualizationContainer.jsx @@ -1,41 +1,59 @@ -import { useState, useEffect } from 'react'; -import { Helmet } from 'react-helmet-async'; -import { Stack, Container, Paper, Group, Button, Tooltip, Title, Anchor, List } from '@mantine/core'; -import { useView } from '../hooks/useView'; -import DateSelector from './DateSelector'; -import ViewSwitchboard from './ViewSwitchboard'; -import ErrorBoundary from './ErrorBoundary'; -import AboutHubOverlay from './AboutHubOverlay'; -import FrontPage from './FrontPage'; -import { IconShare, IconBrandGithub } from '@tabler/icons-react'; -import { useClipboard } from '@mantine/hooks'; +import { useState, useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { + Stack, + Container, + Paper, + Group, + Button, + Tooltip, + Title, + Anchor, + List, +} from "@mantine/core"; +import { useView } from "../hooks/useView"; +import DateSelector from "./DateSelector"; +import ViewSwitchboard from "./ViewSwitchboard"; +import ErrorBoundary from "./ErrorBoundary"; +import AboutHubOverlay from "./AboutHubOverlay"; +import FrontPage from "./FrontPage"; +import { IconShare, IconBrandGithub } from "@tabler/icons-react"; +import { useClipboard } from "@mantine/hooks"; const DataVisualizationContainer = () => { const { selectedLocation, - data, metadata, loading, error, availableDates, models, - selectedModels, setSelectedModels, - selectedDates, setSelectedDates, - activeDate, setActiveDate, + data, + metadata, + loading, + error, + availableDates, + models, + selectedModels, + setSelectedModels, + selectedDates, + setSelectedDates, + activeDate, + setActiveDate, viewType, currentDataset, selectedColumns, setSelectedColumns, - selectedTarget, + selectedTarget, peaks, - availablePeakDates, - availablePeakModels + availablePeakDates, + availablePeakModels, } = useView(); const [windowSize, setWindowSize] = useState({ width: window.innerWidth, - height: window.innerHeight + height: window.innerHeight, }); const clipboard = useClipboard({ timeout: 2000 }); // Configuration for AboutHubOverlay based on viewType const aboutHubConfig = { - 'covid_forecasts': { + covid_forecasts: { title: ( COVID-19 Forecast Hub @@ -53,25 +71,52 @@ const DataVisualizationContainer = () => { content: ( <>

- 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:

Weekly new hospitalizations due to COVID-19 - Weekly incident percentage of emergency department visits due to COVID-19 + + Weekly incident percentage of emergency department visits due to + COVID-19 +

- RespiLens displays forecasts for all models, dates and targets. For attribution and more information, please visit the COVID-19 Forecast Hub GitHub repository. + RespiLens displays forecasts for all models, dates and targets. For + attribution and more information, please visit the COVID-19 Forecast + Hub{" "} + + GitHub repository + + .

- Forecasts + + Forecasts +

- 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.

- ) + ), }, - 'rsv_forecasts': { + rsv_forecasts: { title: ( RSV Forecast Hub @@ -89,25 +134,44 @@ const DataVisualizationContainer = () => { content: ( <>

- 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:

Weekly new hospitalizations due to RSV - Weekly incident percentage of emergency department visits due to RSV + + Weekly incident percentage of emergency department visits due to + RSV +

- RespiLens displays forecasts for all models, dates and targets. For attribution and more information, please visit the RSV Forecast Hub GitHub repository. + RespiLens displays forecasts for all models, dates and targets. For + attribution and more information, please visit the RSV Forecast Hub{" "} + + GitHub repository + + .

- Forecasts + + Forecasts +

- 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.

- ) + ), }, - 'flu_peak': { + flu_peak: { title: ( FluSight Forecast Hub @@ -125,22 +189,53 @@ const DataVisualizationContainer = () => { content: ( <>

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

-
- Forecasts +
+ + Forecasts +

- 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.

- Peaks + + Peaks +

- 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.

- ) + ), }, - 'flu_forecasts': { + flu_forecasts: { title: ( FluSight Forecast Hub @@ -158,22 +253,53 @@ const DataVisualizationContainer = () => { content: ( <>

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

-
- Forecasts +
+ + Forecasts +

- 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.

- Peaks + + Peaks +

- 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.

- ) + ), }, - 'fludetailed': { + fludetailed: { title: ( FluSight Forecast Hub @@ -191,22 +317,53 @@ const DataVisualizationContainer = () => { content: ( <>

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

-
- Forecasts +
+ + Forecasts +

- 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.

- Peaks + + Peaks +

- 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.

- ) + ), }, - 'metrocast_forecasts': { + metrocast_forecasts: { title: ( Flu MetroCast @@ -216,19 +373,72 @@ const DataVisualizationContainer = () => { content: ( <>

- 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.

- Forecasts + + Forecasts +

- 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.

- ) + ), }, - 'nhsnall': { + nhsnall: { title: ( National Healthcare Safety Network (NHSN) @@ -238,28 +448,44 @@ const DataVisualizationContainer = () => { content: ( <>

- 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.

- Columns + + Columns +

- 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.

- ) - } + ), + }, }; const currentAboutConfig = aboutHubConfig[viewType]; useEffect(() => { - const handleResize = () => setWindowSize({ width: window.innerWidth, height: window.innerHeight }); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); + const handleResize = () => + setWindowSize({ width: window.innerWidth, height: window.innerHeight }); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); }, []); const handleShare = () => { @@ -269,20 +495,20 @@ const DataVisualizationContainer = () => { // for when switching from one flu view to flu_peak and there are multiple dates selected useEffect(() => { - if (viewType === 'flu_peak' && selectedDates.length > 1) { + if (viewType === "flu_peak" && selectedDates.length > 1) { // keep only the active date, or the first date if activeDate isn't set const singleDate = activeDate || selectedDates[0]; setSelectedDates([singleDate]); } }, [viewType, selectedDates, activeDate, setSelectedDates]); - if (viewType === 'frontpage') { + if (viewType === "frontpage") { return ( window.location.reload()}> RespiLens | Forecasts - + @@ -290,115 +516,137 @@ const DataVisualizationContainer = () => { ); } - + return ( window.location.reload()}> - RespiLens | {currentDataset?.fullName || 'Forecasts'} + RespiLens | {currentDataset?.fullName || "Forecasts"} - + - -
800 ? 'auto 1fr auto' : '1fr', - gap: '0.5rem', - alignItems: 'center' - }}> -
800 ? 'auto' : '1' - }}> - {currentAboutConfig && ( - - {currentAboutConfig.content} - + +
800 ? "auto 1fr auto" : "1fr", + gap: "0.5rem", + alignItems: "center", + }} + > +
800 ? "auto" : "1", + }} + > + {currentAboutConfig && ( + + {currentAboutConfig.content} + + )} + {windowSize.width <= 800 && ( + + + + )} +
+ {currentDataset?.hasDateSelector && windowSize.width > 800 && ( +
+ +
)} - {windowSize.width <= 800 && ( - - - + + +
+ )} + {currentDataset?.hasDateSelector && windowSize.width <= 800 && ( +
+ +
)}
- {currentDataset?.hasDateSelector && windowSize.width > 800 && ( -
- -
- - )} - {windowSize.width > 800 && ( -
- - - -
- )} - {currentDataset?.hasDateSelector && windowSize.width <= 800 && ( -
- -
- )} -
-
- -
+
+ +
diff --git a/app/src/components/DateSelector.jsx b/app/src/components/DateSelector.jsx index 635f5d2f..bf58d2a7 100644 --- a/app/src/components/DateSelector.jsx +++ b/app/src/components/DateSelector.jsx @@ -1,14 +1,19 @@ -import { useEffect, useCallback, useRef, useState } from 'react'; -import { Group, Text, ActionIcon, Button, Box } from '@mantine/core'; -import { IconChevronLeft, IconChevronRight, IconX, IconPlus } from '@tabler/icons-react'; - -const DateSelector = ({ - availableDates, - selectedDates, - setSelectedDates, - activeDate, - setActiveDate, - multi = true +import { useEffect, useCallback, useRef, useState } from "react"; +import { Group, Text, ActionIcon, Button, Box } from "@mantine/core"; +import { + IconChevronLeft, + IconChevronRight, + IconX, + IconPlus, +} from "@tabler/icons-react"; + +const DateSelector = ({ + availableDates, + selectedDates, + setSelectedDates, + activeDate, + setActiveDate, + multi = true, }) => { const [keyMovementAnchor, setKeyMovementAnchor] = useState(activeDate); // keyMovement responsible for date keydown movement const firstDateBoxRef = useRef(null); @@ -25,68 +30,78 @@ const DateSelector = ({ return () => clearTimeout(timeout); } }, [hasDate]); - const handleMove = useCallback((dateToMove, direction) => { - if (!dateToMove) return; - - const sortedDates = [...selectedDates].sort(); - const dateIndex = availableDates.indexOf(dateToMove); - const currentPositionInSelected = sortedDates.indexOf(dateToMove); - const targetDate = availableDates[dateIndex + direction]; - - if (!targetDate) return; - - const isBlocked = direction === -1 - ? (currentPositionInSelected > 0 && targetDate === sortedDates[currentPositionInSelected - 1]) - : (currentPositionInSelected < sortedDates.length - 1 && targetDate === sortedDates[currentPositionInSelected + 1]); - - if (!isBlocked) { - const newDates = selectedDates.map(d => d === dateToMove ? targetDate : d); - - setSelectedDates(newDates.sort()); - - setActiveDate(targetDate); - - setKeyMovementAnchor(targetDate); - } - }, [availableDates, selectedDates, setSelectedDates, setActiveDate]); + const handleMove = useCallback( + (dateToMove, direction) => { + if (!dateToMove) return; + + const sortedDates = [...selectedDates].sort(); + const dateIndex = availableDates.indexOf(dateToMove); + const currentPositionInSelected = sortedDates.indexOf(dateToMove); + const targetDate = availableDates[dateIndex + direction]; + + if (!targetDate) return; + + const isBlocked = + direction === -1 + ? currentPositionInSelected > 0 && + targetDate === sortedDates[currentPositionInSelected - 1] + : currentPositionInSelected < sortedDates.length - 1 && + targetDate === sortedDates[currentPositionInSelected + 1]; + + if (!isBlocked) { + const newDates = selectedDates.map((d) => + d === dateToMove ? targetDate : d, + ); + + setSelectedDates(newDates.sort()); + + setActiveDate(targetDate); + + setKeyMovementAnchor(targetDate); + } + }, + [availableDates, selectedDates, setSelectedDates, setActiveDate], + ); useEffect(() => { const handleKeyDown = (event) => { if (!keyMovementAnchor) return; - if (['INPUT', 'TEXTAREA'].includes(event.target.tagName)) return; + if (["INPUT", "TEXTAREA"].includes(event.target.tagName)) return; - if (event.key === 'ArrowLeft') { + if (event.key === "ArrowLeft") { event.preventDefault(); handleMove(keyMovementAnchor, -1); - } else if (event.key === 'ArrowRight') { + } else if (event.key === "ArrowRight") { event.preventDefault(); handleMove(keyMovementAnchor, 1); } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, [handleMove, keyMovementAnchor]); return ( - + {selectedDates.map((date, index) => ( handleMove(date, -1)} disabled={ availableDates.indexOf(date) === 0 || - selectedDates.includes(availableDates[availableDates.indexOf(date) - 1]) + selectedDates.includes( + availableDates[availableDates.indexOf(date) - 1], + ) } variant="subtle" - size={{ base: 'sm', sm: 'md' }} + size={{ base: "sm", sm: "md" }} > - - { setKeyMovementAnchor(date); setActiveDate(date); @@ -95,28 +110,29 @@ const DateSelector = ({ setKeyMovementAnchor(date); setActiveDate(date); }} - style={{ outline: 'none', cursor: 'pointer' }} + style={{ outline: "none", cursor: "pointer" }} > - {date} - + {multi && ( { e.stopPropagation(); - const newDates = selectedDates.filter(d => d !== date); + const newDates = selectedDates.filter((d) => d !== date); setSelectedDates(newDates); if (date === keyMovementAnchor && newDates.length > 0) { const fallback = newDates[0]; @@ -139,16 +155,18 @@ const DateSelector = ({ onClick={() => handleMove(date, 1)} disabled={ availableDates.indexOf(date) === availableDates.length - 1 || - selectedDates.includes(availableDates[availableDates.indexOf(date) + 1]) + selectedDates.includes( + availableDates[availableDates.indexOf(date) + 1], + ) } variant="subtle" - size={{ base: 'sm', sm: 'md' }} + size={{ base: "sm", sm: "md" }} > ))} - + {/* Add Date Button */} {multi && selectedDates.length < 5 && ( - + {isDevelopment && error && ( <> @@ -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 logo - RespiLens + RespiLens logo + + 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 pageHubverse dashboardofficial 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 pageHubverse dashboard – official GitHub repository + COVID-19 Forecast Hub:{" "} + + official CDC page + {" "} + –{" "} + + Hubverse dashboard + {" "} + –  + + official GitHub repository + - Flu MetroCast Hub: official dashboardsite – 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 && ( - <> - - - - - - - - )} - setShowAllAvailable(event.currentTarget.checked)} - size="sm" - disabled={disabled} - thumbIcon={ - showAllAvailable ? ( - - ) : ( - - ) - } - /> - + + + Model selection ({selectedModels.length}/{models.length}) + + {allowMultiple && ( + <> + + + + + + + + )} + + 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 = ({ - {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 order={5} style={{ textAlign: "center" }}> {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 = () => { > - - {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. )} ) : ( - + All Challenges Complete! 🎉 )}
- ) : inputMode === 'median' ? ( + ) : inputMode === "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 - + - + - {user?.name || 'User'} + + {user?.name || "User"} + @@ -46,42 +74,42 @@ const DashboardNavigation = ({ activeTab, setActiveTab, user, inHeader = false } <> @@ -91,14 +119,34 @@ const DashboardNavigation = ({ activeTab, setActiveTab, user, inHeader = false } {/* Quick Actions */} - Quick Actions - - - diff --git a/app/src/components/layout/MainNavigation.jsx b/app/src/components/layout/MainNavigation.jsx index c2b24492..dbe33ca0 100644 --- a/app/src/components/layout/MainNavigation.jsx +++ b/app/src/components/layout/MainNavigation.jsx @@ -1,8 +1,8 @@ -import { useLocation, Link } from 'react-router-dom'; -import { Group, Button, Image, Title, Anchor } from '@mantine/core'; -import { IconChartLine, IconTarget, IconDashboard } from '@tabler/icons-react'; -import InfoOverlay from '../InfoOverlay'; -import { useView } from '../../hooks/useView'; +import { useLocation, Link } from "react-router-dom"; +import { Group, Button, Image, Title, Anchor } from "@mantine/core"; +import { IconChartLine, IconTarget, IconDashboard } from "@tabler/icons-react"; +import InfoOverlay from "../InfoOverlay"; +import { useView } from "../../hooks/useView"; const MainNavigation = () => { const location = useLocation(); @@ -11,27 +11,60 @@ const MainNavigation = () => { const isActive = (path) => location.pathname.startsWith(path); const navigationItems = [ - { href: '/', label: 'Forecasts', icon: IconChartLine, active: location.pathname === '/' }, + { + href: "/", + label: "Forecasts", + icon: IconChartLine, + active: location.pathname === "/", + }, // { href: '/narratives', label: 'Narratives', icon: IconBook, active: isActive('/narratives') }, disable narratives for now - { href: '/forecastle', label: 'Forecastle', icon: IconTarget, active: isActive('/forecastle') }, - { href: '/myrespilens', label: 'MyRespiLens', icon: IconDashboard, active: isActive('/myrespilens') }, + { + href: "/forecastle", + label: "Forecastle", + icon: IconTarget, + active: isActive("/forecastle"), + }, + { + href: "/myrespilens", + label: "MyRespiLens", + icon: IconDashboard, + active: isActive("/myrespilens"), + }, // { href: '/documentation', label: 'Documentation', icon: IconClipboard, active: isActive('/documentation')} ]; return ( - + {/* Logo */} setViewType('frontpage')} + onClick={() => setViewType("frontpage")} > - RespiLens Logo + RespiLens Logo - RespiLens<sup style={{ color: 'var(--mantine-color-red-6)', fontSize: '0.75rem' }}></sup> + RespiLens + <sup + style={{ + color: "var(--mantine-color-red-6)", + fontSize: "0.75rem", + }} + ></sup> @@ -43,7 +76,7 @@ const MainNavigation = () => { key={item.href} component={Link} to={item.href} - variant={item.active ? 'filled' : 'subtle'} + variant={item.active ? "filled" : "subtle"} leftSection={} size="sm" > diff --git a/app/src/components/layout/UnifiedAppShell.jsx b/app/src/components/layout/UnifiedAppShell.jsx index 8f3fd3ed..8f3b67bf 100644 --- a/app/src/components/layout/UnifiedAppShell.jsx +++ b/app/src/components/layout/UnifiedAppShell.jsx @@ -1,27 +1,40 @@ -import { useLocation, Link } from 'react-router-dom'; -import { AppShell, Center, Burger, Stack, Button, Divider } from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; -import { IconChartLine, IconTarget, IconTrophy, IconDashboard, IconClipboard } from '@tabler/icons-react'; -import MainNavigation from './MainNavigation'; -import StateSelector from '../StateSelector'; +import { useLocation, Link } from "react-router-dom"; +import { + AppShell, + Center, + Burger, + Stack, + Button, + Divider, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { + IconChartLine, + IconTarget, + IconTrophy, + IconDashboard, + IconClipboard, +} from "@tabler/icons-react"; +import MainNavigation from "./MainNavigation"; +import StateSelector from "../StateSelector"; const getShellConfig = (pathname) => { // For forecast view (main page), show navbar with StateSelector - if (pathname === '/') { + if (pathname === "/") { return { - type: 'forecast', + type: "forecast", header: { height: 60 }, - navbar: { width: 256, breakpoint: 'sm' }, - padding: 0 + navbar: { width: 256, breakpoint: "sm" }, + padding: 0, }; } // For all other pages, show navbar with just navigation return { - type: 'navigation', + type: "navigation", header: { height: 60 }, - navbar: { width: 256, breakpoint: 'sm' }, - padding: 0 + navbar: { width: 256, breakpoint: "sm" }, + padding: 0, }; }; @@ -33,7 +46,11 @@ const UnifiedAppShell = ({ children, forecastProps = {} }) => { const renderHeaderNavigation = () => { return ( -
+
{/* Hamburger - always on mobile, left side */} { }; const navigationItems = [ - { href: '/', label: 'Forecasts', icon: IconChartLine, active: location.pathname === '/' }, - { href: '/forecastle', label: 'Forecastle', icon: IconTarget, active: location.pathname.startsWith('/forecastle') }, - { href: '/epidemics10', label: 'Epidemics10', icon: IconTrophy, active: location.pathname.startsWith('/epidemics10') }, - { href: '/myrespilens', label: 'MyRespiLens', icon: IconDashboard, active: location.pathname.startsWith('/myrespilens') }, - { href: '/documentation', label: 'Documentation', icon: IconClipboard, active: location.pathname.startsWith('/documentation') } + { + href: "/", + label: "Forecasts", + icon: IconChartLine, + active: location.pathname === "/", + }, + { + href: "/forecastle", + label: "Forecastle", + icon: IconTarget, + active: location.pathname.startsWith("/forecastle"), + }, + { + href: "/epidemics10", + label: "Epidemics10", + icon: IconTrophy, + active: location.pathname.startsWith("/epidemics10"), + }, + { + href: "/myrespilens", + label: "MyRespiLens", + icon: IconDashboard, + active: location.pathname.startsWith("/myrespilens"), + }, + { + href: "/documentation", + label: "Documentation", + icon: IconClipboard, + active: location.pathname.startsWith("/documentation"), + }, ]; const renderNavbar = () => { - if (config.type === 'forecast') { + if (config.type === "forecast") { return ( {/* Navigation Links - only visible on mobile */} @@ -66,7 +108,7 @@ const UnifiedAppShell = ({ children, forecastProps = {} }) => { key={item.href} component={Link} to={item.href} - variant={item.active ? 'filled' : 'subtle'} + variant={item.active ? "filled" : "subtle"} leftSection={} size="sm" fullWidth @@ -89,7 +131,7 @@ const UnifiedAppShell = ({ children, forecastProps = {} }) => { ); } - if (config.type === 'navigation') { + if (config.type === "navigation") { return ( {/* Navigation Links for other pages */} @@ -98,7 +140,7 @@ const UnifiedAppShell = ({ children, forecastProps = {} }) => { key={item.href} component={Link} to={item.href} - variant={item.active ? 'filled' : 'subtle'} + variant={item.active ? "filled" : "subtle"} leftSection={} size="sm" fullWidth @@ -124,25 +166,24 @@ const UnifiedAppShell = ({ children, forecastProps = {} }) => { collapsed: { mobile: !mobileOpened, // Only show sidebar on desktop for forecast page - desktop: config.type === 'forecast' ? !desktopOpened : true - } - }) + desktop: config.type === "forecast" ? !desktopOpened : true, + }, + }), }} padding={config.padding} > - - {renderHeaderNavigation()} - + {renderHeaderNavigation()} {config.navbar && ( - + {renderNavbar()} )} - - {children} - + {children} ); }; diff --git a/app/src/components/myrespi/MyRespiLensDashboard.jsx b/app/src/components/myrespi/MyRespiLensDashboard.jsx index 683b5165..df09703b 100644 --- a/app/src/components/myrespi/MyRespiLensDashboard.jsx +++ b/app/src/components/myrespi/MyRespiLensDashboard.jsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import { Helmet } from 'react-helmet-async'; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { Helmet } from "react-helmet-async"; import { Container, Title, @@ -14,20 +14,29 @@ import { Button, Modal, Anchor, - Select -} from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; -import { useSearchParams } from 'react-router-dom'; -import { IconUpload, IconFileText, IconArrowLeft, IconInfoCircle, IconDashboard } from '@tabler/icons-react'; -import Plot from 'react-plotly.js'; -import Plotly from 'plotly.js/dist/plotly'; -import ModelSelector from '../ModelSelector'; -import DateSelector from '../DateSelector'; -import { MODEL_COLORS } from '../../config/datasets'; + Select, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { useSearchParams } from "react-router-dom"; +import { + IconUpload, + IconFileText, + IconArrowLeft, + IconInfoCircle, + IconDashboard, +} from "@tabler/icons-react"; +import Plot from "react-plotly.js"; +import Plotly from "plotly.js/dist/plotly"; +import ModelSelector from "../ModelSelector"; +import DateSelector from "../DateSelector"; +import { MODEL_COLORS } from "../../config/datasets"; const formatTargetNameForTitle = (name) => { - if (!name) return 'Value'; - return name.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); + if (!name) return "Value"; + return name + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); }; const MyRespiLensDashboard = () => { @@ -37,13 +46,11 @@ const MyRespiLensDashboard = () => { }, [setSearchParams]); const [yAxisRange, setYAxisRange] = useState(null); - const [xAxisRange, setXAxisRange] = useState(null); + const [xAxisRange, setXAxisRange] = useState(null); const plotRef = useRef(null); - const isResettingRef = useRef(false); - + const isResettingRef = useRef(false); const [opened, { open, close }] = useDisclosure(false); - const { colorScheme } = useMantineColorScheme(); const [dragActive, setDragActive] = useState(false); @@ -64,22 +71,17 @@ const MyRespiLensDashboard = () => { if (selectedTarget && modelsByTarget[selectedTarget]) { return modelsByTarget[selectedTarget]; } - return []; + return []; }, [selectedTarget, modelsByTarget]); useEffect(() => { - // This effect runs whenever the target changes (because modelsForView changes) - if (modelsForView.length === 0) { - // If the new target has no models, clear the selection setSelectedModels([]); } else { - // Always reset the selection to the first available model for the new target setSelectedModels([modelsForView[0]]); } }, [modelsForView]); - useEffect(() => { if (!uploadedFile) return; @@ -95,41 +97,40 @@ const MyRespiLensDashboard = () => { } setFileData(data); - const forecastDates = Object.keys(data.forecasts || {}).sort((a, b) => new Date(a) - new Date(b)); + const forecastDates = Object.keys(data.forecasts || {}).sort( + (a, b) => new Date(a) - new Date(b), + ); - // Build both the full model list AND the map const allModelsSet = new Set(); const modelsByTargetMap = new Map(); if (data.forecasts) { - Object.values(data.forecasts).forEach(dateData => { + Object.values(data.forecasts).forEach((dateData) => { Object.entries(dateData).forEach(([target, targetData]) => { - if (!modelsByTargetMap.has(target)) { modelsByTargetMap.set(target, new Set()); } const modelSetForTarget = modelsByTargetMap.get(target); - - Object.keys(targetData).forEach(model => { - allModelsSet.add(model); // Add to the master list - modelSetForTarget.add(model); // Add to the target-specific list + Object.keys(targetData).forEach((model) => { + allModelsSet.add(model); + modelSetForTarget.add(model); }); }); }); } const allModels = Array.from(allModelsSet).sort(); - const modelsByTargetState = {}; for (const [target, modelSet] of modelsByTargetMap.entries()) { modelsByTargetState[target] = Array.from(modelSet).sort(); } - setModelsByTarget(modelsByTargetState); - + setModelsByTarget(modelsByTargetState); setAvailableDates(forecastDates); - - const targets = Object.keys(data.ground_truth || {}).filter(key => key !== 'dates'); + + const targets = Object.keys(data.ground_truth || {}).filter( + (key) => key !== "dates", + ); setAvailableTargets(targets); let defaultTarget = null; @@ -148,6 +149,7 @@ const MyRespiLensDashboard = () => { } else { setSelectedModels([]); } + if (forecastDates.length > 0) { const latestDate = forecastDates[forecastDates.length - 1]; setSelectedDates([latestDate]); @@ -156,10 +158,11 @@ const MyRespiLensDashboard = () => { setSelectedDates([]); setActiveDate(null); } - } catch (error) { - console.error('Error parsing JSON file:', error); - alert('Could not read the file. Please ensure it is a valid RespiLens projections JSON file.'); + console.error("Error parsing JSON file:", error); + alert( + "Could not read the file. Please ensure it is a valid RespiLens projections JSON file.", + ); setUploadedFile(null); setFileData(null); } finally { @@ -167,12 +170,6 @@ const MyRespiLensDashboard = () => { } }; - reader.onerror = () => { - setIsProcessing(false); - alert('An error occurred while reading the file.'); - console.error('FileReader error.'); - }; - reader.readAsText(uploadedFile); }, [uploadedFile]); @@ -194,11 +191,11 @@ const MyRespiLensDashboard = () => { }, []); const processFile = useCallback((file) => { - if (file && file.name.endsWith('.json')) { + if (file && file.name.endsWith(".json")) { setFileData(null); setUploadedFile(file); } else { - alert('Please upload a .json file'); + alert("Please upload a .json file"); } }, []); @@ -211,7 +208,7 @@ const MyRespiLensDashboard = () => { processFile(event.dataTransfer.files[0]); } }, - [processFile] + [processFile], ); const handleFileSelect = useCallback( @@ -220,7 +217,7 @@ const MyRespiLensDashboard = () => { processFile(event.target.files[0]); } }, - [processFile] + [processFile], ); const handleReset = useCallback(() => { @@ -236,341 +233,369 @@ const MyRespiLensDashboard = () => { setYAxisRange(null); }, []); + const getModelColor = useCallback( + (model) => { + const index = selectedModels.indexOf(model); + return MODEL_COLORS[index % MODEL_COLORS.length]; + }, + [selectedModels], + ); - const getModelColor = useCallback((model) => { - const index = selectedModels.indexOf(model); - return MODEL_COLORS[index % MODEL_COLORS.length]; - }, [selectedModels]); - const groundTruthTrace = useMemo(() => { if (!fileData) return {}; return { x: fileData.ground_truth?.dates || [], y: selectedTarget ? fileData.ground_truth?.[selectedTarget] || [] : [], - type: 'scatter', - mode: 'lines+markers', - name: 'Observed', - line: { color: 'black', width: 2, dash: 'dash' }, - marker: { size: 4, color: 'black' } + type: "scatter", + mode: "lines+markers", + name: "Observed", + line: { color: "black", width: 2, dash: "dash" }, + marker: { size: 4, color: "black" }, + hoverinfo: "text", + hovertemplate: + `Observed Data
` + + `Value: %{y:.1f}
` + + `Date: %{x}
` + + ``, }; }, [fileData, selectedTarget]); const modelTraces = useMemo(() => { if (!fileData) return []; - return selectedModels.flatMap(model => { + return selectedModels.flatMap((model) => { const modelColor = getModelColor(model); return selectedDates.flatMap((forecastDate, dateIndex) => { - const forecastData = fileData.forecasts?.[forecastDate]?.[selectedTarget]?.[model]; - if (!forecastData || forecastData.type !== 'quantile' || !forecastData.predictions) { + const forecastData = + fileData.forecasts?.[forecastDate]?.[selectedTarget]?.[model]; + if ( + !forecastData || + forecastData.type !== "quantile" || + !forecastData.predictions + ) { return []; } - + const isFirstDate = dateIndex === 0; + const predictions = Object.values(forecastData.predictions || {}).sort( + (a, b) => new Date(a.date) - new Date(b.date), + ); + const forecastDates = predictions.map((pred) => pred.date); + + const getQuantile = (q) => + predictions.map((pred) => { + if (!pred.quantiles || !pred.values) return 0; + const index = pred.quantiles.indexOf(q); + return index !== -1 ? (pred.values[index] ?? 0) : 0; + }); - const predictions = Object.values(forecastData.predictions || {}).sort((a, b) => new Date(a.date) - new Date(b.date)); - const forecastDates = predictions.map(pred => pred.date); - const getQuantile = (q) => predictions.map(pred => { - if (!pred.quantiles || !pred.values) return 0; - const index = pred.quantiles.indexOf(q); - return index !== -1 ? (pred.values[index] ?? 0) : 0; - }); + const availableQuantiles = predictions[0]?.quantiles || []; - return [ - { - x: [...forecastDates, ...[...forecastDates].reverse()], - y: [...getQuantile(0.975), ...[...getQuantile(0.025)].reverse()], - fill: 'toself', - fillcolor: `${modelColor}10`, - line: { color: 'transparent' }, - showlegend: false, - type: 'scatter', - name: `${model} 95% CI`, - hoverinfo: 'none', - legendgroup: model - }, - { + const quantilePairs = availableQuantiles + .filter((q) => q < 0.5) + .map((q) => { + const upper = parseFloat((1 - q).toFixed(3)); + return availableQuantiles.includes(upper) + ? { lower: q, upper, spread: upper - q } + : null; + }) + .filter((pair) => pair !== null) + .sort((a, b) => b.spread - a.spread); + + const areaTraces = quantilePairs.map((pair) => { + const confidenceLevel = Math.round(pair.spread * 100); + const opacityBase = Math.floor(40 - pair.spread * 30); + const opacityHex = Math.max(10, opacityBase).toString(); + + const upperValues = getQuantile(pair.upper); + const lowerValues = getQuantile(pair.lower); + + return { x: [...forecastDates, ...[...forecastDates].reverse()], - y: [...getQuantile(0.75), ...[...getQuantile(0.25)].reverse()], - fill: 'toself', - fillcolor: `${modelColor}30`, - line: { color: 'transparent' }, + y: [...upperValues, ...[...lowerValues].reverse()], + customdata: [...lowerValues, ...[...upperValues].reverse()], + fill: "toself", + fillcolor: `${modelColor}${opacityHex}`, + line: { color: "transparent" }, showlegend: false, - type: 'scatter', - name: `${model} 50% CI`, - hoverinfo: 'none', - legendgroup: model - }, - { - x: forecastDates, - y: getQuantile(0.5), - name: model, - type: 'scatter', - mode: 'lines+markers', - line: { color: modelColor, width: 2 }, - marker: { size: 6, color: modelColor }, - showlegend: isFirstDate, - legendgroup: model - } - ]; + type: "scatter", + name: `${model} ${confidenceLevel}% CI`, + legendgroup: model, + hoverinfo: "text", + hovertemplate: + `${model} - ${confidenceLevel}% CI
` + + `pred. ${forecastDate}
` + + `Upper: %{y:.1f}
` + + `Lower: %{customdata:.1f}
` + + ``, + }; + }); + + const medianTrace = { + x: forecastDates, + y: getQuantile(0.5), + name: model, + type: "scatter", + mode: "lines+markers", + line: { color: modelColor, width: 2 }, + marker: { size: 6, color: modelColor }, + showlegend: isFirstDate, + legendgroup: model, + hoverinfo: "text", + hovertemplate: + `${model}
` + + `pred. ${forecastDate}
` + + `Median Forecast: %{y:.1f}
` + + ``, + }; + + return [...areaTraces, medianTrace]; }); }); }, [fileData, selectedModels, selectedDates, selectedTarget, getModelColor]); const traces = useMemo(() => { - if (!fileData) return []; + if (!fileData) return []; return [groundTruthTrace, ...modelTraces]; }, [groundTruthTrace, modelTraces, fileData]); - /** - * Create a Set of all models that have forecast data for - * the currently selected target AND at least one of the selected dates. - */ const activeModels = useMemo(() => { const activeModelSet = new Set(); - if (!fileData || !fileData.forecasts || !selectedTarget || !selectedDates.length) { + if ( + !fileData || + !fileData.forecasts || + !selectedTarget || + !selectedDates.length + ) return activeModelSet; - } - selectedDates.forEach(date => { - const forecastsForDate = fileData.forecasts[date]; - if (!forecastsForDate) return; - - const targetData = forecastsForDate[selectedTarget]; - if (!targetData) return; - - // Add all models found for this target on this date - Object.keys(targetData).forEach(model => { - activeModelSet.add(model); - }); + selectedDates.forEach((date) => { + const targetData = fileData.forecasts[date]?.[selectedTarget]; + if (targetData) + Object.keys(targetData).forEach((model) => activeModelSet.add(model)); }); return activeModelSet; }, [fileData, selectedDates, selectedTarget]); - const calculateYRange = useCallback((data, xRange) => { - if (!data || !xRange || !Array.isArray(data) || data.length === 0 || !selectedTarget) return null; - let minY = Infinity; - let maxY = -Infinity; - const [startX, endX] = xRange; - const startDate = new Date(startX); - const endDate = new Date(endX); - - data.forEach(trace => { - if (!trace.x || !trace.y) return; - for (let i = 0; i < trace.x.length; i++) { - const pointDate = new Date(trace.x[i]); - if (pointDate >= startDate && pointDate <= endDate) { - const value = Number(trace.y[i]); - if (!isNaN(value)) { - minY = Math.min(minY, value); - maxY = Math.max(maxY, value); + const calculateYRange = useCallback( + (data, xRange) => { + if (!data || !xRange || data.length === 0 || !selectedTarget) return null; + let minY = Infinity, + maxY = -Infinity; + const [startX, endX] = [new Date(xRange[0]), new Date(xRange[1])]; + + data.forEach((trace) => { + if (!trace.x || !trace.y) return; + for (let i = 0; i < trace.x.length; i++) { + const pointDate = new Date(trace.x[i]); + if (pointDate >= startX && pointDate <= endX) { + const val = Number(trace.y[i]); + if (!isNaN(val)) { + minY = Math.min(minY, val); + maxY = Math.max(maxY, val); + } } } - } - }); - if (minY !== Infinity && maxY !== -Infinity) { - const padding = maxY * 0.1; - const rangeMin = Math.max(0, minY - padding); - return [rangeMin, maxY + padding]; - } - return null; - }, [selectedTarget]); - - const getDefaultRange = useCallback((isFullRange = false) => { - if (isFullRange) { - if (!traces || traces.length === 0) return [null, null]; - let minDate = '9999-12-31'; - let maxDate = '1000-01-01'; - traces.forEach(trace => { - if (trace.x && trace.x.length > 0) { - const first = trace.x[0]; - const last = trace.x[trace.x.length - 1]; - if (first && first < minDate) minDate = first; - if (last && last > maxDate) maxDate = last; - } }); - return (minDate === '9999-12-31') ? [null, null] : [minDate, maxDate]; - } - - if (!selectedDates || selectedDates.length === 0) return [null, null]; - const firstDate = new Date(selectedDates[0]); - const lastDate = new Date(selectedDates[selectedDates.length - 1]); - - firstDate.setDate(firstDate.getDate() - 35); // 5 weeks before - lastDate.setDate(lastDate.getDate() + 35); // 5 weeks after + if (minY !== Infinity) { + const padding = maxY * 0.1; + return [Math.max(0, minY - padding), maxY + padding]; + } + return null; + }, + [selectedTarget], + ); - return [ - firstDate.toISOString().split('T')[0], - lastDate.toISOString().split('T')[0] - ]; - }, [traces, selectedDates]); + const getDefaultRange = useCallback( + (isFullRange = false) => { + if (isFullRange) { + if (!traces.length) return [null, null]; + let minDate = "9999-12-31", + maxDate = "1000-01-01"; + traces.forEach((t) => { + if (t.x?.length) { + if (t.x[0] < minDate) minDate = t.x[0]; + if (t.x[t.x.length - 1] > maxDate) maxDate = t.x[t.x.length - 1]; + } + }); + return minDate === "9999-12-31" ? [null, null] : [minDate, maxDate]; + } + if (!selectedDates.length) return [null, null]; + const start = new Date(selectedDates[0]); + const end = new Date(selectedDates[selectedDates.length - 1]); + start.setDate(start.getDate() - 35); + end.setDate(end.getDate() + 35); + return [ + start.toISOString().split("T")[0], + end.toISOString().split("T")[0], + ]; + }, + [traces, selectedDates], + ); const defaultRange = useMemo(() => getDefaultRange(), [getDefaultRange]); - // Reset xaxis range only when target changes useEffect(() => { - setXAxisRange(null); // Reset to auto-update mode on target change + setXAxisRange(null); }, [selectedTarget]); - // Recalculate y-axis when data or x-range changes useEffect(() => { - const currentXRange = xAxisRange || defaultRange; - if (traces.length > 0 && currentXRange && currentXRange[0] !== null) { - const initialYRange = calculateYRange(traces, currentXRange); - setYAxisRange(initialYRange); + const currentX = xAxisRange || defaultRange; + if (traces.length && currentX?.[0]) { + setYAxisRange(calculateYRange(traces, currentX)); } else { setYAxisRange(null); } }, [traces, xAxisRange, defaultRange, calculateYRange]); - // Capture user zoom/pan from plot - 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]); - - const layout = useMemo(() => ({ - template: colorScheme === 'dark' ? 'plotly_dark' : 'plotly_white', - paper_bgcolor: 'transparent', - plot_bgcolor: 'transparent', - font: { color: colorScheme === 'dark' ? '#c1c2c5' : '#000000' }, - height: 600, - margin: { l: 60, r: 30, t: 50, b: 80 }, - showlegend: selectedModels.length < 15, - legend: { - x: 0, - y: 1, - xanchor: 'left', - yanchor: 'top', - bgcolor: colorScheme === 'dark' ? 'rgba(26, 27, 30, 0.8)' : 'rgba(255, 255, 255, 0.8)', - bordercolor: colorScheme === 'dark' ? '#444' : '#ccc', - borderwidth: 1, - font: { - size: 10 + if (figure?.["xaxis.range"]) { + const newX = figure["xaxis.range"]; + if (JSON.stringify(newX) !== JSON.stringify(xAxisRange)) + setXAxisRange(newX); } }, - hovermode: 'x unified', - dragmode: false, - xaxis: { - rangeslider: { - range: getDefaultRange(true) + [xAxisRange], + ); + + const layout = useMemo( + () => ({ + template: colorScheme === "dark" ? "plotly_dark" : "plotly_white", + paper_bgcolor: "transparent", + plot_bgcolor: "transparent", + font: { color: colorScheme === "dark" ? "#c1c2c5" : "#000000" }, + height: 600, + margin: { l: 60, r: 30, t: 50, b: 80 }, + showlegend: selectedModels.length < 15, + legend: { + x: 0, + y: 1, + bgcolor: + colorScheme === "dark" + ? "rgba(26,27,30,0.8)" + : "rgba(255,255,255,0.8)", + font: { size: 10 }, }, - range: xAxisRange || defaultRange - }, - yaxis: { - title: formatTargetNameForTitle(selectedTarget), - range: yAxisRange, - autorange: yAxisRange === null - }, - shapes: selectedDates.map(date => ({ - type: 'line', - x0: date, - x1: date, - y0: 0, - y1: 1, - yref: 'paper', - line: { color: 'red', width: 1, dash: 'dash' } - })), - }), [colorScheme, selectedModels.length, selectedDates, selectedTarget, yAxisRange, xAxisRange, defaultRange, getDefaultRange]); - - const config = useMemo(() => ({ - responsive: true, - displayModeBar: true, - displaylogo: false, - showSendToCloud: false, - plotlyServerURL: "", - scrollZoom: false, - doubleClick: 'reset', - toImageButtonOptions: { - format: 'png', - filename: 'forecast_plot' - }, - modeBarButtonsToRemove: ['resetScale2d', 'select2d', 'lasso2d'], - modeBarButtonsToAdd: [{ - name: 'Reset view', - icon: Plotly.Icons.home, - click: function(gd) { - const range = getDefaultRange(); - if (!range || range[0] === null) return; - const newYRange = traces.length > 0 ? calculateYRange(traces, range) : null; - isResettingRef.current = true; - setXAxisRange(null); - setYAxisRange(newYRange); - Plotly.relayout(gd, { - 'xaxis.range': range, - 'yaxis.range': newYRange, - 'yaxis.autorange': newYRange === null - }); - } - }] - }), [getDefaultRange, traces, calculateYRange]); + hovermode: "x unified", + dragmode: false, + xaxis: { + rangeslider: { range: getDefaultRange(true) }, + range: xAxisRange || defaultRange, + }, + yaxis: { + title: formatTargetNameForTitle(selectedTarget), + range: yAxisRange, + autorange: yAxisRange === null, + }, + shapes: selectedDates.map((date) => ({ + type: "line", + x0: date, + x1: date, + y0: 0, + y1: 1, + yref: "paper", + line: { color: "red", width: 1, dash: "dash" }, + })), + }), + [ + colorScheme, + selectedModels.length, + selectedDates, + selectedTarget, + yAxisRange, + xAxisRange, + defaultRange, + getDefaultRange, + ], + ); + const config = useMemo( + () => ({ + responsive: true, + displaylogo: false, + modeBarButtonsToRemove: ["resetScale2d", "select2d", "lasso2d"], + modeBarButtonsToAdd: [ + { + name: "Reset view", + icon: Plotly.Icons.home, + click: function (gd) { + const r = getDefaultRange(); + const y = traces.length ? calculateYRange(traces, r) : null; + isResettingRef.current = true; + setXAxisRange(null); + setYAxisRange(y); + Plotly.relayout(gd, { + "xaxis.range": r, + "yaxis.range": y, + "yaxis.autorange": y === null, + }); + }, + }, + ], + }), + [getDefaultRange, traces, calculateYRange], + ); - if (isProcessing) { + if (isProcessing) return ( -
+
); - } if (fileData) { return ( - + - - - Forecasts for {fileData.metadata?.location_name || 'Selected Location'} + Forecasts for{" "} + {fileData.metadata?.location_name || "Selected Location"} - - + - - - -
-
- { RespiLens | MyRespiLens - -
- - - - MyRespiLens - - } - centered + +
+ - - About - - MyRespiLens allows epidemiologists to visualize their own public health projections directly in their browser. - All the processing happens locally, meaning your data is never uploaded nor shared on any server. - - Data Structure - MyRespiLens expects uploaded data to be valid JSON and in RespiLens projections format. - - RespiLens projections format is the internally-defined JSON style for - forecast data. Documentation for this JSON format can be found on the MyRespiLens - documentation page. - The documentation will delineate how you can convert your Hubverse-style .csv to ResiLens projections .json. - - If you have questions or concerns, don't hesitate to contact the RespiLens Team.. We would love to make MyRespiLens useful to you! - - - - - - - - - document.getElementById('file-input')?.click()} - > - - - - - -
- - Drop your RespiLens .json file here - - - Upload your RespiLens data file to view your personalized RespiLens dashboard + + About + + MyRespiLens allows epidemiologists to visualize their own + public health projections directly in their browser. All the + processing happens locally, meaning your data is never + uploaded nor shared on any server. + + Data Structure + + MyRespiLens expects uploaded data to be valid JSON and in + RespiLens projections format. -
+ + RespiLens projections format is the internally-defined JSON + style for forecast data. Documentation for this JSON format + can be found on the MyRespiLens + + {" "} + documentation + {" "} + page. + + + {" "} + If you have questions or concerns, don't hesitate to{" "} + + contact the RespiLens Team. + + +
+ + + + + + - - - + document.getElementById("file-input")?.click()} + > + + + - - RespiLens projections-style .json files only - - -
- - - - -
-
+ +
+ + Drop your RespiLens .json file here + + + Upload your RespiLens data file to view your personalized + RespiLens dashboard + +
+ + + + + + + RespiLens projections-style .json files only + + +
+ + + +
+
); }; -export default MyRespiLensDashboard; \ No newline at end of file +export default MyRespiLensDashboard; diff --git a/app/src/components/narratives/NarrativeBrowser.jsx b/app/src/components/narratives/NarrativeBrowser.jsx index dab9c62c..8ff0471f 100644 --- a/app/src/components/narratives/NarrativeBrowser.jsx +++ b/app/src/components/narratives/NarrativeBrowser.jsx @@ -1,25 +1,53 @@ -import { useState, useEffect } from 'react'; -import { Helmet } from 'react-helmet-async'; -import { Container, Card, Title, Text, Group, Badge, Button, Stack, Loader, Center, ThemeIcon, Paper, SimpleGrid, TextInput, Select } from '@mantine/core'; -import { IconBook, IconCalendar, IconUser, IconArrowRight, IconStar, IconClock, IconSearch } from '@tabler/icons-react'; -import { narrativeRegistry, getAllTags, searchNarratives } from '../../data/narratives/index.js'; +import { useState, useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { + Container, + Card, + Title, + Text, + Group, + Badge, + Button, + Stack, + Loader, + Center, + ThemeIcon, + Paper, + SimpleGrid, + TextInput, + Select, +} from "@mantine/core"; +import { + IconBook, + IconCalendar, + IconUser, + IconArrowRight, + IconStar, + IconClock, + IconSearch, +} from "@tabler/icons-react"; +import { + narrativeRegistry, + getAllTags, + searchNarratives, +} from "../../data/narratives/index.js"; const NarrativeBrowser = ({ onNarrativeSelect }) => { const [loading, setLoading] = useState(true); - const [searchTerm, setSearchTerm] = useState(''); - const [filterTag, setFilterTag] = useState(''); - const [sortBy, setSortBy] = useState('date'); + const [searchTerm, setSearchTerm] = useState(""); + const [filterTag, setFilterTag] = useState(""); + const [sortBy, setSortBy] = useState("date"); useEffect(() => { // Load narratives from registry - console.log('Loading narratives from registry:', narrativeRegistry.length); + console.log("Loading narratives from registry:", narrativeRegistry.length); setLoading(false); }, []); // Get filtered narratives based on search/filter criteria const filteredNarratives = searchNarratives(searchTerm, filterTag, sortBy); - const featuredNarratives = filteredNarratives.filter(n => n.featured); - const regularNarratives = filteredNarratives.filter(n => !n.featured); + const featuredNarratives = filteredNarratives.filter((n) => n.featured); + const regularNarratives = filteredNarratives.filter((n) => !n.featured); const allTags = getAllTags(); if (loading) { @@ -40,19 +68,29 @@ const NarrativeBrowser = ({ onNarrativeSelect }) => { radius="md" withBorder style={{ - cursor: 'pointer', - transition: 'all 0.2s ease', - border: featured ? '2px solid var(--mantine-primary-color-filled)' : undefined + cursor: "pointer", + transition: "all 0.2s ease", + border: featured + ? "2px solid var(--mantine-primary-color-filled)" + : undefined, }} onClick={() => onNarrativeSelect(narrative.id)} >
- + {featured ? : } - {featured && Featured} + {featured && ( + + Featured + + )} {narrative.title} @@ -65,8 +103,10 @@ const NarrativeBrowser = ({ onNarrativeSelect }) => { </Text> <Group gap="xs" mb="md" wrap="wrap"> - {narrative.tags.map(tag => ( - <Badge key={tag} size="xs" variant="light">{tag}</Badge> + {narrative.tags.map((tag) => ( + <Badge key={tag} size="xs" variant="light"> + {tag} + </Badge> ))} </Group> @@ -74,21 +114,29 @@ const NarrativeBrowser = ({ onNarrativeSelect }) => { <Stack gap={4}> <Group gap="xs"> <IconUser size={14} /> - <Text size="xs" c="dimmed">{narrative.author}</Text> + <Text size="xs" c="dimmed"> + {narrative.author} + </Text> </Group> <Group gap="md"> <Group gap="xs"> <IconCalendar size={14} /> - <Text size="xs" c="dimmed">{narrative.date}</Text> + <Text size="xs" c="dimmed"> + {narrative.date} + </Text> </Group> <Group gap="xs"> <IconClock size={14} /> - <Text size="xs" c="dimmed">{narrative.readTime}</Text> + <Text size="xs" c="dimmed"> + {narrative.readTime} + </Text> </Group> {narrative.slideCount && ( <Group gap="xs"> <IconBook size={14} /> - <Text size="xs" c="dimmed">{narrative.slideCount} slides</Text> + <Text size="xs" c="dimmed"> + {narrative.slideCount} slides + </Text> </Group> )} </Group> @@ -117,83 +165,90 @@ const NarrativeBrowser = ({ onNarrativeSelect }) => { <Container size="xl" py="xl"> {/* Header */} <Paper shadow="sm" p="lg" mb="xl"> - <Group align="center" mb="md"> - <ThemeIcon size="lg" variant="light"> - <IconBook size={24} /> - </ThemeIcon> - <div> - <Title order={1}>Data Narratives - - Explore interactive stories that bring respiratory disease data to life - -
-
+ + + + +
+ Data Narratives + + Explore interactive stories that bring respiratory disease data + to life + +
+
+ + {/* Search and Filters */} + + setSearchTerm(e.target.value)} + leftSection={} + /> + + +
- {/* Search and Filters */} - - setSearchTerm(e.target.value)} - leftSection={} - /> - - - + {/* Featured Narratives */} + {featuredNarratives.length > 0 && ( + <> + + Featured Narratives + + + {featuredNarratives.map((narrative) => ( + + ))} + + + )} - {/* Featured Narratives */} - {featuredNarratives.length > 0 && ( - <> - Featured Narratives - - {featuredNarratives.map(narrative => ( - + {/* All Narratives */} + + {featuredNarratives.length > 0 ? "More Narratives" : "All Narratives"} + + + {regularNarratives.length > 0 ? ( + + {regularNarratives.map((narrative) => ( + ))} - - )} - - {/* All Narratives */} - - {featuredNarratives.length > 0 ? 'More Narratives' : 'All Narratives'} - - - {regularNarratives.length > 0 ? ( - - {regularNarratives.map(narrative => ( - - ))} - - ) : ( - - - {searchTerm || filterTag - ? 'No narratives found matching your criteria.' - : 'No additional narratives available.'} - - - )} -
+ ) : ( + + + {searchTerm || filterTag + ? "No narratives found matching your criteria." + : "No additional narratives available."} + + + )} +
); }; diff --git a/app/src/components/narratives/NarrativeViewer.jsx b/app/src/components/narratives/NarrativeViewer.jsx index ebf4297e..332364ed 100644 --- a/app/src/components/narratives/NarrativeViewer.jsx +++ b/app/src/components/narratives/NarrativeViewer.jsx @@ -1,20 +1,32 @@ -import { useState, useEffect } from 'react'; -import { Container, Paper, Title, Text, Group, Stack, Badge, ThemeIcon, Loader, Center } from '@mantine/core'; -import { IconBook, IconCalendar, IconUser } from '@tabler/icons-react'; +import { useState, useEffect } from "react"; +import { + Container, + Paper, + Title, + Text, + Group, + Stack, + Badge, + ThemeIcon, + Loader, + Center, +} from "@mantine/core"; +import { IconBook, IconCalendar, IconUser } from "@tabler/icons-react"; const NarrativeViewer = () => { - const [content, setContent] = useState(''); + const [content, setContent] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Narrative metadata const narrative = { - id: 'flu-winter-2024-25', - title: 'Flu Season Winter 2024-25: A Data Story', - description: 'An interactive narrative exploring the 2024-25 flu season trends, forecasting insights, and public health implications.', - author: 'RespiLens Analytics Team', - date: '2024-12-24', - tags: ['Influenza', 'Forecasting', 'Public Health'] + id: "flu-winter-2024-25", + title: "Flu Season Winter 2024-25: A Data Story", + description: + "An interactive narrative exploring the 2024-25 flu season trends, forecasting insights, and public health implications.", + author: "RespiLens Analytics Team", + date: "2024-12-24", + tags: ["Influenza", "Forecasting", "Public Health"], }; useEffect(() => { @@ -93,67 +105,102 @@ This narrative is built upon data from: setContent(markdownContent); setLoading(false); } catch (err) { - console.error('Failed to load narrative content', err); - setError('Unable to load narrative content at this time.'); + console.error("Failed to load narrative content", err); + setError("Unable to load narrative content at this time."); setLoading(false); } }, []); const renderMarkdown = (content) => { - return content - .split('\n') - .map((line, index) => { - if (line.startsWith('# ')) { - return {line.substring(2)}; - } - if (line.startsWith('## ')) { - return {line.substring(3)}; - } - if (line.startsWith('### ')) { - return {line.substring(4)}; - } - if (line.startsWith('- ')) { - const text = line.substring(2); - const parts = text.split(/(\*\*.*?\*\*)/); - return ( - - {parts.map((part, i) => - part.startsWith('**') && part.endsWith('**') - ? {part.slice(2, -2)} - : part - )} - - ); - } - if (line.match(/^\d+\./)) { - return {line.substring(line.indexOf('.') + 2)}; - } - if (line.startsWith('*') && line.endsWith('*') && line.length > 2 && !line.includes('**')) { - return {line.substring(1, line.length - 1)}; - } - if (line.startsWith('---')) { - return
; - } - if (line.trim()) { - const parts = line.split(/(\*\*.*?\*\*)/); - return ( - - {parts.map((part, i) => - part.startsWith('**') && part.endsWith('**') - ? {part.slice(2, -2)} - : part - )} - - ); - } - return
; - }); + return content.split("\n").map((line, index) => { + if (line.startsWith("# ")) { + return ( + + {line.substring(2)} + + ); + } + if (line.startsWith("## ")) { + return ( + + {line.substring(3)} + + ); + } + if (line.startsWith("### ")) { + return ( + + {line.substring(4)} + + ); + } + if (line.startsWith("- ")) { + const text = line.substring(2); + const parts = text.split(/(\*\*.*?\*\*)/); + return ( + + {parts.map((part, i) => + part.startsWith("**") && part.endsWith("**") ? ( + {part.slice(2, -2)} + ) : ( + part + ), + )} + + ); + } + if (line.match(/^\d+\./)) { + return ( + + {line.substring(line.indexOf(".") + 2)} + + ); + } + if ( + line.startsWith("*") && + line.endsWith("*") && + line.length > 2 && + !line.includes("**") + ) { + return ( + + {line.substring(1, line.length - 1)} + + ); + } + if (line.startsWith("---")) { + return ( +
+ ); + } + if (line.trim()) { + const parts = line.split(/(\*\*.*?\*\*)/); + return ( + + {parts.map((part, i) => + part.startsWith("**") && part.endsWith("**") ? ( + {part.slice(2, -2)} + ) : ( + part + ), + )} + + ); + } + return
; + }); }; if (loading) { return ( -
+
Loading narrative... @@ -166,15 +213,17 @@ This narrative is built upon data from: if (error) { return ( -
- {error} +
+ + {error} +
); } return ( - + {/* Header */} @@ -183,10 +232,16 @@ This narrative is built upon data from: - Data Narrative + + Data Narrative + - {narrative.title} - {narrative.description} + + {narrative.title} + + + {narrative.description} + @@ -199,8 +254,10 @@ This narrative is built upon data from:
- {narrative.tags.map(tag => ( - {tag} + {narrative.tags.map((tag) => ( + + {tag} + ))} @@ -208,7 +265,7 @@ This narrative is built upon data from: {/* Content */} -
+
{renderMarkdown(content)}
diff --git a/app/src/components/narratives/SlideNarrativeViewer.jsx b/app/src/components/narratives/SlideNarrativeViewer.jsx index eb43a647..032dbcbf 100644 --- a/app/src/components/narratives/SlideNarrativeViewer.jsx +++ b/app/src/components/narratives/SlideNarrativeViewer.jsx @@ -1,9 +1,31 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { Helmet } from 'react-helmet-async'; -import { Container, Paper, Title, Text, Group, Stack, Badge, ThemeIcon, Loader, Center, Button, ActionIcon, Box, Divider } from '@mantine/core'; -import { IconBook, IconCalendar, IconUser, IconChevronLeft, IconChevronRight, IconCode } from '@tabler/icons-react'; -import DataVisualizationContainer from '../DataVisualizationContainer'; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { Helmet } from "react-helmet-async"; +import { + Container, + Paper, + Title, + Text, + Group, + Stack, + Badge, + ThemeIcon, + Loader, + Center, + Button, + ActionIcon, + Box, + Divider, +} from "@mantine/core"; +import { + IconBook, + IconCalendar, + IconUser, + IconChevronLeft, + IconChevronRight, + IconCode, +} from "@tabler/icons-react"; +import DataVisualizationContainer from "../DataVisualizationContainer"; // Plotly Gaussian Chart Component const PlotlyGaussianChart = () => { @@ -19,13 +41,13 @@ const PlotlyGaussianChart = () => { } if (!document.querySelector('script[src*="plotly"]')) { - const script = document.createElement('script'); - script.src = 'https://cdn.plot.ly/plotly-2.27.0.min.js'; // Use specific version + const script = document.createElement("script"); + script.src = "https://cdn.plot.ly/plotly-2.27.0.min.js"; // Use specific version script.onload = () => { setPlotlyLoaded(true); }; script.onerror = () => { - console.error('Failed to load Plotly'); + console.error("Failed to load Plotly"); }; document.head.appendChild(script); } @@ -39,51 +61,56 @@ const PlotlyGaussianChart = () => { const generateGaussian = (mean = 0, std = 1, points = 200) => { const x = []; const y = []; - + for (let i = 0; i < points; i++) { - const xi = (i - points/2) * 6 / points; // Range from -3 to 3 + const xi = ((i - points / 2) * 6) / points; // Range from -3 to 3 x.push(xi); - y.push((1 / (std * Math.sqrt(2 * Math.PI))) * Math.exp(-0.5 * Math.pow((xi - mean) / std, 2))); + y.push( + (1 / (std * Math.sqrt(2 * Math.PI))) * + Math.exp(-0.5 * Math.pow((xi - mean) / std, 2)), + ); } - + return { x, y }; }; const gaussianData = generateGaussian(0, 1, 200); - - const plotData = [{ - x: gaussianData.x, - y: gaussianData.y, - type: 'scatter', - mode: 'lines', - name: 'Gaussian Distribution', - line: { - color: '#228be6', - width: 3 + + const plotData = [ + { + x: gaussianData.x, + y: gaussianData.y, + type: "scatter", + mode: "lines", + name: "Gaussian Distribution", + line: { + color: "#228be6", + width: 3, + }, + fill: "tonexty", + fillcolor: "rgba(34, 139, 230, 0.2)", }, - fill: 'tonexty', - fillcolor: 'rgba(34, 139, 230, 0.2)' - }]; + ]; const layout = { title: { - text: 'Standard Normal Distribution (μ=0, σ=1)', - font: { size: 16 } + text: "Standard Normal Distribution (μ=0, σ=1)", + font: { size: 16 }, }, xaxis: { - title: 'Value', + title: "Value", showgrid: true, - gridcolor: '#f0f0f0' + gridcolor: "#f0f0f0", }, yaxis: { - title: 'Probability Density', + title: "Probability Density", showgrid: true, - gridcolor: '#f0f0f0' + gridcolor: "#f0f0f0", }, margin: { t: 50, r: 20, b: 50, l: 60 }, - paper_bgcolor: 'rgba(0,0,0,0)', - plot_bgcolor: 'rgba(0,0,0,0)', - font: { family: 'Inter, sans-serif' } + paper_bgcolor: "rgba(0,0,0,0)", + plot_bgcolor: "rgba(0,0,0,0)", + font: { family: "Inter, sans-serif" }, }; setChartData({ data: plotData, layout }); @@ -92,13 +119,18 @@ const PlotlyGaussianChart = () => { // Render chart when data is ready useEffect(() => { if (chartData && plotlyLoaded && window.Plotly && chartRef.current) { - window.Plotly.newPlot(chartRef.current, chartData.data, chartData.layout, { - responsive: true, - displayModeBar: true, - modeBarButtonsToRemove: ['pan2d', 'lasso2d', 'select2d'], - displaylogo: false - }).catch(error => { - console.error('Error creating Plotly chart:', error); + window.Plotly.newPlot( + chartRef.current, + chartData.data, + chartData.layout, + { + responsive: true, + displayModeBar: true, + modeBarButtonsToRemove: ["pan2d", "lasso2d", "select2d"], + displaylogo: false, + }, + ).catch((error) => { + console.error("Error creating Plotly chart:", error); }); } }, [chartData, plotlyLoaded]); @@ -115,8 +147,8 @@ const PlotlyGaussianChart = () => { } return ( -
-
+
+
); }; @@ -132,87 +164,104 @@ const SlideNarrativeViewer = () => { const parseVisualizationUrl = useCallback((url) => { if (!url) return null; - if (url.startsWith('javascript:')) { - return { type: 'custom', code: url.replace('javascript:', '') }; + if (url.startsWith("javascript:")) { + return { type: "custom", code: url.replace("javascript:", "") }; } const urlObj = new URL(url, window.location.origin); const params = new URLSearchParams(urlObj.search); return { - type: 'respilens', - location: params.get('location') || 'US', - view: params.get('view') || 'fludetailed', - dates: params.get('dates')?.split(',') || [], - models: params.get('models')?.split(',') || [] + type: "respilens", + location: params.get("location") || "US", + view: params.get("view") || "fludetailed", + dates: params.get("dates")?.split(",") || [], + models: params.get("models")?.split(",") || [], }; }, []); - const parseNarrative = useCallback((content) => { - try { - const parts = content.split('---'); - - if (parts.length >= 3) { - const frontmatterLines = parts[1].trim().split('\n'); - const parsedMetadata = {}; - frontmatterLines.forEach(line => { - const [key, ...valueParts] = line.split(':'); - if (key && valueParts.length > 0) { - parsedMetadata[key.trim()] = valueParts.join(':').trim().replace(/"/g, ''); - } - }); - setMetadata(parsedMetadata); - - const slideContent = parts.slice(2).join('---'); - - const slideMatches = slideContent.split(/\n# /).filter(s => s.trim()); - - const parsedSlides = slideMatches.map((slide, index) => { - let normalizedSlide = slide; - if (index === 0) { - normalizedSlide = normalizedSlide.replace(/^# /, ''); - } - - const lines = normalizedSlide.split('\n'); - const titleLine = lines[0]; - const titleMatch = titleLine.match(/^(.*?)\s*\[(.*?)\]$/); - - const title = titleMatch ? titleMatch[1].trim() : titleLine.trim(); - const url = titleMatch ? titleMatch[2].trim() : null; - const body = lines.slice(1).join('\n').trim(); - - return { title, url, content: body }; - }); - - setSlides(parsedSlides); - - const initialVisualizationUrl = parsedSlides[0]?.url || parsedMetadata.dataset; - setCurrentVisualization(initialVisualizationUrl ? parseVisualizationUrl(initialVisualizationUrl) : null); - } else { - console.error('Invalid narrative format - not enough parts after splitting by ---'); + const parseNarrative = useCallback( + (content) => { + try { + const parts = content.split("---"); + + if (parts.length >= 3) { + const frontmatterLines = parts[1].trim().split("\n"); + const parsedMetadata = {}; + frontmatterLines.forEach((line) => { + const [key, ...valueParts] = line.split(":"); + if (key && valueParts.length > 0) { + parsedMetadata[key.trim()] = valueParts + .join(":") + .trim() + .replace(/"/g, ""); + } + }); + setMetadata(parsedMetadata); + + const slideContent = parts.slice(2).join("---"); + + const slideMatches = slideContent + .split(/\n# /) + .filter((s) => s.trim()); + + const parsedSlides = slideMatches.map((slide, index) => { + let normalizedSlide = slide; + if (index === 0) { + normalizedSlide = normalizedSlide.replace(/^# /, ""); + } + + const lines = normalizedSlide.split("\n"); + const titleLine = lines[0]; + const titleMatch = titleLine.match(/^(.*?)\s*\[(.*?)\]$/); + + const title = titleMatch ? titleMatch[1].trim() : titleLine.trim(); + const url = titleMatch ? titleMatch[2].trim() : null; + const body = lines.slice(1).join("\n").trim(); + + return { title, url, content: body }; + }); + + setSlides(parsedSlides); + + const initialVisualizationUrl = + parsedSlides[0]?.url || parsedMetadata.dataset; + setCurrentVisualization( + initialVisualizationUrl + ? parseVisualizationUrl(initialVisualizationUrl) + : null, + ); + } else { + console.error( + "Invalid narrative format - not enough parts after splitting by ---", + ); + setMetadata({}); + setSlides([]); + setCurrentVisualization(null); + } + } catch (error) { + console.error("Error parsing narrative:", error); setMetadata({}); setSlides([]); setCurrentVisualization(null); } - } catch (error) { - console.error('Error parsing narrative:', error); - setMetadata({}); - setSlides([]); - setCurrentVisualization(null); - } - setLoading(false); - }, [parseVisualizationUrl]); + setLoading(false); + }, + [parseVisualizationUrl], + ); useEffect(() => { - const narrativeId = id || 'flu-winter-2024-25-slides'; + const narrativeId = id || "flu-winter-2024-25-slides"; const loadNarrative = async () => { try { - const narrativeModule = await import(`../../data/narratives/${narrativeId}.js`); + const narrativeModule = await import( + `../../data/narratives/${narrativeId}.js` + ); parseNarrative(narrativeModule.narrativeContent); } catch (error) { - console.error('Error loading narrative module:', error); + console.error("Error loading narrative module:", error); const fallbackContent = `--- title: "Flu Season Winter 2024-25: A Data Story" @@ -323,42 +372,52 @@ The final view returns to the national perspective with our latest forecasts, sh }, [id, parseNarrative]); const renderMarkdown = (content) => { - return content - .split('\n') - .map((line, index) => { - if (line.startsWith('**') && line.endsWith('**')) { - return {line.slice(2, -2)}; - } - if (line.startsWith('- ')) { - const text = line.substring(2); - const parts = text.split(/(\*\*.*?\*\*)/); - return ( - - {parts.map((part, i) => - part.startsWith('**') && part.endsWith('**') - ? {part.slice(2, -2)} - : part - )} - - ); - } - if (line.match(/^\d+\./)) { - return {line.substring(line.indexOf('.') + 2)}; - } - if (line.trim()) { - const parts = line.split(/(\*\*.*?\*\*)/); - return ( - - {parts.map((part, i) => - part.startsWith('**') && part.endsWith('**') - ? {part.slice(2, -2)} - : part - )} - - ); - } - return
; - }); + return content.split("\n").map((line, index) => { + if (line.startsWith("**") && line.endsWith("**")) { + return ( + + {line.slice(2, -2)} + + ); + } + if (line.startsWith("- ")) { + const text = line.substring(2); + const parts = text.split(/(\*\*.*?\*\*)/); + return ( + + {parts.map((part, i) => + part.startsWith("**") && part.endsWith("**") ? ( + {part.slice(2, -2)} + ) : ( + part + ), + )} + + ); + } + if (line.match(/^\d+\./)) { + return ( + + {line.substring(line.indexOf(".") + 2)} + + ); + } + if (line.trim()) { + const parts = line.split(/(\*\*.*?\*\*)/); + return ( + + {parts.map((part, i) => + part.startsWith("**") && part.endsWith("**") ? ( + {part.slice(2, -2)} + ) : ( + part + ), + )} + + ); + } + return
; + }); }; const goToSlide = (index) => { @@ -372,22 +431,22 @@ The final view returns to the national perspective with our latest forecasts, sh }; const visualizationDetails = useMemo(() => { - if (!currentVisualization || currentVisualization.type !== 'respilens') { + if (!currentVisualization || currentVisualization.type !== "respilens") { return null; } const params = new URLSearchParams(); if (currentVisualization.location) { - params.set('location', currentVisualization.location); + params.set("location", currentVisualization.location); } if (currentVisualization.view) { - params.set('view', currentVisualization.view); + params.set("view", currentVisualization.view); } if (currentVisualization.dates?.length) { - params.set('dates', currentVisualization.dates.join(',')); + params.set("dates", currentVisualization.dates.join(",")); } if (currentVisualization.models?.length) { - params.set('models', currentVisualization.models.join(',')); + params.set("models", currentVisualization.models.join(",")); } return { @@ -395,7 +454,7 @@ The final view returns to the national perspective with our latest forecasts, sh location: currentVisualization.location, view: currentVisualization.view, dates: currentVisualization.dates, - models: currentVisualization.models + models: currentVisualization.models, }; }, [currentVisualization]); @@ -408,12 +467,12 @@ The final view returns to the national perspective with our latest forecasts, sh ); } - if (currentVisualization.type === 'custom') { + if (currentVisualization.type === "custom") { // Handle specific custom visualizations - if (currentVisualization.code === 'plotly-gaussian') { + if (currentVisualization.code === "plotly-gaussian") { return ; } - + // Default custom visualization placeholder return (
@@ -421,9 +480,13 @@ The final view returns to the national perspective with our latest forecasts, sh -
- Custom Visualization - {currentVisualization.code} +
+ + Custom Visualization + + + {currentVisualization.code} + Custom JavaScript visualizations would be rendered here @@ -435,17 +498,19 @@ The final view returns to the national perspective with our latest forecasts, sh // Render RespiLens visualization return ( - + - RespiLens View + + RespiLens View + {visualizationDetails?.url && ( - + {visualizationDetails.url} )} -
- + @@ -457,7 +522,7 @@ The final view returns to the national perspective with our latest forecasts, sh if (loading) { return ( -
+
Loading narrative... @@ -474,101 +539,126 @@ The final view returns to the national perspective with our latest forecasts, sh RespiLens | Narrative Viewer -
+
{/* Header */} - - -
- - - - - Interactive Narrative + + +
+ + + + + + Interactive Narrative + + + {metadata.title} +
+ + + Slide {currentSlide + 1} of {slides.length} + - {metadata.title} -
- - Slide {currentSlide + 1} of {slides.length} -
-
+ - {/* Main Content */} -
- {/* Left Panel - Slide Content */} - - -
- {currentSlideData?.title} -
- {renderMarkdown(currentSlideData?.content || '')} + {/* Main Content */} +
+ {/* Left Panel - Slide Content */} + + +
+ + {currentSlideData?.title} + +
+ {renderMarkdown(currentSlideData?.content || "")} +
-
- - - {/* Navigation */} - - - - - {slides.map((_, index) => ( - goToSlide(index)} - aria-label={`Go to slide ${index + 1}`} - aria-current={index === currentSlide ? 'true' : 'false'} - > - {index + 1} - - ))} + + + {/* Navigation */} + + + + + {slides.map((_, index) => ( + goToSlide(index)} + aria-label={`Go to slide ${index + 1}`} + aria-current={index === currentSlide ? "true" : "false"} + > + {index + 1} + + ))} + + + - - - - {/* Slide metadata */} - - - - {metadata.authors} - - - - {metadata.date} + {/* Slide metadata */} + + + + + {metadata.authors} + + + + + + {metadata.date} + + - - - + + - {/* Right Panel - Visualization */} - - {renderVisualization()} - + {/* Right Panel - Visualization */} + + {renderVisualization()} + +
-
); }; diff --git a/app/src/components/reporting/ReportingDelayPage.jsx b/app/src/components/reporting/ReportingDelayPage.jsx index 357891dd..8b7fa2ba 100644 --- a/app/src/components/reporting/ReportingDelayPage.jsx +++ b/app/src/components/reporting/ReportingDelayPage.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from "react"; import { Anchor, ActionIcon, @@ -23,7 +23,7 @@ import { Text, ThemeIcon, Title, -} from '@mantine/core'; +} from "@mantine/core"; import { IconArrowsMaximize, IconArrowsMinimize, @@ -31,7 +31,7 @@ import { IconClock, IconDownload, IconFileUpload, -} from '@tabler/icons-react'; +} from "@tabler/icons-react"; import { BarElement, CategoryScale, @@ -41,13 +41,21 @@ import { LinearScale, PointElement, Tooltip, -} from 'chart.js'; -import { Chart } from 'react-chartjs-2'; -import Plot from 'react-plotly.js'; -import { driver } from 'driver.js'; -import 'driver.js/dist/driver.css'; +} from "chart.js"; +import { Chart } from "react-chartjs-2"; +import Plot from "react-plotly.js"; +import { driver } from "driver.js"; +import "driver.js/dist/driver.css"; -ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend); +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Tooltip, + Legend, +); const SAMPLE_CSV = `reference_date,report_date,value 2024-02-01,2024-02-02,31 @@ -74,17 +82,17 @@ const SAMPLE_CSV = `reference_date,report_date,value const parseCsv = (text) => { const rows = text.trim().split(/\r?\n/).filter(Boolean); if (rows.length < 2) { - throw new Error('CSV appears to be empty.'); + throw new Error("CSV appears to be empty."); } - const headers = rows[0].split(',').map((header) => header.trim()); + const headers = rows[0].split(",").map((header) => header.trim()); const normalizedHeaders = headers.map((header) => header.toLowerCase()); - const dataRows = rows.slice(1).map((row) => row.split(',')); + const dataRows = rows.slice(1).map((row) => row.split(",")); const records = dataRows.map((parts, index) => { const entry = {}; headers.forEach((header, headerIndex) => { - entry[header] = parts[headerIndex]?.trim() ?? ''; + entry[header] = parts[headerIndex]?.trim() ?? ""; }); entry._rowIndex = index + 2; return entry; @@ -93,7 +101,11 @@ const parseCsv = (text) => { return { headers, normalizedHeaders, records }; }; -const formatDateLabel = (value) => new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +const formatDateLabel = (value) => + new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); const getDefaultReferenceRange = (dates) => { if (!dates.length) return [0, 0]; @@ -102,7 +114,7 @@ const getDefaultReferenceRange = (dates) => { const getFrequencyUnit = (dates) => { if (dates.length < 2) { - return { unit: 'day', unitDays: 1 }; + return { unit: "day", unitDays: 1 }; } const diffs = dates .slice(1) @@ -111,44 +123,66 @@ const getFrequencyUnit = (dates) => { .sort((a, b) => a - b); const median = diffs[Math.floor(diffs.length / 2)]; if (median >= 28) { - return { unit: 'month', unitDays: 30 }; + return { unit: "month", unitDays: 30 }; } if (median >= 6) { - return { unit: 'week', unitDays: 7 }; + return { unit: "week", unitDays: 7 }; } - return { unit: 'day', unitDays: 1 }; + return { unit: "day", unitDays: 1 }; }; - const buildTriangle = (records, { referenceDates, maxLagDays } = {}) => { - const allReferenceDates = Array.from(new Set(records.map((record) => record.referenceDate))).sort(); - const allReportDates = Array.from(new Set(records.map((record) => record.reportDate))).sort(); - const activeReferenceDates = referenceDates?.length ? referenceDates : allReferenceDates; + const allReferenceDates = Array.from( + new Set(records.map((record) => record.referenceDate)), + ).sort(); + const allReportDates = Array.from( + new Set(records.map((record) => record.reportDate)), + ).sort(); + const activeReferenceDates = referenceDates?.length + ? referenceDates + : allReferenceDates; const activeReportDates = maxLagDays ? allReportDates.filter((date) => { return activeReferenceDates.some((referenceDate) => { - const diff = Math.round((new Date(date) - new Date(referenceDate)) / (1000 * 60 * 60 * 24)); + const diff = Math.round( + (new Date(date) - new Date(referenceDate)) / (1000 * 60 * 60 * 24), + ); return diff >= 0 && diff <= maxLagDays; }); }) : allReportDates; - const reportDatesWithDiagonal = Array.from(new Set([...activeReportDates, ...activeReferenceDates])).sort(); + const reportDatesWithDiagonal = Array.from( + new Set([...activeReportDates, ...activeReferenceDates]), + ).sort(); const allowedReferenceDates = new Set(activeReferenceDates); const allowedReportDates = new Set(reportDatesWithDiagonal); const filteredRecords = records.filter( - (record) => allowedReferenceDates.has(record.referenceDate) && allowedReportDates.has(record.reportDate), + (record) => + allowedReferenceDates.has(record.referenceDate) && + allowedReportDates.has(record.reportDate), ); const lagFilteredRecords = maxLagDays ? filteredRecords.filter((record) => { const delay = Math.round( - (new Date(record.reportDate) - new Date(record.referenceDate)) / (1000 * 60 * 60 * 24), + (new Date(record.reportDate) - new Date(record.referenceDate)) / + (1000 * 60 * 60 * 24), ); return delay >= 0 && delay <= maxLagDays; }) : filteredRecords; - const valueMap = new Map(lagFilteredRecords.map((record) => [`${record.referenceDate}|${record.reportDate}`, record.value])); + const valueMap = new Map( + lagFilteredRecords.map((record) => [ + `${record.referenceDate}|${record.reportDate}`, + record.value, + ]), + ); - return { referenceDates: activeReferenceDates, reportDates: reportDatesWithDiagonal, valueMap, filteredRecords: lagFilteredRecords }; + return { + referenceDates: activeReferenceDates, + reportDates: reportDatesWithDiagonal, + valueMap, + filteredRecords: lagFilteredRecords, + }; }; const buildDelayDistribution = (records) => { @@ -156,7 +190,10 @@ const buildDelayDistribution = (records) => { records.forEach((record) => { const delayDays = Math.max( 0, - Math.round((new Date(record.reportDate) - new Date(record.referenceDate)) / (1000 * 60 * 60 * 24)), + Math.round( + (new Date(record.reportDate) - new Date(record.referenceDate)) / + (1000 * 60 * 60 * 24), + ), ); delayMap.set(delayDays, (delayMap.get(delayDays) || 0) + record.value); }); @@ -196,40 +233,48 @@ const buildRecordsFromMapping = (rows, mapping) => { return []; } - return rows.map((row) => { - const referenceValue = row[referenceDate]; - const reportValue = row[reportDate]; - const numericValue = Number(row[value]); - if (!referenceValue || !reportValue || Number.isNaN(numericValue)) { - return null; - } - return { - referenceDate: referenceValue, - reportDate: reportValue, - value: numericValue, - }; - }).filter(Boolean); + return rows + .map((row) => { + const referenceValue = row[referenceDate]; + const reportValue = row[reportDate]; + const numericValue = Number(row[value]); + if (!referenceValue || !reportValue || Number.isNaN(numericValue)) { + return null; + } + return { + referenceDate: referenceValue, + reportDate: reportValue, + value: numericValue, + }; + }) + .filter(Boolean); }; const INITIAL_PARSED = parseCsv(SAMPLE_CSV); const INITIAL_MAPPING = { - referenceDate: '', - reportDate: '', - value: '', + referenceDate: "", + reportDate: "", + value: "", }; const SAMPLE_MAPPING = { referenceDate: - INITIAL_PARSED.headers[INITIAL_PARSED.normalizedHeaders.indexOf('reference_date')] ?? '', + INITIAL_PARSED.headers[ + INITIAL_PARSED.normalizedHeaders.indexOf("reference_date") + ] ?? "", reportDate: - INITIAL_PARSED.headers[INITIAL_PARSED.normalizedHeaders.indexOf('report_date')] ?? '', - value: INITIAL_PARSED.headers[INITIAL_PARSED.normalizedHeaders.indexOf('value')] ?? '', + INITIAL_PARSED.headers[ + INITIAL_PARSED.normalizedHeaders.indexOf("report_date") + ] ?? "", + value: + INITIAL_PARSED.headers[INITIAL_PARSED.normalizedHeaders.indexOf("value")] ?? + "", }; const ReportingDelayPage = () => { const inputRef = useRef(null); const [csvRows, setCsvRows] = useState(() => INITIAL_PARSED.records); const [csvHeaders, setCsvHeaders] = useState(() => INITIAL_PARSED.headers); - const [fileName, setFileName] = useState('sample-epinowcast.csv'); + const [fileName, setFileName] = useState("sample-epinowcast.csv"); const [error, setError] = useState(null); const [isDragging, setIsDragging] = useState(false); const [columnMapping, setColumnMapping] = useState(SAMPLE_MAPPING); @@ -251,10 +296,14 @@ const ReportingDelayPage = () => { const extraColumnOptions = useMemo(() => { return extraColumns.map((column) => ({ column, - options: Array.from(new Set(csvRows.map((row) => row[column]).filter(Boolean))).sort().map((value) => ({ - value, - label: value, - })), + options: Array.from( + new Set(csvRows.map((row) => row[column]).filter(Boolean)), + ) + .sort() + .map((value) => ({ + value, + label: value, + })), })); }, [csvRows, extraColumns]); @@ -273,24 +322,37 @@ const ReportingDelayPage = () => { ); const mappingComplete = Boolean( - columnMapping.referenceDate && columnMapping.reportDate && columnMapping.value, + columnMapping.referenceDate && + columnMapping.reportDate && + columnMapping.value, ); const allReferenceDates = useMemo( - () => Array.from(new Set(records.map((record) => record.referenceDate))).sort(), + () => + Array.from(new Set(records.map((record) => record.referenceDate))).sort(), [records], ); - const [referenceRange, setReferenceRange] = useState(() => getDefaultReferenceRange(allReferenceDates)); - const { unit, unitDays } = useMemo(() => getFrequencyUnit(allReferenceDates), [allReferenceDates]); + const [referenceRange, setReferenceRange] = useState(() => + getDefaultReferenceRange(allReferenceDates), + ); + const { unit, unitDays } = useMemo( + () => getFrequencyUnit(allReferenceDates), + [allReferenceDates], + ); const maxLagDays = useMemo(() => { if (!records.length) return 0; return Math.max( ...records.map((record) => - Math.round((new Date(record.reportDate) - new Date(record.referenceDate)) / (1000 * 60 * 60 * 24)), + Math.round( + (new Date(record.reportDate) - new Date(record.referenceDate)) / + (1000 * 60 * 60 * 24), + ), ), ); }, [records]); - const [maxLagUnits, setMaxLagUnits] = useState(() => Math.max(0, Math.ceil(maxLagDays / unitDays))); + const [maxLagUnits, setMaxLagUnits] = useState(() => + Math.max(0, Math.ceil(maxLagDays / unitDays)), + ); const activeReferenceDates = useMemo(() => { const [start, end] = referenceRange; @@ -318,10 +380,10 @@ const ReportingDelayPage = () => { const hasShortDelay = summary.delay95 <= 3; const recommendation = hasLongDelay - ? 'Nowcasting is recommended to account for substantial reporting delays.' + ? "Nowcasting is recommended to account for substantial reporting delays." : hasShortDelay - ? 'Nowcasting may be optional; delays resolve quickly.' - : 'Consider nowcasting when you need near-real-time situational awareness.'; + ? "Nowcasting may be optional; delays resolve quickly." + : "Consider nowcasting when you need near-real-time situational awareness."; const handleFile = async (file) => { if (!file) return; @@ -353,28 +415,49 @@ const ReportingDelayPage = () => { showProgress: true, steps: [ { - element: '#reporting-triangle-upload', - popover: { title: 'Import CSV', description: 'Drop your file or download the sample CSV to start.' }, + element: "#reporting-triangle-upload", + popover: { + title: "Import CSV", + description: "Drop your file or download the sample CSV to start.", + }, }, { - element: '#reporting-triangle-mapping', - popover: { title: 'Map columns', description: 'Confirm which columns hold reference dates and reports.' }, + element: "#reporting-triangle-mapping", + popover: { + title: "Map columns", + description: + "Confirm which columns hold reference dates and reports.", + }, }, { - element: '#reporting-triangle-trajectory', - popover: { title: 'Trajectories', description: 'Watch how reports revise over time.' }, + element: "#reporting-triangle-trajectory", + popover: { + title: "Trajectories", + description: "Watch how reports revise over time.", + }, }, { - element: '#reporting-triangle-distribution', - popover: { title: 'Delay distribution', description: 'Inspect how long delays typically are.' }, + element: "#reporting-triangle-distribution", + popover: { + title: "Delay distribution", + description: "Inspect how long delays typically are.", + }, }, { - element: '#reporting-triangle-axis-cell', - popover: { title: 'Axes', description: 'Rows are reference dates and columns are report dates.' }, + element: "#reporting-triangle-axis-cell", + popover: { + title: "Axes", + description: + "Rows are reference dates and columns are report dates.", + }, }, { - element: '#reporting-triangle-diagonal-cell', - popover: { title: 'Diagonal', description: 'Delay = 0 reports arrive on the same day as the reference.' }, + element: "#reporting-triangle-diagonal-cell", + popover: { + title: "Diagonal", + description: + "Delay = 0 reports arrive on the same day as the reference.", + }, }, ], }); @@ -405,7 +488,14 @@ const ReportingDelayPage = () => { const sliderMarks = useMemo(() => { if (allReferenceDates.length <= 1) { - return [{ value: 0, label: allReferenceDates[0] ? formatDateLabel(allReferenceDates[0]) : '' }]; + return [ + { + value: 0, + label: allReferenceDates[0] + ? formatDateLabel(allReferenceDates[0]) + : "", + }, + ]; } return [ { value: 0, label: formatDateLabel(allReferenceDates[0]) }, @@ -429,7 +519,7 @@ const ReportingDelayPage = () => { }, [displayReferenceDates, displayReportDates]); const activeRangeLabel = activeReferenceDates.length ? `${formatDateLabel(activeReferenceDates[0])}–${formatDateLabel(activeReferenceDates.at(-1))}` - : 'No data selected'; + : "No data selected"; const diagonalHighlightDate = diagonalDates[0] ?? null; const triangleRows = displayReferenceDates.map((referenceDate) => ( @@ -445,15 +535,21 @@ const ReportingDelayPage = () => { return ( - {value ?? '—'} + {value ?? "—"} ); })} @@ -464,14 +560,18 @@ const ReportingDelayPage = () => { return [ { z: displayReferenceDates.map((referenceDate) => - displayReportDates.map((reportDate) => triangle.valueMap.get(`${referenceDate}|${reportDate}`) ?? null), + displayReportDates.map( + (reportDate) => + triangle.valueMap.get(`${referenceDate}|${reportDate}`) ?? null, + ), ), x: displayReportDates.map(formatDateLabel), y: displayReferenceDates.map(formatDateLabel), - type: 'heatmap', - colorscale: 'Blues', + type: "heatmap", + colorscale: "Blues", showscale: false, - hovertemplate: 'Reference %{y}
Report %{x}
Value %{z}', + hovertemplate: + "Reference %{y}
Report %{x}
Value %{z}", }, ]; }, [displayReferenceDates, displayReportDates, triangle.valueMap]); @@ -481,19 +581,23 @@ const ReportingDelayPage = () => { diagonalDates.length > 1 ? [ { - type: 'line', + type: "line", x0: formatDateLabel(diagonalDates[0]), y0: formatDateLabel(diagonalDates[0]), x1: formatDateLabel(diagonalDates[diagonalDates.length - 1]), y1: formatDateLabel(diagonalDates[diagonalDates.length - 1]), - line: { color: '#1a1b1e', width: 2 }, + line: { color: "#1a1b1e", width: 2 }, }, ] : []; return { margin: { l: 80, r: 20, t: 20, b: 60 }, - xaxis: { title: 'Report date', type: 'category' }, - yaxis: { title: 'Reference date', type: 'category', autorange: 'reversed' }, + xaxis: { title: "Report date", type: "category" }, + yaxis: { + title: "Reference date", + type: "category", + autorange: "reversed", + }, shapes: diagonalLine, height: 520, }; @@ -509,9 +613,12 @@ const ReportingDelayPage = () => { labels, datasets: referenceSeries.map((referenceDate, index) => ({ label: formatDateLabel(referenceDate), - data: triangle.reportDates.map((reportDate) => triangle.valueMap.get(`${referenceDate}|${reportDate}`) ?? null), - borderColor: ['#1c7ed6', '#2f9e44', '#f59f00'][index % 3], - backgroundColor: 'transparent', + data: triangle.reportDates.map( + (reportDate) => + triangle.valueMap.get(`${referenceDate}|${reportDate}`) ?? null, + ), + borderColor: ["#1c7ed6", "#2f9e44", "#f59f00"][index % 3], + backgroundColor: "transparent", tension: 0.3, })), }; @@ -522,9 +629,9 @@ const ReportingDelayPage = () => { labels: distribution.delays.map((delay) => `${delay}d`), datasets: [ { - label: 'Total reports', + label: "Total reports", data: distribution.values, - backgroundColor: '#4dabf7', + backgroundColor: "#4dabf7", }, ], }; @@ -533,8 +640,8 @@ const ReportingDelayPage = () => { const chartOptions = { responsive: true, plugins: { - legend: { position: 'bottom' }, - tooltip: { mode: 'index', intersect: false }, + legend: { position: "bottom" }, + tooltip: { mode: "index", intersect: false }, }, scales: { y: { beginAtZero: true }, @@ -545,12 +652,19 @@ const ReportingDelayPage = () => { - } w="fit-content"> + } + w="fit-content" + > Reporting triangle explorer - Do you need to nowcast? What is your reporting delay distribution? + + Do you need to nowcast? What is your reporting delay distribution? + - Analyze your reporting delay distribution with RespiLens and epinowcast. Everything runs locally in your browser. + Analyze your reporting delay distribution with RespiLens and + epinowcast. Everything runs locally in your browser. @@ -558,16 +672,33 @@ const ReportingDelayPage = () => { Introduction - Do you need nowcasting ? What does your reporting delay distrubution look like ? Let's dive into that using this little app. Nothing leave your computer (say how to check). - So upload a data with some columns indicating the refrence date of an event, the report date when it was reported, and the value reported. Optionally you can have other columns like location, age group, or target type to filter the data. - You'll see your reporting distrubtion and the so called reporting triangle introduced by (probably Kaitlyn Johnson et al. but really i need to check this). This - For any deeper dive open the link below to epinowcast on which this work is based. + Do you need nowcasting ? What does your reporting delay + distrubution look like ? Let's dive into that using this little + app. Nothing leave your computer (say how to check). So upload a + data with some columns indicating the refrence date of an event, + the report date when it was reported, and the value reported. + Optionally you can have other columns like location, age group, or + target type to filter the data. You'll see your reporting + distrubtion and the so called reporting triangle introduced by + (probably Kaitlyn Johnson et al. but really i need to check this). + This For any deeper dive open the link below to epinowcast on + which this work is based. - + EpiNowcast - + Baselinenowcast @@ -594,9 +725,13 @@ const ReportingDelayPage = () => { handleFile(event.dataTransfer.files?.[0]); }} style={{ - borderStyle: 'dashed', - borderColor: isDragging ? 'var(--mantine-color-blue-6)' : undefined, - background: isDragging ? 'var(--mantine-color-blue-0)' : undefined, + borderStyle: "dashed", + borderColor: isDragging + ? "var(--mantine-color-blue-6)" + : undefined, + background: isDragging + ? "var(--mantine-color-blue-0)" + : undefined, }} > @@ -605,16 +740,21 @@ const ReportingDelayPage = () => { Drag & drop a CSV file here - Expected columns: reference_date, report_date, value. + Expected columns: reference_date,{" "} + report_date, value. - Each row should be a cumulative total for one reference date as reported on a later report date. + Each row should be a cumulative total for one reference date as + reported on a later report date. - Optional columns like location, age, or target are supported and can be filtered after upload. + Optional columns like location, age, or target are supported and + can be filtered after upload. - +