From 916356a042c6b4ad65c86fa59ed1aa1ad16682f9 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 18:28:07 +0200 Subject: [PATCH 01/61] cleanup: refactor after fallow audit --- .fallowrc.jsonc | 12 - .vscode/settings.json | 1 + Makefile | 4 + frontend/.fallowrc.jsonc | 15 + frontend/package-lock.json | 399 +++++++++++++++++- frontend/package.json | 11 +- .../components/dashboard/recentExecutions.tsx | 6 +- .../diagnostics/executionsTable.tsx | 2 +- .../execution/executionGroupTable.tsx | 2 +- frontend/src/lib/apiError.ts | 2 +- frontend/src/lib/failures.test.ts | 63 +++ frontend/src/lib/failures.ts | 42 +- frontend/src/lib/logSummary.ts | 262 +++++++----- .../src/routes/_app/executions.failures.tsx | 63 +-- frontend/vitest.config.ts | 2 +- 15 files changed, 702 insertions(+), 184 deletions(-) delete mode 100644 .fallowrc.jsonc create mode 100644 frontend/.fallowrc.jsonc diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc deleted file mode 100644 index de25fe1f..00000000 --- a/.fallowrc.jsonc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", - // Generated code (@hey-api/openapi-ts client, TanStack route tree) stays in the - // module graph so imports resolve, but its findings are hidden. - "ignoreFindings": ["frontend/src/client/**", "**/*.gen.ts"], - "duplicates": { - "ignore": ["frontend/src/client/**", "**/*.gen.ts"] - }, - "health": { - "ignore": ["frontend/src/client/**", "**/*.gen.ts"] - } -} diff --git a/.vscode/settings.json b/.vscode/settings.json index dd665e2c..6bb1b9fe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "makefile.configureOnOpen": false, "biome.configurationPath": "frontend/biome.json", "biome.lsp.bin": "frontend/node_modules/.bin/biome", + "fallow.configPath": "frontend/.fallowrc.jsonc", "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" diff --git a/Makefile b/Makefile index fc9908ae..6c55cf4c 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,10 @@ setup: ## Setup the backend and frontend generate-metadata: ## Generate diagnostic metadata YAML from provider registry $(MAKE) -C backend generate-metadata +.PHONY: fallow-audit +fallow-audit: ## run tests with coverage, then audit frontend changes against main with fallow + cd frontend && npm run test:coverage && npm run fallow:audit + .PHONY: changelog-draft changelog-draft: ## compile a draft of the next changelog cd backend && uv run towncrier build --draft --config ../towncrier.toml diff --git a/frontend/.fallowrc.jsonc b/frontend/.fallowrc.jsonc new file mode 100644 index 00000000..f2c5a453 --- /dev/null +++ b/frontend/.fallowrc.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "./node_modules/fallow/schema.json", + // Generated code (@hey-api/openapi-ts client, TanStack route tree) stays in the + // module graph so imports resolve, but its findings are hidden. + // Globs start with ** so they match whether Fallow runs from frontend/ (CLI) + // or from the repo root (VS Code extension, via fallow.configPath). + "ignoreFindings": ["**/src/client/**", "**/*.gen.ts"], + "duplicates": { + "ignore": ["**/src/client/**", "**/*.gen.ts"] + }, + "health": { + "ignore": ["**/src/client/**", "**/*.gen.ts"], + "coverage": "coverage/coverage-final.json" + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 787c8b7c..9fd40619 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@floating-ui/react": "^0.27.16", "@mdx-js/react": "^3.1.0", - "@mdx-js/rollup": "^3.1.0", "@plausible-analytics/tracker": "^0.4.3", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.11", @@ -27,7 +26,7 @@ "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.8", "@sentry/react": "^10.17.0", - "@tailwindcss/vite": "^4.0.9", + "@tailwindcss/typography": "^0.5.20", "@tanstack/react-query": "^5.66.11", "@tanstack/react-query-devtools": "^5.67.1", "@tanstack/react-router": "^1.115.3", @@ -55,8 +54,9 @@ "devDependencies": { "@biomejs/biome": "^2.2.4", "@hey-api/openapi-ts": "^0.99.0", + "@mdx-js/rollup": "^3.1.1", "@sentry/vite-plugin": "^5.0.0", - "@tailwindcss/typography": "^0.5.18", + "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router-devtools": "^1.115.3", "@tanstack/router-plugin": "^1.115.3", "@testing-library/jest-dom": "^7.0.0", @@ -68,6 +68,7 @@ "@vitejs/plugin-react-swc": "^4.0.0", "@vitest/coverage-v8": "^5.0.0", "@welldone-software/why-did-you-render": "^10.0.1", + "fallow": "^3.34.1", "jsdom": "^30.0.0", "typescript": "~7.0.0", "vite": ">=7.3.2 <9", @@ -718,6 +719,118 @@ } } }, + "node_modules/@fallow-cli/darwin-arm64": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@fallow-cli/darwin-arm64/-/darwin-arm64-3.34.1.tgz", + "integrity": "sha512-AmOfH7XLjwUgPKfqyCm/ELr4DU6ShEsOTGL1DRqk8xVf3e+KPFw6tN68Fl6K8wdAzu/kKnWYrjryEVeu849hdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@fallow-cli/darwin-x64": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@fallow-cli/darwin-x64/-/darwin-x64-3.34.1.tgz", + "integrity": "sha512-ayHBvwNn6ycz9fHxmX6pi0govhZgGEFXq4mRfuesdhpETOkR0RWFaEyLdRaRWXIhR71wKJRycxGUR6pQF8mprg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@fallow-cli/linux-arm64-gnu": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-arm64-gnu/-/linux-arm64-gnu-3.34.1.tgz", + "integrity": "sha512-4m86btsHBPsBnJ4EJR3FlxC4QPjK7hNrx8i8ek/Tu3sKhTnT0RyNUcuJzlcLFvwMdmyuUaOyQQarLgobKkjxfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/linux-arm64-musl": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-arm64-musl/-/linux-arm64-musl-3.34.1.tgz", + "integrity": "sha512-fDidOlgS/J417ANtt4Ymdliv9PV16RJvFEGc+MKuMnVIwJ5Jh3MZr9idiVEE+8opPQ1XW1Ol2nAo1kyZzxkkzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/linux-x64-gnu": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-x64-gnu/-/linux-x64-gnu-3.34.1.tgz", + "integrity": "sha512-kY8Ks05aMw7xxBXxP99+SbgzbwooCEWScVE5TAiP01iZ5FHuErufesGD1sDwP0/pTGv3NXKQje5v9WMRcPdx4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/linux-x64-musl": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-x64-musl/-/linux-x64-musl-3.34.1.tgz", + "integrity": "sha512-PlEu9whAZgog9RNwVtgTGRT5qxaambXINGi2c2LnRZodUtkUHVrBVNQu3L4nun2kkLAJ2cXCT9puOCFpPqCsiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/win32-arm64-msvc": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@fallow-cli/win32-arm64-msvc/-/win32-arm64-msvc-3.34.1.tgz", + "integrity": "sha512-jTnKetAeNMgiz1yMKwWGJRkvTnXctNL6VmuzBv60asaQRIEPReOSbpM52CMahI5fK55j3wmspVhEVm7/dBn4ZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@fallow-cli/win32-x64-msvc": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@fallow-cli/win32-x64-msvc/-/win32-x64-msvc-3.34.1.tgz", + "integrity": "sha512-rdjchbPiFK5ExxyZnj50FN6+n+KYTtbLrtcEegVg+iL97LvLml9sVvF1YcDP69fWDJVAhAwlE2mQZhW/P2HPyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -899,6 +1012,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -909,6 +1023,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -919,6 +1034,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -928,12 +1044,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -961,6 +1079,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -1015,6 +1134,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/rollup/-/rollup-3.1.1.tgz", "integrity": "sha512-v8satFmBB+DqDzYohnm1u2JOvxx6Hl3pUvqzJvfs2Zk/ngZ1aRUhsWpXvwPkNeGN9c2NCm/38H29ZqXQUjf8dw==", + "dev": true, "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", @@ -1034,6 +1154,7 @@ "version": "0.147.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -2014,6 +2135,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2030,6 +2152,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2046,6 +2169,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2062,6 +2186,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2078,6 +2203,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2094,6 +2220,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2110,6 +2237,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -2129,6 +2257,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -2148,6 +2277,7 @@ "cpu": [ "ppc64" ], + "dev": true, "libc": [ "glibc" ], @@ -2167,6 +2297,7 @@ "cpu": [ "s390x" ], + "dev": true, "libc": [ "glibc" ], @@ -2186,6 +2317,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -2205,6 +2337,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -2224,6 +2357,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2240,6 +2374,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2256,6 +2391,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2269,12 +2405,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, "license": "MIT" }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -2297,6 +2435,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -2306,6 +2445,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2320,6 +2460,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2334,6 +2475,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2348,6 +2490,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2362,6 +2505,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2376,6 +2520,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2390,6 +2535,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2404,6 +2550,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2418,6 +2565,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2432,6 +2580,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2446,6 +2595,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2460,6 +2610,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2474,6 +2625,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2488,6 +2640,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2502,6 +2655,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2516,6 +2670,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2530,6 +2685,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2544,6 +2700,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2558,6 +2715,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2572,6 +2730,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2586,6 +2745,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2600,6 +2760,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2614,6 +2775,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2628,6 +2790,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2642,6 +2805,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3436,6 +3600,7 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", @@ -3451,6 +3616,7 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 20" @@ -3477,6 +3643,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3493,6 +3660,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3509,6 +3677,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3525,6 +3694,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3541,6 +3711,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3557,6 +3728,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -3576,6 +3748,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -3595,6 +3768,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -3614,6 +3788,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -3641,6 +3816,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3657,6 +3833,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { "version": "1.11.1", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -3667,6 +3844,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { "version": "1.11.1", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -3676,6 +3854,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -3685,6 +3864,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -3702,6 +3882,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { "version": "0.10.2", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -3711,6 +3892,7 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { "version": "2.8.1", + "dev": true, "inBundle": true, "license": "0BSD", "optional": true @@ -3722,6 +3904,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3738,6 +3921,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3751,7 +3935,6 @@ "version": "0.5.20", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", - "dev": true, "license": "MIT", "dependencies": { "postcss-selector-parser": "6.0.10" @@ -3764,6 +3947,7 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, "license": "MIT", "dependencies": { "@tailwindcss/node": "4.3.3", @@ -4519,6 +4703,7 @@ "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, "license": "MIT", "dependencies": { "@types/ms": "*" @@ -4535,12 +4720,14 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/estree-jsx": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "*" @@ -4557,6 +4744,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "*" @@ -4573,6 +4761,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "*" @@ -4588,13 +4777,14 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, "license": "MIT" }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -4623,6 +4813,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, "license": "MIT" }, "node_modules/@typescript/typescript-aix-ppc64": { @@ -4969,6 +5160,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react-swc": { @@ -5105,6 +5297,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5117,6 +5310,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5242,6 +5436,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "dev": true, "license": "MIT", "bin": { "astring": "bin/astring" @@ -5264,6 +5459,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -5410,6 +5606,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -5430,6 +5627,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -5440,6 +5638,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -5450,6 +5649,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -5460,6 +5660,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -5523,6 +5724,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -5543,6 +5745,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -5618,7 +5821,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -6077,6 +6279,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6107,6 +6310,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, "license": "MIT", "dependencies": { "character-entities": "^2.0.0" @@ -6179,6 +6383,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6195,6 +6400,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -6210,6 +6416,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, "license": "MIT", "dependencies": { "dequal": "^2.0.0" @@ -6258,6 +6465,7 @@ "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -6291,6 +6499,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -6307,6 +6516,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -6333,6 +6543,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -6346,6 +6557,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -6362,6 +6574,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -6372,6 +6585,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -6386,6 +6600,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -6401,6 +6616,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -6415,6 +6631,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -6447,8 +6664,58 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, "license": "MIT" }, + "node_modules/fallow": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/fallow/-/fallow-3.34.1.tgz", + "integrity": "sha512-4jmCmr4EsIn8e6D3j7m1nlgOy+Ms9ddzHOiC2cyWaUlcokyLlIZK8IIR8hXRET+oPvnwRcqpansOru1ckBn5KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "2.1.2" + }, + "bin": { + "fallow": "bin/fallow", + "fallow-lsp": "bin/fallow-lsp", + "fallow-mcp": "bin/fallow-mcp" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/fallow-rs" + }, + "optionalDependencies": { + "@fallow-cli/darwin-arm64": "3.34.1", + "@fallow-cli/darwin-x64": "3.34.1", + "@fallow-cli/linux-arm64-gnu": "3.34.1", + "@fallow-cli/linux-arm64-musl": "3.34.1", + "@fallow-cli/linux-x64-gnu": "3.34.1", + "@fallow-cli/linux-x64-musl": "3.34.1", + "@fallow-cli/win32-arm64-msvc": "3.34.1", + "@fallow-cli/win32-x64-msvc": "3.34.1", + "fallow-type-aware": "3.34.1" + } + }, + "node_modules/fallow-type-aware": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/fallow-type-aware/-/fallow-type-aware-3.34.1.tgz", + "integrity": "sha512-81xwRvAe14rMwH0ORAdM1DoXV1TBl2+s7dK+pJ8veM1zJyvrDdjbSwRGWQxRh+Q+l0RsGHlt4kCcE0/1+JS5Yw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "typescript": "7.0.2" + }, + "bin": { + "fallow-type-aware": "fallow-type-aware.mjs" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -6462,6 +6729,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -6496,6 +6764,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6562,12 +6831,14 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/hast-util-to-estree": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -6596,6 +6867,7 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -6623,6 +6895,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" @@ -6685,6 +6958,7 @@ "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, "license": "MIT" }, "node_modules/internmap": { @@ -6700,6 +6974,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -6710,6 +6985,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, "license": "MIT", "dependencies": { "is-alphabetical": "^2.0.0", @@ -6724,6 +7000,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -6750,6 +7027,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -6792,6 +7070,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6843,6 +7122,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7006,6 +7286,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -7038,6 +7319,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7058,6 +7340,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7078,6 +7361,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7098,6 +7382,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7118,6 +7403,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7138,6 +7424,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7158,6 +7445,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7178,6 +7466,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7198,6 +7487,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7218,6 +7508,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7238,6 +7529,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7277,6 +7569,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -7329,6 +7622,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -7350,6 +7644,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=16" @@ -7362,6 +7657,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -7386,6 +7682,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "dev": true, "license": "MIT", "dependencies": { "mdast-util-from-markdown": "^2.0.0", @@ -7403,6 +7700,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -7421,6 +7719,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -7445,6 +7744,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -7463,6 +7763,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -7477,6 +7778,7 @@ "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -7498,6 +7800,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -7519,6 +7822,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0" @@ -7539,6 +7843,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7574,6 +7879,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7608,6 +7914,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7634,6 +7941,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -7656,6 +7964,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "dev": true, "license": "MIT", "dependencies": { "micromark-util-types": "^2.0.0" @@ -7669,6 +7978,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.0.0", @@ -7689,6 +7999,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -7710,6 +8021,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7731,6 +8043,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7753,6 +8066,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7780,6 +8094,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7800,6 +8115,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7822,6 +8138,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7844,6 +8161,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7864,6 +8182,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7883,6 +8202,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7904,6 +8224,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7924,6 +8245,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7943,6 +8265,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7965,6 +8288,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7981,6 +8305,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8006,6 +8331,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8022,6 +8348,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8041,6 +8368,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8060,6 +8388,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8081,6 +8410,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8103,6 +8433,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8119,6 +8450,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -8155,12 +8487,14 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, "funding": [ { "type": "github", @@ -8318,6 +8652,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^2.0.0", @@ -8337,6 +8672,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, "license": "MIT" }, "node_modules/parse5": { @@ -8390,12 +8726,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -8420,6 +8758,7 @@ "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8448,7 +8787,6 @@ "version": "6.0.10", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -8534,6 +8872,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -8775,6 +9114,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -8790,6 +9130,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "dev": true, "license": "MIT", "dependencies": { "acorn-jsx": "^5.0.0", @@ -8810,6 +9151,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -8826,6 +9168,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -8856,6 +9199,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -8871,6 +9215,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "dev": true, "license": "MIT", "dependencies": { "mdast-util-mdx": "^3.0.0", @@ -8885,6 +9230,7 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -8901,6 +9247,7 @@ "version": "11.1.2", "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -8944,6 +9291,7 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.147.0", @@ -8977,6 +9325,7 @@ "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -9127,6 +9476,7 @@ "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">= 12" @@ -9136,6 +9486,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -9145,6 +9496,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9169,6 +9521,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, "license": "MIT", "dependencies": { "character-entities-html4": "^2.0.0", @@ -9196,6 +9549,7 @@ "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "dev": true, "license": "MIT", "dependencies": { "style-to-object": "1.0.14" @@ -9205,6 +9559,7 @@ "version": "1.0.14", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, "license": "MIT", "dependencies": { "inline-style-parser": "0.2.7" @@ -9252,6 +9607,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9291,6 +9647,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -9363,6 +9720,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9373,6 +9731,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9434,13 +9793,14 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -9460,6 +9820,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -9473,6 +9834,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -9486,6 +9848,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -9499,6 +9862,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -9512,6 +9876,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -9527,6 +9892,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -9679,13 +10045,13 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -9700,6 +10066,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -9736,6 +10103,7 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", @@ -9813,6 +10181,7 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -9845,6 +10214,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9865,6 +10235,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9885,6 +10256,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9905,6 +10277,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9925,6 +10298,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9945,6 +10319,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -9968,6 +10343,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -9991,6 +10367,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -10014,6 +10391,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -10037,6 +10415,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10057,6 +10436,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10318,6 +10698,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, "license": "MIT", "funding": { "type": "github", diff --git a/frontend/package.json b/frontend/package.json index 29a501db..40e92b89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,12 +10,13 @@ "preview": "vite preview", "openapi-ts": "bash ../scripts/generate-client-sdk.sh", "test": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "fallow": "fallow", + "fallow:audit": "fallow audit --base main" }, "dependencies": { "@floating-ui/react": "^0.27.16", "@mdx-js/react": "^3.1.0", - "@mdx-js/rollup": "^3.1.0", "@plausible-analytics/tracker": "^0.4.3", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.11", @@ -32,7 +33,7 @@ "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.8", "@sentry/react": "^10.17.0", - "@tailwindcss/vite": "^4.0.9", + "@tailwindcss/typography": "^0.5.20", "@tanstack/react-query": "^5.66.11", "@tanstack/react-query-devtools": "^5.67.1", "@tanstack/react-router": "^1.115.3", @@ -60,8 +61,9 @@ "devDependencies": { "@biomejs/biome": "^2.2.4", "@hey-api/openapi-ts": "^0.99.0", + "@mdx-js/rollup": "^3.1.1", "@sentry/vite-plugin": "^5.0.0", - "@tailwindcss/typography": "^0.5.18", + "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router-devtools": "^1.115.3", "@tanstack/router-plugin": "^1.115.3", "@testing-library/jest-dom": "^7.0.0", @@ -73,6 +75,7 @@ "@vitejs/plugin-react-swc": "^4.0.0", "@vitest/coverage-v8": "^5.0.0", "@welldone-software/why-did-you-render": "^10.0.1", + "fallow": "^3.34.1", "jsdom": "^30.0.0", "typescript": "~7.0.0", "vite": ">=7.3.2 <9", diff --git a/frontend/src/components/dashboard/recentExecutions.tsx b/frontend/src/components/dashboard/recentExecutions.tsx index 1fb313d8..3c6c58b9 100644 --- a/frontend/src/components/dashboard/recentExecutions.tsx +++ b/frontend/src/components/dashboard/recentExecutions.tsx @@ -52,6 +52,8 @@ export function RecentExecutions({ executions }: RecentExecutionsProps) { execution.provider_slug, execution.diagnostic_slug, ); + // Its successful is null while it runs, which the badge shows + const latest = execution.executions.at(-1); return (
{diagnostic?.provider.name ?? execution.provider_slug} diff --git a/frontend/src/components/diagnostics/executionsTable.tsx b/frontend/src/components/diagnostics/executionsTable.tsx index 3ee255d3..4c88e003 100644 --- a/frontend/src/components/diagnostics/executionsTable.tsx +++ b/frontend/src/components/diagnostics/executionsTable.tsx @@ -58,7 +58,7 @@ function LatestSelectedCell({ row }: CellContext) { return null; } -export const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { accessorKey: "dataset_hash", header: () => ( diff --git a/frontend/src/components/execution/executionGroupTable.tsx b/frontend/src/components/execution/executionGroupTable.tsx index 6eab7eee..9854d4d0 100644 --- a/frontend/src/components/execution/executionGroupTable.tsx +++ b/frontend/src/components/execution/executionGroupTable.tsx @@ -51,7 +51,7 @@ function DiagnosticCell({ ); } -export const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { id: "selectors", header: "Selectors", diff --git a/frontend/src/lib/apiError.ts b/frontend/src/lib/apiError.ts index ebd1958a..a73e7656 100644 --- a/frontend/src/lib/apiError.ts +++ b/frontend/src/lib/apiError.ts @@ -54,7 +54,7 @@ export function installApiErrors(client: Client) { ); } -export function isNotFound(error: unknown): boolean { +function isNotFound(error: unknown): boolean { return error instanceof ApiError && error.status === 404; } diff --git a/frontend/src/lib/failures.test.ts b/frontend/src/lib/failures.test.ts index 6c7da474..3e167e21 100644 --- a/frontend/src/lib/failures.test.ts +++ b/frontend/src/lib/failures.test.ts @@ -6,11 +6,13 @@ import type { } from "@/client"; import { diagnosticHealth, + failedGroupsSummary, failedRunRange, failureDays, failureSummary, failuresByModel, failuresByReason, + filterOptions, groupFacets, lastSucceededAt, reasonBreakdown, @@ -326,6 +328,67 @@ describe("failureDays", () => { }); }); +describe("filterOptions", () => { + const groups = [ + modelGroup("A", [[false, "2026-09-04T10:00:00", "system_error"]]), + modelGroup("B", [[false, "2026-09-20T10:00:00", "system_error"]]), + modelGroup("B", [[false, "2026-10-12T10:00:00", "diagnostic_error"]]), + modelGroup("B", [[null, "2026-10-13T10:00:00"]]), + ]; + const options = (filters: Parameters[2]) => + filterOptions([diagnostic("pmp", "enso", 10, 6)], groups, filters); + + it("counts each filter's values given the other filters", () => { + const { reasons, models, days } = options({ model: "B" }); + expect(reasons).toEqual([ + { key: "diagnostic_error", groups: 1, diagnostics: 1 }, + { key: "system_error", groups: 1, diagnostics: 1 }, + ]); + // The selected model does not narrow the models to choose from + expect(models).toEqual([ + { key: "B", groups: 2, diagnostics: 1 }, + { key: "A", groups: 1, diagnostics: 1 }, + ]); + expect(days).toEqual(new Set(["2026-09-20", "2026-10-12"])); + }); + + it("marks the days outside the time range picked", () => { + const { reasons, models, days } = options({ + reason: "system_error", + from: "2026-09-10", + }); + expect(reasons.map(({ key }) => key)).toEqual([ + "diagnostic_error", + "system_error", + ]); + expect(models).toEqual([{ key: "B", groups: 1, diagnostics: 1 }]); + expect(days).toEqual(new Set(["2026-09-04", "2026-09-20"])); + }); +}); + +describe("failedGroupsSummary", () => { + const catalog = [ + diagnostic("pmp", "enso", 10, 6), + diagnostic("pmp", "other", 2, 2), + ]; + const groups = [ + modelGroup("A", [[false, "2026-09-04T10:00:00"]]), + modelGroup("B", [[false, "2026-09-20T10:00:00"]]), + ]; + + it("counts the failed groups and the diagnostics they are in", () => { + expect(failedGroupsSummary(summariseFailures(catalog, groups))).toBe( + "2 failed execution groups in 1 diagnostic", + ); + expect( + failedGroupsSummary(summariseFailures(catalog, groups, { model: "A" })), + ).toBe("1 failed execution group in 1 diagnostic"); + expect(failedGroupsSummary(summariseFailures(catalog, []))).toBe( + "0 failed execution groups in 0 diagnostics", + ); + }); +}); + describe("failureSummary", () => { it("names the model and failure reason it is narrowed to", () => { const [failures] = summariseFailures( diff --git a/frontend/src/lib/failures.ts b/frontend/src/lib/failures.ts index cb643ec7..55659896 100644 --- a/frontend/src/lib/failures.ts +++ b/frontend/src/lib/failures.ts @@ -232,7 +232,7 @@ const failedOn = (group: ExecutionGroup) => * The model applies to every group. The reason and time range describe failures, * so a running or not started group, which has neither, is kept. */ -export function matchesFilters( +function matchesFilters( group: ExecutionGroup, { model, reason, from, to }: FailureFilters, ): boolean { @@ -252,6 +252,46 @@ export function matchesFilters( return (from === undefined || day >= from) && (to === undefined || day <= to); } +/** What the filters of the failures page can be set to, given the filters already set */ +export interface FilterOptions { + /** Each dropdown counts what selecting a value would show, given the other filters */ + reasons: FailureCount[]; + models: FailureCount[]; + /** The days with failures matching the other filters, as YYYY-MM-DD */ + days: Set; +} + +export function filterOptions( + catalog: DiagnosticCatalogEntry[], + unsuccessful: ExecutionGroup[], + filters: FailureFilters, +): FilterOptions { + const summarise = (narrowed: FailureFilters) => + summariseFailures(catalog, unsuccessful, narrowed); + return { + reasons: failuresByReason(summarise({ ...filters, reason: undefined })), + models: failuresByModel(summarise({ ...filters, model: undefined })), + days: new Set( + summarise({ ...filters, from: undefined, to: undefined }) + .flatMap((failures) => failures.failedGroups) + .map(failedOn) + .filter((day) => day !== undefined), + ), + }; +} + +/** How many groups failed across the diagnostics, e.g. "3 failed execution groups in 2 diagnostics" */ +export function failedGroupsSummary(diagnostics: DiagnosticFailures[]): string { + const failing = diagnostics.filter( + (failures) => failures.failedGroups.length > 0, + ); + const groups = failing.reduce( + (sum, failures) => sum + failures.failedGroups.length, + 0, + ); + return `${groups} failed execution ${groups === 1 ? "group" : "groups"} in ${failing.length} ${failing.length === 1 ? "diagnostic" : "diagnostics"}`; +} + /** The first and last day any of these groups failed, as YYYY-MM-DD, or undefined if none did */ export function failureDays( groups: ExecutionGroup[], diff --git a/frontend/src/lib/logSummary.ts b/frontend/src/lib/logSummary.ts index 9d1768e8..d3456d8c 100644 --- a/frontend/src/lib/logSummary.ts +++ b/frontend/src/lib/logSummary.ts @@ -116,143 +116,189 @@ const clip = (line: string) => /** Lines continuing an error message that ends with ":", such as the dataset ESMValTool could not find */ const CONTINUATION_LINES = 6; -export function summariseLog(text: string, maxLines = 30): LogSummary { - const lines = text.split("\n").map((line) => line.trimEnd()); - const recorded: string[] = []; - let recordedSystemError = false; - const system: string[] = []; - const errors: string[] = []; - const warnings: string[] = []; +/** + * Where a line belongs in the summary: climate-ref's record of the failure, + * an error from the system, an error from the diagnostic, or a warning + */ +type Kind = "recorded" | "system" | "errors" | "warnings"; +type Classified = { kind: Kind; lines: string[] } | undefined; - /** An ESMValTool error, with the lines continuing it when it ends with ":" */ - const addProviderError = (message: string, index: number) => { - const trimmed = message.trim(); - if (isBoilerplate(trimmed)) { - return; - } - errors.push(trimmed); - if (!trimmed.endsWith(":")) { - return; - } - for ( - let k = index + 1; - k <= index + CONTINUATION_LINES && k < lines.length; - k++ +const classified = (kind: Kind, ...lines: string[]): Classified => ({ + kind, + lines, +}); + +const byLevel = (level: string, line: string): Classified => { + if (isErrorLevel(level)) { + return classified("errors", line); + } + return level === "WARNING" ? classified("warnings", line) : undefined; +}; + +/** An ESMValTool error, with the lines continuing it when it ends with ":" */ +function providerError( + message: string, + lines: string[], + index: number, +): Classified { + const trimmed = message.trim(); + if (isBoilerplate(trimmed)) { + return undefined; + } + const found = [trimmed]; + if (!trimmed.endsWith(":")) { + return classified("errors", ...found); + } + for ( + let k = index + 1; + k <= index + CONTINUATION_LINES && k < lines.length; + k++ + ) { + const next = lines[k]; + // A dict or list printed after the message is too verbose to be useful here + if ( + !next.trim() || + REF_LINE.test(next) || + ESMVALTOOL_LINE.test(next) || + /^\s*[{[]/.test(next) ) { - const next = lines[k]; - // A dict or list printed after the message is too verbose to be useful here - if ( - !next.trim() || - REF_LINE.test(next) || - ESMVALTOOL_LINE.test(next) || - /^\s*[{[]/.test(next) - ) { - break; - } - errors.push(next.trim()); + break; } - }; + found.push(next.trim()); + } + return classified("errors", ...found); +} - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (!line.trim() || ANNOTATION.test(line)) { - continue; +/** A message climate-ref logged, either its own or the provider's captured output */ +function classifyRefMessage( + level: string, + raw: string, + message: string, + lines: string[], + index: number, +): Classified { + if (CAPTURED_OUTPUT.test(raw)) { + const esmvaltool = ESMVALTOOL_LINE.exec(message); + if (esmvaltool && isErrorLevel(esmvaltool[1])) { + return providerError(esmvaltool[2], lines, index); } + return PROVIDER_ERROR.test(message) + ? classified("errors", message) + : undefined; + } + if (isErrorLevel(level)) { + return isBoilerplate(raw) ? undefined : classified("errors", raw); + } + return level === "WARNING" ? classified("warnings", raw) : undefined; +} - const failure = REF_FAILURE.exec(line); - if (failure) { - recorded.push(line.trim()); - recordedSystemError ||= failure[1] === "system_error"; - continue; - } +/** A line printed outside climate-ref's log format */ +function classifyOtherLine( + line: string, + message: string, + lines: string[], + index: number, +): Classified { + const esmvaltool = ESMVALTOOL_LINE.exec(line); + if (esmvaltool) { + return isErrorLevel(esmvaltool[1]) + ? providerError(esmvaltool[2], lines, index) + : undefined; + } + if (EXCEPTION.test(message) || PROVIDER_ERROR.test(message)) { + return classified("errors", message); + } + return PROVIDER_WARNING.test(message) + ? classified("warnings", message) + : undefined; +} - const ref = REF_LINE.exec(line); - // What the line says, without climate-ref's prefix or the label of captured output - const message = (ref ? ref[2] : line).replace(CAPTURED_OUTPUT, "").trim(); - if (isSystemError(message)) { - system.push(message); - continue; - } +function classifyLine(lines: string[], index: number): Classified { + const line = lines[index]; + if (!line.trim() || ANNOTATION.test(line)) { + return undefined; + } + if (REF_FAILURE.test(line)) { + return classified("recorded", line.trim()); + } - // Kept whole, as its timestamp places it relative to the failure - const standard = STANDARD_LINE.exec(line); - if (standard) { - if (isErrorLevel(standard[1])) { - errors.push(line); - } else if (standard[1] === "WARNING") { - warnings.push(line); - } - continue; - } + const ref = REF_LINE.exec(line); + // What the line says, without climate-ref's prefix or the label of captured output + const message = (ref ? ref[2] : line).replace(CAPTURED_OUTPUT, "").trim(); + if (isSystemError(message)) { + return classified("system", message); + } - if (ref) { - const [, level, raw] = ref; - if (CAPTURED_OUTPUT.test(raw)) { - const esmvaltool = ESMVALTOOL_LINE.exec(message); - if (esmvaltool && isErrorLevel(esmvaltool[1])) { - addProviderError(esmvaltool[2], i); - } else if (PROVIDER_ERROR.test(message)) { - errors.push(message); - } - } else if (isErrorLevel(level) && !isBoilerplate(raw)) { - errors.push(raw); - } else if (level === "WARNING") { - warnings.push(raw); - } - continue; - } + // Kept whole, as its timestamp places it relative to the failure + const standard = STANDARD_LINE.exec(line); + if (standard) { + return byLevel(standard[1], line); + } + if (ref) { + return classifyRefMessage(ref[1], ref[2], message, lines, index); + } + return classifyOtherLine(line, message, lines, index); +} - const esmvaltool = ESMVALTOOL_LINE.exec(line); - if (esmvaltool) { - if (isErrorLevel(esmvaltool[1])) { - addProviderError(esmvaltool[2], i); - } - } else if (EXCEPTION.test(message) || PROVIDER_ERROR.test(message)) { - errors.push(message); - } else if (PROVIDER_WARNING.test(message)) { - warnings.push(message); - } +const unique = (found: string[]) => [...new Set(found.map(clip))]; + +/** + * A system error repeated with different numbers (a worker's address, a process id) is one problem, + * so only its most recent occurrence is kept + */ +function latestOfEach(found: string[]) { + const byShape = new Map(); + for (const line of found) { + const shape = line.replace(/\d+/g, "#"); + byShape.delete(shape); + byShape.set(shape, line); } + return [...byShape.values()]; +} - const unique = (found: string[]) => [...new Set(found.map(clip))]; - /** - * A system error repeated with different numbers (a worker's address, a process id) is one problem, - * so only its most recent occurrence is kept - */ - const latestOfEach = (found: string[]) => { - const byShape = new Map(); - for (const line of found) { - const shape = line.replace(/\d+/g, "#"); - byShape.delete(shape); - byShape.set(shape, line); - } - return [...byShape.values()]; +const shorten = (found: string[], maxLines: number) => + found.length > maxLines + ? [...found.slice(0, maxLines - 5), "…", ...found.slice(-4)] + : found; + +export function summariseLog(text: string, maxLines = 30): LogSummary { + const lines = text.split("\n").map((line) => line.trimEnd()); + const found: Record = { + recorded: [], + system: [], + errors: [], + warnings: [], }; - const shorten = (found: string[]) => - found.length > maxLines - ? [...found.slice(0, maxLines - 5), "…", ...found.slice(-4)] - : found; + for (let i = 0; i < lines.length; i++) { + const result = classifyLine(lines, i); + if (result) { + found[result.kind].push(...result.lines); + } + } + const { recorded, system, errors, warnings } = found; // Keep the errors that only follow from another one when they are all there is const specific = errors.filter( (line) => !SECONDARY.some((pattern) => pattern.test(line)), ); - const found = [ + const all = [ ...recorded, ...latestOfEach(system), ...(specific.length > 0 || system.length > 0 ? specific : errors), ]; - if (found.length > 0) { + if (all.length > 0) { return { kind: "errors", - lines: shorten(unique(found)), - system: system.length > 0 || recordedSystemError, + lines: shorten(unique(all), maxLines), + system: + system.length > 0 || + recorded.some((line) => REF_FAILURE.exec(line)?.[1] === "system_error"), }; } if (warnings.length > 0) { return { kind: "warnings", - lines: shorten(unique(warnings)), + lines: shorten(unique(warnings), maxLines), system: false, }; } diff --git a/frontend/src/routes/_app/executions.failures.tsx b/frontend/src/routes/_app/executions.failures.tsx index 51cc58f3..0f93f413 100644 --- a/frontend/src/routes/_app/executions.failures.tsx +++ b/frontend/src/routes/_app/executions.failures.tsx @@ -28,9 +28,10 @@ import { apiErrorMessage } from "@/lib/apiError"; import { describeFailureReason } from "@/lib/failureReasons"; import { type FailureFilters, + type FilterOptions, + failedGroupsSummary, failureDays, - failuresByModel, - failuresByReason, + filterOptions, summariseFailures, } from "@/lib/failures"; import { type MipEra, mipEraSearchFields } from "@/lib/mipEras"; @@ -57,6 +58,9 @@ const reasonLabel = (key: string) => describeFailureReason(key).label; const reasonDescription = (key: string) => describeFailureReason(key).description; +/** Nothing to choose from while the failures load */ +const NO_OPTIONS: FilterOptions = { reasons: [], models: [], days: new Set() }; + function FailuresPage() { const search = Route.useSearch(); const { model, reason, from, to } = search; @@ -89,44 +93,20 @@ function FailuresPage() { const selectDays = (days: DayRange) => updateSearch({ from: days.from, to: days.to }); - const { diagnostics, reasonCounts, modelCounts, failureDaySet } = - useMemo(() => { - if (!catalog || !unsuccessful) { - return { - diagnostics: undefined, - reasonCounts: [], - modelCounts: [], - failureDaySet: new Set(), - }; - } - const summarise = (narrowed: FailureFilters) => - summariseFailures(catalog, unsuccessful, narrowed); - return { - diagnostics: summarise(filters), - // Each dropdown counts what selecting a value would show, given the other filters - reasonCounts: failuresByReason( - summarise({ ...filters, reason: undefined }), - ), - modelCounts: failuresByModel( - summarise({ ...filters, model: undefined }), - ), - // The calendar marks the days with failures matching the other filters - failureDaySet: new Set( - summarise({ ...filters, from: undefined, to: undefined }) - .flatMap((failures) => failures.failedGroups) - .map((group) => group.executions.at(-1)?.updated_at.slice(0, 10)) - .filter((day) => day !== undefined), - ), - }; - }, [catalog, unsuccessful, filters]); + const { diagnostics, options } = useMemo( + () => + catalog && unsuccessful + ? { + diagnostics: summariseFailures(catalog, unsuccessful, filters), + options: filterOptions(catalog, unsuccessful, filters), + } + : { diagnostics: undefined, options: NO_OPTIONS }, + [catalog, unsuccessful, filters], + ); const failing = (diagnostics ?? []).filter( (failures) => failures.failedGroups.length > 0, ); - const failedGroups = failing.reduce( - (sum, failures) => sum + failures.failedGroups.length, - 0, - ); const filtered = [model, reason, from, to].some( (value) => value !== undefined, ); @@ -142,17 +122,17 @@ function FailuresPage() { updateSearch({ reason: selected })} reasonLabel={reasonLabel} reasonDescription={reasonDescription} - modelCounts={modelCounts} + modelCounts={options.models} model={model} onModel={(selected) => updateSearch({ model: selected })} days={{ from, to }} allDays={allDays} - failureDays={failureDaySet} + failureDays={options.days} onDays={selectDays} onClear={ filtered @@ -165,10 +145,7 @@ function FailuresPage() { }) : undefined } - summary={ - diagnostics && - `${failedGroups} failed execution ${failedGroups === 1 ? "group" : "groups"} in ${failing.length} ${failing.length === 1 ? "diagnostic" : "diagnostics"}` - } + summary={diagnostics && failedGroupsSummary(diagnostics)} disabled={!diagnostics} /> diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index d776e40d..bcbc6c67 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ css: false, coverage: { provider: "v8", - reporter: ["text", "html", "lcov"], + reporter: ["text", "html", "lcov", "json"], include: ["src/**/*.{ts,tsx}"], exclude: [ "src/**/*.test.{ts,tsx}", From ccacc1e87602a8d56545ba39449e331c9203d0fc Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 18:50:45 +0200 Subject: [PATCH 02/61] cleanup: remove dead code and unused exports --- frontend/.fallowrc.jsonc | 11 +- frontend/src/components/app/errorBoundary.tsx | 18 +- frontend/src/components/app/errorFallback.tsx | 25 --- frontend/src/components/app/index.ts | 14 +- .../execution/executionDatasetTable.tsx | 2 +- .../executionFiles/outputListTable.tsx | 2 +- .../execution/resourceUsageTable.tsx | 2 +- .../execution/values/series/useChartScales.ts | 2 +- .../execution/values/series/utils.ts | 4 +- .../src/components/execution/values/types.ts | 5 - .../src/components/explorer/content/index.ts | 5 - .../explorer/grouping/groupingControls.tsx | 208 ------------------ .../src/components/explorer/grouping/index.ts | 12 +- .../src/components/explorer/grouping/types.ts | 12 - .../components/explorer/thematicContent.tsx | 14 +- .../components/models/diagnosticRunsTable.tsx | 2 +- .../models/ensembleComparisonTable.tsx | 2 +- .../src/components/models/failedRunsTable.tsx | 2 +- frontend/src/components/models/modelTable.tsx | 2 +- frontend/src/lib/mipEras.ts | 4 +- 20 files changed, 34 insertions(+), 314 deletions(-) delete mode 100644 frontend/src/components/explorer/grouping/groupingControls.tsx diff --git a/frontend/.fallowrc.jsonc b/frontend/.fallowrc.jsonc index f2c5a453..cf7527d8 100644 --- a/frontend/.fallowrc.jsonc +++ b/frontend/.fallowrc.jsonc @@ -4,7 +4,16 @@ // module graph so imports resolve, but its findings are hidden. // Globs start with ** so they match whether Fallow runs from frontend/ (CLI) // or from the repo root (VS Code extension, via fallow.configPath). - "ignoreFindings": ["**/src/client/**", "**/*.gen.ts"], + "ignoreFindings": [ + "**/src/client/**", + "**/*.gen.ts", + // shadcn/ui components copied into the repo, which keep their full API + "**/src/components/ui/**" + ], + // Run by scripts/generate-client-sdk.sh, which is outside the project root + "entry": ["openapi-ts.config.ts"], + // Types used in an exported signature in the same file stay exported, so callers can name them + "ignoreExportsUsedInFile": { "type": true, "interface": true }, "duplicates": { "ignore": ["**/src/client/**", "**/*.gen.ts"] }, diff --git a/frontend/src/components/app/errorBoundary.tsx b/frontend/src/components/app/errorBoundary.tsx index 636cd9f0..7057ba6a 100644 --- a/frontend/src/components/app/errorBoundary.tsx +++ b/frontend/src/components/app/errorBoundary.tsx @@ -1,4 +1,4 @@ -import React, { Component, type ErrorInfo, type ReactNode } from "react"; +import { Component, type ErrorInfo, type ReactNode } from "react"; import { ErrorFallback } from "./errorFallback"; interface Props { @@ -98,19 +98,3 @@ export class ErrorBoundary extends Component { return this.props.children; } } - -// Hook-based error boundary for functional components -export function withErrorBoundary

( - Component: React.ComponentType

, - errorFallback?: ReactNode, -) { - const WrappedComponent = (props: P) => ( - - - - ); - - WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name})`; - - return WrappedComponent; -} diff --git a/frontend/src/components/app/errorFallback.tsx b/frontend/src/components/app/errorFallback.tsx index 0cbf10bf..8b8983fd 100644 --- a/frontend/src/components/app/errorFallback.tsx +++ b/frontend/src/components/app/errorFallback.tsx @@ -99,19 +99,6 @@ export function ErrorFallback({ ); } -// Specialized error fallbacks for different contexts -export function PageErrorFallback({ error }: { error?: Error }) { - return ( - - ); -} - export function ComponentErrorFallback({ error, resetError, @@ -130,15 +117,3 @@ export function ComponentErrorFallback({ /> ); } - -export function ApiErrorFallback({ error }: { error?: Error }) { - return ( - - ); -} diff --git a/frontend/src/components/app/index.ts b/frontend/src/components/app/index.ts index 8e7dcdee..615e9e7e 100644 --- a/frontend/src/components/app/index.ts +++ b/frontend/src/components/app/index.ts @@ -1,12 +1,2 @@ -export { ErrorBoundary, withErrorBoundary } from "./errorBoundary"; -export { - ApiErrorFallback, - ComponentErrorFallback, - ErrorFallback, - PageErrorFallback, -} from "./errorFallback"; -export { Footer } from "./footer"; -export { Navbar } from "./navbar"; -export { NavbarLogo } from "./navbarLogo"; -export { StagingBanner } from "./stagingBanner"; -export { VersionInfo } from "./versionInfo"; +export { ErrorBoundary } from "./errorBoundary"; +export { ErrorFallback } from "./errorFallback"; diff --git a/frontend/src/components/execution/executionDatasetTable.tsx b/frontend/src/components/execution/executionDatasetTable.tsx index 33e80b6a..ad06b77f 100644 --- a/frontend/src/components/execution/executionDatasetTable.tsx +++ b/frontend/src/components/execution/executionDatasetTable.tsx @@ -9,7 +9,7 @@ import { DataTable } from "@/components/dataTable/dataTable.tsx"; const columnHelper = createColumnHelper(); -export const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ columnHelper.accessor("slug", { header: "Slug", cell: (cellContext) => { diff --git a/frontend/src/components/execution/executionFiles/outputListTable.tsx b/frontend/src/components/execution/executionFiles/outputListTable.tsx index f2d1f79c..3adffafb 100644 --- a/frontend/src/components/execution/executionFiles/outputListTable.tsx +++ b/frontend/src/components/execution/executionFiles/outputListTable.tsx @@ -10,7 +10,7 @@ import { CardTitle, } from "@/components/ui/card.tsx"; -export const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ // { // accessorKey: "short_name", // header: "Short Name", diff --git a/frontend/src/components/execution/resourceUsageTable.tsx b/frontend/src/components/execution/resourceUsageTable.tsx index a9614471..35d2379b 100644 --- a/frontend/src/components/execution/resourceUsageTable.tsx +++ b/frontend/src/components/execution/resourceUsageTable.tsx @@ -36,7 +36,7 @@ function numericColumn( }; } -export const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { accessorKey: "name", enableSorting: true, diff --git a/frontend/src/components/execution/values/series/useChartScales.ts b/frontend/src/components/execution/values/series/useChartScales.ts index 4ea04535..e771667d 100644 --- a/frontend/src/components/execution/values/series/useChartScales.ts +++ b/frontend/src/components/execution/values/series/useChartScales.ts @@ -10,7 +10,7 @@ export interface ChartMargins { left: number; } -export const DEFAULT_MARGINS: ChartMargins = { +const DEFAULT_MARGINS: ChartMargins = { top: 10, right: 30, bottom: 50, diff --git a/frontend/src/components/execution/values/series/utils.ts b/frontend/src/components/execution/values/series/utils.ts index cbac5d65..66d07c29 100644 --- a/frontend/src/components/execution/values/series/utils.ts +++ b/frontend/src/components/execution/values/series/utils.ts @@ -31,7 +31,7 @@ function isDatetimeString(value: unknown): value is string { /** * Detect if the index values in a series are datetime strings */ -export function detectTimeAxis(indexValues: ReadonlyArray): boolean { +function detectTimeAxis(indexValues: ReadonlyArray): boolean { if (indexValues.length === 0) return false; // Check the first non-null value for (const v of indexValues) { @@ -45,7 +45,7 @@ export function detectTimeAxis(indexValues: ReadonlyArray): boolean { * Parse a datetime string to a millisecond timestamp. * Returns null if the string is not a valid date. */ -export function parseDatetimeToTimestamp(value: string): number | null { +function parseDatetimeToTimestamp(value: string): number | null { const ts = new Date(value).getTime(); return Number.isFinite(ts) ? ts : null; } diff --git a/frontend/src/components/execution/values/types.ts b/frontend/src/components/execution/values/types.ts index 5a218e3f..bb2123ee 100644 --- a/frontend/src/components/execution/values/types.ts +++ b/frontend/src/components/execution/values/types.ts @@ -31,11 +31,6 @@ export type BoxPlot = { points: ScalarValue[]; }; -export type GroupedRawDataEntry = { - name: string; - groups: { label: string; values: number[] }[]; -}; - export type ProcessedGroupedDataEntry = { name: string; groups: { [key: string]: BoxPlot }; diff --git a/frontend/src/components/explorer/content/index.ts b/frontend/src/components/explorer/content/index.ts index 1ea7ab4e..66b0de13 100644 --- a/frontend/src/components/explorer/content/index.ts +++ b/frontend/src/components/explorer/content/index.ts @@ -1,9 +1,4 @@ export { EnsembleChartContent } from "./ensembleChartContent"; export { FigureGalleryContent } from "./figureGalleryContent"; export { SeriesChartContent } from "./seriesChartContent"; -export type { - TaylorDiagramModel, - TaylorDiagramProps, -} from "./taylorDiagramContent"; -export { TaylorDiagramContent } from "./taylorDiagramContent"; export { TaylorDiagramContentWrapper } from "./taylorDiagramContentWrapper"; diff --git a/frontend/src/components/explorer/grouping/groupingControls.tsx b/frontend/src/components/explorer/grouping/groupingControls.tsx deleted file mode 100644 index aa120678..00000000 --- a/frontend/src/components/explorer/grouping/groupingControls.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { HelpCircle } from "lucide-react"; -import { useCallback } from "react"; -import { Card, CardContent } from "@/components/ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import type { AvailableDimensions, ChartGroupingConfig } from "./types"; - -interface GroupingControlsProps { - config: ChartGroupingConfig; - availableDimensions: AvailableDimensions; - onChange: (config: ChartGroupingConfig) => void; - className?: string; - showStyle?: boolean; -} - -export function GroupingControls({ - config, - availableDimensions, - onChange, - className = "", - showStyle = true, -}: GroupingControlsProps) { - const handleGroupByChange = useCallback( - (value: string) => { - onChange({ ...config, groupBy: value }); - }, - [config, onChange], - ); - - const handleHueChange = useCallback( - (value: string) => { - onChange({ ...config, hue: value }); - }, - [config, onChange], - ); - - const handleStyleChange = useCallback( - (value: string) => { - onChange({ ...config, style: value }); - }, - [config, onChange], - ); - - return ( - - - -

-
-
- - Group: - - - - - - -

- Controls how data is organized into categories on the - chart. For scalar charts, this determines the x-axis - grouping. For series charts, this affects legend - categorization. -

-
-
-
- -
- -
-
- - Color: - - - - - - -

- Controls how data points are colored based on a dimension. - Different values of this dimension will be assigned - different colors to help distinguish between data series - or groups. -

-
-
-
- -
- -
-
- - Style: - - - - - - -

- Controls visual styling like line dash patterns for series - charts. Different values of this dimension will be - assigned different visual styles to provide additional - differentiation. -

-
-
-
- {showStyle && ( - - )} -
- - {/* Summary of current configuration */} -
-
- {config.groupBy && config.groupBy !== "none" && ( - - Groups by {config.groupBy} - - )} - {config.hue && config.hue !== "none" && ( - - {config.groupBy && config.groupBy !== "none" ? ", " : ""} - Colors by {config.hue} - - )} - {config.style && config.style !== "none" && ( - - {(config.groupBy && config.groupBy !== "none") || - (config.hue && config.hue !== "none") - ? ", " - : ""} - Styles by {config.style} - - )} - {(!config.groupBy || config.groupBy === "none") && - (!config.hue || config.hue === "none") && - (!config.style || config.style === "none") && ( - Using default grouping and styling - )} -
-
-
- - - - ); -} diff --git a/frontend/src/components/explorer/grouping/index.ts b/frontend/src/components/explorer/grouping/index.ts index 130b13cd..2ff35fcb 100644 --- a/frontend/src/components/explorer/grouping/index.ts +++ b/frontend/src/components/explorer/grouping/index.ts @@ -1,16 +1,6 @@ -export { GroupingControls } from "./groupingControls"; -export type { - AvailableDimensions, - ChartGroupingConfig, - DimensionedData, - GroupingProps, -} from "./types"; +export type { ChartGroupingConfig, DimensionedData } from "./types"; export { - categorizeByGrouping, - createDimensionLabel, - createGroupingKey, extractAvailableDimensions, initializeGroupingConfig, isReferenceItem, - validateGroupingConfig, } from "./utils"; diff --git a/frontend/src/components/explorer/grouping/types.ts b/frontend/src/components/explorer/grouping/types.ts index 8d4609a6..5db3df60 100644 --- a/frontend/src/components/explorer/grouping/types.ts +++ b/frontend/src/components/explorer/grouping/types.ts @@ -10,18 +10,6 @@ export interface ChartGroupingConfig { style?: string; } -/** - * Props for components that support grouping functionality - */ -export interface GroupingProps { - /** Initial grouping configuration */ - initialGrouping?: ChartGroupingConfig; - /** Callback when grouping configuration changes */ - onGroupingChange?: (config: ChartGroupingConfig) => void; - /** Whether to hide the grouping controls */ - hideControls?: boolean; -} - /** * Available dimensions for grouping, extracted from data */ diff --git a/frontend/src/components/explorer/thematicContent.tsx b/frontend/src/components/explorer/thematicContent.tsx index 4aee46a0..8b667a1c 100644 --- a/frontend/src/components/explorer/thematicContent.tsx +++ b/frontend/src/components/explorer/thematicContent.tsx @@ -1,5 +1,5 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; +import { getRouteApi } from "@tanstack/react-router"; import { BookOpen, FlaskConical } from "lucide-react"; import { explorerGetThemeOptions } from "@/client/@tanstack/react-query.gen"; import type { @@ -13,11 +13,13 @@ import type { import { useSelectedMipEra } from "@/components/charts/mipEraContext"; import { Button } from "@/components/ui/button.tsx"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx"; -import { Route } from "@/routes/_app/explorer/themes.tsx"; import { ExplorerThemeLayout } from "./explorerThemeLayout"; import type { ChartGroupingConfig } from "./grouping/types"; import type { ExplorerCard, ExplorerCardContent, FilterControl } from "./types"; +// Looked up by id rather than imported, as the route's module imports this one +const themesRoute = getRouteApi("/_app/explorer/themes"); + const themes = [ { name: "atmosphere", title: "Atmosphere" }, { name: "earth-system", title: "Earth System" }, @@ -70,7 +72,7 @@ export function filterExplorerContentForDiagnostic( .map(toExplorerCardContent); } -export function toExplorerCardContent( +function toExplorerCardContent( apiContent: AftCollectionCardContent, ): ExplorerCardContent { const base = { @@ -208,8 +210,8 @@ function ThemeContent({ } export function ThematicContent({ plainLanguage }: { plainLanguage: boolean }) { - const { theme } = Route.useSearch(); - const navigate = useNavigate(); + const { theme } = themesRoute.useSearch(); + const navigate = themesRoute.useNavigate(); const themeObj = themes.find((t) => t.name === theme); const selectedMipEra = useSelectedMipEra(); @@ -225,7 +227,7 @@ export function ThematicContent({ plainLanguage }: { plainLanguage: boolean }) { value={theme} onValueChange={(value) => { navigate({ - to: Route.fullPath, + to: ".", search: (prev) => ({ ...prev, theme: value }), }); }} diff --git a/frontend/src/components/models/diagnosticRunsTable.tsx b/frontend/src/components/models/diagnosticRunsTable.tsx index f3eb17b9..f528e3eb 100644 --- a/frontend/src/components/models/diagnosticRunsTable.tsx +++ b/frontend/src/components/models/diagnosticRunsTable.tsx @@ -5,7 +5,7 @@ import type { DiagnosticRuns } from "@/client"; import { DataTable } from "@/components/dataTable/dataTable"; import { RunOutcomeBar } from "@/components/models/runOutcomeBar"; -export const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { accessorKey: "diagnostic_name", header: "Diagnostic", diff --git a/frontend/src/components/models/ensembleComparisonTable.tsx b/frontend/src/components/models/ensembleComparisonTable.tsx index a0f9b5f9..14bc6f16 100644 --- a/frontend/src/components/models/ensembleComparisonTable.tsx +++ b/frontend/src/components/models/ensembleComparisonTable.tsx @@ -5,7 +5,7 @@ import { EnsemblePositionBar } from "@/components/models/ensemblePositionBar"; import { Badge } from "@/components/ui/badge"; import { formatValue } from "@/lib/format"; -export const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { accessorKey: "diagnostic_name", header: "Diagnostic", diff --git a/frontend/src/components/models/failedRunsTable.tsx b/frontend/src/components/models/failedRunsTable.tsx index 35d3b9d7..a1eaa8ff 100644 --- a/frontend/src/components/models/failedRunsTable.tsx +++ b/frontend/src/components/models/failedRunsTable.tsx @@ -7,7 +7,7 @@ import { DataTable } from "@/components/dataTable/dataTable"; import { outcomeFor } from "@/components/models/outcomes"; import { Badge } from "@/components/ui/badge"; -export const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { accessorKey: "key", header: () => ( diff --git a/frontend/src/components/models/modelTable.tsx b/frontend/src/components/models/modelTable.tsx index 8d3d6d3e..3451e432 100644 --- a/frontend/src/components/models/modelTable.tsx +++ b/frontend/src/components/models/modelTable.tsx @@ -7,7 +7,7 @@ import { RunOutcomeBar } from "@/components/models/runOutcomeBar"; import { Badge } from "@/components/ui/badge"; import { formatCount } from "@/lib/format"; -export const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { accessorKey: "source_id", header: "Model", diff --git a/frontend/src/lib/mipEras.ts b/frontend/src/lib/mipEras.ts index 903c91fd..0637fcfb 100644 --- a/frontend/src/lib/mipEras.ts +++ b/frontend/src/lib/mipEras.ts @@ -26,7 +26,7 @@ export const mipEraSearchFields = { export const mipEraSearchSchema = z.object(mipEraSearchFields); -export function isMipEra(value: unknown): value is MipEra { +function isMipEra(value: unknown): value is MipEra { return MIP_ERAS.some((era) => era === value); } @@ -58,7 +58,7 @@ export function storeMipEra(era: MipEra) { export const MIN_MODELS_FOR_CHART = 3; /** A chart drawn from fewer than this many distinct models carries a sparse-sample warning. */ -export const MIN_MODELS_FOR_CONFIDENCE = 10; +const MIN_MODELS_FOR_CONFIDENCE = 10; /** * The MIP era a value belongs to, from the `mip_era` dimension the API stamps on each model value. From 907336a425c4a290eb26597ac25e61f0ee8e1791 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 18:50:59 +0200 Subject: [PATCH 03/61] docs: changelog for #139 --- changelog/139.fix.md | 1 + changelog/139.trivial.md | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 changelog/139.fix.md create mode 100644 changelog/139.trivial.md diff --git a/changelog/139.fix.md b/changelog/139.fix.md new file mode 100644 index 00000000..8b262e64 --- /dev/null +++ b/changelog/139.fix.md @@ -0,0 +1 @@ +* The recent executions on the dashboard now show running executions as running instead of failed. diff --git a/changelog/139.trivial.md b/changelog/139.trivial.md new file mode 100644 index 00000000..8b8073bb --- /dev/null +++ b/changelog/139.trivial.md @@ -0,0 +1,4 @@ +* Added [Fallow](https://fallow.tools) to check the frontend for dead code, duplication and complexity (`make fallow-audit`), skipping generated code and the shadcn/ui components. +* Removed unused frontend code and exports, and an import cycle between the theme explorer and its route. +* Simplified the log summary and the failures page filters, with tests for the latter. +* Moved build-only packages to `devDependencies` and `@tailwindcss/typography`, which the styles load, to `dependencies`. From 22c381247235a9a2ad76c11dc192b76cfd807deb Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:10:57 +0200 Subject: [PATCH 04/61] fix: explorer CSV download uses the card's value type --- frontend/src/components/explorer/explorerCardDropdown.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/explorer/explorerCardDropdown.tsx b/frontend/src/components/explorer/explorerCardDropdown.tsx index 03672245..18c58a1a 100644 --- a/frontend/src/components/explorer/explorerCardDropdown.tsx +++ b/frontend/src/components/explorer/explorerCardDropdown.tsx @@ -30,7 +30,9 @@ export function ExplorerCardDropdown({ include_unverified: String(includeUnverified), }; - const uri = contentItem.type === "box-whisker-chart" ? "scalars" : "series"; + const valueType = + contentItem.type === "box-whisker-chart" ? "scalar" : "series"; + const uri = valueType === "scalar" ? "scalars" : "series"; return ( @@ -64,7 +66,7 @@ export function ExplorerCardDropdown({ ...(contentItem.otherFilters ?? {}), detect_outliers: "iqr", include_unverified: includeUnverified ?? false, - value_type: "scalar", + value_type: valueType, format: "csv", }, parseAs: "blob", From 7424645ff1f7ede6e467bef1b067701f8afcf05b Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:10:57 +0200 Subject: [PATCH 05/61] fix: explorer filter defaults fall back to the first sorted option --- .../explorer/content/filterControls.test.ts | 86 ++++++++++++++ .../explorer/content/filterControls.tsx | 108 ++++++++++-------- 2 files changed, 145 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/explorer/content/filterControls.test.ts diff --git a/frontend/src/components/explorer/content/filterControls.test.ts b/frontend/src/components/explorer/content/filterControls.test.ts new file mode 100644 index 00000000..27486af2 --- /dev/null +++ b/frontend/src/components/explorer/content/filterControls.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import type { FilterControl } from "../types"; +import { optionsFor, resolveFilterValues } from "./filterControls"; + +const facets = new Map([ + ["region", ["WAF", "MED", "GIC"]], + ["season", ["JJA", "DJF", "ann"]], +]); + +describe("optionsFor", () => { + it("sorts the options by label", () => { + expect(optionsFor({ filterKey: "region" }, facets)).toEqual([ + { value: "GIC", label: "Greenland and Iceland" }, + { value: "MED", label: "Mediterranean" }, + { value: "WAF", label: "Western Africa" }, + ]); + }); + + it("leaves out excluded values", () => { + const control = { filterKey: "season", excludeValues: ["ann"] }; + expect(optionsFor(control, facets).map((o) => o.value)).toEqual([ + "DJF", + "JJA", + ]); + }); + + it("returns no options for a key without a facet", () => { + expect(optionsFor({ filterKey: "experiment_id" }, facets)).toEqual([]); + }); +}); + +describe("resolveFilterValues", () => { + it("keeps a default that is one of the options", () => { + const controls = [{ filterKey: "region", defaultValue: "WAF" }]; + expect(resolveFilterValues(controls, {}, facets)).toEqual({ + region: "WAF", + }); + }); + + it("replaces a default that is not an option with the first option by label", () => { + const controls = [{ filterKey: "region", defaultValue: "XYZ" }]; + expect(resolveFilterValues(controls, {}, facets)).toEqual({ + region: "GIC", + }); + }); + + it("picks the first option by label when there is no default", () => { + expect(resolveFilterValues([{ filterKey: "region" }], {}, facets)).toEqual({ + region: "GIC", + }); + }); + + it("does not pick or keep an excluded value", () => { + const controls: FilterControl[] = [ + { + filterKey: "season", + defaultValue: "DJF", + excludeValues: ["ann", "DJF"], + }, + ]; + expect(resolveFilterValues(controls, {}, facets)).toEqual({ + season: "JJA", + }); + }); + + it("prefers a chosen value over the default", () => { + const controls = [{ filterKey: "region", defaultValue: "WAF" }]; + expect(resolveFilterValues(controls, { region: "MED" }, facets)).toEqual({ + region: "MED", + }); + }); + + it("falls back to the default when the chosen value is not an option", () => { + const controls = [{ filterKey: "region", defaultValue: "WAF" }]; + expect(resolveFilterValues(controls, { region: "XYZ" }, facets)).toEqual({ + region: "WAF", + }); + }); + + it("leaves out a control with no options", () => { + const controls = [ + { filterKey: "experiment_id", defaultValue: "historical" }, + ]; + expect(resolveFilterValues(controls, {}, facets)).toEqual({}); + }); +}); diff --git a/frontend/src/components/explorer/content/filterControls.tsx b/frontend/src/components/explorer/content/filterControls.tsx index 30f4fdff..d504dd73 100644 --- a/frontend/src/components/explorer/content/filterControls.tsx +++ b/frontend/src/components/explorer/content/filterControls.tsx @@ -1,5 +1,5 @@ -import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; +import { useSuspenseQueries } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; import { diagnosticsListMetricValuesOptions } from "@/client/@tanstack/react-query.gen"; import { useSelectedMipEra } from "@/components/charts/mipEraContext"; import type { MetricValueCollection } from "@/components/execution/values/types"; @@ -20,13 +20,47 @@ interface FilterableCard { filterControls?: FilterControl[]; } -function buildInitialFilterValues( - filterControls: FilterControl[] | undefined, +/** A value a filter control can be set to, with the label its dropdown shows. */ +interface FilterOption { + value: string; + label: string; +} + +function optionLabel(filterKey: string, value: string): string { + return filterKey === "region" ? ipccRegionName(value) : value; +} + +/** The options of a control: its facet values without the excluded ones, sorted by label. */ +export function optionsFor( + control: FilterControl, + facets: Map, +): FilterOption[] { + const excludeSet = new Set(control.excludeValues ?? []); + return (facets.get(control.filterKey) ?? []) + .filter((v) => !excludeSet.has(v)) + .map((v) => ({ value: v, label: optionLabel(control.filterKey, v) })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +/** + * The value each control filters by + * + * The chosen value is used if it is one of the control's options, then the default value, + * and otherwise the first option. A control with no options is left out. + */ +export function resolveFilterValues( + controls: FilterControl[], + chosen: Record, + facets: Map, ): Record { const values: Record = {}; - for (const control of filterControls ?? []) { - if (control.defaultValue) { - values[control.filterKey] = control.defaultValue; + for (const control of controls) { + const options = optionsFor(control, facets).map((o) => o.value); + const value = [chosen[control.filterKey], control.defaultValue].find( + (v) => v !== undefined && options.includes(v), + ); + if (value !== undefined || options.length > 0) { + values[control.filterKey] = value ?? options[0]; } } return values; @@ -34,15 +68,13 @@ function buildInitialFilterValues( /** * Facets are fetched without the controlled keys so every dropdown lists all its options. - * A control with no value is set to the first option it can use. + * They are fetched before the values, so the values are only queried once a control is set. */ export function useFilterControls( { provider, diagnostic, otherFilters, filterControls }: FilterableCard, valueType: "scalar" | "series", ) { - const [filterValues, setFilterValues] = useState>(() => - buildInitialFilterValues(filterControls), - ); + const [chosen, setChosen] = useState>({}); const selectedMipEra = useSelectedMipEra(); const controls = filterControls ?? []; @@ -56,18 +88,20 @@ export function useFilterControls( } } - const { data: facetData } = useQuery({ - ...diagnosticsListMetricValuesOptions({ - path: { provider_slug: provider, diagnostic_slug: diagnostic }, - query: { - ...facetQueryFilters, - value_type: valueType, - limit: 1, - mip_era: selectedMipEra ?? undefined, - }, - }), - enabled: hasFilterControls, + const facetQuery = diagnosticsListMetricValuesOptions({ + path: { provider_slug: provider, diagnostic_slug: diagnostic }, + query: { + ...facetQueryFilters, + value_type: valueType, + limit: 1, + mip_era: selectedMipEra ?? undefined, + }, + }); + // Suspends until the facets are in, and fetches nothing for a card without controls. + const [facetResult] = useSuspenseQueries({ + queries: hasFilterControls ? [facetQuery] : ([] as (typeof facetQuery)[]), }); + const facetData = facetResult?.data; const facetMap = useMemo(() => { const map = new Map(); @@ -78,23 +112,7 @@ export function useFilterControls( return map; }, [facetData]); - useEffect(() => { - if (!facetData) return; - const updates: Record = {}; - for (const control of filterControls ?? []) { - if (filterValues[control.filterKey]) continue; - const excludeSet = new Set(control.excludeValues ?? []); - const firstValid = (facetMap.get(control.filterKey) ?? []).find( - (v) => !excludeSet.has(v), - ); - if (firstValid) { - updates[control.filterKey] = firstValid; - } - } - if (Object.keys(updates).length > 0) { - setFilterValues((prev) => ({ ...prev, ...updates })); - } - }, [facetData, facetMap, filterControls, filterValues]); + const filterValues = resolveFilterValues(controls, chosen, facetMap); const filterBar = hasFilterControls ? ( - setFilterValues((prev) => ({ ...prev, [key]: value })) + setChosen((prev) => ({ ...prev, [key]: value })) } /> ) : null; @@ -113,10 +131,6 @@ export function useFilterControls( }; } -function optionLabel(filterKey: string, value: string): string { - return filterKey === "region" ? ipccRegionName(value) : value; -} - interface FilterControlBarProps { controls: FilterControl[]; filterValues: Record; @@ -133,11 +147,7 @@ function FilterControlBar({ return (
{controls.map((control) => { - const excludeSet = new Set(control.excludeValues ?? []); - const options = (facetMap.get(control.filterKey) ?? []) - .filter((v) => !excludeSet.has(v)) - .map((v) => ({ value: v, label: optionLabel(control.filterKey, v) })) - .sort((a, b) => a.label.localeCompare(b.label)); + const options = optionsFor(control, facetMap); return (
From 92da0b8dddc7e5e5164d376e4cf3ed9c3f8c4c48 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:09:50 +0200 Subject: [PATCH 06/61] fix: show executions list errors and share offset pagination between list pages --- frontend/src/lib/pagination.test.ts | 40 +++++++++++++++++++ frontend/src/lib/pagination.ts | 24 +++++++++++ frontend/src/routes/_app/datasets.index.tsx | 10 ++--- frontend/src/routes/_app/executions.index.tsx | 29 +++----------- 4 files changed, 72 insertions(+), 31 deletions(-) create mode 100644 frontend/src/lib/pagination.test.ts create mode 100644 frontend/src/lib/pagination.ts diff --git a/frontend/src/lib/pagination.test.ts b/frontend/src/lib/pagination.test.ts new file mode 100644 index 00000000..0de06161 --- /dev/null +++ b/frontend/src/lib/pagination.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { nextOffset } from "./pagination"; + +const page = (length: number, total_count?: number | null) => ({ + data: Array.from({ length }, (_, i) => i), + total_count, +}); + +describe("nextOffset", () => { + it("returns the number of items loaded while more remain", () => { + expect(nextOffset([page(50, 120)])).toBe(50); + expect(nextOffset([page(50, 120), page(50, 120)])).toBe(100); + }); + + it("stops after a partial last page", () => { + expect(nextOffset([page(50, 70), page(20, 70)])).toBeUndefined(); + }); + + it("stops when exactly the total has been loaded", () => { + expect(nextOffset([page(50, 100), page(50, 100)])).toBeUndefined(); + }); + + it("stops when the total is unknown", () => { + expect(nextOffset([page(50, null)])).toBeUndefined(); + expect(nextOffset([page(50)])).toBeUndefined(); + }); + + it("stops on an empty page, even if the total says more remain", () => { + expect(nextOffset([page(0, 0)])).toBeUndefined(); + expect(nextOffset([page(50, 120), page(0, 120)])).toBeUndefined(); + }); + + it("returns undefined for no pages", () => { + expect(nextOffset([])).toBeUndefined(); + }); + + it("uses the latest page's total", () => { + expect(nextOffset([page(50, 200), page(50, 100)])).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/pagination.ts b/frontend/src/lib/pagination.ts new file mode 100644 index 00000000..3260fbf5 --- /dev/null +++ b/frontend/src/lib/pagination.ts @@ -0,0 +1,24 @@ +/** One page of an offset-paginated collection from the API. */ +interface CollectionPage { + data: unknown[]; + total_count?: number | null; +} + +/** + * The offset of the next page to fetch, or `undefined` when there is nothing left + * + * Counts the items in every page loaded so far and compares them with the latest page's `total_count`. + * Stops when the total is unknown or the latest page came back empty, so a wrong total cannot fetch forever. + */ +export function nextOffset(pages: CollectionPage[]): number | undefined { + const lastPage = pages.at(-1); + if (lastPage == null || lastPage.data.length === 0) { + return undefined; + } + const totalCount = lastPage.total_count; + if (totalCount == null) { + return undefined; + } + const loaded = pages.reduce((acc, page) => acc + page.data.length, 0); + return loaded < totalCount ? loaded : undefined; +} diff --git a/frontend/src/routes/_app/datasets.index.tsx b/frontend/src/routes/_app/datasets.index.tsx index e75a2f08..d81c0cdc 100644 --- a/frontend/src/routes/_app/datasets.index.tsx +++ b/frontend/src/routes/_app/datasets.index.tsx @@ -10,6 +10,7 @@ import DatasetTable from "@/components/datasets/datasetTable"; import { FilterPanel } from "@/components/datasets/filterPanel"; import { Button } from "@/components/ui/button.tsx"; import { Card, CardContent } from "@/components/ui/card"; +import { nextOffset } from "@/lib/pagination"; const DatasetsSearchSchema = z.object({ dataset_type: z.string().default("cmip6"), @@ -44,13 +45,8 @@ function SourcesIndexPage() { ...datasetsListInfiniteOptions({ query: { ...search, limit: 50 }, }), - getNextPageParam: (lastPage, allPages) => { - const loaded = allPages.reduce((acc, page) => acc + (page.count || 0), 0); - if (lastPage.total_count != null && loaded < lastPage.total_count) { - return loaded; - } - return undefined; - }, + initialPageParam: 0, + getNextPageParam: (_lastPage, allPages) => nextOffset(allPages), }); const datasets = data?.pages.flatMap((page) => page.data) || []; diff --git a/frontend/src/routes/_app/executions.index.tsx b/frontend/src/routes/_app/executions.index.tsx index 44873e3b..5a59f72e 100644 --- a/frontend/src/routes/_app/executions.index.tsx +++ b/frontend/src/routes/_app/executions.index.tsx @@ -1,8 +1,7 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { z } from "zod"; -import { executionsListRecentExecutionGroupsQueryKey } from "@/client/@tanstack/react-query.gen"; -import { executionsListRecentExecutionGroups } from "@/client/sdk.gen"; +import { executionsListRecentExecutionGroupsInfiniteOptions } from "@/client/@tanstack/react-query.gen"; import { PageHeader } from "@/components/app/pageHeader"; import { MipEraEmptyState, MipEraScope } from "@/components/charts/mipEraBar"; import ExecutionGroupTable from "@/components/execution/executionGroupTable"; @@ -11,6 +10,7 @@ import { Button } from "@/components/ui/button.tsx"; import { Card, CardContent } from "@/components/ui/card"; import { useMipEra } from "@/hooks/useMipEra"; import { mipEraSearchFields } from "@/lib/mipEras"; +import { nextOffset } from "@/lib/pagination"; const ExecutionsSearchSchema = z.object({ diagnostic_name_contains: z.string().optional(), @@ -62,31 +62,12 @@ function ExecutionsListPage() { isLoading, error, } = useInfiniteQuery({ - queryKey: executionsListRecentExecutionGroupsQueryKey(queryOptions), - queryFn: async ({ pageParam = 0 }) => { - const { data } = await executionsListRecentExecutionGroups({ - ...queryOptions, - query: { - ...queryOptions.query, - offset: pageParam, - }, - }); - return data; - }, + ...executionsListRecentExecutionGroupsInfiniteOptions(queryOptions), initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - const loaded = (allPages ?? []).reduce( - (acc, page) => acc + (page?.data?.length ?? 0), - 0, - ); - if (lastPage?.total_count && loaded < lastPage.total_count) { - return loaded; - } - return undefined; - }, + getNextPageParam: (_lastPage, allPages) => nextOffset(allPages), }); - const executionGroups = data?.pages.flatMap((page) => page?.data ?? []) || []; + const executionGroups = data?.pages.flatMap((page) => page.data) ?? []; const updateSearch = (patch: Partial) => { // Remove empty values to keep URL clean and keep types aligned with Route.validateSearch From 76dc0fcf5733d9867d4188907587de4feee1d893 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:13:36 +0200 Subject: [PATCH 07/61] fix: debounce list filter inputs and parse dataset facet filters safely --- .../components/app/debouncedInput.test.tsx | 67 ++++++++++++++++ .../src/components/app/debouncedInput.tsx | 62 +++++++++++++++ .../src/components/datasets/filterPanel.tsx | 14 +--- .../src/components/execution/filterPanel.tsx | 30 +++---- frontend/src/routes/_app/datasets.index.tsx | 78 ++++++++++++------- 5 files changed, 193 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/app/debouncedInput.test.tsx create mode 100644 frontend/src/components/app/debouncedInput.tsx diff --git a/frontend/src/components/app/debouncedInput.test.tsx b/frontend/src/components/app/debouncedInput.test.tsx new file mode 100644 index 00000000..5a71fea9 --- /dev/null +++ b/frontend/src/components/app/debouncedInput.test.tsx @@ -0,0 +1,67 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DebouncedInput } from "./debouncedInput"; + +describe("DebouncedInput", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("reports only the last change once typing pauses", () => { + const onValueChange = vi.fn(); + render(); + const input = screen.getByRole("textbox"); + + fireEvent.change(input, { target: { value: "a" } }); + fireEvent.change(input, { target: { value: "ab" } }); + act(() => { + vi.advanceTimersByTime(299); + }); + expect(onValueChange).not.toHaveBeenCalled(); + expect(input).toHaveValue("ab"); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledWith("ab"); + }); + + it("follows a value cleared from outside and drops the pending change", () => { + const onValueChange = vi.fn(); + const { rerender } = render( + , + ); + const input = screen.getByRole("textbox"); + + fireEvent.change(input, { target: { value: "older" } }); + rerender(); + act(() => { + vi.runAllTimers(); + }); + + expect(input).toHaveValue(""); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it("keeps the typed text when its own change comes back as the value", () => { + const onValueChange = vi.fn(); + const { rerender } = render( + , + ); + const input = screen.getByRole("textbox"); + + fireEvent.change(input, { target: { value: "ab" } }); + act(() => { + vi.runAllTimers(); + }); + fireEvent.change(input, { target: { value: "abc" } }); + rerender(); + + expect(input).toHaveValue("abc"); + }); +}); diff --git a/frontend/src/components/app/debouncedInput.tsx b/frontend/src/components/app/debouncedInput.tsx new file mode 100644 index 00000000..fbf0be45 --- /dev/null +++ b/frontend/src/components/app/debouncedInput.tsx @@ -0,0 +1,62 @@ +import { type ComponentProps, useEffect, useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; + +interface DebouncedInputProps + extends Omit< + ComponentProps, + "value" | "defaultValue" | "onChange" + > { + value: string; + /** Called with the typed text once typing pauses for `delay` milliseconds */ + onValueChange: (value: string) => void; + delay?: number; +} + +/** + * A text input that reports changes only once typing pauses + * + * It shows what is typed straight away and follows `value` when it changes from outside, + * such as when filters are cleared. + */ +export function DebouncedInput({ + value, + onValueChange, + delay = 300, + ...props +}: DebouncedInputProps) { + const [draft, setDraft] = useState(value); + const timer = useRef>(undefined); + const lastSent = useRef(value); + const onValueChangeRef = useRef(onValueChange); + + useEffect(() => { + onValueChangeRef.current = onValueChange; + }); + + // A value we did not send came from outside, so it replaces the draft and any pending change + useEffect(() => { + if (value !== lastSent.current) { + clearTimeout(timer.current); + lastSent.current = value; + setDraft(value); + } + }, [value]); + + useEffect(() => () => clearTimeout(timer.current), []); + + return ( + { + const next = e.target.value; + setDraft(next); + clearTimeout(timer.current); + timer.current = setTimeout(() => { + lastSent.current = next; + onValueChangeRef.current(next); + }, delay); + }} + /> + ); +} diff --git a/frontend/src/components/datasets/filterPanel.tsx b/frontend/src/components/datasets/filterPanel.tsx index 943d0d8f..3f2280ee 100644 --- a/frontend/src/components/datasets/filterPanel.tsx +++ b/frontend/src/components/datasets/filterPanel.tsx @@ -1,7 +1,7 @@ import { X } from "lucide-react"; +import { DebouncedInput } from "@/components/app/debouncedInput"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -52,16 +52,10 @@ export function FilterPanel({ - { - const value = e.target.value; - const handler = setTimeout(() => { - onFilterChange({ name_contains: value }); - }, 200); - return () => clearTimeout(handler); - }} + value={filters.name_contains || ""} + onValueChange={(value) => onFilterChange({ name_contains: value })} className="max-w-sm" />
- { - const value = e.target.value; - const handler = setTimeout(() => { - onFilterChange({ provider_name_contains: value }); - }, 200); - return () => clearTimeout(handler); - }} + value={filters.provider_name_contains || ""} + onValueChange={(value) => + onFilterChange({ provider_name_contains: value }) + } className="max-w-sm" /> - { - const value = e.target.value; - const handler = setTimeout(() => { - onFilterChange({ diagnostic_name_contains: value }); - }, 200); - return () => clearTimeout(handler); - }} + value={filters.diagnostic_name_contains || ""} + onValueChange={(value) => + onFilterChange({ diagnostic_name_contains: value }) + } className="max-w-sm" /> diff --git a/frontend/src/routes/_app/datasets.index.tsx b/frontend/src/routes/_app/datasets.index.tsx index d81c0cdc..db0a1755 100644 --- a/frontend/src/routes/_app/datasets.index.tsx +++ b/frontend/src/routes/_app/datasets.index.tsx @@ -1,10 +1,7 @@ -import { useInfiniteQuery } from "@tanstack/react-query"; +import { infiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { z } from "zod"; -import { - datasetsListInfiniteOptions, - datasetsListOptions, -} from "@/client/@tanstack/react-query.gen"; +import { datasetsListInfiniteOptions } from "@/client/@tanstack/react-query.gen"; import { PageHeader } from "@/components/app/pageHeader"; import DatasetTable from "@/components/datasets/datasetTable"; import { FilterPanel } from "@/components/datasets/filterPanel"; @@ -12,21 +9,54 @@ import { Button } from "@/components/ui/button.tsx"; import { Card, CardContent } from "@/components/ui/card"; import { nextOffset } from "@/lib/pagination"; +/** Facet filters by name, also read from the JSON string older links carry. Anything malformed means no facet filters. */ +const facetsSearch = z + .preprocess((value) => { + if (typeof value !== "string") { + return value; + } + try { + return JSON.parse(value); + } catch { + return undefined; + } + }, z.record(z.string(), z.string()).optional()) + .catch(undefined); + const DatasetsSearchSchema = z.object({ dataset_type: z.string().default("cmip6"), name_contains: z.string().optional(), - facets: z.string().optional(), + facets: facetsSearch, }); +type DatasetsSearch = z.infer; + +/** The infinite query for the datasets matching the page's filters, shared by the loader and the page. */ +const datasetsQueryOptions = ({ facets, ...search }: DatasetsSearch) => + infiniteQueryOptions({ + ...datasetsListInfiniteOptions({ + query: { + ...search, + facets: + facets && Object.keys(facets).length > 0 + ? JSON.stringify(facets) + : undefined, + limit: 50, + }, + }), + initialPageParam: 0, + getNextPageParam: (_lastPage, allPages) => nextOffset(allPages), + }); + export const Route = createFileRoute("/_app/datasets/")({ component: SourcesIndexPage, validateSearch: DatasetsSearchSchema, staticData: { title: "Datasets", }, - loader: async ({ context: { queryClient } }) => { - return queryClient.ensureQueryData(datasetsListOptions()); - }, + loaderDeps: ({ search }) => search, + loader: ({ context: { queryClient }, deps }) => + queryClient.prefetchInfiniteQuery(datasetsQueryOptions(deps)), }); function SourcesIndexPage() { @@ -41,13 +71,7 @@ function SourcesIndexPage() { status, isLoading, error, - } = useInfiniteQuery({ - ...datasetsListInfiniteOptions({ - query: { ...search, limit: 50 }, - }), - initialPageParam: 0, - getNextPageParam: (_lastPage, allPages) => nextOffset(allPages), - }); + } = useInfiniteQuery(datasetsQueryOptions(search)); const datasets = data?.pages.flatMap((page) => page.data) || []; const allFacets = datasets.reduce( @@ -72,16 +96,15 @@ function SourcesIndexPage() { ]), ); - const handleFilterChange = (newFilters: Record) => { - const searchParams: Record = { ...search, ...newFilters }; - - if (Object.keys(newFilters.facets ?? {}).length > 0) { - searchParams.facets = JSON.stringify(newFilters.facets); - } else { - delete searchParams.facets; + const handleFilterChange = (newFilters: Partial) => { + const next = { ...search, ...newFilters }; + if (!next.name_contains) { + delete next.name_contains; } - - navigate({ search: searchParams }); + if (next.facets && Object.keys(next.facets).length === 0) { + delete next.facets; + } + navigate({ search: next }); }; const handleClearFilters = () => { @@ -100,10 +123,7 @@ function SourcesIndexPage() {
From c2a9379c6acd6de540fba9062783533802322227 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:13:36 +0200 Subject: [PATCH 08/61] fix: diagnostics search history, skeleton keys, log file name and model error message --- frontend/src/components/dashboard/executionStats.tsx | 2 +- .../execution/executionLogs/executionLogContainer.tsx | 8 +++++++- frontend/src/lib/apiError.ts | 2 +- frontend/src/routes/_app/diagnostics.index.tsx | 4 +++- frontend/src/routes/_app/models.$sourceId.tsx | 5 ++++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/dashboard/executionStats.tsx b/frontend/src/components/dashboard/executionStats.tsx index a8ac6ab7..389a7c09 100644 --- a/frontend/src/components/dashboard/executionStats.tsx +++ b/frontend/src/components/dashboard/executionStats.tsx @@ -30,7 +30,7 @@ export function ExecutionStats() { if (error) { return ( - + Execution Statistics Failed to load execution statistics diff --git a/frontend/src/components/execution/executionLogs/executionLogContainer.tsx b/frontend/src/components/execution/executionLogs/executionLogContainer.tsx index f027cb37..b27227a4 100644 --- a/frontend/src/components/execution/executionLogs/executionLogContainer.tsx +++ b/frontend/src/components/execution/executionLogs/executionLogContainer.tsx @@ -171,7 +171,13 @@ export function ExecutionLogContainer({ disabled={isBusy} size="sm" onClick={() => - downloadTextFile(logContent, `logs-${executionId}.txt`) + downloadTextFile( + logContent, + // Without an execution the logs are the group's latest execution's + executionId + ? `logs-${groupId}-${executionId}.txt` + : `logs-${groupId}.txt`, + ) } > diff --git a/frontend/src/lib/apiError.ts b/frontend/src/lib/apiError.ts index a73e7656..ebd1958a 100644 --- a/frontend/src/lib/apiError.ts +++ b/frontend/src/lib/apiError.ts @@ -54,7 +54,7 @@ export function installApiErrors(client: Client) { ); } -function isNotFound(error: unknown): boolean { +export function isNotFound(error: unknown): boolean { return error instanceof ApiError && error.status === 404; } diff --git a/frontend/src/routes/_app/diagnostics.index.tsx b/frontend/src/routes/_app/diagnostics.index.tsx index 9221adda..34904d7b 100644 --- a/frontend/src/routes/_app/diagnostics.index.tsx +++ b/frontend/src/routes/_app/diagnostics.index.tsx @@ -135,6 +135,7 @@ const Diagnostics = () => { metricValues: metricValues === null ? undefined : metricValues ? "true" : "false", }), + replace: true, }); }; @@ -294,7 +295,8 @@ const Diagnostics = () => {
{Array.from({ length: 8 }).map((_, index) => ( diff --git a/frontend/src/routes/_app/models.$sourceId.tsx b/frontend/src/routes/_app/models.$sourceId.tsx index 8c150ba0..bfd95613 100644 --- a/frontend/src/routes/_app/models.$sourceId.tsx +++ b/frontend/src/routes/_app/models.$sourceId.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useMipEra } from "@/hooks/useMipEra"; +import { apiErrorMessage, isNotFound } from "@/lib/apiError"; import { formatCount } from "@/lib/format"; import { mipEraSearchFields } from "@/lib/mipEras"; @@ -114,7 +115,9 @@ function ModelDetailPage() { {error && (
- This model has no {mipEra} runs. {String(error)} + {isNotFound(error) + ? `This model has no ${mipEra} runs.` + : `Error loading model: ${apiErrorMessage(error)}`}
)} {isLoading &&
Loading model...
} From b92e727deab8ac54eb133eb068749ebe10515d8e Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:16:49 +0200 Subject: [PATCH 09/61] fix: show execution groups without executions as not run instead of failed --- .../components/dashboard/recentExecutions.tsx | 50 +++++++++----- .../diagnostics/executionsTable.tsx | 21 ++---- .../execution/executionGroupTable.tsx | 30 ++++----- .../execution/executionStatusBadge.tsx | 21 ++++++ frontend/src/lib/executionStatus.test.ts | 51 +++++++++++++++ frontend/src/lib/executionStatus.ts | 65 +++++++++++++++++++ .../routes/_app/executions.$groupId/route.tsx | 15 ++--- 7 files changed, 194 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/execution/executionStatusBadge.tsx create mode 100644 frontend/src/lib/executionStatus.test.ts create mode 100644 frontend/src/lib/executionStatus.ts diff --git a/frontend/src/components/dashboard/recentExecutions.tsx b/frontend/src/components/dashboard/recentExecutions.tsx index 3c6c58b9..680490da 100644 --- a/frontend/src/components/dashboard/recentExecutions.tsx +++ b/frontend/src/components/dashboard/recentExecutions.tsx @@ -1,5 +1,12 @@ import { Link } from "@tanstack/react-router"; -import { ArrowRight, CheckCircle, Clock, XCircle } from "lucide-react"; +import { + ArrowRight, + CheckCircle, + CircleDashed, + Clock, + type LucideIcon, + XCircle, +} from "lucide-react"; import type { ExecutionGroup } from "@/client"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -12,24 +19,37 @@ import { CardTitle, } from "@/components/ui/card"; import { useDiagnosticLookup } from "@/hooks/useDiagnosticLookup"; +import { + type ExecutionStatus, + executionStatusDisplay, + latestExecutionStatus, +} from "@/lib/executionStatus"; interface RecentExecutionsProps { executions: ExecutionGroup[]; } -/** Null while the execution is still running. */ -function ExecutionStatusBadge({ successful }: { successful: boolean | null }) { - if (successful === null) { - return ( - - - - ); +/** The compact badge for each status, an icon named in its tooltip. */ +const statusIcons: Record< + ExecutionStatus, + { + icon: LucideIcon; + variant: "default" | "secondary" | "destructive" | "outline"; } +> = { + success: { icon: CheckCircle, variant: "default" }, + failed: { icon: XCircle, variant: "destructive" }, + running: { icon: Clock, variant: "secondary" }, + not_run: { icon: CircleDashed, variant: "outline" }, +}; + +function ExecutionStatusIcon({ status }: { status: ExecutionStatus }) { + const { icon: Icon, variant } = statusIcons[status]; + const { label } = executionStatusDisplay[status]; return ( - - {successful && } - {!successful && } + + + {label} ); } @@ -52,8 +72,6 @@ export function RecentExecutions({ executions }: RecentExecutionsProps) { execution.provider_slug, execution.diagnostic_slug, ); - // Its successful is null while it runs, which the badge shows - const latest = execution.executions.at(-1); return (
- {diagnostic?.provider.name ?? execution.provider_slug} diff --git a/frontend/src/components/diagnostics/executionsTable.tsx b/frontend/src/components/diagnostics/executionsTable.tsx index 4c88e003..986a9c49 100644 --- a/frontend/src/components/diagnostics/executionsTable.tsx +++ b/frontend/src/components/diagnostics/executionsTable.tsx @@ -8,6 +8,7 @@ import { format } from "date-fns"; import { SquareArrowOutUpRight } from "lucide-react"; import type { Execution } from "@/client"; import { DataTable } from "@/components/dataTable/dataTable"; +import { ExecutionStatusBadge } from "@/components/execution/executionStatusBadge"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -16,6 +17,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { type ExecutionStatus, executionStatus } from "@/lib/executionStatus"; import { formatBytes, formatDuration } from "@/lib/format"; import { Route } from "@/routes/_app/executions.$groupId/index"; @@ -75,21 +77,10 @@ const columns: ColumnDef[] = [ { id: "status", header: "Status", - accessorFn: (row) => row.successful, - cell: (cell) => - cell.getValue() ? ( - - Success - - ) : cell.getValue() === null ? ( - - Running - - ) : ( - - Failed - - ), + accessorFn: (row) => executionStatus(row), + cell: (cell) => ( + ()} /> + ), }, { id: "latest", diff --git a/frontend/src/components/execution/executionGroupTable.tsx b/frontend/src/components/execution/executionGroupTable.tsx index 9854d4d0..b556478d 100644 --- a/frontend/src/components/execution/executionGroupTable.tsx +++ b/frontend/src/components/execution/executionGroupTable.tsx @@ -8,7 +8,8 @@ import { diagnosticsListExecutionGroupsOptions } from "@/client/@tanstack/react- import { MipEraEmptyState } from "@/components/charts/mipEraBar"; import { useSelectedMipEra } from "@/components/charts/mipEraContext"; import { DataTable } from "@/components/dataTable/dataTable.tsx"; -import { Badge, SourceTypeBadge } from "@/components/ui/badge.tsx"; +import { ExecutionStatusBadge } from "@/components/execution/executionStatusBadge"; +import { SourceTypeBadge } from "@/components/ui/badge.tsx"; import { Card, CardContent, @@ -22,6 +23,10 @@ import { TooltipTrigger, } from "@/components/ui/tooltip.tsx"; import { useDiagnosticLookup } from "@/hooks/useDiagnosticLookup"; +import { + type ExecutionStatus, + latestExecutionStatus, +} from "@/lib/executionStatus"; import { formatDuration } from "@/lib/format"; const columnHelper = createColumnHelper(); @@ -152,26 +157,15 @@ const columns: ColumnDef[] = [ }, { header: () => ( - - Successful + + Status ), id: "successful", - accessorFn: (row) => row.executions.at(-1)?.successful, - cell: (cell) => - cell.getValue() ? ( - - Yes - - ) : cell.getValue() === null ? ( - - Running - - ) : ( - - No - - ), + accessorFn: (row) => latestExecutionStatus(row), + cell: (cell) => ( + ()} /> + ), }, { id: "wall_seconds", diff --git a/frontend/src/components/execution/executionStatusBadge.tsx b/frontend/src/components/execution/executionStatusBadge.tsx new file mode 100644 index 00000000..7995d150 --- /dev/null +++ b/frontend/src/components/execution/executionStatusBadge.tsx @@ -0,0 +1,21 @@ +import { Badge } from "@/components/ui/badge"; +import { + type ExecutionStatus, + executionStatusDisplay, +} from "@/lib/executionStatus"; + +/** A badge naming an execution status, described in its tooltip. */ +export function ExecutionStatusBadge({ + status, + className, +}: { + status: ExecutionStatus; + className?: string; +}) { + const { label, description, badgeVariant } = executionStatusDisplay[status]; + return ( + + {label} + + ); +} diff --git a/frontend/src/lib/executionStatus.test.ts b/frontend/src/lib/executionStatus.test.ts new file mode 100644 index 00000000..f4e83e71 --- /dev/null +++ b/frontend/src/lib/executionStatus.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + executionStatus, + executionStatusDisplay, + latestExecutionStatus, +} from "./executionStatus"; + +const group = (...successful: (boolean | null)[]) => ({ + executions: successful.map((value) => ({ successful: value })), +}); + +describe("executionStatus", () => { + it("maps whether an execution succeeded", () => { + expect(executionStatus({ successful: true })).toBe("success"); + expect(executionStatus({ successful: false })).toBe("failed"); + expect(executionStatus({ successful: null })).toBe("running"); + }); + + it("is not_run without an execution", () => { + expect(executionStatus(undefined)).toBe("not_run"); + expect(executionStatus(null)).toBe("not_run"); + }); +}); + +describe("latestExecutionStatus", () => { + it("uses the last execution, which is the latest", () => { + expect(latestExecutionStatus(group(false, true))).toBe("success"); + expect(latestExecutionStatus(group(true, false))).toBe("failed"); + expect(latestExecutionStatus(group(true, null))).toBe("running"); + }); + + it("is not_run for a group without executions", () => { + expect(latestExecutionStatus(group())).toBe("not_run"); + }); +}); + +describe("executionStatusDisplay", () => { + it("labels every status", () => { + expect(executionStatusDisplay.success.label).toBe("Success"); + expect(executionStatusDisplay.failed.label).toBe("Failed"); + expect(executionStatusDisplay.running.label).toBe("Running"); + expect(executionStatusDisplay.not_run.label).toBe("Not run"); + }); + + it("shows only a failure as destructive", () => { + const destructive = Object.entries(executionStatusDisplay) + .filter(([, display]) => display.badgeVariant === "destructive") + .map(([status]) => status); + expect(destructive).toEqual(["failed"]); + }); +}); diff --git a/frontend/src/lib/executionStatus.ts b/frontend/src/lib/executionStatus.ts new file mode 100644 index 00000000..af0fc856 --- /dev/null +++ b/frontend/src/lib/executionStatus.ts @@ -0,0 +1,65 @@ +import type { Execution } from "@/client"; + +/** + * How an execution turned out + * + * * `success` -- it completed without errors + * * `failed` -- it finished with an error + * * `running` -- it has not finished yet + * * `not_run` -- there is no execution, such as for a group that has never run + */ +export type ExecutionStatus = "success" | "failed" | "running" | "not_run"; + +/** The status of an execution, which is `not_run` when there is none. */ +export function executionStatus( + execution: Pick | null | undefined, +): ExecutionStatus { + if (execution == null) { + return "not_run"; + } + if (execution.successful === null) { + return "running"; + } + return execution.successful ? "success" : "failed"; +} + +/** The status of a group's latest execution. Executions are oldest first, so the last one is the latest. */ +export function latestExecutionStatus(group: { + executions: Pick[]; +}): ExecutionStatus { + return executionStatus(group.executions.at(-1)); +} + +/** How to show an execution status. */ +interface ExecutionStatusDisplay { + label: string; + /** A sentence for a tooltip */ + description: string; + badgeVariant: "default" | "secondary" | "destructive" | "outline"; +} + +export const executionStatusDisplay: Record< + ExecutionStatus, + ExecutionStatusDisplay +> = { + success: { + label: "Success", + description: "Completed without errors.", + badgeVariant: "outline", + }, + failed: { + label: "Failed", + description: "Finished with an error.", + badgeVariant: "destructive", + }, + running: { + label: "Running", + description: "Still running.", + badgeVariant: "secondary", + }, + not_run: { + label: "Not run", + description: "Has not run yet.", + badgeVariant: "secondary", + }, +}; diff --git a/frontend/src/routes/_app/executions.$groupId/route.tsx b/frontend/src/routes/_app/executions.$groupId/route.tsx index 4251cd00..ad4000a3 100644 --- a/frontend/src/routes/_app/executions.$groupId/route.tsx +++ b/frontend/src/routes/_app/executions.$groupId/route.tsx @@ -13,10 +13,11 @@ import { executionsGetOptions, } from "@/client/@tanstack/react-query.gen"; import { DownloadOutputs } from "@/components/execution/downloadOutputs.tsx"; -import { Badge } from "@/components/ui/badge"; +import { ExecutionStatusBadge } from "@/components/execution/executionStatusBadge"; import { DetailsPanel } from "@/components/ui/detailsPanel.tsx"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { executionStatus } from "@/lib/executionStatus"; import { formatBytes, formatDuration } from "@/lib/format"; const ExecutionInfoLayout = () => { @@ -107,16 +108,10 @@ const ExecutionInfoLayout = () => { { label: "Status", value: ( - - {execution.successful === null - ? "Running" - : execution.successful - ? "Success" - : "Failed"} - + status={executionStatus(execution)} + /> ), }, { From 06e3dda9761c6755bbcbdb42346b749bec1eb30d Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:16:54 +0200 Subject: [PATCH 10/61] fix: series chart tooltip, tick labels, legend groups and crosshair colour --- .../values/series/canvasTooltip.test.ts | 61 +++++++++ .../execution/values/series/canvasTooltip.tsx | 95 +++++++------ .../execution/values/series/seriesCanvas.tsx | 42 +++--- .../execution/values/series/seriesLegend.tsx | 19 ++- .../values/series/seriesVisualization.tsx | 44 ++++-- .../values/series/useSpatialIndex.test.ts | 39 ++++++ .../values/series/useSpatialIndex.ts | 22 +++ .../execution/values/series/utils.test.ts | 125 ++++++++++++++---- .../execution/values/series/utils.ts | 84 ++++++++---- 9 files changed, 389 insertions(+), 142 deletions(-) diff --git a/frontend/src/components/execution/values/series/canvasTooltip.test.ts b/frontend/src/components/execution/values/series/canvasTooltip.test.ts index e477d60b..de9e1677 100644 --- a/frontend/src/components/execution/values/series/canvasTooltip.test.ts +++ b/frontend/src/components/execution/values/series/canvasTooltip.test.ts @@ -4,7 +4,68 @@ import { formatXValue, formatYValue, ordinal, + otherTooltipEntries, } from "./canvasTooltip"; +import type { NearestResult } from "./useSpatialIndex"; + +/** A point of series `seriesIndex` at x = 0 */ +function entry(seriesIndex: number): NearestResult { + return { + point: { + seriesIndex, + dataIndex: 0, + x: 0, + y: seriesIndex, + pixelX: 0, + pixelY: seriesIndex, + }, + metadata: { + seriesIndex, + label: `Series ${seriesIndex}`, + color: "#4e79a7", + isReference: false, + dimensions: {}, + }, + distance: seriesIndex, + }; +} + +/** `count` entries for series 0, 1, 2, ... */ +function entries(count: number): NearestResult[] { + return Array.from({ length: count }, (_, i) => entry(i)); +} + +describe("otherTooltipEntries", () => { + it("lists every other series when they fit", () => { + const allAtX = entries(5); + const { others, remaining } = otherTooltipEntries(allAtX, allAtX[0]); + expect(others.map((r) => r.metadata.seriesIndex)).toEqual([1, 2, 3, 4]); + expect(remaining).toBe(0); + }); + + it("counts the series left out when the nearest is shown first", () => { + const allAtX = entries(12); + const { others, remaining } = otherTooltipEntries(allAtX, allAtX[0]); + expect(others).toHaveLength(7); + // 1 nearest + 7 others shown, 4 left out + expect(remaining).toBe(4); + }); + + it("counts the series left out when the nearest is not among the first 8", () => { + const allAtX = entries(12); + const { others, remaining } = otherTooltipEntries(allAtX, allAtX[10]); + expect(others.map((r) => r.metadata.seriesIndex)).toEqual([ + 0, 1, 2, 3, 4, 5, 6, + ]); + expect(remaining).toBe(4); + }); + + it("counts every listed series as left out when the nearest is not in the list", () => { + const { others, remaining } = otherTooltipEntries(entries(12), entry(20)); + expect(others).toHaveLength(7); + expect(remaining).toBe(5); + }); +}); describe("formatXValue", () => { it("shows integer years without decimals", () => { diff --git a/frontend/src/components/execution/values/series/canvasTooltip.tsx b/frontend/src/components/execution/values/series/canvasTooltip.tsx index b2337e89..3b3705b1 100644 --- a/frontend/src/components/execution/values/series/canvasTooltip.tsx +++ b/frontend/src/components/execution/values/series/canvasTooltip.tsx @@ -75,6 +75,23 @@ export function ordinal(n: number): string { return n + (s[(v - 20) % 10] || s[v] || s[0]); } +/** + * The series listed under the nearest one, and how many are left out + * + * The nearest series has its own block, so it is never listed again + * and the entries shown add up to at most `MAX_TOOLTIP_ENTRIES`. + */ +export function otherTooltipEntries( + allAtX: NearestResult[], + nearest: NearestResult, +): { others: NearestResult[]; remaining: number } { + const candidates = allAtX.filter( + (r) => r.metadata.seriesIndex !== nearest.metadata.seriesIndex, + ); + const others = candidates.slice(0, MAX_TOOLTIP_ENTRIES - 1); + return { others, remaining: candidates.length - others.length }; +} + export function CanvasTooltip({ visible, x, @@ -90,8 +107,7 @@ export function CanvasTooltip({ return null; } - const displayed = allAtX.slice(0, MAX_TOOLTIP_ENTRIES); - const remaining = allAtX.length - displayed.length; + const { others, remaining } = otherTooltipEntries(allAtX, nearest); // Find reference value at this X for delta computation const referenceEntry = allAtX.find((r) => r.metadata.isReference); @@ -182,49 +198,44 @@ export function CanvasTooltip({
{/* Other series at this X position */} - {displayed.length > 1 && ( + {others.length > 0 && (
- {displayed - .filter( - (r) => r.metadata.seriesIndex !== nearest.metadata.seriesIndex, - ) - .slice(0, MAX_TOOLTIP_ENTRIES - 1) - .map(({ point, metadata }) => { - const delta = - referenceValue !== null && !metadata.isReference - ? point.y - referenceValue - : null; - return ( + {others.map(({ point, metadata }) => { + const delta = + referenceValue !== null && !metadata.isReference + ? point.y - referenceValue + : null; + return ( +
-
- - {metadata.label} - - - {formatYValue(point.y)} + className="w-2 h-2 rounded-full flex-shrink-0" + style={{ + backgroundColor: metadata.color, + }} + /> + + {metadata.label} + + + {formatYValue(point.y)} + + {delta !== null && ( + = 0 + ? "text-red-600/60 dark:text-red-400/60" + : "text-blue-600/60 dark:text-blue-400/60" + }`} + > + {formatDelta(delta)} - {delta !== null && ( - = 0 - ? "text-red-600/60 dark:text-red-400/60" - : "text-blue-600/60 dark:text-blue-400/60" - }`} - > - {formatDelta(delta)} - - )} -
- ); - })} + )} +
+ ); + })}
)} diff --git a/frontend/src/components/execution/values/series/seriesCanvas.tsx b/frontend/src/components/execution/values/series/seriesCanvas.tsx index bbd965e3..e2c96baa 100644 --- a/frontend/src/components/execution/values/series/seriesCanvas.tsx +++ b/frontend/src/components/execution/values/series/seriesCanvas.tsx @@ -2,7 +2,19 @@ import * as d3 from "d3"; import { memo, useCallback, useEffect, useRef } from "react"; import type { SeriesMetadata } from "../types"; import type { ChartMargins, XScaleType } from "./useChartScales"; -import { type ChartDataPoint, isIntegerAxis } from "./utils"; +import { + type ChartDataPoint, + createScaledTickFormatter, + isIntegerAxis, +} from "./utils"; + +const Y_TICK_COUNT = 8; + +/** The colour a series' line is drawn in, with reference lines contrasting the background */ +export function seriesLineColor(meta: SeriesMetadata, isDark: boolean): string { + if (!meta.isReference) return meta.color; + return isDark ? "#FFFFFF" : "#000000"; +} export interface CrosshairPosition { dataPixelX: number; @@ -109,17 +121,16 @@ export const SeriesCanvas = memo(function SeriesCanvas({ const isHovered = meta.label === hoveredLabel; const key = `series_${meta.seriesIndex}`; - const refColor = isDark ? "#FFFFFF" : "#000000"; if (isHidden) { ctx.strokeStyle = isDark ? "#4B5563" : "#D1D5DB"; ctx.lineWidth = 0.5; ctx.globalAlpha = 0.15; } else if (isHovered) { - ctx.strokeStyle = meta.isReference ? refColor : meta.color; + ctx.strokeStyle = seriesLineColor(meta, isDark); ctx.lineWidth = meta.isReference ? 5 : 3.5; ctx.globalAlpha = 1; } else { - ctx.strokeStyle = meta.isReference ? refColor : meta.color; + ctx.strokeStyle = seriesLineColor(meta, isDark); ctx.lineWidth = meta.isReference ? 3 : 1.5; ctx.globalAlpha = meta.isReference ? 1 : 0.8; } @@ -262,7 +273,7 @@ function drawGrid( ctx.setLineDash([3, 3]); // Horizontal grid lines - const yTicks = yScale.ticks(8); + const yTicks = yScale.ticks(Y_TICK_COUNT); for (const tick of yTicks) { const y = yScale(tick); ctx.beginPath(); @@ -284,20 +295,6 @@ function drawGrid( ctx.setLineDash([]); } -function formatTickValue(value: number, range: number): string { - if (Math.abs(value) >= 1e6 || (range > 0 && Math.abs(value) >= 1e4)) { - return value.toExponential(1); - } - if (Math.abs(value) < 1e-3 && value !== 0) { - return value.toExponential(1); - } - if (range < 0.1) return value.toFixed(3); - if (range < 1) return value.toFixed(2); - if (range < 10) return value.toFixed(2); - if (range < 100) return value.toFixed(1); - return value.toFixed(0); -} - function formatXTickValue(value: number, indexName: string): string { if (isIntegerAxis(indexName) && Number.isInteger(value)) { return value.toString(); @@ -350,8 +347,7 @@ function drawAxes( ) { ctx.save(); - const yDomain = yScale.domain(); - const yRange = yDomain[1] - yDomain[0]; + const formatYTick = createScaledTickFormatter(yScale.domain(), Y_TICK_COUNT); // Y axis ctx.strokeStyle = isDark ? "#374151" : "#E5E7EB"; @@ -366,10 +362,10 @@ function drawAxes( ctx.lineTo(margins.left, margins.top + innerHeight); ctx.stroke(); - const yTicks = yScale.ticks(8); + const yTicks = yScale.ticks(Y_TICK_COUNT); for (const tick of yTicks) { const y = margins.top + yScale(tick); - ctx.fillText(formatTickValue(tick, yRange), margins.left - 8, y); + ctx.fillText(formatYTick(tick), margins.left - 8, y); } // Y axis label diff --git a/frontend/src/components/execution/values/series/seriesLegend.tsx b/frontend/src/components/execution/values/series/seriesLegend.tsx index 1a9e05dd..7859683e 100644 --- a/frontend/src/components/execution/values/series/seriesLegend.tsx +++ b/frontend/src/components/execution/values/series/seriesLegend.tsx @@ -21,6 +21,8 @@ interface SeriesLegendProps { hoveredLabel: string | null; soloedLabel: string | null; onToggleLabel: (label: string) => void; + /** Hide or show several labels at once, leaving solo mode */ + onSetLabelsHidden: (labels: string[], hidden: boolean) => void; onHoverLabel: (label: string | null) => void; onSoloLabel: (label: string) => void; onShowAll: () => void; @@ -37,6 +39,7 @@ export const SeriesLegend = memo(function SeriesLegend({ hoveredLabel, soloedLabel, onToggleLabel, + onSetLabelsHidden, onHoverLabel, onSoloLabel, onShowAll, @@ -94,18 +97,14 @@ export const SeriesLegend = memo(function SeriesLegend({ const toggleGroupVisibility = useCallback( (items: LegendItem[]) => { + // If all are hidden, show all; otherwise hide all const allHidden = items.every((item) => hiddenLabels.has(item.label)); - for (const item of items) { - const isHidden = hiddenLabels.has(item.label); - // If all are hidden, show all; otherwise hide all - if (allHidden && isHidden) { - onToggleLabel(item.label); - } else if (!allHidden && !isHidden) { - onToggleLabel(item.label); - } - } + onSetLabelsHidden( + items.map((item) => item.label), + !allHidden, + ); }, - [hiddenLabels, onToggleLabel], + [hiddenLabels, onSetLabelsHidden], ); const visibleCount = uniqueLabels.filter( diff --git a/frontend/src/components/execution/values/series/seriesVisualization.tsx b/frontend/src/components/execution/values/series/seriesVisualization.tsx index 4cbfe972..ea150cee 100644 --- a/frontend/src/components/execution/values/series/seriesVisualization.tsx +++ b/frontend/src/components/execution/values/series/seriesVisualization.tsx @@ -6,12 +6,16 @@ import { Card, CardContent } from "@/components/ui/card"; import { useTheme } from "@/hooks/useTheme"; import type { SeriesValue } from "../types"; import { CanvasTooltip } from "./canvasTooltip"; -import { type CrosshairPosition, SeriesCanvas } from "./seriesCanvas"; +import { + type CrosshairPosition, + SeriesCanvas, + seriesLineColor, +} from "./seriesCanvas"; import { SeriesLegend } from "./seriesLegend"; import { useChartScales } from "./useChartScales"; import type { NearestResult } from "./useSpatialIndex"; -import { useSpatialIndex } from "./useSpatialIndex"; -import { createChartData, getDimensionKeys } from "./utils"; +import { findTooltipPoints, useSpatialIndex } from "./useSpatialIndex"; +import { createChartData, getDimensionKeys, withLabelsHidden } from "./utils"; const REFERENCE_MISMATCH_ISSUE = "https://github.com/Climate-REF/climate-ref/issues/927"; @@ -210,6 +214,22 @@ export function SeriesVisualization({ [soloedLabel], ); + // Hide or show several labels at once. Leaving solo mode keeps what it was + // showing, so only these labels change. + const setLabelsHidden = useCallback( + (labels: string[], hidden: boolean) => { + setSoloedLabel(null); + setHiddenLabels((prev) => + withLabelsHidden( + soloedLabel ? effectiveHiddenLabels : prev, + labels, + hidden, + ), + ); + }, + [soloedLabel, effectiveHiddenLabels], + ); + // Solo mode const handleSolo = useCallback((label: string) => { setSoloedLabel((prev) => (prev === label ? null : label)); @@ -257,18 +277,15 @@ export function SeriesVisualization({ cancelAnimationFrame(tooltipRafRef.current); tooltipRafRef.current = requestAnimationFrame(() => { - const nearest = spatialIndex.findNearest( + const found = findTooltipPoints( + spatialIndex, mouseX, mouseY, effectiveHiddenLabels, ); - if (nearest) { - const allAtX = spatialIndex.findNearestAtX( - mouseX, - mouseY, - effectiveHiddenLabels, - ); + if (found) { + const { nearest, allAtX } = found; hoveredLabelRef.current = nearest.metadata.label; @@ -277,9 +294,7 @@ export function SeriesVisualization({ dataPixelX: nearest.point.pixelX, nearestPixelX: nearest.point.pixelX, nearestPixelY: nearest.point.pixelY, - nearestColor: nearest.metadata.isReference - ? "#000000" - : nearest.metadata.color, + nearestColor: seriesLineColor(nearest.metadata, isDark), }; canvas.redraw?.(); @@ -305,7 +320,7 @@ export function SeriesVisualization({ } }); }, - [spatialIndex, effectiveHiddenLabels, margins], + [spatialIndex, effectiveHiddenLabels, margins, isDark], ); const handleCanvasMouseLeave = useCallback(() => { @@ -451,6 +466,7 @@ export function SeriesVisualization({ hoveredLabel={hoveredLabel} soloedLabel={soloedLabel} onToggleLabel={toggleLabel} + onSetLabelsHidden={setLabelsHidden} onHoverLabel={handleLabelHover} onSoloLabel={handleSolo} onShowAll={showAll} diff --git a/frontend/src/components/execution/values/series/useSpatialIndex.test.ts b/frontend/src/components/execution/values/series/useSpatialIndex.test.ts index 44c73e12..e02bf775 100644 --- a/frontend/src/components/execution/values/series/useSpatialIndex.test.ts +++ b/frontend/src/components/execution/values/series/useSpatialIndex.test.ts @@ -1,6 +1,8 @@ +import { renderHook } from "@testing-library/react"; import * as d3 from "d3"; import { describe, expect, it } from "vitest"; import type { SeriesMetadata } from "../types"; +import { findTooltipPoints, useSpatialIndex } from "./useSpatialIndex"; // Test spatial index logic directly (extracted from the hook) @@ -291,3 +293,40 @@ describe("buildSpatialIndex", () => { expect(Number.isFinite(result!.point.y)).toBe(true); }); }); + +describe("findTooltipPoints", () => { + // Two close x positions, so the cursor can be nearer one x while the + // nearest point sits at the other + const closeData: ChartDataPoint[] = [ + { year: 2020, series_0: 7.5, series_1: 2.5 }, + { year: 2021, series_0: 2.5, series_1: 2.5 }, + ]; + const closeXScale = d3.scaleLinear().domain([2020, 2021]).range([0, 40]); + const closeYScale = d3.scaleLinear().domain([0, 10]).range([400, 0]); + + function renderIndex() { + return renderHook(() => + useSpatialIndex( + closeData, + meta.slice(0, 2), + "year", + closeXScale, + closeYScale, + ), + ).result.current; + } + + it("collects the other series at the nearest point's x, not the cursor's", () => { + // The cursor is nearer 2021 in x, but ModelA at 2020 is the nearest point + const found = findTooltipPoints(renderIndex(), 25, 100, new Set()); + expect(found).not.toBeNull(); + expect(found!.nearest.metadata.label).toBe("ModelA"); + expect(found!.nearest.point.x).toBe(2020); + expect(found!.allAtX.map((r) => r.point.x)).toEqual([2020, 2020]); + expect(found!.allAtX[0].metadata.label).toBe("ModelA"); + }); + + it("returns null when no point is near the cursor", () => { + expect(findTooltipPoints(renderIndex(), 500, 0, new Set())).toBeNull(); + }); +}); diff --git a/frontend/src/components/execution/values/series/useSpatialIndex.ts b/frontend/src/components/execution/values/series/useSpatialIndex.ts index d4862c27..4989db3d 100644 --- a/frontend/src/components/execution/values/series/useSpatialIndex.ts +++ b/frontend/src/components/execution/values/series/useSpatialIndex.ts @@ -92,6 +92,28 @@ export interface SpatialIndex { ) => NearestResult[]; } +/** + * The point nearest the cursor and every visible point sharing its x + * + * The nearest point can sit at a different x than the cursor, + * so the other series are looked up at the nearest point's x rather than the cursor's. + */ +export function findTooltipPoints( + index: SpatialIndex, + pixelX: number, + pixelY: number, + hiddenLabels: Set, +): { nearest: NearestResult; allAtX: NearestResult[] } | null { + const nearest = index.findNearest(pixelX, pixelY, hiddenLabels); + if (!nearest) return null; + const allAtX = index.findNearestAtX( + nearest.point.pixelX, + pixelY, + hiddenLabels, + ); + return { nearest, allAtX }; +} + export function useSpatialIndex( chartData: ChartDataPoint[], seriesMetadata: SeriesMetadata[], diff --git a/frontend/src/components/execution/values/series/utils.test.ts b/frontend/src/components/execution/values/series/utils.test.ts index 262bb047..64f9835d 100644 --- a/frontend/src/components/execution/values/series/utils.test.ts +++ b/frontend/src/components/execution/values/series/utils.test.ts @@ -1,9 +1,11 @@ +import * as d3 from "d3"; import { describe, expect, it } from "vitest"; import type { SeriesMetadata, SeriesValue } from "../types"; import { createChartData, createScaledTickFormatter, getDimensionKeys, + withLabelsHidden, } from "./utils"; const mockSeriesValue: SeriesValue = { @@ -34,49 +36,120 @@ describe("createScaledTickFormatter", () => { expect(formatter("test")).toBe("test"); }); - it("formats large numbers with scientific notation", () => { - const formatter = createScaledTickFormatter([1e6, 2e6, 3e6]); - expect(formatter(1500000)).toBe("1.5e+6"); + /** Format the ticks d3 draws for `[min, max]` */ + function tickLabels(min: number, max: number, count = 5): string[] { + const formatter = createScaledTickFormatter([min, max], count); + return d3.ticks(min, max, count).map(formatter); + } + + it.each([ + [101300, 101350, 8], + [0.0001, 0.0003, 5], + [1e6, 3e6, 5], + [1.0, 1.08, 5], + [-0.5, 0.5, 5], + [-273.15, 5000, 8], + [1234567, 1234568, 5], + ])("gives every tick of [%d, %d] a distinct label", (min, max, count) => { + const labels = tickLabels(min, max, count); + expect(new Set(labels).size).toBe(labels.length); + }); + + it("keeps large values with a small range in fixed notation", () => { + expect(tickLabels(101300, 101350, 8)).toEqual([ + "101300", + "101305", + "101310", + "101315", + "101320", + "101325", + "101330", + "101335", + "101340", + "101345", + "101350", + ]); + }); + + it("keeps values below 1e6 in fixed notation", () => { + const formatter = createScaledTickFormatter([10000, 100000]); + expect(formatter(20000)).toBe("20000"); + expect(formatter(25000)).toBe("25000"); + }); + + it("uses exponent notation for values of at least 1e6", () => { + expect(tickLabels(1e6, 3e6)).toEqual([ + "1.0e+6", + "1.5e+6", + "2.0e+6", + "2.5e+6", + "3.0e+6", + ]); }); - it("formats very small numbers with scientific notation", () => { - const formatter = createScaledTickFormatter([0.0001, 0.0002, 0.0003]); - expect(formatter(0.00015)).toBe("1.5e-4"); + it("uses exponent notation for small values", () => { + expect(tickLabels(0.0001, 0.0003)).toEqual([ + "1.0e-4", + "1.5e-4", + "2.0e-4", + "2.5e-4", + "3.0e-4", + ]); }); - it("does not use scientific notation for zero", () => { + it("does not use exponent notation for zero", () => { const formatter = createScaledTickFormatter([0, 0.0001, 0.0002]); - expect(formatter(0)).toBe("0.000"); + expect(formatter(0)).toBe("0"); + }); + + it("formats mixed signs with the same precision", () => { + expect(tickLabels(-0.5, 0.5)).toEqual([ + "-0.4", + "-0.2", + "0.0", + "0.2", + "0.4", + ]); }); - it("formats range < 0.1 with 3 decimal places", () => { - const formatter = createScaledTickFormatter([1.0, 1.05, 1.08]); - expect(formatter(1.025)).toBe("1.025"); + it("does not print rounding noise around zero as negative", () => { + const formatter = createScaledTickFormatter([-0.5, 0.5]); + expect(formatter(-1e-17)).toBe("0.0"); }); - it("formats range < 1 with 2 decimal places", () => { - const formatter = createScaledTickFormatter([1.0, 1.5, 1.8]); - expect(formatter(1.25)).toBe("1.25"); + it("formats a zero range by the magnitude of the value", () => { + expect(createScaledTickFormatter([101300, 101300])(101300)).toBe("101300"); + expect(createScaledTickFormatter([0.5, 0.5])(0.5)).toBe("0.5"); + expect(createScaledTickFormatter([0, 0])(0)).toBe("0.0"); }); - it("formats range < 10 with 2 decimal places", () => { - const formatter = createScaledTickFormatter([10, 15, 18]); - expect(formatter(12.5)).toBe("12.50"); + it("gives values between ticks up to two more digits", () => { + expect(createScaledTickFormatter([10, 90])(42.567)).toBe("42.6"); + expect(createScaledTickFormatter([1.0, 1.8])(1.25)).toBe("1.25"); + expect(createScaledTickFormatter([0, 500])(250.789)).toBe("251"); + expect(createScaledTickFormatter([1e6, 3e6])(1234567)).toBe("1.235e+6"); }); +}); - it("formats range < 100 with 1 decimal place", () => { - const formatter = createScaledTickFormatter([10, 50, 90]); - expect(formatter(42.567)).toBe("42.6"); +describe("withLabelsHidden", () => { + it("hides every label", () => { + const result = withLabelsHidden(new Set(["a"]), ["b", "c"], true); + expect(result).toEqual(new Set(["a", "b", "c"])); }); - it("formats range >= 100 with 0 decimal places", () => { - const formatter = createScaledTickFormatter([0, 100, 500]); - expect(formatter(250.789)).toBe("251"); + it("shows every label", () => { + const result = withLabelsHidden( + new Set(["a", "b", "c"]), + ["a", "b"], + false, + ); + expect(result).toEqual(new Set(["c"])); }); - it("uses scientific notation for large numbers with large range", () => { - const formatter = createScaledTickFormatter([10000, 50000, 100000]); - expect(formatter(25000)).toBe("2.5e+4"); + it("does not change the given set", () => { + const hidden = new Set(["a"]); + withLabelsHidden(hidden, ["b"], true); + expect(hidden).toEqual(new Set(["a"])); }); }); diff --git a/frontend/src/components/execution/values/series/utils.ts b/frontend/src/components/execution/values/series/utils.ts index 66d07c29..57a007d1 100644 --- a/frontend/src/components/execution/values/series/utils.ts +++ b/frontend/src/components/execution/values/series/utils.ts @@ -1,3 +1,4 @@ +import * as d3 from "d3"; import { getFixedDimensionColor, unreservedPalette, @@ -50,43 +51,55 @@ function parseDatetimeToTimestamp(value: string): number | null { return Number.isFinite(ts) ? ts : null; } +/** Drop trailing zeros after the decimal point, keeping any exponent */ +function trimTrailingZeros(text: string): string { + const [mantissa, exponent] = text.split("e"); + const trimmed = mantissa.includes(".") + ? mantissa.replace(/\.?0+$/, "") + : mantissa; + return exponent === undefined ? trimmed : `${trimmed}e${exponent}`; +} + +/** + * Build a formatter for value-axis ticks spanning `values` + * + * Ticks get just enough digits to tell neighbouring ticks apart, + * using `tickCount` to estimate the tick step d3 will pick. + * Values between ticks, such as tooltip statistics, get up to two more digits. + * Exponent notation is only used for magnitudes of at least 1e6 or below 1e-3. + */ export function createScaledTickFormatter( values: number[], + tickCount = 5, ): (value: number | string) => string { if (values.length === 0) return (value: string | number) => String(value); const min = Math.min(...values); const max = Math.max(...values); - const range = max - min; + const magnitude = Math.max(Math.abs(min), Math.abs(max)); + // A zero range has no tick step, so space ticks as if the range were the magnitude + const step = d3.tickStep(0, max - min || magnitude || 1, tickCount); + const useExponent = magnitude >= 1e6 || (magnitude > 0 && magnitude < 1e-3); - // Determine appropriate formatting based on range and magnitude return (value: string | number): string => { - const numValue = Number(value); - - // Handle very large numbers (scientific notation) - if (Math.abs(numValue) >= 1e6 || (range > 0 && Math.abs(numValue) >= 1e4)) { - return numValue.toExponential(1); - } - - // Handle very small numbers (scientific notation) - if (Math.abs(numValue) < 1e-3 && numValue !== 0) { - return numValue.toExponential(1); - } - - // Handle decimal precision based on range - if (range < 0.1) { - return numValue.toFixed(3); - } - if (range < 1) { - return numValue.toFixed(2); - } - if (range < 10) { - return numValue.toFixed(2); - } - if (range < 100) { - return numValue.toFixed(1); + let numValue = Number(value); + if (!Number.isFinite(numValue)) return String(value); + // Snap rounding noise around zero, which would otherwise print as "-0.0" or "1e-17" + if (Math.abs(numValue) < step * 1e-6) numValue = 0; + + const onTick = + Math.abs(numValue / step - Math.round(numValue / step)) < 1e-6; + const resolution = onTick ? step : step / 100; + + if (useExponent) { + if (numValue === 0) return "0"; + const text = numValue.toExponential( + d3.precisionRound(resolution, magnitude) - 1, + ); + return onTick ? text : trimTrailingZeros(text); } - return numValue.toFixed(0); + const text = numValue.toFixed(d3.precisionFixed(resolution)); + return onTick ? text : trimTrailingZeros(text); }; } @@ -369,3 +382,20 @@ export function getDimensionKeys(seriesMetadata: SeriesMetadata[]): string[] { } return Array.from(keys).sort(); } + +/** A copy of `hiddenLabels` with every one of `labels` hidden, or shown when `hidden` is false */ +export function withLabelsHidden( + hiddenLabels: ReadonlySet, + labels: Iterable, + hidden: boolean, +): Set { + const next = new Set(hiddenLabels); + for (const label of labels) { + if (hidden) { + next.add(label); + } else { + next.delete(label); + } + } + return next; +} From b5043dd527405249a5ebe324f238dad5695253f1 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:16:54 +0200 Subject: [PATCH 11/61] fix: View Details execution id and filter updaters running twice --- .../execution/values/scalarDataTable.tsx | 3 +- .../execution/values/seriesDataTable.tsx | 3 +- frontend/src/hooks/useValuesProcessor.test.ts | 70 +++++++++++++++++++ frontend/src/hooks/useValuesProcessor.ts | 20 ++++-- 4 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 frontend/src/hooks/useValuesProcessor.test.ts diff --git a/frontend/src/components/execution/values/scalarDataTable.tsx b/frontend/src/components/execution/values/scalarDataTable.tsx index c075d0fe..d7ae1574 100644 --- a/frontend/src/components/execution/values/scalarDataTable.tsx +++ b/frontend/src/components/execution/values/scalarDataTable.tsx @@ -133,8 +133,7 @@ function ScalarDataTable({ groupId: cell.row.original.execution_group_id.toString(), }} search={{ - executionId: - cell.row.original.execution_group_id.toString(), + executionId: cell.row.original.execution_id.toString(), }} > diff --git a/frontend/src/components/execution/values/seriesDataTable.tsx b/frontend/src/components/execution/values/seriesDataTable.tsx index 986519a1..6a54974c 100644 --- a/frontend/src/components/execution/values/seriesDataTable.tsx +++ b/frontend/src/components/execution/values/seriesDataTable.tsx @@ -115,8 +115,7 @@ function SeriesDataTable({ groupId: cell.row.original.execution_group_id.toString(), }} search={{ - executionId: - cell.row.original.execution_group_id.toString(), + executionId: cell.row.original.execution_id.toString(), }} > diff --git a/frontend/src/hooks/useValuesProcessor.test.ts b/frontend/src/hooks/useValuesProcessor.test.ts new file mode 100644 index 00000000..79ef01aa --- /dev/null +++ b/frontend/src/hooks/useValuesProcessor.test.ts @@ -0,0 +1,70 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { FacetFilter, MetricFilter } from "@/components/explorer/types"; +import { useValuesProcessor } from "./useValuesProcessor"; + +const facetFilter = (id: string): FacetFilter => ({ + type: "facet", + id, + facetKey: "source_id", + values: ["A"], +}); + +describe("useValuesProcessor setFilters", () => { + it("runs an updater once and hands the parent the same filters", () => { + const onFiltersChange = vi.fn(); + const { result } = renderHook(() => + useValuesProcessor({ + initialValues: [], + loading: false, + onFiltersChange, + }), + ); + + const updater = vi.fn((existing: MetricFilter[]) => [ + ...existing, + facetFilter(crypto.randomUUID()), + ]); + act(() => result.current.setFilters(updater)); + + expect(updater).toHaveBeenCalledTimes(1); + expect(onFiltersChange).toHaveBeenCalledTimes(1); + expect(onFiltersChange).toHaveBeenCalledWith(result.current.filters); + }); + + it("builds updaters in the same tick on each other", () => { + const onFiltersChange = vi.fn(); + const { result } = renderHook(() => + useValuesProcessor({ + initialValues: [], + loading: false, + onFiltersChange, + }), + ); + + act(() => { + result.current.setFilters((existing) => [...existing, facetFilter("a")]); + result.current.setFilters((existing) => [...existing, facetFilter("b")]); + }); + + expect(result.current.filters.map((f) => f.id)).toEqual(["a", "b"]); + expect(onFiltersChange).toHaveBeenLastCalledWith(result.current.filters); + }); + + it("passes plain filter arrays through", () => { + const onFiltersChange = vi.fn(); + const { result } = renderHook(() => + useValuesProcessor({ + initialValues: [], + loading: false, + initialFilters: [facetFilter("a")], + onFiltersChange, + }), + ); + + act(() => result.current.setFilters([])); + + expect(result.current.filters).toEqual([]); + expect(onFiltersChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/frontend/src/hooks/useValuesProcessor.ts b/frontend/src/hooks/useValuesProcessor.ts index 27a1749e..0bd782f0 100644 --- a/frontend/src/hooks/useValuesProcessor.ts +++ b/frontend/src/hooks/useValuesProcessor.ts @@ -1,5 +1,5 @@ import type { RowSelectionState } from "@tanstack/react-table"; -import { type SetStateAction, useMemo, useState } from "react"; +import { type SetStateAction, useMemo, useRef, useState } from "react"; import type { ScalarValue, SeriesValue, @@ -54,13 +54,19 @@ export function useValuesProcessor< ); const [rowSelection, setRowSelection] = useState({}); + // Latest filters, so updaters called in the same tick build on each other + const filtersRef = useRef(filters); + + // Run an updater once, so the parent and internal state get the same + // filters even when the updater generates ids. const setFilters = (newFilters: SetStateAction) => { - setInternalFilters(newFilters); - if (onFiltersChange) { - const resolvedFilters = - typeof newFilters === "function" ? newFilters(filters) : newFilters; - onFiltersChange(resolvedFilters); - } + const resolvedFilters = + typeof newFilters === "function" + ? newFilters(filtersRef.current) + : newFilters; + filtersRef.current = resolvedFilters; + setInternalFilters(resolvedFilters); + onFiltersChange?.(resolvedFilters); }; const handleExcludeRowIds = (excluded: SetStateAction>) => { From f87ceaadd0e7001c59b52f043adfc41be1636b11 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:25:47 +0200 Subject: [PATCH 12/61] cleanup: share the explorer charts' metric values query in useExplorerMetricValues --- .../explorer/content/ensembleChartContent.tsx | 110 ++++++++---------- .../explorer/content/filterControls.tsx | 3 +- .../explorer/content/seriesChartContent.tsx | 76 ++++-------- .../content/taylorDiagramContentWrapper.tsx | 23 +--- .../content/useExplorerMetricValues.ts | 54 +++++++++ 5 files changed, 126 insertions(+), 140 deletions(-) create mode 100644 frontend/src/components/explorer/content/useExplorerMetricValues.ts diff --git a/frontend/src/components/explorer/content/ensembleChartContent.tsx b/frontend/src/components/explorer/content/ensembleChartContent.tsx index 9df3e4bb..e1a5c547 100644 --- a/frontend/src/components/explorer/content/ensembleChartContent.tsx +++ b/frontend/src/components/explorer/content/ensembleChartContent.tsx @@ -1,8 +1,4 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; import { useState } from "react"; -import { diagnosticsListMetricValuesOptions } from "@/client/@tanstack/react-query.gen"; -import type { MetricValueCollection } from "@/client/types.gen"; -import { useSelectedMipEra } from "@/components/charts/mipEraContext"; import { MipEraSections } from "@/components/charts/mipEraSections"; import { EmptyEnsembleChart, @@ -12,7 +8,7 @@ import type { ScalarValue } from "@/components/execution/values/types"; import { Button } from "@/components/ui/button"; import type { ExplorerCardContent } from "../types"; -import { useFilterControls } from "./filterControls"; +import { useExplorerMetricValues } from "./useExplorerMetricValues"; interface EnsembleChartContentProps { contentItem: Extract; @@ -22,39 +18,11 @@ export function EnsembleChartContent({ contentItem, }: EnsembleChartContentProps) { const [includeUnverified, setIncludeUnverified] = useState(false); - // Outlier detection runs over whatever the query returns, so the era has to be filtered - // here rather than when the sections are split. - const selectedMipEra = useSelectedMipEra(); - const { filterBar, queryFilters } = useFilterControls(contentItem, "scalar"); - - // Extract potential ID filters (isolate/exclude) from otherFilters and pass - // them through to the backend. Backend expects 'isolate_ids' and 'exclude_ids' - // as comma-separated lists. Keep otherFilters for facet/dimension filters. - const isolateIdsParam = contentItem.otherFilters?.isolate_ids; - const excludeIdsParam = contentItem.otherFilters?.exclude_ids; - - const { data } = useSuspenseQuery( - diagnosticsListMetricValuesOptions({ - path: { - provider_slug: contentItem.provider, - diagnostic_slug: contentItem.diagnostic, - }, - query: { - ...queryFilters, - // Ensure explicit flags for type and outlier handling - value_type: "scalar", - limit: 500, - mip_era: selectedMipEra ?? undefined, - detect_outliers: "iqr", - include_unverified: includeUnverified, - // Only include isolate/exclude params if present - ...(isolateIdsParam ? { isolate_ids: isolateIdsParam } : {}), - ...(excludeIdsParam ? { exclude_ids: excludeIdsParam } : {}), - }, - }), + const { filterBar, collection } = useExplorerMetricValues( + contentItem, + "scalar", + { detect_outliers: "iqr", include_unverified: includeUnverified }, ); - - const collection = data as MetricValueCollection; const values = (collection?.data as ScalarValue[]) ?? []; if (values.length === 0) { @@ -67,36 +35,17 @@ export function EnsembleChartContent({ } const hasOutliers = collection?.had_outliers ?? false; - const outlierCount = collection?.outlier_count ?? 0; return (
{filterBar} - {/* Outlier indicator and controls */} {(hasOutliers || includeUnverified) && ( -
-
- {hasOutliers && !includeUnverified && ( - - Outliers detected ({outlierCount} filtered values) - - )} - {hasOutliers && includeUnverified && ( - - Showing unverified values ({outlierCount} outliers) - - )} -
-
- -
-
+ setIncludeUnverified(!includeUnverified)} + /> )} @@ -118,3 +67,40 @@ export function EnsembleChartContent({
); } + +interface OutlierNoticeProps { + hasOutliers: boolean; + outlierCount: number; + includeUnverified: boolean; + onToggle: () => void; +} + +/** How many outliers were found, with a button to show or hide the unverified values. */ +function OutlierNotice({ + hasOutliers, + outlierCount, + includeUnverified, + onToggle, +}: OutlierNoticeProps) { + return ( +
+
+ {hasOutliers && !includeUnverified && ( + + Outliers detected ({outlierCount} filtered values) + + )} + {hasOutliers && includeUnverified && ( + + Showing unverified values ({outlierCount} outliers) + + )} +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/explorer/content/filterControls.tsx b/frontend/src/components/explorer/content/filterControls.tsx index d504dd73..b8b572f5 100644 --- a/frontend/src/components/explorer/content/filterControls.tsx +++ b/frontend/src/components/explorer/content/filterControls.tsx @@ -13,7 +13,8 @@ import { import { ipccRegionName } from "@/lib/ipccRegions"; import type { FilterControl } from "../types"; -interface FilterableCard { +/** A card whose values can be narrowed by its filters and filter controls. */ +export interface FilterableCard { provider: string; diagnostic: string; otherFilters?: Record; diff --git a/frontend/src/components/explorer/content/seriesChartContent.tsx b/frontend/src/components/explorer/content/seriesChartContent.tsx index 80ee55ac..dff48097 100644 --- a/frontend/src/components/explorer/content/seriesChartContent.tsx +++ b/frontend/src/components/explorer/content/seriesChartContent.tsx @@ -1,48 +1,21 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -import { diagnosticsListMetricValuesOptions } from "@/client/@tanstack/react-query.gen"; -import { useSelectedMipEra } from "@/components/charts/mipEraContext"; import { MipEraSections } from "@/components/charts/mipEraSections"; import { SeriesVisualization } from "@/components/execution/values/series"; -import type { - MetricValueCollection, - SeriesValue, -} from "@/components/execution/values/types"; +import type { SeriesValue } from "@/components/execution/values/types"; import { isSeriesValue } from "@/components/execution/values/types"; import type { SeriesChartContent as SeriesChartContentType } from "../types"; -import { useFilterControls } from "./filterControls"; +import { useExplorerMetricValues } from "./useExplorerMetricValues"; interface SeriesChartContentProps { contentItem: SeriesChartContentType; } export function SeriesChartContent({ contentItem }: SeriesChartContentProps) { - const selectedMipEra = useSelectedMipEra(); - const { filterBar, queryFilters } = useFilterControls(contentItem, "series"); - - // Forward isolate/exclude id filters (if present) from card config to backend. - const isolateIdsParam = contentItem.otherFilters?.isolate_ids; - const excludeIdsParam = contentItem.otherFilters?.exclude_ids; - - const { data } = useSuspenseQuery( - diagnosticsListMetricValuesOptions({ - path: { - provider_slug: contentItem.provider, - diagnostic_slug: contentItem.diagnostic, - }, - query: { - ...queryFilters, - value_type: "series", - limit: 500, - mip_era: selectedMipEra ?? undefined, - ...(isolateIdsParam ? { isolate_ids: isolateIdsParam } : {}), - ...(excludeIdsParam ? { exclude_ids: excludeIdsParam } : {}), - }, - }), + const { filterBar, collection } = useExplorerMetricValues( + contentItem, + "series", ); - // Extract series values from the data - const collection = data as MetricValueCollection; // Missing or "model" kind is a model series, only "reference" is a reference series. // Memoised because the charts below key their own memos on these array identities. const { allSeriesValues, regularSeries, referenceSeries } = useMemo(() => { @@ -71,36 +44,27 @@ export function SeriesChartContent({ contentItem }: SeriesChartContentProps) { ); } + const renderSeries = (seriesValues: SeriesValue[]) => ( + + ); + return (
{filterBar} {regularSeries.length === 0 ? ( // Nothing to split on, so show the references rather than an empty panel. - + renderSeries(regularSeries) ) : ( - - {(mipEraSeries) => ( - - )} - + {renderSeries} )}
); diff --git a/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx b/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx index 27b928f5..e99b7aec 100644 --- a/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx +++ b/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx @@ -1,12 +1,8 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -import { diagnosticsListMetricValuesOptions } from "@/client/@tanstack/react-query.gen"; -import type { MetricValueCollection } from "@/client/types.gen"; import { experimentLegend, getFixedDimensionColor, } from "@/components/charts/experimentColors"; -import { useSelectedMipEra } from "@/components/charts/mipEraContext"; import { MipEraSections } from "@/components/charts/mipEraSections"; import type { ScalarValue } from "@/components/execution/values/types"; import type { ExplorerCardContent } from "../types"; @@ -14,6 +10,7 @@ import { TaylorDiagramContent, type TaylorDiagramModel, } from "./taylorDiagramContent"; +import { useExplorerMetricValues } from "./useExplorerMetricValues"; interface TaylorDiagramContentWrapperProps { contentItem: Extract; @@ -125,23 +122,7 @@ export function TaylorDiagramContentWrapper({ height = 460, width = 500, }: TaylorDiagramContentWrapperProps) { - const selectedMipEra = useSelectedMipEra(); - const { data } = useSuspenseQuery( - diagnosticsListMetricValuesOptions({ - path: { - provider_slug: contentItem.provider, - diagnostic_slug: contentItem.diagnostic, - }, - query: { - value_type: "scalar", - limit: 500, - ...contentItem.otherFilters, - mip_era: selectedMipEra ?? undefined, - }, - }), - ); - - const collection = data as MetricValueCollection; + const { collection } = useExplorerMetricValues(contentItem, "scalar"); const values = (collection?.data as ScalarValue[]) ?? []; const allModels = useMemo(() => transformToTaylorModels(values), [values]); diff --git a/frontend/src/components/explorer/content/useExplorerMetricValues.ts b/frontend/src/components/explorer/content/useExplorerMetricValues.ts new file mode 100644 index 00000000..feb20e8f --- /dev/null +++ b/frontend/src/components/explorer/content/useExplorerMetricValues.ts @@ -0,0 +1,54 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { diagnosticsListMetricValuesOptions } from "@/client/@tanstack/react-query.gen"; +import type { + DiagnosticsListMetricValuesData, + MetricValueCollection, +} from "@/client/types.gen"; +import { useSelectedMipEra } from "@/components/charts/mipEraContext"; +import { type FilterableCard, useFilterControls } from "./filterControls"; + +/** The query parameters of the metric values a chart card shows. */ +export type MetricValuesQuery = DiagnosticsListMetricValuesData["query"]; + +/** Query parameters a chart adds on top of the card's filters, such as outlier handling. */ +export type ExtraMetricValuesQuery = Pick< + MetricValuesQuery, + "detect_outliers" | "include_unverified" +>; + +/** + * The metric values a chart card shows, with the bar of its filter controls + * + * The card's filters and the filter controls are applied first, so they cannot override + * the value type, the limit or the selected MIP era. + * Suspends until the values are in. + */ +export function useExplorerMetricValues( + contentItem: FilterableCard, + valueType: "scalar" | "series", + extraQuery: ExtraMetricValuesQuery = {}, +) { + const selectedMipEra = useSelectedMipEra(); + const { filterBar, queryFilters } = useFilterControls(contentItem, valueType); + + const query: MetricValuesQuery = { + ...queryFilters, + ...extraQuery, + value_type: valueType, + limit: 500, + // Outlier detection runs over whatever the query returns, so the era has to be filtered + // here rather than when the sections are split. + mip_era: selectedMipEra ?? undefined, + }; + const { data } = useSuspenseQuery( + diagnosticsListMetricValuesOptions({ + path: { + provider_slug: contentItem.provider, + diagnostic_slug: contentItem.diagnostic, + }, + query, + }), + ); + + return { filterBar, collection: data as MetricValueCollection }; +} From f290e1b9666212345e909a985642e2a849e01fa9 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:28:24 +0200 Subject: [PATCH 13/61] fix: explorer card menu uses the chart's filter controls, MIP era and unverified toggle --- .../components/explorer/chartValues.test.ts | 39 +++++ .../src/components/explorer/chartValues.ts | 32 ++++ .../explorer/content/ensembleChartContent.tsx | 69 +++++---- .../explorer/content/seriesChartContent.tsx | 47 +++--- .../content/useExplorerMetricValues.ts | 10 +- .../explorer/explorerCardContent.tsx | 140 +++--------------- .../explorer/explorerCardDropdown.tsx | 27 +--- .../components/explorer/explorerCardFrame.tsx | 108 ++++++++++++++ 8 files changed, 279 insertions(+), 193 deletions(-) create mode 100644 frontend/src/components/explorer/chartValues.test.ts create mode 100644 frontend/src/components/explorer/chartValues.ts create mode 100644 frontend/src/components/explorer/explorerCardFrame.tsx diff --git a/frontend/src/components/explorer/chartValues.test.ts b/frontend/src/components/explorer/chartValues.test.ts new file mode 100644 index 00000000..739695e7 --- /dev/null +++ b/frontend/src/components/explorer/chartValues.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { chartValuesSearch } from "./chartValues"; + +describe("chartValuesSearch", () => { + it("keeps the card filters, filter control values and MIP era", () => { + expect( + chartValuesSearch({ + value_type: "scalar", + limit: 500, + mip_era: "CMIP7", + region: "NWN", + statistic: "Bias", + detect_outliers: "iqr", + include_unverified: true, + }), + ).toEqual({ + mip_era: "CMIP7", + region: "NWN", + statistic: "Bias", + detect_outliers: "iqr", + include_unverified: "true", + }); + }); + + it("leaves out the value type, paging and unset filters", () => { + const search = chartValuesSearch({ + value_type: "series", + limit: 500, + offset: 0, + mip_era: undefined, + isolate_ids: null, + }); + + expect(search).toEqual({ + detect_outliers: "iqr", + include_unverified: "false", + }); + }); +}); diff --git a/frontend/src/components/explorer/chartValues.ts b/frontend/src/components/explorer/chartValues.ts new file mode 100644 index 00000000..80a6e85e --- /dev/null +++ b/frontend/src/components/explorer/chartValues.ts @@ -0,0 +1,32 @@ +import type { MetricValuesQuery } from "./content/useExplorerMetricValues"; + +/** + * The search of the values page listing the values a chart card shows + * + * The page pages through the values itself, so the chart's limit is left out. + * Outliers are detected and unverified values hidden unless the chart says otherwise. + */ +export function chartValuesSearch( + query: MetricValuesQuery, +): Record { + const { + value_type: _valueType, + limit: _limit, + offset: _offset, + format: _format, + detect_outliers, + include_unverified, + ...filters + } = query; + const search: Record = {}; + for (const [key, value] of Object.entries(filters)) { + if (value !== null && value !== undefined) { + search[key] = String(value); + } + } + return { + ...search, + detect_outliers: detect_outliers ?? "iqr", + include_unverified: String(include_unverified ?? false), + }; +} diff --git a/frontend/src/components/explorer/content/ensembleChartContent.tsx b/frontend/src/components/explorer/content/ensembleChartContent.tsx index e1a5c547..c31df138 100644 --- a/frontend/src/components/explorer/content/ensembleChartContent.tsx +++ b/frontend/src/components/explorer/content/ensembleChartContent.tsx @@ -7,6 +7,8 @@ import { import type { ScalarValue } from "@/components/execution/values/types"; import { Button } from "@/components/ui/button"; +import { ExplorerCardDropdown } from "../explorerCardDropdown"; +import { ExplorerCardFrame } from "../explorerCardFrame"; import type { ExplorerCardContent } from "../types"; import { useExplorerMetricValues } from "./useExplorerMetricValues"; @@ -18,53 +20,58 @@ export function EnsembleChartContent({ contentItem, }: EnsembleChartContentProps) { const [includeUnverified, setIncludeUnverified] = useState(false); - const { filterBar, collection } = useExplorerMetricValues( + const { filterBar, collection, query } = useExplorerMetricValues( contentItem, "scalar", { detect_outliers: "iqr", include_unverified: includeUnverified }, ); const values = (collection?.data as ScalarValue[]) ?? []; + const menu = ; if (values.length === 0) { return ( -
- {filterBar} - -
+ +
+ {filterBar} + +
+
); } const hasOutliers = collection?.had_outliers ?? false; return ( -
- {filterBar} - {(hasOutliers || includeUnverified) && ( - setIncludeUnverified(!includeUnverified)} - /> - )} - - - {(mipEraValues) => ( - +
+ {filterBar} + {(hasOutliers || includeUnverified) && ( + setIncludeUnverified(!includeUnverified)} /> )} - -
+ + + {(mipEraValues) => ( + + )} + +
+ ); } diff --git a/frontend/src/components/explorer/content/seriesChartContent.tsx b/frontend/src/components/explorer/content/seriesChartContent.tsx index dff48097..52745e73 100644 --- a/frontend/src/components/explorer/content/seriesChartContent.tsx +++ b/frontend/src/components/explorer/content/seriesChartContent.tsx @@ -3,6 +3,8 @@ import { MipEraSections } from "@/components/charts/mipEraSections"; import { SeriesVisualization } from "@/components/execution/values/series"; import type { SeriesValue } from "@/components/execution/values/types"; import { isSeriesValue } from "@/components/execution/values/types"; +import { ExplorerCardDropdown } from "../explorerCardDropdown"; +import { ExplorerCardFrame } from "../explorerCardFrame"; import type { SeriesChartContent as SeriesChartContentType } from "../types"; import { useExplorerMetricValues } from "./useExplorerMetricValues"; @@ -11,7 +13,7 @@ interface SeriesChartContentProps { } export function SeriesChartContent({ contentItem }: SeriesChartContentProps) { - const { filterBar, collection } = useExplorerMetricValues( + const { filterBar, collection, query } = useExplorerMetricValues( contentItem, "series", ); @@ -26,21 +28,24 @@ export function SeriesChartContent({ contentItem }: SeriesChartContentProps) { referenceSeries: all.filter((series) => series.kind === "reference"), }; }, [collection?.data]); + const menu = ; if (allSeriesValues.length === 0) { return ( -
- {filterBar} -
-
-

No series data available

-

- This diagnostic may not have series data or filters may be too - restrictive -

+ +
+ {filterBar} +
+
+

No series data available

+

+ This diagnostic may not have series data or filters may be too + restrictive +

+
-
+ ); } @@ -58,14 +63,16 @@ export function SeriesChartContent({ contentItem }: SeriesChartContentProps) { ); return ( -
- {filterBar} - {regularSeries.length === 0 ? ( - // Nothing to split on, so show the references rather than an empty panel. - renderSeries(regularSeries) - ) : ( - {renderSeries} - )} -
+ +
+ {filterBar} + {regularSeries.length === 0 ? ( + // Nothing to split on, so show the references rather than an empty panel. + renderSeries(regularSeries) + ) : ( + {renderSeries} + )} +
+
); } diff --git a/frontend/src/components/explorer/content/useExplorerMetricValues.ts b/frontend/src/components/explorer/content/useExplorerMetricValues.ts index feb20e8f..406e523e 100644 --- a/frontend/src/components/explorer/content/useExplorerMetricValues.ts +++ b/frontend/src/components/explorer/content/useExplorerMetricValues.ts @@ -7,8 +7,10 @@ import type { import { useSelectedMipEra } from "@/components/charts/mipEraContext"; import { type FilterableCard, useFilterControls } from "./filterControls"; -/** The query parameters of the metric values a chart card shows. */ -export type MetricValuesQuery = DiagnosticsListMetricValuesData["query"]; +/** The query parameters of the metric values a chart card shows, with its dimension filters. */ +export type MetricValuesQuery = DiagnosticsListMetricValuesData["query"] & { + [dimension: string]: string | number | boolean | null | undefined; +}; /** Query parameters a chart adds on top of the card's filters, such as outlier handling. */ export type ExtraMetricValuesQuery = Pick< @@ -17,7 +19,7 @@ export type ExtraMetricValuesQuery = Pick< >; /** - * The metric values a chart card shows, with the bar of its filter controls + * The metric values a chart card shows, with the bar of its filter controls and the query * * The card's filters and the filter controls are applied first, so they cannot override * the value type, the limit or the selected MIP era. @@ -50,5 +52,5 @@ export function useExplorerMetricValues( }), ); - return { filterBar, collection: data as MetricValueCollection }; + return { filterBar, collection: data as MetricValueCollection, query }; } diff --git a/frontend/src/components/explorer/explorerCardContent.tsx b/frontend/src/components/explorer/explorerCardContent.tsx index 14aeab85..3fcadd89 100644 --- a/frontend/src/components/explorer/explorerCardContent.tsx +++ b/frontend/src/components/explorer/explorerCardContent.tsx @@ -1,49 +1,17 @@ -import type { ReferenceDatasetLink } from "@/client/types.gen"; -import { cn } from "@/lib/utils"; -import { Badge } from "../ui/badge"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "../ui/card"; -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { Card, CardContent, CardHeader } from "../ui/card"; import { EnsembleChartContent, FigureGalleryContent, SeriesChartContent, TaylorDiagramContentWrapper, } from "./content"; -import { ExplorerCardDropdown } from "./explorerCardDropdown"; -import { ExplorerTooltip } from "./explorerTooltip"; +import { ExplorerCardFrame } from "./explorerCardFrame"; import type { ExplorerCardContent as ExplorerCardContentType } from "./types"; interface ExplorerCardContentProps { contentItem: ExplorerCardContentType; } -function ExplorerCardContentInner({ contentItem }: ExplorerCardContentProps) { - if (contentItem.type === "box-whisker-chart") { - return ; - } - - if (contentItem.type === "figure-gallery") { - return ; - } - - if (contentItem.type === "series-chart") { - return ; - } - - if (contentItem.type === "taylor-diagram") { - return ; - } - - return null; -} - export const ExplorerCardContentSkeleton = () => { return ( @@ -59,90 +27,24 @@ export const ExplorerCardContentSkeleton = () => { // Main component to render different types of Explorer Card Content // Each is a card in itself, but it is wrapped in a suspense and error boundary in the parent ExplorerCard component +// The charts with a menu frame themselves, as the menu acts on the values they query. export function ExplorerCardContent({ contentItem }: ExplorerCardContentProps) { - const spanClass = contentItem.span === 2 ? "lg:col-span-2" : "lg:col-span-1"; - - return ( -
- - -
- - {contentItem.placeholder ? "PLACEHOLDER: " : ""}{" "} - {contentItem.title} - -
- {contentItem.description && ( - {contentItem.description} - )} -
-
- -
- - {contentItem.type === "box-whisker-chart" || - contentItem.type === "series-chart" ? ( - - ) : null} -
-
- - - - - {contentItem.interpretation && ( -
- - Interpretation: - {" "} - {contentItem.interpretation} -
- )} - {contentItem.referenceDatasets && - contentItem.referenceDatasets.length > 0 && ( -
- - Reference datasets: - - {contentItem.referenceDatasets.map( - (ref: ReferenceDatasetLink) => ( - - - - {ref.slug.slice(ref.slug.indexOf(".") + 1)} - - - -
-
{ref.slug}
- {ref.description && ( -
- {ref.description} -
- )} -
-
-
- ), - )} -
- )} -
-
-
- ); + switch (contentItem.type) { + case "box-whisker-chart": + return ; + case "series-chart": + return ; + case "figure-gallery": + return ( + + + + ); + case "taylor-diagram": + return ( + + + + ); + } } diff --git a/frontend/src/components/explorer/explorerCardDropdown.tsx b/frontend/src/components/explorer/explorerCardDropdown.tsx index 18c58a1a..f5129324 100644 --- a/frontend/src/components/explorer/explorerCardDropdown.tsx +++ b/frontend/src/components/explorer/explorerCardDropdown.tsx @@ -13,26 +13,21 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { downloadBlob } from "@/lib/downloadUtils"; +import { chartValuesSearch } from "./chartValues"; +import type { MetricValuesQuery } from "./content/useExplorerMetricValues"; interface ExplorerCardDropdownProps { contentItem: BoxWhiskerChartContent | SeriesChartContent; - includeUnverified?: boolean; + /** The query of the values the chart shows, so the menu acts on the same values */ + query: MetricValuesQuery; } export function ExplorerCardDropdown({ contentItem, - includeUnverified, + query, }: ExplorerCardDropdownProps) { const navigate = useNavigate(); - const searchParams: Record = { - ...(contentItem?.otherFilters ?? {}), - detect_outliers: "iqr", - include_unverified: String(includeUnverified), - }; - - const valueType = - contentItem.type === "box-whisker-chart" ? "scalar" : "series"; - const uri = valueType === "scalar" ? "scalars" : "series"; + const uri = contentItem.type === "box-whisker-chart" ? "scalars" : "series"; return ( @@ -47,7 +42,7 @@ export function ExplorerCardDropdown({ onClick={() => navigate({ to: `/diagnostics/${contentItem.provider}/${contentItem.diagnostic}/${uri}`, - search: searchParams, + search: chartValuesSearch(query), }) } > @@ -62,13 +57,7 @@ export function ExplorerCardDropdown({ provider_slug: contentItem.provider, diagnostic_slug: contentItem.diagnostic, }, - query: { - ...(contentItem.otherFilters ?? {}), - detect_outliers: "iqr", - include_unverified: includeUnverified ?? false, - value_type: valueType, - format: "csv", - }, + query: { ...query, format: "csv" }, parseAs: "blob", }); diff --git a/frontend/src/components/explorer/explorerCardFrame.tsx b/frontend/src/components/explorer/explorerCardFrame.tsx new file mode 100644 index 00000000..3255687e --- /dev/null +++ b/frontend/src/components/explorer/explorerCardFrame.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from "react"; +import type { ReferenceDatasetLink } from "@/client/types.gen"; +import { cn } from "@/lib/utils"; +import { Badge } from "../ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { ExplorerTooltip } from "./explorerTooltip"; +import type { ExplorerCardContent as ExplorerCardContentType } from "./types"; + +interface ExplorerCardFrameProps { + contentItem: ExplorerCardContentType; + /** The menu next to the info tooltip, if the card has one */ + menu?: ReactNode; + children: ReactNode; +} + +/** The card around a content item, with its title and description, menu and footer. */ +export function ExplorerCardFrame({ + contentItem, + menu, + children, +}: ExplorerCardFrameProps) { + const spanClass = contentItem.span === 2 ? "lg:col-span-2" : "lg:col-span-1"; + + return ( +
+ + +
+ + {contentItem.placeholder ? "PLACEHOLDER: " : ""}{" "} + {contentItem.title} + +
+ {contentItem.description && ( + {contentItem.description} + )} +
+
+ +
+ + {menu} +
+
+ + {children} + + + {contentItem.interpretation && ( +
+ + Interpretation: + {" "} + {contentItem.interpretation} +
+ )} + {contentItem.referenceDatasets && + contentItem.referenceDatasets.length > 0 && ( +
+ + Reference datasets: + + {contentItem.referenceDatasets.map( + (ref: ReferenceDatasetLink) => ( + + + + {ref.slug.slice(ref.slug.indexOf(".") + 1)} + + + +
+
{ref.slug}
+ {ref.description && ( +
+ {ref.description} +
+ )} +
+
+
+ ), + )} +
+ )} +
+
+
+ ); +} From fad6abc5a986ba608e19a37603280c4037b301f1 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:29:58 +0200 Subject: [PATCH 14/61] cleanup: move the explorer API mappers into a pure mapping module with shared chart fields and tests --- ...xplorerContent.test.ts => mapping.test.ts} | 215 +++++++++++++++++- frontend/src/components/explorer/mapping.ts | 143 ++++++++++++ .../components/explorer/thematicContent.tsx | 141 +----------- .../explorer.tsx | 2 +- 4 files changed, 359 insertions(+), 142 deletions(-) rename frontend/src/components/explorer/{filterExplorerContent.test.ts => mapping.test.ts} (57%) create mode 100644 frontend/src/components/explorer/mapping.ts diff --git a/frontend/src/components/explorer/filterExplorerContent.test.ts b/frontend/src/components/explorer/mapping.test.ts similarity index 57% rename from frontend/src/components/explorer/filterExplorerContent.test.ts rename to frontend/src/components/explorer/mapping.test.ts index 0c9c4162..72ef483e 100644 --- a/frontend/src/components/explorer/filterExplorerContent.test.ts +++ b/frontend/src/components/explorer/mapping.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; -import type { AftCollectionCard } from "@/client/types.gen"; -import { filterExplorerContentForDiagnostic } from "./thematicContent"; +import type { + AftCollectionCard, + AftCollectionCardContent, + AftCollectionDetail, +} from "@/client/types.gen"; +import { + buildCollectionGroups, + filterExplorerContentForDiagnostic, + toExplorerCardContent, +} from "./mapping"; function makeCard( title: string, @@ -300,3 +308,206 @@ describe("filterExplorerContentForDiagnostic", () => { ]); }); }); + +function makeContent( + overrides: Partial, +): AftCollectionCardContent { + return { + type: "box-whisker-chart", + provider: "ilamb", + diagnostic: "amoc", + title: "Content", + ...overrides, + }; +} + +describe("toExplorerCardContent", () => { + it("maps the clip and y bounds and the zero line of a box-whisker chart", () => { + const item = toExplorerCardContent( + makeContent({ + clip_min: -1, + clip_max: 2, + y_min: -5, + y_max: 5, + show_zero_line: false, + symmetrical_axes: true, + }), + ); + + expect(item).toMatchObject({ + type: "box-whisker-chart", + clipMin: -1, + clipMax: 2, + yMin: -5, + yMax: 5, + showZeroLine: false, + symmetricalAxes: true, + }); + }); + + it("maps the label template and chart fields of a series chart", () => { + const item = toExplorerCardContent( + makeContent({ + type: "series-chart", + label_template: "{source_id} ({experiment_id})", + metric_units: "K", + other_filters: { statistic: "mean" }, + grouping_config: { group_by: "source_id", hue: "experiment_id" }, + }), + ); + + expect(item).toMatchObject({ + type: "series-chart", + labelTemplate: "{source_id} ({experiment_id})", + metricUnits: "K", + otherFilters: { statistic: "mean" }, + groupingConfig: { groupBy: "source_id", hue: "experiment_id" }, + }); + // Box-whisker only fields are not carried over to a series chart + expect(item).not.toHaveProperty("clipMin"); + }); + + it("maps the filters and reference standard deviation of a Taylor diagram", () => { + const item = toExplorerCardContent( + makeContent({ + type: "taylor-diagram", + other_filters: { region: "global" }, + reference_stddev: 1.5, + metric_units: "ignored", + }), + ); + + expect(item).toEqual({ + provider: "ilamb", + diagnostic: "amoc", + title: "Content", + type: "taylor-diagram", + otherFilters: { region: "global" }, + referenceStddev: 1.5, + }); + }); + + it("maps the filename filter of a figure gallery", () => { + const item = toExplorerCardContent( + makeContent({ type: "figure-gallery", filename_filter: "bias" }), + ); + + expect(item).toEqual({ + provider: "ilamb", + diagnostic: "amoc", + title: "Content", + type: "figure-gallery", + filenameFilter: "bias", + }); + }); + + it("leaves null fields undefined", () => { + const item = toExplorerCardContent( + makeContent({ + type: "series-chart", + description: null, + interpretation: null, + span: null, + reference_datasets: null, + metric_units: null, + other_filters: null, + symmetrical_axes: null, + grouping_config: null, + filter_controls: null, + label_template: null, + }), + ); + + for (const [key, value] of Object.entries(item)) { + if (!["provider", "diagnostic", "title", "type"].includes(key)) { + expect(value, key).toBeUndefined(); + } + } + }); + + it("leaves null fields of a grouping config and filter control undefined", () => { + const item = toExplorerCardContent( + makeContent({ + grouping_config: { + group_by: "source_id", + hue: "source_id", + style: null, + }, + filter_controls: [ + { + filter_key: "region", + label: null, + default_value: null, + exclude_values: null, + }, + ], + }), + ); + + expect(item).toMatchObject({ + groupingConfig: { + groupBy: "source_id", + hue: "source_id", + style: undefined, + }, + filterControls: [ + { + filterKey: "region", + label: undefined, + defaultValue: undefined, + excludeValues: undefined, + }, + ], + }); + }); +}); + +describe("buildCollectionGroups", () => { + function makeCollection( + id: string, + explorerCards: AftCollectionCard[], + ): AftCollectionDetail { + return { + id, + name: `Collection ${id}`, + diagnostics: [], + explorer_cards: explorerCards, + }; + } + + it("leaves out placeholder cards and content, and collections without cards", () => { + const shown = makeCollection("1", [ + makeCard( + "Card", + { provider: "ilamb", diagnostic: "amoc", title: "Shown" }, + { + provider: "ilamb", + diagnostic: "amoc", + title: "Hidden", + placeholder: true, + }, + ), + { ...makeCard("Placeholder card"), placeholder: true }, + ]); + const empty = makeCollection("2", [ + { ...makeCard("Placeholder card"), placeholder: true }, + ]); + + const groups = buildCollectionGroups({ + slug: "ocean", + title: "Ocean", + collections: [shown, empty], + explorer_cards: [], + }); + + expect(groups).toHaveLength(1); + expect(groups[0].collection).toBe(shown); + expect(groups[0].cards).toEqual([ + { + title: "Card", + description: undefined, + content: [expect.objectContaining({ title: "Shown" })], + }, + ]); + }); +}); diff --git a/frontend/src/components/explorer/mapping.ts b/frontend/src/components/explorer/mapping.ts new file mode 100644 index 00000000..147eec61 --- /dev/null +++ b/frontend/src/components/explorer/mapping.ts @@ -0,0 +1,143 @@ +import type { + AftCollectionCard, + AftCollectionCardContent, + AftCollectionDetail, + AftCollectionFilterControl, + AftCollectionGroupingConfig, + ThemeDetail, +} from "@/client/types.gen"; +import type { ChartGroupingConfig } from "./grouping/types"; +import type { ExplorerCard, ExplorerCardContent, FilterControl } from "./types"; + +function toChartGroupingConfig( + apiConfig: AftCollectionGroupingConfig, +): ChartGroupingConfig { + return { + groupBy: apiConfig.group_by, + hue: apiConfig.hue, + style: apiConfig.style ?? undefined, + }; +} + +function toFilterControl( + apiControl: AftCollectionFilterControl, +): FilterControl { + return { + filterKey: apiControl.filter_key, + label: apiControl.label ?? undefined, + defaultValue: apiControl.default_value ?? undefined, + excludeValues: apiControl.exclude_values ?? undefined, + }; +} + +/** The fields every type of content has. */ +function baseFields(api: AftCollectionCardContent) { + return { + provider: api.provider, + diagnostic: api.diagnostic, + title: api.title, + description: api.description ?? undefined, + interpretation: api.interpretation ?? undefined, + span: api.span ?? undefined, + placeholder: api.placeholder ?? undefined, + referenceDatasets: api.reference_datasets ?? undefined, + }; +} + +/** The fields box-whisker and series charts share. */ +function chartFields(api: AftCollectionCardContent) { + return { + metricUnits: api.metric_units ?? undefined, + otherFilters: api.other_filters ?? undefined, + symmetricalAxes: api.symmetrical_axes ?? undefined, + groupingConfig: api.grouping_config + ? toChartGroupingConfig(api.grouping_config) + : undefined, + filterControls: api.filter_controls?.map(toFilterControl), + }; +} + +/** Convert a content item of the API to the frontend type, with null fields left undefined. */ +export function toExplorerCardContent( + apiContent: AftCollectionCardContent, +): ExplorerCardContent { + const base = baseFields(apiContent); + switch (apiContent.type) { + case "box-whisker-chart": + return { + ...base, + ...chartFields(apiContent), + type: "box-whisker-chart", + clipMin: apiContent.clip_min ?? undefined, + clipMax: apiContent.clip_max ?? undefined, + yMin: apiContent.y_min ?? undefined, + yMax: apiContent.y_max ?? undefined, + showZeroLine: apiContent.show_zero_line ?? undefined, + }; + case "series-chart": + return { + ...base, + ...chartFields(apiContent), + type: "series-chart", + labelTemplate: apiContent.label_template ?? undefined, + }; + case "taylor-diagram": + return { + ...base, + type: "taylor-diagram", + otherFilters: apiContent.other_filters ?? undefined, + referenceStddev: apiContent.reference_stddev ?? undefined, + }; + case "figure-gallery": + return { + ...base, + type: "figure-gallery", + filenameFilter: apiContent.filename_filter ?? undefined, + }; + } +} + +/** + * Filter explorer cards to only content items matching a specific diagnostic, + * then convert to frontend types. + */ +export function filterExplorerContentForDiagnostic( + cards: AftCollectionCard[], + providerSlug: string, + diagnosticSlug: string, +) { + return cards + .filter((card: AftCollectionCard) => !card.placeholder) + .flatMap((card) => card.content) + .filter( + (c: AftCollectionCardContent) => + c.provider === providerSlug && + c.diagnostic === diagnosticSlug && + !c.placeholder, + ) + .map(toExplorerCardContent); +} + +function collectionToExplorerCards( + collection: AftCollectionDetail, +): ExplorerCard[] { + return collection.explorer_cards + .filter((card: AftCollectionCard) => !card.placeholder) + .map((card: AftCollectionCard) => ({ + title: card.title, + description: card.description ?? undefined, + content: card.content + .filter((c: AftCollectionCardContent) => !c.placeholder) + .map(toExplorerCardContent), + })); +} + +/** The cards of each collection of a theme, leaving out placeholders and collections without cards. */ +export function buildCollectionGroups(theme: ThemeDetail) { + return theme.collections + .map((collection) => ({ + collection, + cards: collectionToExplorerCards(collection), + })) + .filter((group) => group.cards.length > 0); +} diff --git a/frontend/src/components/explorer/thematicContent.tsx b/frontend/src/components/explorer/thematicContent.tsx index 8b667a1c..c9100e86 100644 --- a/frontend/src/components/explorer/thematicContent.tsx +++ b/frontend/src/components/explorer/thematicContent.tsx @@ -2,20 +2,12 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { getRouteApi } from "@tanstack/react-router"; import { BookOpen, FlaskConical } from "lucide-react"; import { explorerGetThemeOptions } from "@/client/@tanstack/react-query.gen"; -import type { - AftCollectionCard, - AftCollectionCardContent, - AftCollectionDetail, - AftCollectionFilterControl, - AftCollectionGroupingConfig, - ThemeDetail, -} from "@/client/types.gen"; +import type { ThemeDetail } from "@/client/types.gen"; import { useSelectedMipEra } from "@/components/charts/mipEraContext"; import { Button } from "@/components/ui/button.tsx"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx"; import { ExplorerThemeLayout } from "./explorerThemeLayout"; -import type { ChartGroupingConfig } from "./grouping/types"; -import type { ExplorerCard, ExplorerCardContent, FilterControl } from "./types"; +import { buildCollectionGroups } from "./mapping"; // Looked up by id rather than imported, as the route's module imports this one const themesRoute = getRouteApi("/_app/explorer/themes"); @@ -30,135 +22,6 @@ const themes = [ type ThemeName = (typeof themes)[number]["name"]; -function toChartGroupingConfig( - apiConfig: AftCollectionGroupingConfig, -): ChartGroupingConfig { - return { - groupBy: apiConfig.group_by, - hue: apiConfig.hue, - style: apiConfig.style ?? undefined, - }; -} - -function toFilterControl( - apiControl: AftCollectionFilterControl, -): FilterControl { - return { - filterKey: apiControl.filter_key, - label: apiControl.label ?? undefined, - defaultValue: apiControl.default_value ?? undefined, - excludeValues: apiControl.exclude_values ?? undefined, - }; -} - -/** - * Filter explorer cards to only content items matching a specific diagnostic, - * then convert to frontend types. - */ -export function filterExplorerContentForDiagnostic( - cards: AftCollectionCard[], - providerSlug: string, - diagnosticSlug: string, -) { - return cards - .filter((card: AftCollectionCard) => !card.placeholder) - .flatMap((card) => card.content) - .filter( - (c: AftCollectionCardContent) => - c.provider === providerSlug && - c.diagnostic === diagnosticSlug && - !c.placeholder, - ) - .map(toExplorerCardContent); -} - -function toExplorerCardContent( - apiContent: AftCollectionCardContent, -): ExplorerCardContent { - const base = { - provider: apiContent.provider, - diagnostic: apiContent.diagnostic, - title: apiContent.title, - description: apiContent.description ?? undefined, - interpretation: apiContent.interpretation ?? undefined, - span: apiContent.span ?? undefined, - placeholder: apiContent.placeholder ?? undefined, - referenceDatasets: apiContent.reference_datasets ?? undefined, - }; - switch (apiContent.type) { - case "box-whisker-chart": - return { - ...base, - type: "box-whisker-chart", - metricUnits: apiContent.metric_units ?? undefined, - otherFilters: apiContent.other_filters ?? undefined, - clipMin: apiContent.clip_min ?? undefined, - clipMax: apiContent.clip_max ?? undefined, - yMin: apiContent.y_min ?? undefined, - yMax: apiContent.y_max ?? undefined, - showZeroLine: apiContent.show_zero_line ?? undefined, - symmetricalAxes: apiContent.symmetrical_axes ?? undefined, - groupingConfig: apiContent.grouping_config - ? toChartGroupingConfig(apiContent.grouping_config) - : undefined, - filterControls: apiContent.filter_controls - ? apiContent.filter_controls.map(toFilterControl) - : undefined, - }; - case "series-chart": - return { - ...base, - type: "series-chart", - metricUnits: apiContent.metric_units ?? undefined, - otherFilters: apiContent.other_filters ?? undefined, - symmetricalAxes: apiContent.symmetrical_axes ?? undefined, - groupingConfig: apiContent.grouping_config - ? toChartGroupingConfig(apiContent.grouping_config) - : undefined, - filterControls: apiContent.filter_controls - ? apiContent.filter_controls.map(toFilterControl) - : undefined, - labelTemplate: apiContent.label_template ?? undefined, - }; - case "taylor-diagram": - return { - ...base, - type: "taylor-diagram", - otherFilters: apiContent.other_filters ?? undefined, - referenceStddev: apiContent.reference_stddev ?? undefined, - }; - case "figure-gallery": - return { - ...base, - type: "figure-gallery", - filenameFilter: apiContent.filename_filter ?? undefined, - }; - } -} - -function collectionToExplorerCards( - collection: AftCollectionDetail, -): ExplorerCard[] { - return collection.explorer_cards - .filter((card: AftCollectionCard) => !card.placeholder) - .map((card: AftCollectionCard) => ({ - title: card.title, - description: card.description ?? undefined, - content: card.content - .filter((c: AftCollectionCardContent) => !c.placeholder) - .map(toExplorerCardContent), - })); -} - -function buildCollectionGroups(theme: ThemeDetail) { - return theme.collections - .map((collection) => ({ - collection, - cards: collectionToExplorerCards(collection), - })) - .filter((group) => group.cards.length > 0); -} - /** Swap the collection descriptions between the technical wording and the plain language one. */ export function PlainLanguageToggle({ plainLanguage, diff --git a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx index 336d80ff..1c92dc95 100644 --- a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx +++ b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx @@ -8,7 +8,7 @@ import { ExplorerCardContent, ExplorerCardContentSkeleton, } from "@/components/explorer/explorerCardContent"; -import { filterExplorerContentForDiagnostic } from "@/components/explorer/thematicContent"; +import { filterExplorerContentForDiagnostic } from "@/components/explorer/mapping"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useMipEra } from "@/hooks/useMipEra"; import { mipEraSearchSchema } from "@/lib/mipEras"; From a94dce0db821932118a22255d8c0399faa07418f Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:31:32 +0200 Subject: [PATCH 15/61] cleanup: move transformToTaylorModels into taylorModels.ts with tests --- .../content/taylorDiagramContentWrapper.tsx | 65 +-------- .../explorer/content/taylorModels.test.ts | 125 ++++++++++++++++++ .../explorer/content/taylorModels.ts | 62 +++++++++ 3 files changed, 190 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/explorer/content/taylorModels.test.ts create mode 100644 frontend/src/components/explorer/content/taylorModels.ts diff --git a/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx b/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx index e99b7aec..356dff87 100644 --- a/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx +++ b/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx @@ -1,15 +1,10 @@ import { useMemo } from "react"; -import { - experimentLegend, - getFixedDimensionColor, -} from "@/components/charts/experimentColors"; +import { experimentLegend } from "@/components/charts/experimentColors"; import { MipEraSections } from "@/components/charts/mipEraSections"; import type { ScalarValue } from "@/components/execution/values/types"; import type { ExplorerCardContent } from "../types"; -import { - TaylorDiagramContent, - type TaylorDiagramModel, -} from "./taylorDiagramContent"; +import { TaylorDiagramContent } from "./taylorDiagramContent"; +import { transformToTaylorModels } from "./taylorModels"; import { useExplorerMetricValues } from "./useExplorerMetricValues"; interface TaylorDiagramContentWrapperProps { @@ -18,60 +13,6 @@ interface TaylorDiagramContentWrapperProps { width?: number; } -/** - * Transforms ILAMB scalar metric values into TaylorDiagramModel format. - * Expects metrics with metric="Spatial Distribution" containing - * "Correlation" and "Normalized Standard Deviation" statistics. - */ -function transformToTaylorModels(values: ScalarValue[]): TaylorDiagramModel[] { - // Group values by model/dataset identifier - const modelGroups = new Map< - string, - { correlation?: number; stddev?: number; color?: string } - >(); - - for (const value of values) { - // Check if this is a Spatial Distribution metric - if (value.dimensions.metric !== "Spatial Distribution") continue; - - // A model can run both historical and esm-hist, so key on both. - const sourceId = - value.dimensions.source_id || - value.dimensions.reference_dataset_slug || - "unknown"; - const experimentId = value.dimensions.experiment_id; - const modelId = experimentId ? `${sourceId} (${experimentId})` : sourceId; - - let group = modelGroups.get(modelId); - if (!group) { - group = { color: getFixedDimensionColor("experiment_id", experimentId) }; - modelGroups.set(modelId, group); - } - - // Extract the relevant statistics - if (value.dimensions.statistic === "Correlation") { - group.correlation = value.value; - } else if (value.dimensions.statistic === "Normalized Standard Deviation") { - group.stddev = value.value; - } - } - - // Convert to TaylorDiagramModel array, filtering out incomplete data - const models: TaylorDiagramModel[] = []; - for (const [name, data] of modelGroups) { - if (data.correlation !== undefined && data.stddev !== undefined) { - models.push({ - name, - correlation: data.correlation, - stddev: data.stddev, - color: data.color, - }); - } - } - - return models; -} - interface TaylorDiagramSectionProps { values: ScalarValue[]; width: number; diff --git a/frontend/src/components/explorer/content/taylorModels.test.ts b/frontend/src/components/explorer/content/taylorModels.test.ts new file mode 100644 index 00000000..be08870b --- /dev/null +++ b/frontend/src/components/explorer/content/taylorModels.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { getFixedDimensionColor } from "@/components/charts/experimentColors"; +import type { ScalarValue } from "@/components/execution/values/types"; +import { transformToTaylorModels } from "./taylorModels"; + +let nextId = 1; + +function makeValue( + dimensions: Record, + value: number, +): ScalarValue { + const id = nextId++; + return { + id, + execution_group_id: 1, + execution_id: id, + value, + dimensions: { metric: "Spatial Distribution", ...dimensions }, + }; +} + +/** A correlation and a normalised standard deviation for the same model. */ +function makePair( + dimensions: Record, + correlation: number, + stddev: number, +): ScalarValue[] { + return [ + makeValue({ ...dimensions, statistic: "Correlation" }, correlation), + makeValue( + { ...dimensions, statistic: "Normalized Standard Deviation" }, + stddev, + ), + ]; +} + +describe("transformToTaylorModels", () => { + it("keys models on source id and experiment", () => { + const models = transformToTaylorModels([ + ...makePair( + { source_id: "ACCESS", experiment_id: "historical" }, + 0.9, + 1.1, + ), + ...makePair({ source_id: "ACCESS", experiment_id: "esm-hist" }, 0.8, 1.2), + ]); + + expect(models).toEqual([ + expect.objectContaining({ + name: "ACCESS (historical)", + correlation: 0.9, + stddev: 1.1, + }), + expect.objectContaining({ + name: "ACCESS (esm-hist)", + correlation: 0.8, + stddev: 1.2, + }), + ]); + }); + + it("names a model without an experiment by its source id alone", () => { + const models = transformToTaylorModels( + makePair({ source_id: "ACCESS" }, 0.9, 1.1), + ); + + expect(models.map((m) => m.name)).toEqual(["ACCESS"]); + }); + + it("falls back to the reference dataset slug, then to unknown", () => { + const models = transformToTaylorModels([ + ...makePair({ reference_dataset_slug: "obs4mips.HadISST" }, 0.9, 1.1), + ...makePair({}, 0.5, 0.7), + ]); + + expect(models.map((m) => m.name)).toEqual(["obs4mips.HadISST", "unknown"]); + }); + + it("skips values of other metrics", () => { + const models = transformToTaylorModels([ + ...makePair({ source_id: "ACCESS" }, 0.9, 1.1), + makeValue( + { source_id: "ACCESS", metric: "Bias", statistic: "Correlation" }, + 0.1, + ), + ...makePair({ source_id: "CESM", metric: "Bias" }, 0.2, 0.3), + ]); + + expect(models).toEqual([ + expect.objectContaining({ name: "ACCESS", correlation: 0.9 }), + ]); + }); + + it("drops models missing either statistic", () => { + const models = transformToTaylorModels([ + makeValue({ source_id: "ACCESS", statistic: "Correlation" }, 0.9), + makeValue( + { source_id: "CESM", statistic: "Normalized Standard Deviation" }, + 1.1, + ), + makeValue({ source_id: "CESM", statistic: "Bias" }, 0.1), + ]); + + expect(models).toEqual([]); + }); + + it("colours models by experiment", () => { + const models = transformToTaylorModels([ + ...makePair( + { source_id: "ACCESS", experiment_id: "historical" }, + 0.9, + 1.1, + ), + ...makePair({ source_id: "ACCESS", experiment_id: "esm-hist" }, 0.8, 1.2), + ...makePair({ source_id: "ACCESS", experiment_id: "ssp585" }, 0.7, 1.3), + ]); + + expect(models.map((m) => m.color)).toEqual([ + getFixedDimensionColor("experiment_id", "historical"), + getFixedDimensionColor("experiment_id", "esm-hist"), + undefined, + ]); + expect(models[0].color).not.toEqual(models[1].color); + }); +}); diff --git a/frontend/src/components/explorer/content/taylorModels.ts b/frontend/src/components/explorer/content/taylorModels.ts new file mode 100644 index 00000000..2cb6fe5c --- /dev/null +++ b/frontend/src/components/explorer/content/taylorModels.ts @@ -0,0 +1,62 @@ +import { getFixedDimensionColor } from "@/components/charts/experimentColors"; +import type { ScalarValue } from "@/components/execution/values/types"; +import type { TaylorDiagramModel } from "./taylorDiagramContent"; + +/** + * The models of a Taylor diagram, from ILAMB's scalar values + * + * Only the "Spatial Distribution" values are read, and a model is left out unless it has + * both its "Correlation" and its "Normalized Standard Deviation" statistic. + * Models are named by source id, or else reference dataset, and experiment, + * and coloured by experiment. + */ +export function transformToTaylorModels( + values: ScalarValue[], +): TaylorDiagramModel[] { + // Group values by model/dataset identifier + const modelGroups = new Map< + string, + { correlation?: number; stddev?: number; color?: string } + >(); + + for (const value of values) { + // Check if this is a Spatial Distribution metric + if (value.dimensions.metric !== "Spatial Distribution") continue; + + // A model can run both historical and esm-hist, so key on both. + const sourceId = + value.dimensions.source_id || + value.dimensions.reference_dataset_slug || + "unknown"; + const experimentId = value.dimensions.experiment_id; + const modelId = experimentId ? `${sourceId} (${experimentId})` : sourceId; + + let group = modelGroups.get(modelId); + if (!group) { + group = { color: getFixedDimensionColor("experiment_id", experimentId) }; + modelGroups.set(modelId, group); + } + + // Extract the relevant statistics + if (value.dimensions.statistic === "Correlation") { + group.correlation = value.value; + } else if (value.dimensions.statistic === "Normalized Standard Deviation") { + group.stddev = value.value; + } + } + + // Convert to TaylorDiagramModel array, filtering out incomplete data + const models: TaylorDiagramModel[] = []; + for (const [name, data] of modelGroups) { + if (data.correlation !== undefined && data.stddev !== undefined) { + models.push({ + name, + correlation: data.correlation, + stddev: data.stddev, + color: data.color, + }); + } + } + + return models; +} From 91a58e32845e4d935d11462531b9bc0dd9f4d048 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:32:33 +0200 Subject: [PATCH 16/61] cleanup: pick collection texts with one collectionText helper and share the callout markup --- .../components/explorer/collectionHeader.tsx | 72 ++++----------- .../explorer/collectionText.test.ts | 87 +++++++++++++++++++ .../src/components/explorer/collectionText.ts | 30 +++++++ .../components/explorer/thematicContent.tsx | 8 -- frontend/src/routes/_app/explorer/themes.tsx | 2 +- 5 files changed, 134 insertions(+), 65 deletions(-) create mode 100644 frontend/src/components/explorer/collectionText.test.ts create mode 100644 frontend/src/components/explorer/collectionText.ts diff --git a/frontend/src/components/explorer/collectionHeader.tsx b/frontend/src/components/explorer/collectionHeader.tsx index 730bebca..120db011 100644 --- a/frontend/src/components/explorer/collectionHeader.tsx +++ b/frontend/src/components/explorer/collectionHeader.tsx @@ -1,55 +1,31 @@ import { Link } from "@tanstack/react-router"; -import type { - AftCollectionContent, - AftCollectionDetail, -} from "@/client/types.gen"; +import type { AftCollectionDetail } from "@/client/types.gen"; import { Badge } from "@/components/ui/badge"; +import { collectionText } from "./collectionText"; interface CollectionHeaderProps { collection: AftCollectionDetail; plainLanguage: boolean; } -function getDescription( - content: AftCollectionContent | null | undefined, - plainLanguage: boolean, -): string | null | undefined { - if (!content) return null; - if (plainLanguage && content.plain_language?.description) { - return content.plain_language.description; - } - return content.description; -} - -function getWhyItMatters( - content: AftCollectionContent | null | undefined, - plainLanguage: boolean, -): string | null | undefined { - if (!content) return null; - if (plainLanguage && content.plain_language?.why_it_matters) { - return content.plain_language.why_it_matters; - } - return content.why_it_matters; -} - -function getTakeaway( - content: AftCollectionContent | null | undefined, - plainLanguage: boolean, -): string | null | undefined { - if (!content) return null; - if (plainLanguage && content.plain_language?.takeaway) { - return content.plain_language.takeaway; - } - return content.takeaway; +/** A titled text set off from the description by a rule on its left. */ +function Callout({ title, text }: { title: string; text: string }) { + return ( +
+

{title}

+

{text}

+
+ ); } export function CollectionHeader({ collection, plainLanguage, }: CollectionHeaderProps) { - const description = getDescription(collection.content, plainLanguage); - const whyItMatters = getWhyItMatters(collection.content, plainLanguage); - const takeaway = getTakeaway(collection.content, plainLanguage); + const { content } = collection; + const description = collectionText(content, "description", plainLanguage); + const whyItMatters = collectionText(content, "why_it_matters", plainLanguage); + const takeaway = collectionText(content, "takeaway", plainLanguage); return (
@@ -70,25 +46,9 @@ export function CollectionHeader({

)} - {whyItMatters && ( -
-

- Why it matters -

-

- {whyItMatters} -

-
- )} + {whyItMatters && } - {takeaway && ( -
-

Takeaway

-

- {takeaway} -

-
- )} + {takeaway && }
{collection.reference_dataset && ( diff --git a/frontend/src/components/explorer/collectionText.test.ts b/frontend/src/components/explorer/collectionText.test.ts new file mode 100644 index 00000000..c216c379 --- /dev/null +++ b/frontend/src/components/explorer/collectionText.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import type { + AftCollectionContent, + AftCollectionDetail, + ThemeDetail, +} from "@/client/types.gen"; +import { collectionText, hasPlainLanguageContent } from "./collectionText"; + +const content: AftCollectionContent = { + description: "Technical description", + why_it_matters: "Technical why", + takeaway: "Technical takeaway", + plain_language: { + description: "Plain description", + why_it_matters: "Plain why", + takeaway: null, + }, +}; + +describe("collectionText", () => { + it("returns the technical text unless plain language is asked for", () => { + expect(collectionText(content, "description", false)).toBe( + "Technical description", + ); + expect(collectionText(content, "why_it_matters", false)).toBe( + "Technical why", + ); + }); + + it("returns the plain language text when asked for", () => { + expect(collectionText(content, "description", true)).toBe( + "Plain description", + ); + expect(collectionText(content, "why_it_matters", true)).toBe("Plain why"); + }); + + it("falls back to the technical text when there is no plain language one", () => { + expect(collectionText(content, "takeaway", true)).toBe( + "Technical takeaway", + ); + expect( + collectionText({ takeaway: "Only technical" }, "takeaway", true), + ).toBe("Only technical"); + }); + + it("returns null for a collection without content", () => { + expect(collectionText(null, "description", true)).toBeNull(); + expect(collectionText(undefined, "takeaway", false)).toBeNull(); + }); +}); + +describe("hasPlainLanguageContent", () => { + function makeTheme( + ...contents: (AftCollectionContent | null)[] + ): ThemeDetail { + return { + slug: "ocean", + title: "Ocean", + explorer_cards: [], + collections: contents.map( + (c, i): AftCollectionDetail => ({ + id: String(i), + name: `Collection ${i}`, + diagnostics: [], + explorer_cards: [], + content: c, + }), + ), + }; + } + + it("is true when any collection has a plain language text", () => { + expect( + hasPlainLanguageContent( + makeTheme(null, { plain_language: { takeaway: "Plain takeaway" } }), + ), + ).toBe(true); + }); + + it("is false when no collection has a plain language text", () => { + expect( + hasPlainLanguageContent( + makeTheme(null, { description: "Technical" }, { plain_language: {} }), + ), + ).toBe(false); + }); +}); diff --git a/frontend/src/components/explorer/collectionText.ts b/frontend/src/components/explorer/collectionText.ts new file mode 100644 index 00000000..911cc63f --- /dev/null +++ b/frontend/src/components/explorer/collectionText.ts @@ -0,0 +1,30 @@ +import type { AftCollectionContent, ThemeDetail } from "@/client/types.gen"; + +/** The texts of a collection that can have a plain language version. */ +const PLAIN_LANGUAGE_FIELDS = [ + "description", + "why_it_matters", + "takeaway", +] as const; + +type PlainLanguageField = (typeof PLAIN_LANGUAGE_FIELDS)[number]; + +/** A text of a collection, in plain language when asked for and the collection has it. */ +export function collectionText( + content: AftCollectionContent | null | undefined, + field: PlainLanguageField, + plainLanguage: boolean, +): string | null | undefined { + if (!content) return null; + if (plainLanguage && content.plain_language?.[field]) { + return content.plain_language[field]; + } + return content[field]; +} + +/** True when at least one collection carries a plain language summary worth toggling to. */ +export function hasPlainLanguageContent(theme: ThemeDetail): boolean { + return theme.collections.some((c) => + PLAIN_LANGUAGE_FIELDS.some((field) => c.content?.plain_language?.[field]), + ); +} diff --git a/frontend/src/components/explorer/thematicContent.tsx b/frontend/src/components/explorer/thematicContent.tsx index c9100e86..9917fbc3 100644 --- a/frontend/src/components/explorer/thematicContent.tsx +++ b/frontend/src/components/explorer/thematicContent.tsx @@ -48,14 +48,6 @@ export function PlainLanguageToggle({ ); } -/** True when at least one collection carries a plain language summary worth toggling to. */ -export function hasPlainLanguageContent(theme: ThemeDetail): boolean { - return theme.collections.some((c) => { - const pl = c.content?.plain_language; - return pl?.description || pl?.why_it_matters || pl?.takeaway; - }); -} - function ThemeContent({ theme, plainLanguage, diff --git a/frontend/src/routes/_app/explorer/themes.tsx b/frontend/src/routes/_app/explorer/themes.tsx index 6d6ef896..afe24025 100644 --- a/frontend/src/routes/_app/explorer/themes.tsx +++ b/frontend/src/routes/_app/explorer/themes.tsx @@ -4,8 +4,8 @@ import { useState } from "react"; import { z } from "zod"; import { explorerGetThemeOptions } from "@/client/@tanstack/react-query.gen"; import { MipEraScope } from "@/components/charts/mipEraBar"; +import { hasPlainLanguageContent } from "@/components/explorer/collectionText"; import { - hasPlainLanguageContent, PlainLanguageToggle, ThematicContent, } from "@/components/explorer/thematicContent.tsx"; From 5855e1d4fa31777ebbf32464ed97fce4da85ef21 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:33:11 +0200 Subject: [PATCH 17/61] cleanup: drop the explorer card placeholder styling, as placeholders are filtered out before rendering --- .../src/components/explorer/explorerCardFrame.tsx | 12 ++---------- frontend/src/components/explorer/mapping.ts | 1 - frontend/src/components/explorer/types.ts | 2 -- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/explorer/explorerCardFrame.tsx b/frontend/src/components/explorer/explorerCardFrame.tsx index 3255687e..16181ae6 100644 --- a/frontend/src/components/explorer/explorerCardFrame.tsx +++ b/frontend/src/components/explorer/explorerCardFrame.tsx @@ -31,18 +31,10 @@ export function ExplorerCardFrame({ return (
- +
- - {contentItem.placeholder ? "PLACEHOLDER: " : ""}{" "} - {contentItem.title} - + {contentItem.title}
{contentItem.description && ( {contentItem.description} diff --git a/frontend/src/components/explorer/mapping.ts b/frontend/src/components/explorer/mapping.ts index 147eec61..c9ca3b87 100644 --- a/frontend/src/components/explorer/mapping.ts +++ b/frontend/src/components/explorer/mapping.ts @@ -39,7 +39,6 @@ function baseFields(api: AftCollectionCardContent) { description: api.description ?? undefined, interpretation: api.interpretation ?? undefined, span: api.span ?? undefined, - placeholder: api.placeholder ?? undefined, referenceDatasets: api.reference_datasets ?? undefined, }; } diff --git a/frontend/src/components/explorer/types.ts b/frontend/src/components/explorer/types.ts index 8f5d0d6f..f52571c6 100644 --- a/frontend/src/components/explorer/types.ts +++ b/frontend/src/components/explorer/types.ts @@ -18,7 +18,6 @@ export type BaseCardContent = { description?: string | ReactNode; interpretation?: string; span?: 1 | 2; - placeholder?: boolean; referenceDatasets?: ReferenceDatasetLink[]; }; @@ -71,7 +70,6 @@ export type ExplorerCardContent = export type ExplorerCard = { title: string; description?: string | ReactNode; - placeholder?: boolean; content: ExplorerCardContent[]; }; From 7f9cec132a1a2290f119cd2e66392e90ef991e8e Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:34:17 +0200 Subject: [PATCH 18/61] cleanup: move the explorer card CSV download into a downloadChartValues helper with tests --- .../components/explorer/chartValues.test.ts | 52 ++++++++++++++++++- .../src/components/explorer/chartValues.ts | 25 +++++++++ .../explorer/explorerCardDropdown.tsx | 26 ++-------- 3 files changed, 80 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/explorer/chartValues.test.ts b/frontend/src/components/explorer/chartValues.test.ts index 739695e7..2e5d594a 100644 --- a/frontend/src/components/explorer/chartValues.test.ts +++ b/frontend/src/components/explorer/chartValues.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { chartValuesSearch } from "./chartValues"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { diagnosticsListMetricValues } from "@/client/sdk.gen"; +import { downloadBlob } from "@/lib/downloadUtils"; +import { chartValuesSearch, downloadChartValues } from "./chartValues"; + +vi.mock("@/client/sdk.gen", () => ({ diagnosticsListMetricValues: vi.fn() })); +vi.mock("@/lib/downloadUtils", () => ({ downloadBlob: vi.fn() })); describe("chartValuesSearch", () => { it("keeps the card filters, filter control values and MIP era", () => { @@ -37,3 +42,46 @@ describe("chartValuesSearch", () => { }); }); }); + +describe("downloadChartValues", () => { + const card = { provider: "ilamb", diagnostic: "amoc" }; + const query = { + value_type: "scalar" as const, + limit: 500, + region: "NWN", + include_unverified: true, + }; + + beforeEach(() => { + vi.mocked(diagnosticsListMetricValues).mockReset(); + vi.mocked(downloadBlob).mockReset(); + }); + + it("downloads the chart's query as CSV", async () => { + const blob = new Blob(["a,b"]); + vi.mocked(diagnosticsListMetricValues).mockResolvedValue({ + data: blob, + error: undefined, + } as never); + + await downloadChartValues(card, query); + + expect(diagnosticsListMetricValues).toHaveBeenCalledWith({ + path: { provider_slug: "ilamb", diagnostic_slug: "amoc" }, + query: { ...query, format: "csv" }, + parseAs: "blob", + }); + expect(downloadBlob).toHaveBeenCalledWith(blob, "ilamb-amoc-values.csv"); + }); + + it("throws the error of a failed request without downloading", async () => { + const error = { detail: "Not found" }; + vi.mocked(diagnosticsListMetricValues).mockResolvedValue({ + data: undefined, + error, + } as never); + + await expect(downloadChartValues(card, query)).rejects.toBe(error); + expect(downloadBlob).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/explorer/chartValues.ts b/frontend/src/components/explorer/chartValues.ts index 80a6e85e..4150c089 100644 --- a/frontend/src/components/explorer/chartValues.ts +++ b/frontend/src/components/explorer/chartValues.ts @@ -1,3 +1,5 @@ +import { diagnosticsListMetricValues } from "@/client/sdk.gen"; +import { downloadBlob } from "@/lib/downloadUtils"; import type { MetricValuesQuery } from "./content/useExplorerMetricValues"; /** @@ -30,3 +32,26 @@ export function chartValuesSearch( include_unverified: String(include_unverified ?? false), }; } + +/** + * Download the values a chart card shows as a CSV file + * + * The file holds every matching value, as the CSV export ignores the chart's limit. + * Throws if the request fails. + */ +export async function downloadChartValues( + card: { provider: string; diagnostic: string }, + query: MetricValuesQuery, +): Promise { + // TODO: replace with generic fetchDownload from useMetricValues when available + const { data, error } = await diagnosticsListMetricValues({ + path: { provider_slug: card.provider, diagnostic_slug: card.diagnostic }, + query: { ...query, format: "csv" }, + parseAs: "blob", + }); + if (error) throw error; + downloadBlob( + data as unknown as Blob, + `${card.provider}-${card.diagnostic}-values.csv`, + ); +} diff --git a/frontend/src/components/explorer/explorerCardDropdown.tsx b/frontend/src/components/explorer/explorerCardDropdown.tsx index f5129324..41396f54 100644 --- a/frontend/src/components/explorer/explorerCardDropdown.tsx +++ b/frontend/src/components/explorer/explorerCardDropdown.tsx @@ -1,6 +1,5 @@ import { useNavigate } from "@tanstack/react-router"; import { MoreHorizontal } from "lucide-react"; -import { diagnosticsListMetricValues } from "@/client/sdk.gen"; import type { BoxWhiskerChartContent, SeriesChartContent, @@ -12,8 +11,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { downloadBlob } from "@/lib/downloadUtils"; -import { chartValuesSearch } from "./chartValues"; +import { chartValuesSearch, downloadChartValues } from "./chartValues"; import type { MetricValuesQuery } from "./content/useExplorerMetricValues"; interface ExplorerCardDropdownProps { @@ -49,26 +47,12 @@ export function ExplorerCardDropdown({ View chart values { - // TODO: replace with generic fetchDownload from useMetricValues when available - try { - const { data, error } = await diagnosticsListMetricValues({ - path: { - provider_slug: contentItem.provider, - diagnostic_slug: contentItem.diagnostic, - }, - query: { ...query, format: "csv" }, - parseAs: "blob", - }); - - if (error) throw error; - const filename = `${contentItem.provider}-${contentItem.diagnostic}-values.csv`; - downloadBlob(data as unknown as Blob, filename); - } catch (err) { + onClick={() => + downloadChartValues(contentItem, query).catch((err) => { console.error("Download failed:", err); alert("Failed to download data. Please try again."); - } - }} + }) + } > Download values (CSV) From acc694d38cd47629bef1ceaad04b958992dcb5e9 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:25:57 +0200 Subject: [PATCH 19/61] cleanup: test the real chart scales and spatial index instead of copies --- .../execution/values/series/seriesCanvas.tsx | 17 +- .../values/series/useChartScales.test.ts | 131 +++-------- .../execution/values/series/useChartScales.ts | 160 +++++++------ .../values/series/useSpatialIndex.test.ts | 186 ++------------- .../values/series/useSpatialIndex.ts | 216 +++++++++--------- .../execution/values/series/utils.test.ts | 18 ++ .../execution/values/series/utils.ts | 18 ++ 7 files changed, 294 insertions(+), 452 deletions(-) diff --git a/frontend/src/components/execution/values/series/seriesCanvas.tsx b/frontend/src/components/execution/values/series/seriesCanvas.tsx index e2c96baa..13244df8 100644 --- a/frontend/src/components/execution/values/series/seriesCanvas.tsx +++ b/frontend/src/components/execution/values/series/seriesCanvas.tsx @@ -6,6 +6,7 @@ import { type ChartDataPoint, createScaledTickFormatter, isIntegerAxis, + plottablePoint, } from "./utils"; const Y_TICK_COUNT = 8; @@ -119,7 +120,6 @@ export const SeriesCanvas = memo(function SeriesCanvas({ for (const meta of sortedMeta) { const isHidden = hiddenLabels.has(meta.label); const isHovered = meta.label === hoveredLabel; - const key = `series_${meta.seriesIndex}`; if (isHidden) { ctx.strokeStyle = isDark ? "#4B5563" : "#D1D5DB"; @@ -137,19 +137,14 @@ export const SeriesCanvas = memo(function SeriesCanvas({ ctx.beginPath(); let started = false; - for (let i = 0; i < chartData.length; i++) { - const xVal = chartData[i][indexName]; - const yVal = chartData[i][key]; - if ( - typeof xVal !== "number" || - typeof yVal !== "number" || - !Number.isFinite(yVal) - ) { + for (const row of chartData) { + const value = plottablePoint(row, indexName, meta.seriesIndex); + if (!value) { started = false; continue; } - const px = xScale(xVal); - const py = yScale(yVal); + const px = xScale(value.x); + const py = yScale(value.y); if (!started) { ctx.moveTo(px, py); started = true; diff --git a/frontend/src/components/execution/values/series/useChartScales.test.ts b/frontend/src/components/execution/values/series/useChartScales.test.ts index b5c03acb..1cc10ce8 100644 --- a/frontend/src/components/execution/values/series/useChartScales.test.ts +++ b/frontend/src/components/execution/values/series/useChartScales.test.ts @@ -1,87 +1,7 @@ -import * as d3 from "d3"; import { describe, expect, it } from "vitest"; import type { SeriesMetadata } from "../types"; - -// Test the scale computation logic directly (extracted from the hook) -// since hooks require a React test environment - -interface ChartDataPoint { - [key: string]: number | string | null; -} - -function computeScales( - chartData: ChartDataPoint[], - seriesMetadata: SeriesMetadata[], - hiddenLabels: Set, - indexName: string, - width: number, - height: number, - symmetricalAxes: boolean, - isTimeAxis = false, - margins = { top: 10, right: 30, bottom: 60, left: 70 }, -) { - const innerWidth = Math.max(0, width - margins.left - margins.right); - const innerHeight = Math.max(0, height - margins.top - margins.bottom); - - const xValues = chartData - .map((d) => d[indexName]) - .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); - - const xDomain: [number, number] = - xValues.length > 0 - ? [d3.min(xValues) as number, d3.max(xValues) as number] - : [0, 1]; - - const visibleKeys = new Set(); - for (const meta of seriesMetadata) { - if (!hiddenLabels.has(meta.label)) { - visibleKeys.add(`series_${meta.seriesIndex}`); - } - } - - const yValues: number[] = []; - for (const d of chartData) { - for (const key of visibleKeys) { - const v = d[key]; - if (typeof v === "number" && Number.isFinite(v)) { - yValues.push(v); - } - } - } - - let yDomain: [number, number]; - if (yValues.length === 0) { - yDomain = [0, 1]; - } else if (symmetricalAxes) { - const maxAbs = d3.max(yValues.map(Math.abs)) as number; - yDomain = [-maxAbs, maxAbs]; - } else { - const yMin = d3.min(yValues) as number; - const yMax = d3.max(yValues) as number; - const padding = (yMax - yMin) * 0.05 || 0.1; - yDomain = [yMin - padding, yMax + padding]; - } - - const xScale = isTimeAxis - ? d3 - .scaleTime() - .domain([new Date(xDomain[0]), new Date(xDomain[1])]) - .range([0, innerWidth]) - : d3.scaleLinear().domain(xDomain).range([0, innerWidth]); - - const yScale = d3.scaleLinear().domain(yDomain).range([innerHeight, 0]); - - return { - xScale, - yScale, - xDomain, - yDomain, - innerWidth, - innerHeight, - margins, - isTimeAxis, - }; -} +import { computeChartScales } from "./useChartScales"; +import type { ChartDataPoint } from "./utils"; const meta: SeriesMetadata[] = [ { @@ -106,9 +26,9 @@ const chartData: ChartDataPoint[] = [ { year: 2022, series_0: 2.0, series_1: 0.5 }, ]; -describe("computeScales", () => { +describe("computeChartScales", () => { it("computes correct X domain from index values", () => { - const { xDomain } = computeScales( + const { xDomain } = computeChartScales( chartData, meta, new Set(), @@ -116,13 +36,14 @@ describe("computeScales", () => { 800, 600, false, + false, ); expect(xDomain).toEqual([2020, 2022]); }); it("computes Y domain from visible series only", () => { const hidden = new Set(["ModelB"]); - const { yDomain } = computeScales( + const { yDomain } = computeChartScales( chartData, meta, hidden, @@ -130,6 +51,7 @@ describe("computeScales", () => { 800, 600, false, + false, ); // Only ModelA values: [1, 3, 2], min=1, max=3, padding = 0.1 expect(yDomain[0]).toBeCloseTo(0.9, 1); @@ -138,7 +60,7 @@ describe("computeScales", () => { it("returns fallback Y domain when all series hidden", () => { const allHidden = new Set(["ModelA", "ModelB"]); - const { yDomain } = computeScales( + const { yDomain } = computeChartScales( chartData, meta, allHidden, @@ -146,12 +68,13 @@ describe("computeScales", () => { 800, 600, false, + false, ); expect(yDomain).toEqual([0, 1]); }); it("creates symmetrical Y domain when symmetricalAxes is true", () => { - const { yDomain } = computeScales( + const { yDomain } = computeChartScales( chartData, meta, new Set(), @@ -159,6 +82,7 @@ describe("computeScales", () => { 800, 600, true, + false, ); // Max abs value is 3.0, so domain should be [-3, 3] expect(yDomain[0]).toBe(-3); @@ -167,7 +91,7 @@ describe("computeScales", () => { it("maps X values to pixel range correctly", () => { const margins = { top: 10, right: 30, bottom: 60, left: 70 }; - const { xScale, innerWidth } = computeScales( + const { xScale, innerWidth } = computeChartScales( chartData, meta, new Set(), @@ -184,7 +108,7 @@ describe("computeScales", () => { }); it("maps Y values with inverted axis (high value = low pixel)", () => { - const { yScale } = computeScales( + const { yScale } = computeChartScales( chartData, meta, new Set(), @@ -192,15 +116,16 @@ describe("computeScales", () => { 800, 600, false, + false, ); const yDomain = yScale.domain(); // Higher values should map to lower pixel values expect(yScale(yDomain[1])).toBe(0); - expect(yScale(yDomain[0])).toBe(530); // 600 - 10 - 60 + expect(yScale(yDomain[0])).toBe(540); // 600 - 10 top - 50 bottom (default margins) }); it("handles empty chart data gracefully", () => { - const { xDomain, yDomain } = computeScales( + const { xDomain, yDomain } = computeChartScales( [], meta, new Set(), @@ -208,19 +133,21 @@ describe("computeScales", () => { 800, 600, false, + false, ); expect(xDomain).toEqual([0, 1]); expect(yDomain).toEqual([0, 1]); }); - it("handles zero-size dimensions without crashing", () => { - const result = computeScales( + it("clamps the inner size to zero when the margins do not fit", () => { + const result = computeChartScales( chartData, meta, new Set(), "year", - 70, - 70, + 100, + 50, + false, false, ); expect(result.innerWidth).toBe(0); @@ -228,7 +155,7 @@ describe("computeScales", () => { }); }); -describe("computeScales with time axis", () => { +describe("computeChartScales with time axis", () => { const ts1 = new Date("2020-01-15T00:00:00Z").getTime(); const ts2 = new Date("2020-06-15T00:00:00Z").getTime(); const ts3 = new Date("2021-01-15T00:00:00Z").getTime(); @@ -240,7 +167,7 @@ describe("computeScales with time axis", () => { ]; it("computes X domain from timestamp values", () => { - const { xDomain } = computeScales( + const { xDomain } = computeChartScales( timeChartData, meta, new Set(), @@ -256,7 +183,7 @@ describe("computeScales with time axis", () => { it("creates a time scale that maps timestamps to pixels", () => { const margins = { top: 10, right: 30, bottom: 60, left: 70 }; - const { xScale, innerWidth } = computeScales( + const { xScale, innerWidth } = computeChartScales( timeChartData, meta, new Set(), @@ -274,7 +201,7 @@ describe("computeScales with time axis", () => { }); it("produces Date ticks for time scale", () => { - const { xScale } = computeScales( + const { xScale } = computeChartScales( timeChartData, meta, new Set(), @@ -292,7 +219,7 @@ describe("computeScales with time axis", () => { it("maps midpoint timestamp to middle pixel range", () => { const margins = { top: 10, right: 30, bottom: 60, left: 70 }; - const { xScale } = computeScales( + const { xScale } = computeChartScales( timeChartData, meta, new Set(), @@ -308,7 +235,7 @@ describe("computeScales with time axis", () => { }); it("Y domain is unaffected by time axis mode", () => { - const linearResult = computeScales( + const linearResult = computeChartScales( timeChartData, meta, new Set(), @@ -318,7 +245,7 @@ describe("computeScales with time axis", () => { false, false, ); - const timeResult = computeScales( + const timeResult = computeChartScales( timeChartData, meta, new Set(), diff --git a/frontend/src/components/execution/values/series/useChartScales.ts b/frontend/src/components/execution/values/series/useChartScales.ts index e771667d..b4e47aef 100644 --- a/frontend/src/components/execution/values/series/useChartScales.ts +++ b/frontend/src/components/execution/values/series/useChartScales.ts @@ -36,7 +36,13 @@ export interface ChartScales { isTimeAxis: boolean; } -export function useChartScales( +/** + * The x and y scales of a chart `width` by `height` pixels + * + * The x domain spans every index value, the y domain only the series not in `hiddenLabels`. + * Without values to span, a domain falls back to [0, 1]. + */ +export function computeChartScales( chartData: ChartDataPoint[], seriesMetadata: SeriesMetadata[], hiddenLabels: Set, @@ -47,79 +53,105 @@ export function useChartScales( isTimeAxis: boolean, margins: ChartMargins = DEFAULT_MARGINS, ): ChartScales { - return useMemo(() => { - const innerWidth = Math.max(0, width - margins.left - margins.right); - const innerHeight = Math.max(0, height - margins.top - margins.bottom); + const innerWidth = Math.max(0, width - margins.left - margins.right); + const innerHeight = Math.max(0, height - margins.top - margins.bottom); - // X domain from index values (already numeric — timestamps for datetime axes) - const xValues = chartData - .map((d) => d[indexName]) - .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + // X domain from index values (already numeric — timestamps for datetime axes) + const xValues = chartData + .map((d) => d[indexName]) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); - const xDomain: [number, number] = - xValues.length > 0 - ? [d3.min(xValues) as number, d3.max(xValues) as number] - : [0, 1]; + const xDomain: [number, number] = + xValues.length > 0 + ? [d3.min(xValues) as number, d3.max(xValues) as number] + : [0, 1]; - // Y domain from visible series only - const visibleKeys = new Set(); - for (const meta of seriesMetadata) { - if (!hiddenLabels.has(meta.label)) { - visibleKeys.add(`series_${meta.seriesIndex}`); - } + // Y domain from visible series only + const visibleKeys = new Set(); + for (const meta of seriesMetadata) { + if (!hiddenLabels.has(meta.label)) { + visibleKeys.add(`series_${meta.seriesIndex}`); } + } - const yValues: number[] = []; - for (const d of chartData) { - for (const key of visibleKeys) { - const v = d[key]; - if (typeof v === "number" && Number.isFinite(v)) { - yValues.push(v); - } + const yValues: number[] = []; + for (const d of chartData) { + for (const key of visibleKeys) { + const v = d[key]; + if (typeof v === "number" && Number.isFinite(v)) { + yValues.push(v); } } + } - let yDomain: [number, number]; - if (yValues.length === 0) { - yDomain = [0, 1]; - } else if (symmetricalAxes) { - const maxAbs = d3.max(yValues.map(Math.abs)) as number; - yDomain = [-maxAbs, maxAbs]; - } else { - const yMin = d3.min(yValues) as number; - const yMax = d3.max(yValues) as number; - const padding = (yMax - yMin) * 0.05 || 0.1; - yDomain = [yMin - padding, yMax + padding]; - } + let yDomain: [number, number]; + if (yValues.length === 0) { + yDomain = [0, 1]; + } else if (symmetricalAxes) { + const maxAbs = d3.max(yValues.map(Math.abs)) as number; + yDomain = [-maxAbs, maxAbs]; + } else { + const yMin = d3.min(yValues) as number; + const yMax = d3.max(yValues) as number; + const padding = (yMax - yMin) * 0.05 || 0.1; + yDomain = [yMin - padding, yMax + padding]; + } - const xScale: XScaleType = isTimeAxis - ? d3 - .scaleTime() - .domain([new Date(xDomain[0]), new Date(xDomain[1])]) - .range([0, innerWidth]) - : d3.scaleLinear().domain(xDomain).range([0, innerWidth]); + const xScale: XScaleType = isTimeAxis + ? d3 + .scaleTime() + .domain([new Date(xDomain[0]), new Date(xDomain[1])]) + .range([0, innerWidth]) + : d3.scaleLinear().domain(xDomain).range([0, innerWidth]); - const yScale = d3.scaleLinear().domain(yDomain).range([innerHeight, 0]); + const yScale = d3.scaleLinear().domain(yDomain).range([innerHeight, 0]); - return { - xScale, - yScale, - xDomain, - yDomain, - innerWidth, - innerHeight, - margins, - isTimeAxis, - }; - }, [ - chartData, - seriesMetadata, - hiddenLabels, - indexName, - width, - height, - symmetricalAxes, - isTimeAxis, + return { + xScale, + yScale, + xDomain, + yDomain, + innerWidth, + innerHeight, margins, - ]); + isTimeAxis, + }; +} + +export function useChartScales( + chartData: ChartDataPoint[], + seriesMetadata: SeriesMetadata[], + hiddenLabels: Set, + indexName: string, + width: number, + height: number, + symmetricalAxes: boolean, + isTimeAxis: boolean, + margins: ChartMargins = DEFAULT_MARGINS, +): ChartScales { + return useMemo( + () => + computeChartScales( + chartData, + seriesMetadata, + hiddenLabels, + indexName, + width, + height, + symmetricalAxes, + isTimeAxis, + margins, + ), + [ + chartData, + seriesMetadata, + hiddenLabels, + indexName, + width, + height, + symmetricalAxes, + isTimeAxis, + margins, + ], + ); } diff --git a/frontend/src/components/execution/values/series/useSpatialIndex.test.ts b/frontend/src/components/execution/values/series/useSpatialIndex.test.ts index e02bf775..9530b32a 100644 --- a/frontend/src/components/execution/values/series/useSpatialIndex.test.ts +++ b/frontend/src/components/execution/values/series/useSpatialIndex.test.ts @@ -2,165 +2,12 @@ import { renderHook } from "@testing-library/react"; import * as d3 from "d3"; import { describe, expect, it } from "vitest"; import type { SeriesMetadata } from "../types"; -import { findTooltipPoints, useSpatialIndex } from "./useSpatialIndex"; - -// Test spatial index logic directly (extracted from the hook) - -interface ChartDataPoint { - [key: string]: number | string | null; -} - -interface IndexedPoint { - seriesIndex: number; - dataIndex: number; - x: number; - y: number; - pixelX: number; - pixelY: number; -} - -interface NearestResult { - point: IndexedPoint; - metadata: SeriesMetadata; - distance: number; -} - -function cellKey(cx: number, cy: number): string { - return `${cx},${cy}`; -} - -function buildSpatialIndex( - chartData: ChartDataPoint[], - seriesMetadata: SeriesMetadata[], - indexName: string, - xScale: d3.ScaleLinear, - yScale: d3.ScaleLinear, -) { - const cellSize = 30; - const cells = new Map(); - - for (const meta of seriesMetadata) { - const key = `series_${meta.seriesIndex}`; - for (let i = 0; i < chartData.length; i++) { - const xVal = chartData[i][indexName]; - const yVal = chartData[i][key]; - if ( - typeof xVal !== "number" || - typeof yVal !== "number" || - !Number.isFinite(yVal) - ) { - continue; - } - - const pixelX = xScale(xVal); - const pixelY = yScale(yVal); - const cx = Math.floor(pixelX / cellSize); - const cy = Math.floor(pixelY / cellSize); - const k = cellKey(cx, cy); - - const point: IndexedPoint = { - seriesIndex: meta.seriesIndex, - dataIndex: i, - x: xVal, - y: yVal, - pixelX, - pixelY, - }; - - const bucket = cells.get(k); - if (bucket) { - bucket.push(point); - } else { - cells.set(k, [point]); - } - } - } - - function findNearest( - pixelX: number, - pixelY: number, - hiddenLabels: Set, - maxDistance = 50, - ): NearestResult | null { - const cx = Math.floor(pixelX / cellSize); - const cy = Math.floor(pixelY / cellSize); - const searchRadius = Math.ceil(maxDistance / cellSize); - - let best: NearestResult | null = null; - let bestDist = maxDistance * maxDistance; - - for (let dx = -searchRadius; dx <= searchRadius; dx++) { - for (let dy = -searchRadius; dy <= searchRadius; dy++) { - const bucket = cells.get(cellKey(cx + dx, cy + dy)); - if (!bucket) continue; - - for (const pt of bucket) { - const meta = seriesMetadata[pt.seriesIndex]; - if (!meta || hiddenLabels.has(meta.label)) continue; - - const distSq = (pt.pixelX - pixelX) ** 2 + (pt.pixelY - pixelY) ** 2; - if (distSq < bestDist) { - bestDist = distSq; - best = { point: pt, metadata: meta, distance: Math.sqrt(distSq) }; - } - } - } - } - - return best; - } - - function findNearestAtX( - pixelX: number, - pixelY: number, - hiddenLabels: Set, - ): NearestResult[] { - const xVal = xScale.invert(pixelX); - let closestDataIdx = 0; - let closestXDist = Number.POSITIVE_INFINITY; - for (let i = 0; i < chartData.length; i++) { - const v = chartData[i][indexName]; - if (typeof v !== "number") continue; - const dist = Math.abs(v - xVal); - if (dist < closestXDist) { - closestXDist = dist; - closestDataIdx = i; - } - } - - const results: NearestResult[] = []; - for (const meta of seriesMetadata) { - if (hiddenLabels.has(meta.label)) continue; - const key = `series_${meta.seriesIndex}`; - const yVal = chartData[closestDataIdx]?.[key]; - if (typeof yVal !== "number" || !Number.isFinite(yVal)) continue; - - const ptPixelX = xScale(chartData[closestDataIdx][indexName] as number); - const ptPixelY = yScale(yVal); - const dist = Math.sqrt( - (ptPixelX - pixelX) ** 2 + (ptPixelY - pixelY) ** 2, - ); - - results.push({ - point: { - seriesIndex: meta.seriesIndex, - dataIndex: closestDataIdx, - x: chartData[closestDataIdx][indexName] as number, - y: yVal, - pixelX: ptPixelX, - pixelY: ptPixelY, - }, - metadata: meta, - distance: dist, - }); - } - - results.sort((a, b) => a.distance - b.distance); - return results; - } - - return { findNearest, findNearestAtX }; -} +import { + createSpatialIndex, + findTooltipPoints, + useSpatialIndex, +} from "./useSpatialIndex"; +import type { ChartDataPoint } from "./utils"; const meta: SeriesMetadata[] = [ { @@ -195,9 +42,9 @@ const chartData: ChartDataPoint[] = [ const xScale = d3.scaleLinear().domain([2020, 2022]).range([0, 600]); const yScale = d3.scaleLinear().domain([0, 6]).range([400, 0]); -describe("buildSpatialIndex", () => { +describe("createSpatialIndex", () => { it("findNearest returns the closest point to cursor", () => { - const index = buildSpatialIndex(chartData, meta, "year", xScale, yScale); + const index = createSpatialIndex(chartData, meta, "year", xScale, yScale); // Pixel position near the first data point of ModelA (2020, 1.0) // pixelX = 0, pixelY = yScale(1.0) = 333.33 const result = index.findNearest(0, 333, new Set()); @@ -208,14 +55,14 @@ describe("buildSpatialIndex", () => { }); it("findNearest returns null when cursor is too far from any point", () => { - const index = buildSpatialIndex(chartData, meta, "year", xScale, yScale); + const index = createSpatialIndex(chartData, meta, "year", xScale, yScale); // Way outside the chart area const result = index.findNearest(-500, -500, new Set(), 10); expect(result).toBeNull(); }); it("findNearest skips hidden series", () => { - const index = buildSpatialIndex(chartData, meta, "year", xScale, yScale); + const index = createSpatialIndex(chartData, meta, "year", xScale, yScale); // At x=2022 (pixel 600), all three series have y=3.0 (pixelY = yScale(3.0) = 200) // Hide ModelA, cursor right on that shared point const hidden = new Set(["ModelA"]); @@ -226,7 +73,7 @@ describe("buildSpatialIndex", () => { }); it("findNearest distinguishes between close series by Y proximity", () => { - const index = buildSpatialIndex(chartData, meta, "year", xScale, yScale); + const index = createSpatialIndex(chartData, meta, "year", xScale, yScale); // At x=2022 (pixel 600), ModelA=3.0 and ModelB=3.0 and Ref=3.0 // They share the same y, so all are at same distance // At x=2021 (pixel 300), ModelA=2.0 (pixelY=266.67), ModelB=4.0 (pixelY=133.33) @@ -239,7 +86,7 @@ describe("buildSpatialIndex", () => { }); it("findNearestAtX returns all visible series at closest X index", () => { - const index = buildSpatialIndex(chartData, meta, "year", xScale, yScale); + const index = createSpatialIndex(chartData, meta, "year", xScale, yScale); // Near x=2021 (pixel ~300) const results = index.findNearestAtX(300, 200, new Set()); expect(results).toHaveLength(3); // ModelA, ModelB, Reference @@ -251,7 +98,7 @@ describe("buildSpatialIndex", () => { }); it("findNearestAtX excludes hidden series", () => { - const index = buildSpatialIndex(chartData, meta, "year", xScale, yScale); + const index = createSpatialIndex(chartData, meta, "year", xScale, yScale); const hidden = new Set(["ModelB"]); const results = index.findNearestAtX(300, 200, hidden); const labels = results.map((r) => r.metadata.label); @@ -260,7 +107,7 @@ describe("buildSpatialIndex", () => { }); it("findNearestAtX sorts by distance to cursor", () => { - const index = buildSpatialIndex(chartData, meta, "year", xScale, yScale); + const index = createSpatialIndex(chartData, meta, "year", xScale, yScale); // Cursor Y near ModelA value at 2021 (y=2.0, pixelY=266.67) const cursorY = yScale(2.0); const results = index.findNearestAtX(300, cursorY, new Set()); @@ -270,9 +117,10 @@ describe("buildSpatialIndex", () => { }); it("handles empty chart data", () => { - const index = buildSpatialIndex([], meta, "year", xScale, yScale); + const index = createSpatialIndex([], meta, "year", xScale, yScale); const result = index.findNearest(100, 100, new Set()); expect(result).toBeNull(); + expect(index.findNearestAtX(100, 100, new Set())).toEqual([]); }); it("handles non-finite values in series data", () => { @@ -285,7 +133,7 @@ describe("buildSpatialIndex", () => { series_2: 3.0, }, ]; - const index = buildSpatialIndex(dataWithNaN, meta, "year", xScale, yScale); + const index = createSpatialIndex(dataWithNaN, meta, "year", xScale, yScale); // Should still find valid points const result = index.findNearest(300, yScale(2.0), new Set()); expect(result).not.toBeNull(); diff --git a/frontend/src/components/execution/values/series/useSpatialIndex.ts b/frontend/src/components/execution/values/series/useSpatialIndex.ts index 4989db3d..c2026303 100644 --- a/frontend/src/components/execution/values/series/useSpatialIndex.ts +++ b/frontend/src/components/execution/values/series/useSpatialIndex.ts @@ -2,7 +2,7 @@ import type * as d3 from "d3"; import { useMemo } from "react"; import type { SeriesMetadata } from "../types"; import type { XScaleType } from "./useChartScales"; -import type { ChartDataPoint } from "./utils"; +import { type ChartDataPoint, plottablePoint } from "./utils"; export interface IndexedPoint { seriesIndex: number; @@ -33,20 +33,12 @@ function buildGrid( const cells = new Map(); for (const meta of seriesMetadata) { - const key = `series_${meta.seriesIndex}`; for (let i = 0; i < chartData.length; i++) { - const xVal = chartData[i][indexName]; - const yVal = chartData[i][key]; - if ( - typeof xVal !== "number" || - typeof yVal !== "number" || - !Number.isFinite(yVal) - ) { - continue; - } + const value = plottablePoint(chartData[i], indexName, meta.seriesIndex); + if (!value) continue; - const pixelX = xScale(xVal); - const pixelY = yScale(yVal); + const pixelX = xScale(value.x); + const pixelY = yScale(value.y); const cx = Math.floor(pixelX / cellSize); const cy = Math.floor(pixelY / cellSize); const k = cellKey(cx, cy); @@ -54,8 +46,8 @@ function buildGrid( const point: IndexedPoint = { seriesIndex: meta.seriesIndex, dataIndex: i, - x: xVal, - y: yVal, + x: value.x, + y: value.y, pixelX, pixelY, }; @@ -114,111 +106,123 @@ export function findTooltipPoints( return { nearest, allAtX }; } -export function useSpatialIndex( +/** A grid of every drawable point, for finding the point nearest the cursor */ +export function createSpatialIndex( chartData: ChartDataPoint[], seriesMetadata: SeriesMetadata[], indexName: string, xScale: XScaleType, yScale: d3.ScaleLinear, ): SpatialIndex { - return useMemo(() => { - const cellSize = 30; - const grid = buildGrid( - chartData, - seriesMetadata, - indexName, - xScale, - yScale, - cellSize, - ); - - function findNearest( - pixelX: number, - pixelY: number, - hiddenLabels: Set, - maxDistance = 50, - ): NearestResult | null { - const cx = Math.floor(pixelX / cellSize); - const cy = Math.floor(pixelY / cellSize); - const searchRadius = Math.ceil(maxDistance / cellSize); - - let best: NearestResult | null = null; - let bestDist = maxDistance * maxDistance; - - for (let dx = -searchRadius; dx <= searchRadius; dx++) { - for (let dy = -searchRadius; dy <= searchRadius; dy++) { - const bucket = grid.cells.get(cellKey(cx + dx, cy + dy)); - if (!bucket) continue; - - for (const pt of bucket) { - const meta = seriesMetadata[pt.seriesIndex]; - if (!meta || hiddenLabels.has(meta.label)) continue; - - const distSq = - (pt.pixelX - pixelX) ** 2 + (pt.pixelY - pixelY) ** 2; - if (distSq < bestDist) { - bestDist = distSq; - best = { point: pt, metadata: meta, distance: Math.sqrt(distSq) }; - } + const cellSize = 30; + const grid = buildGrid( + chartData, + seriesMetadata, + indexName, + xScale, + yScale, + cellSize, + ); + + function findNearest( + pixelX: number, + pixelY: number, + hiddenLabels: Set, + maxDistance = 50, + ): NearestResult | null { + const cx = Math.floor(pixelX / cellSize); + const cy = Math.floor(pixelY / cellSize); + const searchRadius = Math.ceil(maxDistance / cellSize); + + let best: NearestResult | null = null; + let bestDist = maxDistance * maxDistance; + + for (let dx = -searchRadius; dx <= searchRadius; dx++) { + for (let dy = -searchRadius; dy <= searchRadius; dy++) { + const bucket = grid.cells.get(cellKey(cx + dx, cy + dy)); + if (!bucket) continue; + + for (const pt of bucket) { + const meta = seriesMetadata[pt.seriesIndex]; + if (!meta || hiddenLabels.has(meta.label)) continue; + + const distSq = (pt.pixelX - pixelX) ** 2 + (pt.pixelY - pixelY) ** 2; + if (distSq < bestDist) { + bestDist = distSq; + best = { point: pt, metadata: meta, distance: Math.sqrt(distSq) }; } } } - - return best; } - function findNearestAtX( - pixelX: number, - pixelY: number, - hiddenLabels: Set, - ): NearestResult[] { - // Find the closest data index by X pixel position - const xVal = xScale.invert(pixelX) as number; - let closestDataIdx = 0; - let closestXDist = Number.POSITIVE_INFINITY; - for (let i = 0; i < chartData.length; i++) { - const v = chartData[i][indexName]; - if (typeof v !== "number") continue; - const dist = Math.abs(v - xVal); - if (dist < closestXDist) { - closestXDist = dist; - closestDataIdx = i; - } - } + return best; + } - // Collect all visible series values at this data index - const results: NearestResult[] = []; - for (const meta of seriesMetadata) { - if (hiddenLabels.has(meta.label)) continue; - const key = `series_${meta.seriesIndex}`; - const yVal = chartData[closestDataIdx]?.[key]; - if (typeof yVal !== "number" || !Number.isFinite(yVal)) continue; - - const ptPixelX = xScale(chartData[closestDataIdx][indexName] as number); - const ptPixelY = yScale(yVal); - const dist = Math.sqrt( - (ptPixelX - pixelX) ** 2 + (ptPixelY - pixelY) ** 2, - ); - - results.push({ - point: { - seriesIndex: meta.seriesIndex, - dataIndex: closestDataIdx, - x: chartData[closestDataIdx][indexName] as number, - y: yVal, - pixelX: ptPixelX, - pixelY: ptPixelY, - }, - metadata: meta, - distance: dist, - }); + function findNearestAtX( + pixelX: number, + pixelY: number, + hiddenLabels: Set, + ): NearestResult[] { + // Find the closest data index by X pixel position + const xVal = xScale.invert(pixelX) as number; + let closestDataIdx = 0; + let closestXDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < chartData.length; i++) { + const v = chartData[i][indexName]; + if (typeof v !== "number") continue; + const dist = Math.abs(v - xVal); + if (dist < closestXDist) { + closestXDist = dist; + closestDataIdx = i; } + } - // Sort by distance to cursor Y - results.sort((a, b) => a.distance - b.distance); - return results; + // Collect all visible series values at this data index + const row = chartData[closestDataIdx]; + const results: NearestResult[] = []; + for (const meta of seriesMetadata) { + if (!row || hiddenLabels.has(meta.label)) continue; + const value = plottablePoint(row, indexName, meta.seriesIndex); + if (!value) continue; + + const ptPixelX = xScale(value.x); + const ptPixelY = yScale(value.y); + const dist = Math.sqrt( + (ptPixelX - pixelX) ** 2 + (ptPixelY - pixelY) ** 2, + ); + + results.push({ + point: { + seriesIndex: meta.seriesIndex, + dataIndex: closestDataIdx, + x: value.x, + y: value.y, + pixelX: ptPixelX, + pixelY: ptPixelY, + }, + metadata: meta, + distance: dist, + }); } - return { findNearest, findNearestAtX }; - }, [chartData, seriesMetadata, indexName, xScale, yScale]); + // Sort by distance to cursor Y + results.sort((a, b) => a.distance - b.distance); + return results; + } + + return { findNearest, findNearestAtX }; +} + +export function useSpatialIndex( + chartData: ChartDataPoint[], + seriesMetadata: SeriesMetadata[], + indexName: string, + xScale: XScaleType, + yScale: d3.ScaleLinear, +): SpatialIndex { + return useMemo( + () => + createSpatialIndex(chartData, seriesMetadata, indexName, xScale, yScale), + [chartData, seriesMetadata, indexName, xScale, yScale], + ); } diff --git a/frontend/src/components/execution/values/series/utils.test.ts b/frontend/src/components/execution/values/series/utils.test.ts index 64f9835d..07f09d68 100644 --- a/frontend/src/components/execution/values/series/utils.test.ts +++ b/frontend/src/components/execution/values/series/utils.test.ts @@ -5,6 +5,7 @@ import { createChartData, createScaledTickFormatter, getDimensionKeys, + plottablePoint, withLabelsHidden, } from "./utils"; @@ -131,6 +132,23 @@ describe("createScaledTickFormatter", () => { }); }); +describe("plottablePoint", () => { + it("returns the index and value of the series", () => { + const row = { year: 2020, series_0: 1.5, series_1: 2 }; + expect(plottablePoint(row, "year", 1)).toEqual({ x: 2020, y: 2 }); + }); + + it.each([ + ["a missing value", { year: 2020 }], + ["a null value", { year: 2020, series_0: null }], + ["a non-finite value", { year: 2020, series_0: Number.NaN }], + ["a missing index", { series_0: 1 }], + ["a string index", { year: "2020", series_0: 1 }], + ])("returns null for %s", (_, row) => { + expect(plottablePoint(row, "year", 0)).toBeNull(); + }); +}); + describe("withLabelsHidden", () => { it("hides every label", () => { const result = withLabelsHidden(new Set(["a"]), ["b", "c"], true); diff --git a/frontend/src/components/execution/values/series/utils.ts b/frontend/src/components/execution/values/series/utils.ts index 57a007d1..aa5ad417 100644 --- a/frontend/src/components/execution/values/series/utils.ts +++ b/frontend/src/components/execution/values/series/utils.ts @@ -20,6 +20,24 @@ export function isIntegerAxis(indexName: string): boolean { ); } +/** + * The x and y of series `seriesIndex` in `row`, or null when the row has no point to draw + * + * A row has no point when its index or the series' value is missing, or the value is not finite. + */ +export function plottablePoint( + row: ChartDataPoint, + indexName: string, + seriesIndex: number, +): { x: number; y: number } | null { + const x = row[indexName]; + const y = row[`series_${seriesIndex}`]; + if (typeof x !== "number" || typeof y !== "number" || !Number.isFinite(y)) { + return null; + } + return { x, y }; +} + /** * Check if a value looks like an ISO 8601 datetime string */ From f22d5fed2ef21d7cbb85a58727b6e79627198c04 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:27:20 +0200 Subject: [PATCH 20/61] cleanup: share the index value formatter between the x axis and tooltip and test time ticks --- .../execution/values/series/canvasTooltip.tsx | 13 +----- .../execution/values/series/seriesCanvas.tsx | 42 ++----------------- .../execution/values/series/utils.test.ts | 39 +++++++++++++++++ .../execution/values/series/utils.ts | 32 ++++++++++++++ 4 files changed, 77 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/execution/values/series/canvasTooltip.tsx b/frontend/src/components/execution/values/series/canvasTooltip.tsx index 3b3705b1..6213ea2c 100644 --- a/frontend/src/components/execution/values/series/canvasTooltip.tsx +++ b/frontend/src/components/execution/values/series/canvasTooltip.tsx @@ -1,5 +1,5 @@ import type { NearestResult } from "./useSpatialIndex"; -import { isIntegerAxis } from "./utils"; +import { formatIndexValue } from "./utils"; const MAX_TOOLTIP_ENTRIES = 8; @@ -42,16 +42,7 @@ export function formatXValue( minute: "2-digit", }); } - if (Number.isInteger(value) && isIntegerAxis(indexName)) { - return value.toString(); - } - if (Number.isInteger(value)) { - return value.toString(); - } - if (isIntegerAxis(indexName) && Math.abs(value - Math.round(value)) < 1e-9) { - return Math.round(value).toString(); - } - return value.toPrecision(6); + return formatIndexValue(value, indexName); } export function formatYValue(value: number): string { diff --git a/frontend/src/components/execution/values/series/seriesCanvas.tsx b/frontend/src/components/execution/values/series/seriesCanvas.tsx index 13244df8..bbb80191 100644 --- a/frontend/src/components/execution/values/series/seriesCanvas.tsx +++ b/frontend/src/components/execution/values/series/seriesCanvas.tsx @@ -1,10 +1,12 @@ -import * as d3 from "d3"; +import type * as d3 from "d3"; import { memo, useCallback, useEffect, useRef } from "react"; import type { SeriesMetadata } from "../types"; import type { ChartMargins, XScaleType } from "./useChartScales"; import { type ChartDataPoint, createScaledTickFormatter, + formatIndexValue, + formatTimeTick, isIntegerAxis, plottablePoint, } from "./utils"; @@ -290,42 +292,6 @@ function drawGrid( ctx.setLineDash([]); } -function formatXTickValue(value: number, indexName: string): string { - if (isIntegerAxis(indexName) && Number.isInteger(value)) { - return value.toString(); - } - if (Number.isInteger(value)) { - return value.toString(); - } - // For non-integer values on an integer axis, still show as integer if close - if (isIntegerAxis(indexName) && Math.abs(value - Math.round(value)) < 1e-9) { - return Math.round(value).toString(); - } - return value.toPrecision(6); -} - -function formatTimeTick(date: Date): string { - if ( - date.getMonth() === 0 && - date.getDate() === 1 && - date.getHours() === 0 && - date.getMinutes() === 0 - ) { - return d3.timeFormat("%Y")(date); - } - if ( - date.getDate() === 1 && - date.getHours() === 0 && - date.getMinutes() === 0 - ) { - return d3.timeFormat("%b %Y")(date); - } - if (date.getHours() === 0 && date.getMinutes() === 0) { - return d3.timeFormat("%b %d")(date); - } - return d3.timeFormat("%b %d %H:%M")(date); -} - function drawAxes( ctx: CanvasRenderingContext2D, xScale: XScaleType, @@ -399,7 +365,7 @@ function drawAxes( for (const tick of xTicks) { const x = margins.left + xScale(tick); ctx.fillText( - formatXTickValue(tick, indexName), + formatIndexValue(tick, indexName), x, margins.top + innerHeight + 8, ); diff --git a/frontend/src/components/execution/values/series/utils.test.ts b/frontend/src/components/execution/values/series/utils.test.ts index 07f09d68..cdfc11d5 100644 --- a/frontend/src/components/execution/values/series/utils.test.ts +++ b/frontend/src/components/execution/values/series/utils.test.ts @@ -4,6 +4,8 @@ import type { SeriesMetadata, SeriesValue } from "../types"; import { createChartData, createScaledTickFormatter, + formatIndexValue, + formatTimeTick, getDimensionKeys, plottablePoint, withLabelsHidden, @@ -132,6 +134,43 @@ describe("createScaledTickFormatter", () => { }); }); +describe("formatIndexValue", () => { + it("shows integers whole on any axis", () => { + expect(formatIndexValue(2021, "year")).toBe("2021"); + expect(formatIndexValue(850, "pressure")).toBe("850"); + }); + + it("rounds rounding noise on an integer axis", () => { + expect(formatIndexValue(2021 + 1e-12, "year")).toBe("2021"); + }); + + it("keeps six significant digits otherwise", () => { + expect(formatIndexValue(0.1 + 0.2, "latitude")).toBe("0.300000"); + expect(formatIndexValue(2021.5, "year")).toBe("2021.50"); + }); +}); + +describe("formatTimeTick", () => { + // Built in local time, which the ticks are formatted in + it("shows the year on new year", () => { + expect(formatTimeTick(new Date(2020, 0, 1))).toBe("2020"); + }); + + it("shows the month and year on other month starts", () => { + expect(formatTimeTick(new Date(2020, 5, 1))).toBe("Jun 2020"); + }); + + it("shows the month and day on other midnights", () => { + expect(formatTimeTick(new Date(2020, 0, 15))).toBe("Jan 15"); + expect(formatTimeTick(new Date(2020, 5, 5))).toBe("Jun 05"); + }); + + it("shows the time of day when not at midnight", () => { + expect(formatTimeTick(new Date(2020, 0, 1, 6))).toBe("Jan 01 06:00"); + expect(formatTimeTick(new Date(2020, 0, 1, 0, 30))).toBe("Jan 01 00:30"); + }); +}); + describe("plottablePoint", () => { it("returns the index and value of the series", () => { const row = { year: 2020, series_0: 1.5, series_1: 2 }; diff --git a/frontend/src/components/execution/values/series/utils.ts b/frontend/src/components/execution/values/series/utils.ts index aa5ad417..081ac899 100644 --- a/frontend/src/components/execution/values/series/utils.ts +++ b/frontend/src/components/execution/values/series/utils.ts @@ -20,6 +20,38 @@ export function isIntegerAxis(indexName: string): boolean { ); } +/** + * Format a numeric index value, such as an x axis tick + * + * Integers are shown whole, as are values within rounding noise of one on an integer axis. + * Other values get six significant digits. + */ +export function formatIndexValue(value: number, indexName: string): string { + if (Number.isInteger(value)) { + return value.toString(); + } + if (isIntegerAxis(indexName) && Math.abs(value - Math.round(value)) < 1e-9) { + return Math.round(value).toString(); + } + return value.toPrecision(6); +} + +/** + * Format a time axis tick by the coarsest unit it falls on, in local time + * + * New years show the year, other month starts the month and year, + * other midnights the day and anything else the time of day too. + */ +export function formatTimeTick(date: Date): string { + if (date.getHours() !== 0 || date.getMinutes() !== 0) { + return d3.timeFormat("%b %d %H:%M")(date); + } + if (date.getDate() !== 1) { + return d3.timeFormat("%b %d")(date); + } + return d3.timeFormat(date.getMonth() === 0 ? "%Y" : "%b %Y")(date); +} + /** * The x and y of series `seriesIndex` in `row`, or null when the row has no point to draw * From de3466dc08851e34eed4bd39f298ca345ff9f978 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:28:57 +0200 Subject: [PATCH 21/61] cleanup: move series draw order, stroke styles and x axis ticks out of the canvas drawing --- .../values/series/seriesCanvas.test.ts | 158 ++++++++++++++++++ .../execution/values/series/seriesCanvas.tsx | 146 +++++++++------- 2 files changed, 245 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/execution/values/series/seriesCanvas.test.ts diff --git a/frontend/src/components/execution/values/series/seriesCanvas.test.ts b/frontend/src/components/execution/values/series/seriesCanvas.test.ts new file mode 100644 index 00000000..6eea9e4b --- /dev/null +++ b/frontend/src/components/execution/values/series/seriesCanvas.test.ts @@ -0,0 +1,158 @@ +import * as d3 from "d3"; +import { describe, expect, it } from "vitest"; +import type { SeriesMetadata } from "../types"; +import { + orderSeriesForDraw, + seriesLineColor, + seriesStroke, + xAxisTicks, +} from "./seriesCanvas"; + +function series(label: string, isReference = false): SeriesMetadata { + return { + seriesIndex: 0, + label, + color: "#4e79a7", + isReference, + dimensions: {}, + }; +} + +const labels = (metadata: SeriesMetadata[]) => metadata.map((m) => m.label); + +describe("seriesLineColor", () => { + it("uses the series colour for regular series", () => { + expect(seriesLineColor(series("a"), false)).toBe("#4e79a7"); + expect(seriesLineColor(series("a"), true)).toBe("#4e79a7"); + }); + + it("contrasts reference series with the background", () => { + expect(seriesLineColor(series("ref", true), false)).toBe("#000000"); + expect(seriesLineColor(series("ref", true), true)).toBe("#FFFFFF"); + }); +}); + +describe("orderSeriesForDraw", () => { + it("draws hidden series first and the hovered series last", () => { + const metadata = [series("hovered"), series("hidden"), series("plain")]; + const ordered = orderSeriesForDraw( + metadata, + new Set(["hidden"]), + "hovered", + ); + expect(labels(ordered)).toEqual(["hidden", "plain", "hovered"]); + }); + + it("draws reference series above regular ones", () => { + const metadata = [series("ref", true), series("a"), series("b")]; + const ordered = orderSeriesForDraw(metadata, new Set(), null); + expect(labels(ordered)).toEqual(["a", "b", "ref"]); + }); + + it("draws a hovered regular series above the reference", () => { + const metadata = [series("a"), series("ref", true)]; + const ordered = orderSeriesForDraw(metadata, new Set(), "a"); + expect(labels(ordered)).toEqual(["ref", "a"]); + }); + + it("orders hidden series by hover and reference too", () => { + const metadata = [ + series("hovered"), + series("ref", true), + series("a"), + series("visible"), + ]; + const hidden = new Set(["hovered", "ref", "a"]); + const ordered = orderSeriesForDraw(metadata, hidden, "hovered"); + expect(labels(ordered)).toEqual(["a", "ref", "hovered", "visible"]); + }); + + it("keeps the given order otherwise and does not change it", () => { + const metadata = [series("b"), series("a"), series("c")]; + expect(labels(orderSeriesForDraw(metadata, new Set(), null))).toEqual([ + "b", + "a", + "c", + ]); + expect(labels(metadata)).toEqual(["b", "a", "c"]); + }); +}); + +describe("seriesStroke", () => { + const plain = { hidden: false, hovered: false }; + + it("draws hidden series faint and grey, even when hovered", () => { + const hidden = { hidden: true, hovered: true }; + expect(seriesStroke(series("a"), hidden, false)).toEqual({ + color: "#D1D5DB", + width: 0.5, + alpha: 0.15, + }); + expect(seriesStroke(series("ref", true), hidden, true).color).toBe( + "#4B5563", + ); + }); + + it("draws regular series thin and slightly transparent", () => { + expect(seriesStroke(series("a"), plain, false)).toEqual({ + color: "#4e79a7", + width: 1.5, + alpha: 0.8, + }); + }); + + it("draws reference series bolder in the background's contrast colour", () => { + expect(seriesStroke(series("ref", true), plain, true)).toEqual({ + color: "#FFFFFF", + width: 3, + alpha: 1, + }); + }); + + it("draws the hovered series thicker and opaque", () => { + const hovered = { hidden: false, hovered: true }; + expect(seriesStroke(series("a"), hovered, false)).toEqual({ + color: "#4e79a7", + width: 3.5, + alpha: 1, + }); + expect(seriesStroke(series("ref", true), hovered, false).width).toBe(5); + }); +}); + +describe("xAxisTicks", () => { + it("leaves out fractional ticks on an integer axis", () => { + const xScale = d3.scaleLinear().domain([2020, 2022]).range([0, 200]); + expect(xAxisTicks(xScale, "year", false)).toEqual([ + { position: 0, label: "2020" }, + { position: 100, label: "2021" }, + { position: 200, label: "2022" }, + ]); + }); + + it("keeps fractional ticks on other axes", () => { + const xScale = d3.scaleLinear().domain([0, 1]).range([0, 100]); + const ticks = xAxisTicks(xScale, "latitude", false); + expect(ticks).toHaveLength(11); + expect(ticks[1]).toEqual({ position: 10, label: "0.100000" }); + }); + + it("labels time ticks as dates", () => { + // Built in local time, which the ticks are formatted in + const xScale = d3 + .scaleTime() + .domain([new Date(2020, 0, 1), new Date(2025, 0, 1)]) + .range([0, 500]); + const ticks = xAxisTicks(xScale, "time", true); + expect(ticks.map((t) => t.label)).toEqual([ + "2020", + "2021", + "2022", + "2023", + "2024", + "2025", + ]); + expect(ticks[0].position).toBe(0); + expect(ticks[5].position).toBe(500); + }); +}); diff --git a/frontend/src/components/execution/values/series/seriesCanvas.tsx b/frontend/src/components/execution/values/series/seriesCanvas.tsx index bbb80191..2fc65c3a 100644 --- a/frontend/src/components/execution/values/series/seriesCanvas.tsx +++ b/frontend/src/components/execution/values/series/seriesCanvas.tsx @@ -19,6 +19,44 @@ export function seriesLineColor(meta: SeriesMetadata, isDark: boolean): string { return isDark ? "#FFFFFF" : "#000000"; } +/** + * The series in the order their lines are drawn, so later lines sit on top + * + * Hidden series go first and the hovered series last, + * with reference series above regular ones within each group. + */ +export function orderSeriesForDraw( + seriesMetadata: SeriesMetadata[], + hiddenLabels: ReadonlySet, + hoveredLabel: string | null, +): SeriesMetadata[] { + const layer = (meta: SeriesMetadata) => + (hiddenLabels.has(meta.label) ? 0 : 4) + + (meta.label === hoveredLabel ? 2 : 0) + + (meta.isReference ? 1 : 0); + return [...seriesMetadata].sort((a, b) => layer(a) - layer(b)); +} + +/** How a series' line is stroked: faint grey when hidden, thicker when hovered, references bolder */ +export function seriesStroke( + meta: SeriesMetadata, + state: { hidden: boolean; hovered: boolean }, + isDark: boolean, +): { color: string; width: number; alpha: number } { + if (state.hidden) { + return { color: isDark ? "#4B5563" : "#D1D5DB", width: 0.5, alpha: 0.15 }; + } + const color = seriesLineColor(meta, isDark); + if (state.hovered) { + return { color, width: meta.isReference ? 5 : 3.5, alpha: 1 }; + } + return { + color, + width: meta.isReference ? 3 : 1.5, + alpha: meta.isReference ? 1 : 0.8, + }; +} + export interface CrosshairPosition { dataPixelX: number; nearestPixelX: number; @@ -102,40 +140,23 @@ export const SeriesCanvas = memo(function SeriesCanvas({ // Draw grid drawGrid(ctx, xScale, yScale, innerWidth, innerHeight, isDark); - // Draw lines: hidden first, then visible, hovered last const hoveredLabel = hoveredLabelRef.current; - const sortedMeta = [...seriesMetadata].sort((a, b) => { - const aHidden = hiddenLabels.has(a.label); - const bHidden = hiddenLabels.has(b.label); - if (aHidden && !bHidden) return -1; - if (!aHidden && bHidden) return 1; - const aHovered = a.label === hoveredLabel; - const bHovered = b.label === hoveredLabel; - if (aHovered && !bHovered) return 1; - if (!aHovered && bHovered) return -1; - // Reference series on top of regular - if (a.isReference && !b.isReference) return 1; - if (!a.isReference && b.isReference) return -1; - return 0; - }); - - for (const meta of sortedMeta) { - const isHidden = hiddenLabels.has(meta.label); - const isHovered = meta.label === hoveredLabel; - - if (isHidden) { - ctx.strokeStyle = isDark ? "#4B5563" : "#D1D5DB"; - ctx.lineWidth = 0.5; - ctx.globalAlpha = 0.15; - } else if (isHovered) { - ctx.strokeStyle = seriesLineColor(meta, isDark); - ctx.lineWidth = meta.isReference ? 5 : 3.5; - ctx.globalAlpha = 1; - } else { - ctx.strokeStyle = seriesLineColor(meta, isDark); - ctx.lineWidth = meta.isReference ? 3 : 1.5; - ctx.globalAlpha = meta.isReference ? 1 : 0.8; - } + for (const meta of orderSeriesForDraw( + seriesMetadata, + hiddenLabels, + hoveredLabel, + )) { + const stroke = seriesStroke( + meta, + { + hidden: hiddenLabels.has(meta.label), + hovered: meta.label === hoveredLabel, + }, + isDark, + ); + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.globalAlpha = stroke.alpha; ctx.beginPath(); let started = false; @@ -292,6 +313,31 @@ function drawGrid( ctx.setLineDash([]); } +/** The x axis ticks as pixel positions and labels, leaving out fractional ticks on an integer axis */ +export function xAxisTicks( + xScale: XScaleType, + indexName: string, + isTimeAxis: boolean, +): { position: number; label: string }[] { + if (isTimeAxis) { + return (xScale as d3.ScaleTime) + .ticks(10) + .map((tick) => ({ position: xScale(tick), label: formatTimeTick(tick) })); + } + const ticks = (xScale as d3.ScaleLinear).ticks(10); + return ( + isIntegerAxis(indexName) ? ticks.filter((t) => Number.isInteger(t)) : ticks + ).map((tick) => ({ + position: xScale(tick), + label: formatIndexValue(tick, indexName), + })); +} + +/** An axis title, with its units when there are any */ +function axisTitle(name: string, units?: string): string { + return units ? `${name} (${units})` : name; +} + function drawAxes( ctx: CanvasRenderingContext2D, xScale: XScaleType, @@ -335,10 +381,11 @@ function drawAxes( ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.font = "14px system-ui, sans-serif"; - const baseName = metricName || "Value"; - const yLabel = - units && units !== "unitless" ? `${baseName} (${units})` : baseName; - ctx.fillText(yLabel, 0, 0); + ctx.fillText( + axisTitle(metricName || "Value", units === "unitless" ? undefined : units), + 0, + 0, + ); ctx.restore(); // X axis @@ -351,35 +398,16 @@ function drawAxes( ctx.lineTo(margins.left + innerWidth, margins.top + innerHeight); ctx.stroke(); - if (isTimeAxis) { - const timeTicks = (xScale as d3.ScaleTime).ticks(10); - for (const tick of timeTicks) { - const x = margins.left + xScale(tick); - ctx.fillText(formatTimeTick(tick), x, margins.top + innerHeight + 8); - } - } else { - const xTicksRaw = (xScale as d3.ScaleLinear).ticks(10); - const xTicks = isIntegerAxis(indexName) - ? xTicksRaw.filter((t) => Number.isInteger(t)) - : xTicksRaw; - for (const tick of xTicks) { - const x = margins.left + xScale(tick); - ctx.fillText( - formatIndexValue(tick, indexName), - x, - margins.top + innerHeight + 8, - ); - } + for (const { position, label } of xAxisTicks(xScale, indexName, isTimeAxis)) { + ctx.fillText(label, margins.left + position, margins.top + innerHeight + 8); } // X axis label. Units don't apply to time axes (already rendered as dates). ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.font = "14px system-ui, sans-serif"; - const xLabel = - !isTimeAxis && indexUnits ? `${indexName} (${indexUnits})` : indexName; ctx.fillText( - xLabel, + axisTitle(indexName, isTimeAxis ? undefined : indexUnits), margins.left + innerWidth / 2, margins.top + innerHeight + 30, ); From 45086dd31706132bf345936fe6d62c020c6a188c Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:30:10 +0200 Subject: [PATCH 22/61] cleanup: build the tooltip's differences from the reference and rank in a tested function --- .../values/series/canvasTooltip.test.ts | 58 ++++++++ .../execution/values/series/canvasTooltip.tsx | 127 +++++++++++------- 2 files changed, 135 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/execution/values/series/canvasTooltip.test.ts b/frontend/src/components/execution/values/series/canvasTooltip.test.ts index de9e1677..843b6159 100644 --- a/frontend/src/components/execution/values/series/canvasTooltip.test.ts +++ b/frontend/src/components/execution/values/series/canvasTooltip.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildTooltipModel, formatDelta, formatXValue, formatYValue, @@ -67,6 +68,63 @@ describe("otherTooltipEntries", () => { }); }); +describe("buildTooltipModel", () => { + /** A point of series `seriesIndex` with value `y` */ + function valued(seriesIndex: number, y: number, isReference = false) { + const result = entry(seriesIndex); + return { + ...result, + point: { ...result.point, y }, + metadata: { ...result.metadata, isReference }, + }; + } + + it("gives each series its difference from the reference", () => { + const nearest = valued(0, 5); + const reference = valued(1, 3, true); + const other = valued(2, 1); + const model = buildTooltipModel(nearest, [nearest, reference, other]); + expect(model.delta).toBe(2); + expect( + model.others.map((other) => [ + other.entry.metadata.seriesIndex, + other.delta, + ]), + ).toEqual([ + [1, null], + [2, -2], + ]); + }); + + it("gives no differences without a reference", () => { + const allAtX = [valued(0, 5), valued(1, 3)]; + const model = buildTooltipModel(allAtX[0], allAtX); + expect(model.delta).toBeNull(); + expect(model.others[0].delta).toBeNull(); + }); + + it("gives the reference itself no difference", () => { + const reference = valued(0, 3, true); + const model = buildTooltipModel(reference, [reference, valued(1, 4)]); + expect(model.delta).toBeNull(); + expect(model.others[0].delta).toBe(1); + }); + + it("ranks the nearest series by value, highest first", () => { + const allAtX = [valued(0, 2), valued(1, 9), valued(2, 5)]; + expect(buildTooltipModel(allAtX[0], allAtX).rank).toBe(3); + expect(buildTooltipModel(allAtX[1], allAtX).rank).toBe(1); + expect(buildTooltipModel(allAtX[2], allAtX).rank).toBe(2); + }); + + it("counts the series left out of the list", () => { + const allAtX = entries(10); + const model = buildTooltipModel(allAtX[0], allAtX); + expect(model.others).toHaveLength(7); + expect(model.remaining).toBe(2); + }); +}); + describe("formatXValue", () => { it("shows integer years without decimals", () => { expect(formatXValue(2021, "year")).toBe("2021"); diff --git a/frontend/src/components/execution/values/series/canvasTooltip.tsx b/frontend/src/components/execution/values/series/canvasTooltip.tsx index 6213ea2c..7170447d 100644 --- a/frontend/src/components/execution/values/series/canvasTooltip.tsx +++ b/frontend/src/components/execution/values/series/canvasTooltip.tsx @@ -83,6 +83,50 @@ export function otherTooltipEntries( return { others, remaining: candidates.length - others.length }; } +interface TooltipModel { + /** The series listed under the nearest one, with their difference from the reference */ + others: { entry: NearestResult; delta: number | null }[]; + /** How many series are left out of the list */ + remaining: number; + /** The nearest series' difference from the reference */ + delta: number | null; + /** Where the nearest series ranks among the series at its x, highest value first */ + rank: number; +} + +/** + * What the tooltip shows for the series at the nearest point's x + * + * A difference from the reference is null when there is no reference at this x + * or the series is a reference itself. + */ +export function buildTooltipModel( + nearest: NearestResult, + allAtX: NearestResult[], +): TooltipModel { + const { others, remaining } = otherTooltipEntries(allAtX, nearest); + + const referenceValue = + allAtX.find((r) => r.metadata.isReference)?.point.y ?? null; + const deltaOf = ({ point, metadata }: NearestResult) => + referenceValue !== null && !metadata.isReference + ? point.y - referenceValue + : null; + + const sortedByValue = [...allAtX].sort((a, b) => b.point.y - a.point.y); + const rank = + sortedByValue.findIndex( + (r) => r.metadata.seriesIndex === nearest.metadata.seriesIndex, + ) + 1; + + return { + others: others.map((entry) => ({ entry, delta: deltaOf(entry) })), + remaining, + delta: deltaOf(nearest), + rank, + }; +} + export function CanvasTooltip({ visible, x, @@ -98,18 +142,7 @@ export function CanvasTooltip({ return null; } - const { others, remaining } = otherTooltipEntries(allAtX, nearest); - - // Find reference value at this X for delta computation - const referenceEntry = allAtX.find((r) => r.metadata.isReference); - const referenceValue = referenceEntry?.point.y ?? null; - - // Compute rank of nearest among all visible series (sorted by value descending) - const sortedByValue = [...allAtX].sort((a, b) => b.point.y - a.point.y); - const nearestRank = - sortedByValue.findIndex( - (r) => r.metadata.seriesIndex === nearest.metadata.seriesIndex, - ) + 1; + const { others, remaining, delta, rank } = buildTooltipModel(nearest, allAtX); // Position tooltip to avoid going off-screen const tooltipWidth = 320; @@ -158,20 +191,20 @@ export function CanvasTooltip({ )} - {referenceValue !== null && !nearest.metadata.isReference && ( + {delta !== null && ( = 0 + delta >= 0 ? "text-red-600 dark:text-red-400" : "text-blue-600 dark:text-blue-400" }`} > - {formatDelta(nearest.point.y - referenceValue)} vs ref + {formatDelta(delta)} vs ref )} {allAtX.length > 1 && ( - {ordinal(nearestRank)} of {allAtX.length} + {ordinal(rank)} of {allAtX.length} )}
@@ -191,42 +224,36 @@ export function CanvasTooltip({ {/* Other series at this X position */} {others.length > 0 && (
- {others.map(({ point, metadata }) => { - const delta = - referenceValue !== null && !metadata.isReference - ? point.y - referenceValue - : null; - return ( + {others.map(({ entry: { point, metadata }, delta }) => ( +
-
- - {metadata.label} - - - {formatYValue(point.y)} + className="w-2 h-2 rounded-full flex-shrink-0" + style={{ + backgroundColor: metadata.color, + }} + /> + + {metadata.label} + + + {formatYValue(point.y)} + + {delta !== null && ( + = 0 + ? "text-red-600/60 dark:text-red-400/60" + : "text-blue-600/60 dark:text-blue-400/60" + }`} + > + {formatDelta(delta)} - {delta !== null && ( - = 0 - ? "text-red-600/60 dark:text-red-400/60" - : "text-blue-600/60 dark:text-blue-400/60" - }`} - > - {formatDelta(delta)} - - )} -
- ); - })} + )} +
+ ))}
)} From a59a54e10210d12cd3234b592e34cc04a07e1c1e Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:32:06 +0200 Subject: [PATCH 23/61] cleanup: move legend items, label dimensions and solo hiding out of SeriesVisualization into tested functions --- .../execution/values/series/seriesLegend.tsx | 8 +- .../values/series/seriesVisualization.tsx | 83 +++++------------ .../execution/values/series/utils.test.ts | 89 +++++++++++++++++++ .../execution/values/series/utils.ts | 73 +++++++++++++++ 4 files changed, 185 insertions(+), 68 deletions(-) diff --git a/frontend/src/components/execution/values/series/seriesLegend.tsx b/frontend/src/components/execution/values/series/seriesLegend.tsx index 7859683e..481dfb68 100644 --- a/frontend/src/components/execution/values/series/seriesLegend.tsx +++ b/frontend/src/components/execution/values/series/seriesLegend.tsx @@ -7,13 +7,7 @@ import { } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; - -export interface LegendItem { - label: string; - color: string; - count: number; - isReference: boolean; -} +import type { LegendItem } from "./utils"; interface SeriesLegendProps { uniqueLabels: LegendItem[]; diff --git a/frontend/src/components/execution/values/series/seriesVisualization.tsx b/frontend/src/components/execution/values/series/seriesVisualization.tsx index ea150cee..92a1d64f 100644 --- a/frontend/src/components/execution/values/series/seriesVisualization.tsx +++ b/frontend/src/components/execution/values/series/seriesVisualization.tsx @@ -15,7 +15,15 @@ import { SeriesLegend } from "./seriesLegend"; import { useChartScales } from "./useChartScales"; import type { NearestResult } from "./useSpatialIndex"; import { findTooltipPoints, useSpatialIndex } from "./useSpatialIndex"; -import { createChartData, getDimensionKeys, withLabelsHidden } from "./utils"; +import { + createChartData, + getDimensionKeys, + hiddenLabelsWithSolo, + labelDimensions, + legendItems, + toggleInSet, + withLabelsHidden, +} from "./utils"; const REFERENCE_MISMATCH_ISSUE = "https://github.com/Climate-REF/climate-ref/issues/927"; @@ -113,65 +121,26 @@ export function SeriesVisualization({ [seriesMetadata], ); - // Map from label to its dimensions - const labelDimensionMap = useMemo(() => { - const map = new Map>(); - for (const meta of seriesMetadata) { - if (!map.has(meta.label)) { - map.set(meta.label, meta.dimensions); - } - } - return map; - }, [seriesMetadata]); + const labelDimensionMap = useMemo( + () => labelDimensions(seriesMetadata), + [seriesMetadata], + ); // State for hidden labels const [hiddenLabels, setHiddenLabels] = useState>( () => new Set(), ); - // Get unique labels with their colors and counts - const uniqueLabels = useMemo(() => { - const labelMap = new Map< - string, - { color: string; count: number; isReference: boolean } - >(); - for (const meta of seriesMetadata) { - const existing = labelMap.get(meta.label); - if (existing) { - existing.count += 1; - } else { - labelMap.set(meta.label, { - color: meta.color, - count: 1, - isReference: meta.isReference, - }); - } - } - return Array.from(labelMap.entries()) - .map(([label, { color, count, isReference }]) => ({ - label, - color, - count, - isReference, - })) - .sort((a, b) => { - if (a.isReference && !b.isReference) return -1; - if (!a.isReference && b.isReference) return 1; - return a.label.localeCompare(b.label); - }); - }, [seriesMetadata]); + const uniqueLabels = useMemo( + () => legendItems(seriesMetadata), + [seriesMetadata], + ); // Effective hidden labels accounting for solo mode - const effectiveHiddenLabels = useMemo(() => { - if (!soloedLabel) return hiddenLabels; - const hidden = new Set(); - for (const item of uniqueLabels) { - if (item.label !== soloedLabel && !item.isReference) { - hidden.add(item.label); - } - } - return hidden; - }, [soloedLabel, hiddenLabels, uniqueLabels]); + const effectiveHiddenLabels = useMemo( + () => hiddenLabelsWithSolo(hiddenLabels, soloedLabel, uniqueLabels), + [soloedLabel, hiddenLabels, uniqueLabels], + ); // Compute scales const { xScale, yScale, innerWidth, innerHeight, margins } = useChartScales( @@ -201,15 +170,7 @@ export function SeriesVisualization({ setSoloedLabel(null); return; } - setHiddenLabels((prev) => { - const newSet = new Set(prev); - if (newSet.has(label)) { - newSet.delete(label); - } else { - newSet.add(label); - } - return newSet; - }); + setHiddenLabels((prev) => toggleInSet(prev, label)); }, [soloedLabel], ); diff --git a/frontend/src/components/execution/values/series/utils.test.ts b/frontend/src/components/execution/values/series/utils.test.ts index cdfc11d5..1d8604db 100644 --- a/frontend/src/components/execution/values/series/utils.test.ts +++ b/frontend/src/components/execution/values/series/utils.test.ts @@ -7,7 +7,12 @@ import { formatIndexValue, formatTimeTick, getDimensionKeys, + hiddenLabelsWithSolo, + type LegendItem, + labelDimensions, + legendItems, plottablePoint, + toggleInSet, withLabelsHidden, } from "./utils"; @@ -888,3 +893,87 @@ describe("getDimensionKeys", () => { expect(getDimensionKeys(metadata)).toEqual(["source_id"]); }); }); + +/** Series metadata for `label`, with any fields overridden */ +function meta( + seriesIndex: number, + label: string, + options: Partial = {}, +): SeriesMetadata { + return { + seriesIndex, + label, + color: `#00000${seriesIndex}`, + isReference: false, + dimensions: {}, + ...options, + }; +} + +/** A legend item for `label` */ +function item(label: string, isReference = false): LegendItem { + return { label, color: "#000000", count: 1, isReference }; +} + +describe("legendItems", () => { + it("counts the series sharing a label and keeps the first colour", () => { + const items = legendItems([meta(0, "A"), meta(1, "B"), meta(2, "A")]); + expect(items).toEqual([ + { label: "A", color: "#000000", count: 2, isReference: false }, + { label: "B", color: "#000001", count: 1, isReference: false }, + ]); + }); + + it("puts references first and orders the rest by label", () => { + const items = legendItems([ + meta(0, "b"), + meta(1, "Ref", { isReference: true }), + meta(2, "a"), + ]); + expect(items.map((i) => i.label)).toEqual(["Ref", "a", "b"]); + }); +}); + +describe("labelDimensions", () => { + it("maps each label to the dimensions of its first series", () => { + const map = labelDimensions([ + meta(0, "A", { dimensions: { source_id: "first" } }), + meta(1, "A", { dimensions: { source_id: "second" } }), + meta(2, "B", { dimensions: { source_id: "other" } }), + ]); + expect(map).toEqual( + new Map([ + ["A", { source_id: "first" }], + ["B", { source_id: "other" }], + ]), + ); + }); +}); + +describe("hiddenLabelsWithSolo", () => { + const items = [item("Ref", true), item("A"), item("B"), item("C")]; + + it("keeps the hidden labels when not soloing", () => { + const hidden = new Set(["A"]); + expect(hiddenLabelsWithSolo(hidden, null, items)).toBe(hidden); + }); + + it("hides all but the soloed label and references while soloing", () => { + expect(hiddenLabelsWithSolo(new Set(["B"]), "B", items)).toEqual( + new Set(["A", "C"]), + ); + }); +}); + +describe("toggleInSet", () => { + it("adds a missing value and removes a present one", () => { + expect(toggleInSet(new Set(["a"]), "b")).toEqual(new Set(["a", "b"])); + expect(toggleInSet(new Set(["a", "b"]), "a")).toEqual(new Set(["b"])); + }); + + it("does not change the given set", () => { + const set = new Set(["a"]); + toggleInSet(set, "a"); + expect(set).toEqual(new Set(["a"])); + }); +}); diff --git a/frontend/src/components/execution/values/series/utils.ts b/frontend/src/components/execution/values/series/utils.ts index 081ac899..d06b2823 100644 --- a/frontend/src/components/execution/values/series/utils.ts +++ b/frontend/src/components/execution/values/series/utils.ts @@ -433,6 +433,79 @@ export function getDimensionKeys(seriesMetadata: SeriesMetadata[]): string[] { return Array.from(keys).sort(); } +/** A label in the legend, standing for every series that shares it */ +export interface LegendItem { + label: string; + color: string; + count: number; + isReference: boolean; +} + +/** + * One legend item per label, references first and the rest by label + * + * A label takes its colour from the first series with that label. + */ +export function legendItems(seriesMetadata: SeriesMetadata[]): LegendItem[] { + const items = new Map(); + for (const meta of seriesMetadata) { + const existing = items.get(meta.label); + if (existing) { + existing.count += 1; + } else { + items.set(meta.label, { + label: meta.label, + color: meta.color, + count: 1, + isReference: meta.isReference, + }); + } + } + return Array.from(items.values()).sort( + (a, b) => + Number(b.isReference) - Number(a.isReference) || + a.label.localeCompare(b.label), + ); +} + +/** Each label's dimensions, taken from the first series with that label */ +export function labelDimensions( + seriesMetadata: SeriesMetadata[], +): Map> { + const map = new Map>(); + for (const meta of seriesMetadata) { + if (!map.has(meta.label)) { + map.set(meta.label, meta.dimensions); + } + } + return map; +} + +/** The labels hidden from the chart: all but the soloed label and references while soloing, otherwise `hiddenLabels` */ +export function hiddenLabelsWithSolo( + hiddenLabels: Set, + soloedLabel: string | null, + items: LegendItem[], +): Set { + if (!soloedLabel) return hiddenLabels; + return new Set( + items + .filter((item) => item.label !== soloedLabel && !item.isReference) + .map((item) => item.label), + ); +} + +/** A copy of `set` with `value` removed when it is in the set, and added otherwise */ +export function toggleInSet(set: ReadonlySet, value: T): Set { + const next = new Set(set); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + return next; +} + /** A copy of `hiddenLabels` with every one of `labels` hidden, or shown when `hidden` is false */ export function withLabelsHidden( hiddenLabels: ReadonlySet, From 6f140a6eb85276a7a4f052c53e7077593ac3508e Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:33:48 +0200 Subject: [PATCH 24/61] cleanup: share the legend entry markup and move legend grouping into a tested function --- .../execution/values/series/seriesLegend.tsx | 78 ++++++------------- .../execution/values/series/utils.test.ts | 35 +++++++++ .../execution/values/series/utils.ts | 24 ++++++ 3 files changed, 83 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/execution/values/series/seriesLegend.tsx b/frontend/src/components/execution/values/series/seriesLegend.tsx index 481dfb68..ff1d66e4 100644 --- a/frontend/src/components/execution/values/series/seriesLegend.tsx +++ b/frontend/src/components/execution/values/series/seriesLegend.tsx @@ -7,7 +7,7 @@ import { } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; -import type { LegendItem } from "./utils"; +import { groupLegendItems, type LegendItem, toggleInSet } from "./utils"; interface SeriesLegendProps { uniqueLabels: LegendItem[]; @@ -56,37 +56,16 @@ export const SeriesLegend = memo(function SeriesLegend({ ); }, [uniqueLabels, searchQuery]); - // Group labels by dimension value - const groupedLabels = useMemo(() => { - if (!groupByDimension) { - return null; - } - - const groups = new Map(); - for (const item of filteredLabels) { - const dims = labelDimensionMap.get(item.label); - const groupKey = dims?.[groupByDimension] ?? "Unknown"; - const group = groups.get(groupKey); - if (group) { - group.push(item); - } else { - groups.set(groupKey, [item]); - } - } - - return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b)); - }, [filteredLabels, groupByDimension, labelDimensionMap]); + const groupedLabels = useMemo( + () => + groupByDimension + ? groupLegendItems(filteredLabels, groupByDimension, labelDimensionMap) + : null, + [filteredLabels, groupByDimension, labelDimensionMap], + ); const toggleGroupCollapse = useCallback((groupKey: string) => { - setCollapsedGroups((prev) => { - const next = new Set(prev); - if (next.has(groupKey)) { - next.delete(groupKey); - } else { - next.add(groupKey); - } - return next; - }); + setCollapsedGroups((prev) => toggleInSet(prev, groupKey)); }, []); const toggleGroupVisibility = useCallback( @@ -105,6 +84,19 @@ export const SeriesLegend = memo(function SeriesLegend({ (item) => !hiddenLabels.has(item.label), ).length; + const renderEntry = (item: LegendItem) => ( + onToggleLabel(item.label)} + onHover={onHoverLabel} + onSolo={() => onSoloLabel(item.label)} + /> + ); + return (
{/* Search */} @@ -221,18 +213,7 @@ export const SeriesLegend = memo(function SeriesLegend({
- {items.map((item) => ( - onToggleLabel(item.label)} - onHover={onHoverLabel} - onSolo={() => onSoloLabel(item.label)} - /> - ))} + {items.map(renderEntry)}
@@ -241,18 +222,7 @@ export const SeriesLegend = memo(function SeriesLegend({
) : (
- {filteredLabels.map((item) => ( - onToggleLabel(item.label)} - onHover={onHoverLabel} - onSolo={() => onSoloLabel(item.label)} - /> - ))} + {filteredLabels.map(renderEntry)}
)} diff --git a/frontend/src/components/execution/values/series/utils.test.ts b/frontend/src/components/execution/values/series/utils.test.ts index 1d8604db..03b0d636 100644 --- a/frontend/src/components/execution/values/series/utils.test.ts +++ b/frontend/src/components/execution/values/series/utils.test.ts @@ -7,6 +7,7 @@ import { formatIndexValue, formatTimeTick, getDimensionKeys, + groupLegendItems, hiddenLabelsWithSolo, type LegendItem, labelDimensions, @@ -950,6 +951,40 @@ describe("labelDimensions", () => { }); }); +describe("groupLegendItems", () => { + const dims = new Map>([ + ["A", { experiment_id: "ssp585" }], + ["B", { experiment_id: "historical" }], + ["C", { experiment_id: "ssp585" }], + ["D", {}], + ]); + + it("groups items by their label's dimension value, ordered by value", () => { + const groups = groupLegendItems( + [item("A"), item("B"), item("C")], + "experiment_id", + dims, + ); + expect( + groups.map(([key, items]) => [key, items.map((i) => i.label)]), + ).toEqual([ + ["historical", ["B"]], + ["ssp585", ["A", "C"]], + ]); + }); + + it("groups items without the dimension under Unknown", () => { + const groups = groupLegendItems( + [item("D"), item("E")], + "experiment_id", + dims, + ); + expect( + groups.map(([key, items]) => [key, items.map((i) => i.label)]), + ).toEqual([["Unknown", ["D", "E"]]]); + }); +}); + describe("hiddenLabelsWithSolo", () => { const items = [item("Ref", true), item("A"), item("B"), item("C")]; diff --git a/frontend/src/components/execution/values/series/utils.ts b/frontend/src/components/execution/values/series/utils.ts index d06b2823..25155a19 100644 --- a/frontend/src/components/execution/values/series/utils.ts +++ b/frontend/src/components/execution/values/series/utils.ts @@ -481,6 +481,30 @@ export function labelDimensions( return map; } +/** + * Group legend items by their label's value of `dimension`, ordered by that value + * + * Items whose label has no value for the dimension are grouped under "Unknown". + */ +export function groupLegendItems( + items: LegendItem[], + dimension: string, + labelDimensionMap: Map>, +): [string, LegendItem[]][] { + const groups = new Map(); + for (const item of items) { + const groupKey = + labelDimensionMap.get(item.label)?.[dimension] ?? "Unknown"; + const group = groups.get(groupKey); + if (group) { + group.push(item); + } else { + groups.set(groupKey, [item]); + } + } + return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b)); +} + /** The labels hidden from the chart: all but the soloed label and references while soloing, otherwise `hiddenLabels` */ export function hiddenLabelsWithSolo( hiddenLabels: Set, From 0e82306d87657e89bfe39c48a23941c185a53f3e Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:34:56 +0200 Subject: [PATCH 25/61] fix: colour tooltip dots like the series lines so reference dots show on the dark background --- ...Tooltip.test.ts => canvasTooltip.test.tsx} | 49 +++++++++++++++++++ .../execution/values/series/canvasTooltip.tsx | 7 ++- .../values/series/seriesVisualization.tsx | 1 + 3 files changed, 55 insertions(+), 2 deletions(-) rename frontend/src/components/execution/values/series/{canvasTooltip.test.ts => canvasTooltip.test.tsx} (84%) diff --git a/frontend/src/components/execution/values/series/canvasTooltip.test.ts b/frontend/src/components/execution/values/series/canvasTooltip.test.tsx similarity index 84% rename from frontend/src/components/execution/values/series/canvasTooltip.test.ts rename to frontend/src/components/execution/values/series/canvasTooltip.test.tsx index 843b6159..f4d883b1 100644 --- a/frontend/src/components/execution/values/series/canvasTooltip.test.ts +++ b/frontend/src/components/execution/values/series/canvasTooltip.test.tsx @@ -1,6 +1,8 @@ +import { render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { buildTooltipModel, + CanvasTooltip, formatDelta, formatXValue, formatYValue, @@ -125,6 +127,53 @@ describe("buildTooltipModel", () => { }); }); +describe("CanvasTooltip", () => { + /** The background colours of the tooltip's series dots, nearest first */ + function dotColors( + nearest: NearestResult, + allAtX: NearestResult[], + isDark: boolean, + ) { + const { container } = render( + , + ); + return Array.from( + container.querySelectorAll(".rounded-full"), + (dot) => dot.style.backgroundColor, + ); + } + + const reference: NearestResult = { + ...entry(1), + metadata: { ...entry(1).metadata, color: "#000000", isReference: true }, + }; + + it("colours reference dots to contrast the dark background", () => { + expect(dotColors(reference, [reference, entry(0)], true)).toEqual([ + "rgb(255, 255, 255)", + "rgb(78, 121, 167)", + ]); + expect(dotColors(entry(0), [entry(0), reference], true)).toEqual([ + "rgb(78, 121, 167)", + "rgb(255, 255, 255)", + ]); + }); + + it("colours reference dots black on the light background", () => { + expect(dotColors(reference, [reference], false)).toEqual(["rgb(0, 0, 0)"]); + }); +}); + describe("formatXValue", () => { it("shows integer years without decimals", () => { expect(formatXValue(2021, "year")).toBe("2021"); diff --git a/frontend/src/components/execution/values/series/canvasTooltip.tsx b/frontend/src/components/execution/values/series/canvasTooltip.tsx index 7170447d..a5c7752b 100644 --- a/frontend/src/components/execution/values/series/canvasTooltip.tsx +++ b/frontend/src/components/execution/values/series/canvasTooltip.tsx @@ -1,3 +1,4 @@ +import { seriesLineColor } from "./seriesCanvas"; import type { NearestResult } from "./useSpatialIndex"; import { formatIndexValue } from "./utils"; @@ -12,6 +13,7 @@ interface CanvasTooltipProps { containerWidth: number; indexName: string; isTimeAxis: boolean; + isDark: boolean; units?: string; } @@ -136,6 +138,7 @@ export function CanvasTooltip({ containerWidth, indexName, isTimeAxis, + isDark, units, }: CanvasTooltipProps) { if (!visible || !nearest || allAtX.length === 0) { @@ -173,7 +176,7 @@ export function CanvasTooltip({
@@ -232,7 +235,7 @@ export function CanvasTooltip({
diff --git a/frontend/src/components/execution/values/series/seriesVisualization.tsx b/frontend/src/components/execution/values/series/seriesVisualization.tsx index 92a1d64f..c41aa8a9 100644 --- a/frontend/src/components/execution/values/series/seriesVisualization.tsx +++ b/frontend/src/components/execution/values/series/seriesVisualization.tsx @@ -414,6 +414,7 @@ export function SeriesVisualization({ containerWidth={containerWidth} indexName={indexName} isTimeAxis={isTimeAxis} + isDark={isDark} units={effectiveUnits} />
From d73eff5618ac037f219d38393673fcb948db9078 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:37:28 +0200 Subject: [PATCH 26/61] fix: colour legend swatches like the series lines so reference swatches show in dark mode --- .../execution/values/series/seriesCanvas.tsx | 5 +- .../values/series/seriesLegend.test.tsx | 51 +++++++++++++++++++ .../execution/values/series/seriesLegend.tsx | 9 +++- .../values/series/seriesVisualization.tsx | 1 + 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/execution/values/series/seriesLegend.test.tsx diff --git a/frontend/src/components/execution/values/series/seriesCanvas.tsx b/frontend/src/components/execution/values/series/seriesCanvas.tsx index 2fc65c3a..367e39d1 100644 --- a/frontend/src/components/execution/values/series/seriesCanvas.tsx +++ b/frontend/src/components/execution/values/series/seriesCanvas.tsx @@ -14,7 +14,10 @@ import { const Y_TICK_COUNT = 8; /** The colour a series' line is drawn in, with reference lines contrasting the background */ -export function seriesLineColor(meta: SeriesMetadata, isDark: boolean): string { +export function seriesLineColor( + meta: Pick, + isDark: boolean, +): string { if (!meta.isReference) return meta.color; return isDark ? "#FFFFFF" : "#000000"; } diff --git a/frontend/src/components/execution/values/series/seriesLegend.test.tsx b/frontend/src/components/execution/values/series/seriesLegend.test.tsx new file mode 100644 index 00000000..7ff3f44a --- /dev/null +++ b/frontend/src/components/execution/values/series/seriesLegend.test.tsx @@ -0,0 +1,51 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SeriesLegend } from "./seriesLegend"; +import type { LegendItem } from "./utils"; + +const items: LegendItem[] = [ + { label: "Reference", color: "#000000", count: 1, isReference: true }, + { label: "CanESM5", color: "#4e79a7", count: 1, isReference: false }, +]; + +/** The background colours of the legend's swatches, in the order they are listed */ +function swatchColors(isDark: boolean, hidden: string[] = []) { + const noop = () => {}; + const { container } = render( + , + ); + return Array.from( + container.querySelectorAll(".rounded-sm.w-3"), + (swatch) => swatch.style.backgroundColor, + ); +} + +describe("SeriesLegend", () => { + it("colours reference swatches like the reference line in each theme", () => { + expect(swatchColors(false)).toEqual(["rgb(0, 0, 0)", "rgb(78, 121, 167)"]); + expect(swatchColors(true)).toEqual([ + "rgb(255, 255, 255)", + "rgb(78, 121, 167)", + ]); + }); + + it("greys out hidden series", () => { + expect(swatchColors(true, ["Reference"])[0]).toBe("rgb(156, 163, 175)"); + }); +}); diff --git a/frontend/src/components/execution/values/series/seriesLegend.tsx b/frontend/src/components/execution/values/series/seriesLegend.tsx index ff1d66e4..f1fab88f 100644 --- a/frontend/src/components/execution/values/series/seriesLegend.tsx +++ b/frontend/src/components/execution/values/series/seriesLegend.tsx @@ -7,6 +7,7 @@ import { } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { seriesLineColor } from "./seriesCanvas"; import { groupLegendItems, type LegendItem, toggleInSet } from "./utils"; interface SeriesLegendProps { @@ -25,6 +26,8 @@ interface SeriesLegendProps { onGroupByDimensionChange: (dimension: string | null) => void; availableDimensions: string[]; labelDimensionMap: Map>; + /** Reference series are drawn in the theme's foreground colour */ + isDark: boolean; } export const SeriesLegend = memo(function SeriesLegend({ @@ -42,6 +45,7 @@ export const SeriesLegend = memo(function SeriesLegend({ onGroupByDimensionChange, availableDimensions, labelDimensionMap, + isDark, }: SeriesLegendProps) { const [searchQuery, setSearchQuery] = useState(""); const [collapsedGroups, setCollapsedGroups] = useState>( @@ -91,6 +95,7 @@ export const SeriesLegend = memo(function SeriesLegend({ isHidden={hiddenLabels.has(item.label)} isHovered={hoveredLabel === item.label} isSoloed={soloedLabel === item.label} + isDark={isDark} onToggle={() => onToggleLabel(item.label)} onHover={onHoverLabel} onSolo={() => onSoloLabel(item.label)} @@ -235,6 +240,7 @@ function LegendEntry({ isHidden, isHovered, isSoloed, + isDark, onToggle, onHover, onSolo, @@ -243,6 +249,7 @@ function LegendEntry({ isHidden: boolean; isHovered: boolean; isSoloed: boolean; + isDark: boolean; onToggle: () => void; onHover: (label: string | null) => void; onSolo: () => void; @@ -269,7 +276,7 @@ function LegendEntry({
diff --git a/frontend/src/components/execution/values/series/seriesVisualization.tsx b/frontend/src/components/execution/values/series/seriesVisualization.tsx index c41aa8a9..ed3c6567 100644 --- a/frontend/src/components/execution/values/series/seriesVisualization.tsx +++ b/frontend/src/components/execution/values/series/seriesVisualization.tsx @@ -437,6 +437,7 @@ export function SeriesVisualization({ onGroupByDimensionChange={setGroupByDimension} availableDimensions={availableDimensions} labelDimensionMap={labelDimensionMap} + isDark={isDark} /> )}
From cbba74b8d3a230f0baead9c53d7214b8cf0cb89c Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:24:43 +0200 Subject: [PATCH 27/61] cleanup: merge scalar and series data tables into a generic ValuesDataTable --- .../execution/values/orderFacets.test.ts | 41 ++++ .../execution/values/orderFacets.ts | 12 + .../execution/values/scalarDataTable.tsx | 212 ++++-------------- .../execution/values/seriesDataTable.tsx | 171 ++------------ .../execution/values/valuesDataTable.tsx | 153 +++++++++++++ 5 files changed, 273 insertions(+), 316 deletions(-) create mode 100644 frontend/src/components/execution/values/orderFacets.test.ts create mode 100644 frontend/src/components/execution/values/orderFacets.ts create mode 100644 frontend/src/components/execution/values/valuesDataTable.tsx diff --git a/frontend/src/components/execution/values/orderFacets.test.ts b/frontend/src/components/execution/values/orderFacets.test.ts new file mode 100644 index 00000000..71d1067a --- /dev/null +++ b/frontend/src/components/execution/values/orderFacets.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { orderFacets } from "./orderFacets"; +import type { Facet } from "./types"; + +const facets = (...keys: string[]): Facet[] => + keys.map((key) => ({ key, values: [] })); + +const keysOf = (ordered: Facet[]) => ordered.map((facet) => facet.key); + +describe("orderFacets", () => { + it("keeps the order when no keys go last", () => { + expect( + keysOf(orderFacets(facets("statistic", "source_id", "metric"))), + ).toEqual(["statistic", "source_id", "metric"]); + }); + + it("moves the named keys last, keeping their relative order", () => { + const ordered = orderFacets( + facets("statistic", "source_id", "metric", "region"), + ["metric", "statistic"], + ); + expect(keysOf(ordered)).toEqual([ + "source_id", + "region", + "statistic", + "metric", + ]); + }); + + it("ignores named keys that are not facets", () => { + expect(keysOf(orderFacets(facets("source_id"), ["metric"]))).toEqual([ + "source_id", + ]); + }); + + it("does not change the input", () => { + const input = facets("metric", "source_id"); + orderFacets(input, ["metric"]); + expect(keysOf(input)).toEqual(["metric", "source_id"]); + }); +}); diff --git a/frontend/src/components/execution/values/orderFacets.ts b/frontend/src/components/execution/values/orderFacets.ts new file mode 100644 index 00000000..3cbc30bd --- /dev/null +++ b/frontend/src/components/execution/values/orderFacets.ts @@ -0,0 +1,12 @@ +import type { Facet } from "./types"; + +/** + * Move the facets named in `lastKeys` to the end, keeping the order of both parts. + */ +export function orderFacets(facets: Facet[], lastKeys: string[] = []): Facet[] { + const isLast = (facet: Facet) => lastKeys.includes(facet.key); + return [ + ...facets.filter((facet) => !isLast(facet)), + ...facets.filter(isLast), + ]; +} diff --git a/frontend/src/components/execution/values/scalarDataTable.tsx b/frontend/src/components/execution/values/scalarDataTable.tsx index d7ae1574..d1448132 100644 --- a/frontend/src/components/execution/values/scalarDataTable.tsx +++ b/frontend/src/components/execution/values/scalarDataTable.tsx @@ -1,172 +1,48 @@ -import { Link } from "@tanstack/react-router"; -import { - type ColumnDef, - getCoreRowModel, - getFilteredRowModel, - getSortedRowModel, - type OnChangeFn, - type RowSelectionState, - type SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { AlertTriangle, Eye, MoreHorizontal } from "lucide-react"; -import { useMemo, useState } from "react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { AlertTriangle } from "lucide-react"; import { DataTableColumnHeader } from "@/components/dataTable/columnHeader.tsx"; -import { InnerDataTable } from "@/components/dataTable/innerDataTable.tsx"; -import type { Facet, ScalarValue } from "@/components/execution/values/types"; -import { Button } from "@/components/ui/button.tsx"; -import { Checkbox } from "@/components/ui/checkbox.tsx"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu.tsx"; - -// Update to use ProcessedMetricValue which includes rowId -type ProcessedMetricValue = ScalarValue & { rowId: string }; - -interface DataTableProps { - values: ProcessedMetricValue[]; - facets: Facet[]; - loading: boolean; - rowSelection: RowSelectionState; - setRowSelection: OnChangeFn; -} - -function ScalarDataTable({ - values, - facets, - loading, - rowSelection, - setRowSelection, -}: DataTableProps) { - const columns: ColumnDef[] = useMemo(() => { - // Order so that "metric" and "statistic" are last closest to the value column - const sortedFacets = [...facets].sort((a, b) => { - const specialKeys = ["metric", "statistic"]; - const aIsSpecial = specialKeys.includes(a.key); - const bIsSpecial = specialKeys.includes(b.key); - if (aIsSpecial && !bIsSpecial) return 1; - if (!aIsSpecial && bIsSpecial) return -1; - - return 0; - }); - const indexColumns: ColumnDef[] = sortedFacets.map( - (facet) => ({ - id: facet.key, - header: ({ column }) => ( - - ), - accessorFn: (cell: ProcessedMetricValue) => cell.dimensions[facet.key], - }), - ); - - return [ - { - id: "select", - header: ({ table }) => ( - - table.toggleAllPageRowsSelected(!!value) - } - aria-label="Select all" - className="translate-y-[2px]" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-[2px]" - /> - ), - enableSorting: false, - enableHiding: false, - }, - ...indexColumns, - { - id: "value", - header: ({ column }) => ( - - ), - accessorFn: (cell) => cell.value, - cell: (cell) => { - const value = (cell.row.original.value as number).toPrecision(3); - const isOutlier = cell.row.original?.is_outlier === true; - return ( -
- {value} - {isOutlier && ( - - - - )} -
- ); - }, - enableSorting: true, - }, - { - id: "info", - cell: (cell) => ( - - - - - - - - - View Details - - - - - ), - }, - ]; - }, [facets]); - - const [sorting, setSorting] = useState([]); - const table = useReactTable({ - data: values, - columns, - getCoreRowModel: getCoreRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - onRowSelectionChange: setRowSelection, - state: { - sorting, - rowSelection, +import type { ProcessedScalarValue } from "@/hooks/useValuesProcessor"; +import { ValuesDataTable, type ValuesTableProps } from "./valuesDataTable.tsx"; + +const valueColumns: ColumnDef[] = [ + { + id: "value", + header: ({ column }) => ( + + ), + accessorFn: (cell) => cell.value, + cell: (cell) => { + const value = (cell.row.original.value as number).toPrecision(3); + const isOutlier = cell.row.original?.is_outlier === true; + return ( +
+ {value} + {isOutlier && ( + + + + )} +
+ ); }, - getRowId: (row) => row.rowId, - }); - - if (loading && values.length === 0) { - return
Loading table data...
; - } - - return ; + enableSorting: true, + }, +]; + +// "metric" and "statistic" go last, closest to the value column +const trailingFacetKeys = ["metric", "statistic"]; + +function ScalarDataTable(props: ValuesTableProps) { + return ( + + ); } + export default ScalarDataTable; diff --git a/frontend/src/components/execution/values/seriesDataTable.tsx b/frontend/src/components/execution/values/seriesDataTable.tsx index 6a54974c..d37c42cf 100644 --- a/frontend/src/components/execution/values/seriesDataTable.tsx +++ b/frontend/src/components/execution/values/seriesDataTable.tsx @@ -1,155 +1,30 @@ -import { Link } from "@tanstack/react-router"; -import { - type ColumnDef, - getCoreRowModel, - getFilteredRowModel, - getSortedRowModel, - type OnChangeFn, - type RowSelectionState, - type SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { Eye, MoreHorizontal } from "lucide-react"; -import { useMemo, useState } from "react"; +import type { ColumnDef } from "@tanstack/react-table"; import { DataTableColumnHeader } from "@/components/dataTable/columnHeader.tsx"; -import { InnerDataTable } from "@/components/dataTable/innerDataTable.tsx"; -import type { Facet, SeriesValue } from "@/components/execution/values/types"; -import { Button } from "@/components/ui/button.tsx"; -import { Checkbox } from "@/components/ui/checkbox.tsx"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu.tsx"; +import type { ProcessedSeriesValue } from "@/hooks/useValuesProcessor"; import { Sparkline } from "./sparkline"; - -// Update to use ProcessedSeriesValue which includes rowId -type ProcessedSeriesValue = SeriesValue & { rowId: string }; - -interface SeriesDataTableProps { - values: ProcessedSeriesValue[]; - facets: Facet[]; - loading: boolean; - rowSelection: RowSelectionState; - setRowSelection: OnChangeFn; -} - -function SeriesDataTable({ - values, - facets, - loading, - rowSelection, - setRowSelection, -}: SeriesDataTableProps) { - const columns: ColumnDef[] = useMemo(() => { - const indexColumns: ColumnDef[] = facets.map( - (facet) => ({ - id: facet.key, - header: ({ column }) => ( - - ), - accessorFn: (cell: ProcessedSeriesValue) => cell.dimensions[facet.key], - }), - ); - - return [ - { - id: "select", - header: ({ table }) => ( - - table.toggleAllPageRowsSelected(!!value) - } - aria-label="Select all" - className="translate-y-[2px]" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-[2px]" - /> - ), - enableSorting: false, - enableHiding: false, - }, - ...indexColumns, - { - id: "sparkline", - header: ({ column }) => ( - - ), - accessorFn: (cell) => cell.values, - cell: (cell) => { - const values = cell.row.original.values; - return ( -
- value !== null)} /> -
- ); - }, - enableSorting: false, // Can't sort by series data easily - }, - { - id: "info", - cell: (cell) => ( - - - - - - - - - View Details - - - - - ), - }, - ]; - }, [facets]); - - const [sorting, setSorting] = useState([]); - const table = useReactTable({ - data: values, - columns, - getCoreRowModel: getCoreRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - onRowSelectionChange: setRowSelection, - state: { - sorting, - rowSelection, +import { ValuesDataTable, type ValuesTableProps } from "./valuesDataTable.tsx"; + +const valueColumns: ColumnDef[] = [ + { + id: "sparkline", + header: ({ column }) => ( + + ), + accessorFn: (cell) => cell.values, + cell: (cell) => { + const values = cell.row.original.values; + return ( +
+ value !== null)} /> +
+ ); }, - getRowId: (row) => row.rowId, - }); - - if (loading && values.length === 0) { - return
Loading table data...
; - } + enableSorting: false, // Can't sort by series data easily + }, +]; - return ; +function SeriesDataTable(props: ValuesTableProps) { + return ; } export default SeriesDataTable; diff --git a/frontend/src/components/execution/values/valuesDataTable.tsx b/frontend/src/components/execution/values/valuesDataTable.tsx new file mode 100644 index 00000000..f6863236 --- /dev/null +++ b/frontend/src/components/execution/values/valuesDataTable.tsx @@ -0,0 +1,153 @@ +import { Link } from "@tanstack/react-router"; +import { + type ColumnDef, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + type OnChangeFn, + type RowSelectionState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { Eye, MoreHorizontal } from "lucide-react"; +import { useMemo, useState } from "react"; +import { DataTableColumnHeader } from "@/components/dataTable/columnHeader.tsx"; +import { InnerDataTable } from "@/components/dataTable/innerDataTable.tsx"; +import type { Facet } from "@/components/execution/values/types"; +import { Button } from "@/components/ui/button.tsx"; +import { Checkbox } from "@/components/ui/checkbox.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu.tsx"; +import type { + ProcessedScalarValue, + ProcessedSeriesValue, +} from "@/hooks/useValuesProcessor"; +import { orderFacets } from "./orderFacets"; + +type ProcessedRow = ProcessedScalarValue | ProcessedSeriesValue; + +export interface ValuesTableProps { + values: T[]; + facets: Facet[]; + loading: boolean; + rowSelection: RowSelectionState; + setRowSelection: OnChangeFn; +} + +interface ValuesDataTableProps + extends ValuesTableProps { + /** Columns between the facets and the row menu, showing the values themselves */ + valueColumns: ColumnDef[]; + /** Facets shown last, next to the value columns */ + trailingFacetKeys?: string[]; +} + +/** A selectable table of metric values with a column per facet and a link to each execution. */ +export function ValuesDataTable({ + values, + facets, + loading, + rowSelection, + setRowSelection, + valueColumns, + trailingFacetKeys, +}: ValuesDataTableProps) { + const columns: ColumnDef[] = useMemo(() => { + const indexColumns: ColumnDef[] = orderFacets( + facets, + trailingFacetKeys, + ).map((facet) => ({ + id: facet.key, + header: ({ column }) => ( + + ), + accessorFn: (cell: T) => cell.dimensions[facet.key], + })); + + return [ + { + id: "select", + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + }, + ...indexColumns, + ...valueColumns, + { + id: "info", + cell: (cell) => ( + + + + + + + + + View Details + + + + + ), + }, + ]; + }, [facets, valueColumns, trailingFacetKeys]); + + const [sorting, setSorting] = useState([]); + const table = useReactTable({ + data: values, + columns, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setRowSelection, + state: { + sorting, + rowSelection, + }, + getRowId: (row) => row.rowId, + }); + + if (loading && values.length === 0) { + return
Loading table data...
; + } + + return ; +} From 75b89192ebd7b8e7bb26b64eec885e0f1d963d37 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:27:23 +0200 Subject: [PATCH 28/61] cleanup: drop unused metric values state and move search/filter conversion to lib/valuesSearch --- .../execution/values/filterAddPopover.tsx | 6 +- .../components/execution/values/values.tsx | 25 +- frontend/src/hooks/useMetricValues.test.ts | 144 ++-------- frontend/src/hooks/useMetricValues.ts | 248 ++---------------- frontend/src/lib/metricFilters.test.ts | 24 ++ frontend/src/lib/metricFilters.ts | 9 + frontend/src/lib/valuesSearch.test.ts | 112 ++++++++ frontend/src/lib/valuesSearch.ts | 92 +++++++ .../scalars.tsx | 3 +- .../series.tsx | 4 +- .../_app/executions.$groupId/scalar.tsx | 2 +- .../_app/executions.$groupId/series.tsx | 2 +- 12 files changed, 286 insertions(+), 385 deletions(-) create mode 100644 frontend/src/lib/metricFilters.test.ts create mode 100644 frontend/src/lib/metricFilters.ts create mode 100644 frontend/src/lib/valuesSearch.test.ts create mode 100644 frontend/src/lib/valuesSearch.ts diff --git a/frontend/src/components/execution/values/filterAddPopover.tsx b/frontend/src/components/execution/values/filterAddPopover.tsx index 4016ecdd..31b2732c 100644 --- a/frontend/src/components/execution/values/filterAddPopover.tsx +++ b/frontend/src/components/execution/values/filterAddPopover.tsx @@ -23,6 +23,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select.tsx"; +import { parseCommaList } from "@/lib/metricFilters"; import { cn } from "@/lib/utils"; interface FilterAddPopoverProps extends ComponentProps { @@ -245,10 +246,7 @@ export function FilterAddPopover({ variant="outline" disabled={idText.trim().length === 0 || !onAddIds} onClick={() => { - const ids = idText - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + const ids = parseCommaList(idText); if (onAddIds && ids.length > 0) { onAddIds(idMode, new Set(ids)); setIdText(""); diff --git a/frontend/src/components/execution/values/values.tsx b/frontend/src/components/execution/values/values.tsx index 14f02bf1..ec043ac9 100644 --- a/frontend/src/components/execution/values/values.tsx +++ b/frontend/src/components/execution/values/values.tsx @@ -1,5 +1,5 @@ import { Download } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { PaginationControls } from "@/components/dataTable/paginationControls.tsx"; import { Button } from "@/components/ui/button.tsx"; import { Card, CardContent } from "@/components/ui/card.tsx"; @@ -30,14 +30,6 @@ type ValuesProps = { onDownload?: () => void; initialFilters?: Filter[]; onFiltersChange?: (filters: Filter[]) => void; - // Callback to expose current grouping configuration - onCurrentGroupingChange?: (config: { - groupBy?: string; - hue?: string; - style?: string; - }) => void; - // Callback to expose filtered data - onFilteredDataChange?: (filteredData: (ScalarValue | SeriesValue)[]) => void; // Outlier detection parameters hadOutliers?: boolean; outlierCount?: number; @@ -84,26 +76,15 @@ export function Values({ valueType, ...props }: ValuesProps) { onFiltersChange: props.onFiltersChange, }); - // Notify parent component when filtered data changes - useEffect(() => { - if (props.onFilteredDataChange) { - props.onFilteredDataChange(finalDisplayedValues); - } - }, [finalDisplayedValues, props.onFilteredDataChange]); - // Handle outlier control changes const handleDetectOutliersChange = (value: "off" | "iqr") => { setDetectOutliers(value); - if (props.onDetectOutliersChange) { - props.onDetectOutliersChange(value); - } + props.onDetectOutliersChange?.(value); }; const handleIncludeUnverifiedChange = (value: boolean) => { setIncludeUnverified(value); - if (props.onIncludeUnverifiedChange) { - props.onIncludeUnverifiedChange(value); - } + props.onIncludeUnverifiedChange?.(value); }; // Outlier banner diff --git a/frontend/src/hooks/useMetricValues.test.ts b/frontend/src/hooks/useMetricValues.test.ts index 3648967c..61621664 100644 --- a/frontend/src/hooks/useMetricValues.test.ts +++ b/frontend/src/hooks/useMetricValues.test.ts @@ -2,7 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook } from "@testing-library/react"; import { createElement, type ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_PAGE_SIZE, useMetricValues } from "./useMetricValues"; +import { DEFAULT_PAGE_SIZE } from "@/lib/valuesSearch"; +import { useMetricValues } from "./useMetricValues"; // Mock useQuery to return controlled data without a real backend vi.mock("@tanstack/react-query", async () => { @@ -230,46 +231,6 @@ describe("useMetricValues", () => { }); }); - describe("filter extraction", () => { - it("excludes pagination params from currentFilters", () => { - const options = createDefaultOptions({ - offset: 50, - limit: 25, - model: "cesm2", - }); - const { result } = renderHook(() => useMetricValues(options), { - wrapper: createWrapper(), - }); - - expect(result.current.currentFilters).toHaveProperty("model", "cesm2"); - expect(result.current.currentFilters).not.toHaveProperty("offset"); - expect(result.current.currentFilters).not.toHaveProperty("limit"); - expect(result.current.currentFilters).not.toHaveProperty( - "detect_outliers", - ); - expect(result.current.currentFilters).not.toHaveProperty( - "include_unverified", - ); - }); - - it("excludes UI params from currentFilters", () => { - const options = createDefaultOptions({ - tab: "series", - groupBy: "model", - hue: "experiment", - style: "variant", - }); - const { result } = renderHook(() => useMetricValues(options), { - wrapper: createWrapper(), - }); - - expect(result.current.currentFilters).not.toHaveProperty("tab"); - expect(result.current.currentFilters).not.toHaveProperty("groupBy"); - expect(result.current.currentFilters).not.toHaveProperty("hue"); - expect(result.current.currentFilters).not.toHaveProperty("style"); - }); - }); - describe("API query construction", () => { it("passes offset and limit to fetchQueryOptions", () => { const options = createDefaultOptions({ offset: 25, limit: 100 }); @@ -310,57 +271,24 @@ describe("useMetricValues", () => { }); describe("initialFilters", () => { - it("builds facet filters from search params", () => { - const options = createDefaultOptions({ model: "cesm2,gfdl" }); - const { result } = renderHook(() => useMetricValues(options), { - wrapper: createWrapper(), - }); - - const facetFilter = result.current.initialFilters.find( - (f: any) => f.type === "facet" && f.facetKey === "model", - ); - expect(facetFilter).toBeDefined(); - expect(facetFilter.values).toEqual(["cesm2", "gfdl"]); - }); - - it("parses isolate_ids from search params", () => { - const options = createDefaultOptions({ isolate_ids: "1,2,3" }); - const { result } = renderHook(() => useMetricValues(options), { - wrapper: createWrapper(), - }); - - const isolateFilter = result.current.initialFilters.find( - (f: any) => f.type === "isolate", - ); - expect(isolateFilter).toBeDefined(); - expect(isolateFilter.ids).toEqual(new Set(["1", "2", "3"])); - }); - - it("parses exclude_ids from search params", () => { - const options = createDefaultOptions({ exclude_ids: "4,5" }); - const { result } = renderHook(() => useMetricValues(options), { - wrapper: createWrapper(), + it("builds the filters from the search params", () => { + const options = createDefaultOptions({ + model: "cesm2,gfdl", + isolate_ids: "1,2", }); - - const excludeFilter = result.current.initialFilters.find( - (f: any) => f.type === "exclude", - ); - expect(excludeFilter).toBeDefined(); - expect(excludeFilter.ids).toEqual(new Set(["4", "5"])); - }); - - it("does not create id filters when params are absent", () => { - const options = createDefaultOptions(); const { result } = renderHook(() => useMetricValues(options), { wrapper: createWrapper(), }); - expect( - result.current.initialFilters.filter((f: any) => f.type === "isolate"), - ).toHaveLength(0); - expect( - result.current.initialFilters.filter((f: any) => f.type === "exclude"), - ).toHaveLength(0); + expect(result.current.initialFilters).toEqual([ + { + type: "facet", + id: expect.any(String), + facetKey: "model", + values: ["cesm2", "gfdl"], + }, + { type: "isolate", id: expect.any(String), ids: new Set(["1", "2"]) }, + ]); }); }); @@ -399,48 +327,6 @@ describe("useMetricValues", () => { ); }); - it("includes isolate_ids in search params", () => { - const options = createDefaultOptions(); - const { result } = renderHook(() => useMetricValues(options), { - wrapper: createWrapper(), - }); - - act(() => { - result.current.handlers.onFiltersChange([ - { type: "isolate", id: "1", ids: new Set(["10", "20"]) }, - ]); - }); - - expect(options.navigate).toHaveBeenCalledWith( - expect.objectContaining({ - search: expect.objectContaining({ - isolate_ids: "10,20", - }), - }), - ); - }); - - it("includes exclude_ids in search params", () => { - const options = createDefaultOptions(); - const { result } = renderHook(() => useMetricValues(options), { - wrapper: createWrapper(), - }); - - act(() => { - result.current.handlers.onFiltersChange([ - { type: "exclude", id: "1", ids: new Set(["30"]) }, - ]); - }); - - expect(options.navigate).toHaveBeenCalledWith( - expect.objectContaining({ - search: expect.objectContaining({ - exclude_ids: "30", - }), - }), - ); - }); - it("preserves detect_outliers and include_unverified across filter changes", () => { const options = createDefaultOptions({ detect_outliers: "off", diff --git a/frontend/src/hooks/useMetricValues.ts b/frontend/src/hooks/useMetricValues.ts index 439f625d..d559dac4 100644 --- a/frontend/src/hooks/useMetricValues.ts +++ b/frontend/src/hooks/useMetricValues.ts @@ -1,19 +1,19 @@ import { useQuery } from "@tanstack/react-query"; import type { useNavigate } from "@tanstack/react-router"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { diagnosticsListMetricValues } from "@/client"; -import type { - MetricValueCollection, - ScalarValue, - SeriesValue, -} from "@/components/execution/values/types.ts"; +import type { MetricValueCollection } from "@/components/execution/values/types.ts"; +import type { MetricFilter } from "@/components/explorer/types"; +import { + DEFAULT_PAGE_SIZE, + filtersToSearch, + searchToFilters, +} from "@/lib/valuesSearch"; // Define flexible API function types using any for compatibility type FetchQueryOptions = (options: any) => any; type FetchDownloadFunction = (options: any) => Promise; -export const DEFAULT_PAGE_SIZE = 50; - interface UseValuesTabOptions< TPath extends Record = Record, > { @@ -29,26 +29,19 @@ interface UseValuesTabOptions< getDownloadFilename: (pathParams: TPath, valueType: string) => string; } -// Parameters that should not be treated as facet filters -const EXCLUDED_FROM_FACET_FILTERS = [ - "tab", - "groupBy", - "hue", - "style", - "detect_outliers", - "include_unverified", - "mip_era", - "isolate_ids", - "exclude_ids", - "offset", - "limit", - "execution_id", -]; - // Parameters that should not be passed to the API query // (offset and limit ARE passed to the API, so they are not excluded here) const EXCLUDED_FROM_API = ["tab", "groupBy", "hue", "style"]; +/** The search params passed on to the API, without the view params and unset params. */ +function apiQuery(search: Record): Record { + return Object.fromEntries( + Object.entries(search).filter( + ([key, value]) => value !== undefined && !EXCLUDED_FROM_API.includes(key), + ), + ); +} + /** * Shared hook for values tabs (series and scalars). * Handles data fetching, filter management, and CSV downloads. @@ -62,177 +55,26 @@ export function useMetricValues>({ fetchDownload = diagnosticsListMetricValues, getDownloadFilename, }: UseValuesTabOptions) { - // Extract search values to prevent unnecessary callback recreation - const { - tab: searchTab, - groupBy: searchGroupBy, - hue: searchHue, - style: searchStyle, - detect_outliers: searchDetectOutliers, - include_unverified: searchIncludeUnverified, - mip_era: searchMipEra, - limit: searchLimit, - } = search; + const query = apiQuery(search); // Fetch metric values using the provided query options function const { data: metricValues, isLoading } = useQuery( fetchQueryOptions({ path: pathParams, - query: { - ...Object.fromEntries( - Object.entries(search).filter( - ([key, value]) => - value !== undefined && !EXCLUDED_FROM_API.includes(key), - ), - ), - value_type: valueType, - }, + query: { ...query, value_type: valueType }, }), ); - // Extract current filters (excluding UI params and ID filters) - const currentFilters = useMemo(() => { - const filtered = Object.fromEntries( - Object.entries(search).filter( - ([key, value]) => - value !== undefined && !EXCLUDED_FROM_FACET_FILTERS.includes(key), - ), - ); - return Object.fromEntries( - Object.entries(filtered).filter(([, value]) => value !== undefined), - ) as Record; - }, [search]); + const initialFilters = useMemo(() => searchToFilters(search), [search]); - // State for grouping configuration - const [currentGroupingConfig, setCurrentGroupingConfig] = useState({ - groupBy: search.groupBy, - hue: search.hue, - style: search.style, - }); - - // State for filtered data (used by series tab) - const [filteredData, setFilteredData] = useState< - (ScalarValue | SeriesValue)[] - >([]); - - // Build initial filters from URL search params - const initialFilters = useMemo(() => { - const facetFilters = Object.entries(search) - .filter( - ([key, value]) => - value !== undefined && !EXCLUDED_FROM_FACET_FILTERS.includes(key), - ) - .map(([facetKey, value]) => ({ - type: "facet", - id: crypto.randomUUID(), - facetKey, - values: (value as string) - .split(",") - .map((v) => v.trim()) - .filter(Boolean), - })); - - const combinedFilters: any[] = [...facetFilters]; - - // Parse isolate/exclude id params from URL - if (search.isolate_ids) { - const isolateIds = String(search.isolate_ids) - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - if (isolateIds.length > 0) { - combinedFilters.push({ - type: "isolate", - id: crypto.randomUUID(), - ids: new Set(isolateIds), - }); - } - } - - if (search.exclude_ids) { - const excludeIds = String(search.exclude_ids) - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - if (excludeIds.length > 0) { - combinedFilters.push({ - type: "exclude", - id: crypto.randomUUID(), - ids: new Set(excludeIds), - }); - } - } - - return combinedFilters; - }, [search]); - - // Handle filter changes const handleFiltersChange = useCallback( - (newFilters: any[]) => { - // Build facet filter params - const facetFilters = (newFilters ?? []).filter((f) => f.type === "facet"); - const filterParams = - facetFilters.length > 0 - ? Object.fromEntries( - facetFilters.map((filter: any) => [ - filter.facetKey, - filter.values.join(","), - ]), - ) - : {}; - - // Collect isolate and exclude ids - const isolateIds = (newFilters ?? []) - .filter((f) => f.type === "isolate") - .flatMap((f: any) => Array.from(f.ids ?? [])); - const excludeIds = (newFilters ?? []) - .filter((f) => f.type === "exclude") - .flatMap((f: any) => Array.from(f.ids ?? [])); - - // Preserve tab, series visualization, and outlier parameters - const otherParams: Record = { - ...(searchTab ? { tab: String(searchTab) } : {}), - ...(searchGroupBy ? { groupBy: String(searchGroupBy) } : {}), - ...(searchHue ? { hue: String(searchHue) } : {}), - ...(searchStyle ? { style: String(searchStyle) } : {}), - ...(searchDetectOutliers !== undefined - ? { detect_outliers: String(searchDetectOutliers) } - : {}), - ...(searchIncludeUnverified !== undefined - ? { include_unverified: String(searchIncludeUnverified) } - : {}), - ...(searchMipEra ? { mip_era: String(searchMipEra) } : {}), - }; - - // Add isolate/exclude params if present - if (isolateIds.length > 0) { - otherParams.isolate_ids = isolateIds.join(","); - } - if (excludeIds.length > 0) { - otherParams.exclude_ids = excludeIds.join(","); - } - + (newFilters: MetricFilter[]) => { navigate({ - search: { - ...filterParams, - ...otherParams, - ...(searchLimit !== undefined ? { limit: searchLimit } : {}), - offset: 0, - } as any, + search: filtersToSearch(newFilters, search) as any, replace: true, }); }, - [ - searchTab, - searchGroupBy, - searchHue, - searchStyle, - searchDetectOutliers, - searchIncludeUnverified, - searchMipEra, - searchLimit, - navigate, - ], + [search, navigate], ); // Handle outlier detection changes (reset offset since total_count may change) @@ -261,26 +103,6 @@ export function useMetricValues>({ [search, navigate], ); - // Handle grouping config changes - const handleCurrentGroupingChange = useCallback( - (groupingConfig: { groupBy?: string; hue?: string; style?: string }) => { - setCurrentGroupingConfig({ - groupBy: groupingConfig.groupBy, - hue: groupingConfig.hue, - style: groupingConfig.style, - }); - }, - [], - ); - - // Handle filtered data changes (for series tab) - const handleFilteredDataChange = useCallback( - (data: (ScalarValue | SeriesValue)[]) => { - setFilteredData(data); - }, - [], - ); - // Handle pagination offset changes const handleOffsetChange = useCallback( (newOffset: number) => { @@ -310,12 +132,7 @@ export function useMetricValues>({ query: { format: "csv", value_type: valueType, - ...Object.fromEntries( - Object.entries(search).filter( - ([key, value]) => - value !== undefined && !EXCLUDED_FROM_API.includes(key), - ), - ), + ...query, }, meta: { responseType: "text", @@ -330,25 +147,12 @@ export function useMetricValues>({ a.download = getDownloadFilename(pathParams, valueType); a.click(); window.URL.revokeObjectURL(url); - }, [pathParams, valueType, search, fetchDownload, getDownloadFilename]); - - // Extract isolate/exclude IDs from search params - const isolateIds = search.isolate_ids - ? String(search.isolate_ids).split(",").filter(Boolean).join(",") - : undefined; - const excludeIds = search.exclude_ids - ? String(search.exclude_ids).split(",").filter(Boolean).join(",") - : undefined; + }, [pathParams, valueType, query, fetchDownload, getDownloadFilename]); return { metricValues: metricValues as MetricValueCollection | undefined, isLoading, - currentFilters, - currentGroupingConfig, - filteredData, initialFilters, - isolateIds, - excludeIds, pagination: { offset: Number(search.offset) || 0, limit: Number(search.limit) || DEFAULT_PAGE_SIZE, @@ -361,8 +165,6 @@ export function useMetricValues>({ onFiltersChange: handleFiltersChange, onDetectOutliersChange: handleDetectOutliersChange, onIncludeUnverifiedChange: handleIncludeUnverifiedChange, - onCurrentGroupingChange: handleCurrentGroupingChange, - onFilteredDataChange: handleFilteredDataChange, onDownload: handleDownload, }, }; diff --git a/frontend/src/lib/metricFilters.test.ts b/frontend/src/lib/metricFilters.test.ts new file mode 100644 index 00000000..4b97ceed --- /dev/null +++ b/frontend/src/lib/metricFilters.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { parseCommaList } from "./metricFilters"; + +describe("parseCommaList", () => { + it("splits on commas and trims each entry", () => { + expect(parseCommaList("1, 2 ,3")).toEqual(["1", "2", "3"]); + }); + + it("drops empty entries", () => { + expect(parseCommaList(",1,, ,2,")).toEqual(["1", "2"]); + }); + + it("keeps spaces inside an entry", () => { + expect(parseCommaList("ACCESS ESM, CESM2")).toEqual([ + "ACCESS ESM", + "CESM2", + ]); + }); + + it("returns no entries for blank text", () => { + expect(parseCommaList("")).toEqual([]); + expect(parseCommaList(" ")).toEqual([]); + }); +}); diff --git a/frontend/src/lib/metricFilters.ts b/frontend/src/lib/metricFilters.ts new file mode 100644 index 00000000..a5900906 --- /dev/null +++ b/frontend/src/lib/metricFilters.ts @@ -0,0 +1,9 @@ +/** + * Split a comma separated list, trimming each entry and dropping empty ones. + */ +export function parseCommaList(text: string): string[] { + return text + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} diff --git a/frontend/src/lib/valuesSearch.test.ts b/frontend/src/lib/valuesSearch.test.ts new file mode 100644 index 00000000..bfdf2e80 --- /dev/null +++ b/frontend/src/lib/valuesSearch.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import type { MetricFilter } from "@/components/explorer/types"; +import { filtersToSearch, searchToFilters } from "./valuesSearch"; + +describe("searchToFilters", () => { + it("turns each facet param into a facet filter", () => { + expect( + searchToFilters({ + source_id: "CESM2, GFDL", + experiment_id: "historical", + }), + ).toEqual([ + { + type: "facet", + id: expect.any(String), + facetKey: "source_id", + values: ["CESM2", "GFDL"], + }, + { + type: "facet", + id: expect.any(String), + facetKey: "experiment_id", + values: ["historical"], + }, + ]); + }); + + it("leaves out view, outlier, era, pagination and unset params", () => { + expect( + searchToFilters({ + tab: "series", + groupBy: "source_id", + hue: "experiment_id", + style: "member_id", + detect_outliers: "iqr", + include_unverified: false, + mip_era: "CMIP6", + offset: 50, + limit: 25, + execution_id: "7", + region: undefined, + }), + ).toEqual([]); + }); + + it("turns id params into isolate and exclude filters", () => { + expect(searchToFilters({ isolate_ids: "1,2", exclude_ids: "3" })).toEqual([ + { type: "isolate", id: expect.any(String), ids: new Set(["1", "2"]) }, + { type: "exclude", id: expect.any(String), ids: new Set(["3"]) }, + ]); + }); + + it("skips id params without ids", () => { + expect(searchToFilters({ isolate_ids: " , ", exclude_ids: "" })).toEqual( + [], + ); + }); +}); + +describe("filtersToSearch", () => { + const filters: MetricFilter[] = [ + { + type: "facet", + id: "a", + facetKey: "source_id", + values: ["CESM2", "GFDL"], + }, + { type: "isolate", id: "b", ids: new Set(["1", "2"]) }, + { type: "exclude", id: "c", ids: new Set(["3"]) }, + ]; + + it("writes facet values and ids as comma separated params", () => { + expect(filtersToSearch(filters, {})).toEqual({ + source_id: "CESM2,GFDL", + isolate_ids: "1,2", + exclude_ids: "3", + offset: 0, + }); + }); + + it("keeps the view, outlier, era and page size params", () => { + const search = { + tab: "series", + groupBy: "source_id", + hue: "experiment_id", + style: "member_id", + detect_outliers: "off", + include_unverified: true, + mip_era: "CMIP7", + limit: 100, + }; + expect(filtersToSearch([], search)).toEqual({ ...search, offset: 0 }); + }); + + it("drops the old filters, ids and offset", () => { + expect( + filtersToSearch([], { + source_id: "CESM2", + isolate_ids: "1", + exclude_ids: "2", + offset: 50, + }), + ).toEqual({ offset: 0 }); + }); + + it("round trips through searchToFilters", () => { + const search = filtersToSearch(filters, { detect_outliers: "iqr" }); + expect(searchToFilters(search)).toEqual( + filters.map((filter) => ({ ...filter, id: expect.any(String) })), + ); + }); +}); diff --git a/frontend/src/lib/valuesSearch.ts b/frontend/src/lib/valuesSearch.ts new file mode 100644 index 00000000..bd6c3e51 --- /dev/null +++ b/frontend/src/lib/valuesSearch.ts @@ -0,0 +1,92 @@ +import type { MetricFilter } from "@/components/explorer/types"; +import { parseCommaList } from "./metricFilters"; + +export const DEFAULT_PAGE_SIZE = 50; + +// Search params that are not facet filters +const NOT_FACET_PARAMS = [ + "tab", + "groupBy", + "hue", + "style", + "detect_outliers", + "include_unverified", + "mip_era", + "isolate_ids", + "exclude_ids", + "offset", + "limit", + "execution_id", +]; + +// Search params kept when the filters change +const KEPT_ON_FILTER_CHANGE = [ + "tab", + "groupBy", + "hue", + "style", + "detect_outliers", + "include_unverified", + "mip_era", + "limit", +]; + +type Search = Record; + +/** + * The filters a values tab's search params describe + * + * Every param that is not a view, outlier, era or pagination param filters its facet by a comma separated list of values. + * `isolate_ids` and `exclude_ids` become an isolate and an exclude filter. + */ +export function searchToFilters(search: Search): MetricFilter[] { + const filters: MetricFilter[] = Object.entries(search) + .filter( + ([key, value]) => value !== undefined && !NOT_FACET_PARAMS.includes(key), + ) + .map(([facetKey, value]) => ({ + type: "facet", + id: crypto.randomUUID(), + facetKey, + values: parseCommaList(String(value)), + })); + + for (const type of ["isolate", "exclude"] as const) { + const ids = parseCommaList(String(search[`${type}_ids`] ?? "")); + if (ids.length > 0) { + filters.push({ type, id: crypto.randomUUID(), ids: new Set(ids) }); + } + } + return filters; +} + +/** + * The search params for `filters`, going back to the first page + * + * The view, outlier, era and page size params of `search` are kept, any other param is replaced. + */ +export function filtersToSearch( + filters: MetricFilter[], + search: Search, +): Search { + const params: Search = {}; + for (const filter of filters) { + if (filter.type === "facet") { + params[filter.facetKey] = filter.values.join(","); + } + } + for (const key of KEPT_ON_FILTER_CHANGE) { + if (search[key]) { + params[key] = search[key]; + } + } + for (const type of ["isolate", "exclude"] as const) { + const ids = filters.flatMap((filter) => + filter.type === type ? Array.from(filter.ids) : [], + ); + if (ids.length > 0) { + params[`${type}_ids`] = ids.join(","); + } + } + return { ...params, offset: 0 }; +} diff --git a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/scalars.tsx b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/scalars.tsx index be8374fd..c07eac0d 100644 --- a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/scalars.tsx +++ b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/scalars.tsx @@ -3,9 +3,9 @@ import { z } from "zod"; import { MipEraScope } from "@/components/charts/mipEraBar"; import { Values } from "@/components/execution/values"; import { useDiagnosticMetricValues } from "@/hooks/useDiagnosticMetricValues"; -import { DEFAULT_PAGE_SIZE } from "@/hooks/useMetricValues"; import { useMipEra } from "@/hooks/useMipEra"; import { mipEraSearchFields } from "@/lib/mipEras"; +import { DEFAULT_PAGE_SIZE } from "@/lib/valuesSearch"; const valuesSearchSchema = z .object({ @@ -59,7 +59,6 @@ const ScalarsValuesTab = () => { initialFilters={initialFilters} valueType="scalars" onFiltersChange={handlers.onFiltersChange} - onCurrentGroupingChange={handlers.onCurrentGroupingChange} onDownload={handlers.onDownload} pagination={pagination} /> diff --git a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/series.tsx b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/series.tsx index 86d3f410..0110f7fb 100644 --- a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/series.tsx +++ b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/series.tsx @@ -3,9 +3,9 @@ import { z } from "zod"; import { MipEraScope } from "@/components/charts/mipEraBar"; import { Values } from "@/components/execution/values"; import { useDiagnosticMetricValues } from "@/hooks/useDiagnosticMetricValues"; -import { DEFAULT_PAGE_SIZE } from "@/hooks/useMetricValues"; import { useMipEra } from "@/hooks/useMipEra"; import { mipEraSearchFields } from "@/lib/mipEras"; +import { DEFAULT_PAGE_SIZE } from "@/lib/valuesSearch"; const valuesSearchSchema = z .object({ @@ -59,8 +59,6 @@ const SeriesValuesTab = () => { initialFilters={initialFilters} valueType="series" onFiltersChange={handlers.onFiltersChange} - onCurrentGroupingChange={handlers.onCurrentGroupingChange} - onFilteredDataChange={handlers.onFilteredDataChange} onDownload={handlers.onDownload} pagination={pagination} /> diff --git a/frontend/src/routes/_app/executions.$groupId/scalar.tsx b/frontend/src/routes/_app/executions.$groupId/scalar.tsx index 298943e4..0426914b 100644 --- a/frontend/src/routes/_app/executions.$groupId/scalar.tsx +++ b/frontend/src/routes/_app/executions.$groupId/scalar.tsx @@ -9,7 +9,7 @@ import { CardTitle, } from "@/components/ui/card"; import { useExecutionMetricValues } from "@/hooks/useExecutionMetricValues"; -import { DEFAULT_PAGE_SIZE } from "@/hooks/useMetricValues"; +import { DEFAULT_PAGE_SIZE } from "@/lib/valuesSearch"; const scalarValuesSearchSchema = z .object({ diff --git a/frontend/src/routes/_app/executions.$groupId/series.tsx b/frontend/src/routes/_app/executions.$groupId/series.tsx index 84d46ad9..1d1705bf 100644 --- a/frontend/src/routes/_app/executions.$groupId/series.tsx +++ b/frontend/src/routes/_app/executions.$groupId/series.tsx @@ -9,7 +9,7 @@ import { CardTitle, } from "@/components/ui/card"; import { useExecutionMetricValues } from "@/hooks/useExecutionMetricValues"; -import { DEFAULT_PAGE_SIZE } from "@/hooks/useMetricValues"; +import { DEFAULT_PAGE_SIZE } from "@/lib/valuesSearch"; const seriesValuesSearchSchema = z .object({ From 9532c68291f40ccee1e081652ed9d428038fb9f6 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:28:46 +0200 Subject: [PATCH 29/61] cleanup: lift facet filtering out of useValuesProcessor into applyFacetFilters --- .../components/execution/values/values.tsx | 7 +- frontend/src/hooks/useValuesProcessor.test.ts | 22 +++++- frontend/src/hooks/useValuesProcessor.ts | 68 +++++------------ frontend/src/lib/metricFilters.test.ts | 75 ++++++++++++++++++- frontend/src/lib/metricFilters.ts | 30 ++++++++ 5 files changed, 143 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/execution/values/values.tsx b/frontend/src/components/execution/values/values.tsx index ec043ac9..eb5dd837 100644 --- a/frontend/src/components/execution/values/values.tsx +++ b/frontend/src/components/execution/values/values.tsx @@ -64,14 +64,13 @@ export function Values({ valueType, ...props }: ValuesProps) { const { filters, setFilters, - finalDisplayedValues, + displayedValues, setRowSelection, rowSelection, excludedRowIds, setExcludedRowIds, } = useValuesProcessor({ initialValues: filteredValues, - loading: props.loading, initialFilters: props.initialFilters, onFiltersChange: props.onFiltersChange, }); @@ -151,7 +150,7 @@ export function Values({ valueType, ...props }: ValuesProps) { {valueType === "scalars" ? ( ) : ( { const { result } = renderHook(() => useValuesProcessor({ initialValues: [], - loading: false, onFiltersChange, }), ); @@ -37,7 +36,6 @@ describe("useValuesProcessor setFilters", () => { const { result } = renderHook(() => useValuesProcessor({ initialValues: [], - loading: false, onFiltersChange, }), ); @@ -56,7 +54,6 @@ describe("useValuesProcessor setFilters", () => { const { result } = renderHook(() => useValuesProcessor({ initialValues: [], - loading: false, initialFilters: [facetFilter("a")], onFiltersChange, }), @@ -68,3 +65,22 @@ describe("useValuesProcessor setFilters", () => { expect(onFiltersChange).toHaveBeenCalledWith([]); }); }); + +describe("useValuesProcessor displayedValues", () => { + it("applies the facet filters and keys rows by their id", () => { + const initialValues = [ + { id: 1, dimensions: { source_id: "A" } }, + { id: 2, dimensions: { source_id: "B" } }, + ]; + const { result } = renderHook(() => + useValuesProcessor({ + initialValues, + initialFilters: [facetFilter("a")], + }), + ); + + expect(result.current.displayedValues).toEqual([ + { ...initialValues[0], rowId: "1" }, + ]); + }); +}); diff --git a/frontend/src/hooks/useValuesProcessor.ts b/frontend/src/hooks/useValuesProcessor.ts index 0bd782f0..3979e169 100644 --- a/frontend/src/hooks/useValuesProcessor.ts +++ b/frontend/src/hooks/useValuesProcessor.ts @@ -4,7 +4,8 @@ import type { ScalarValue, SeriesValue, } from "@/components/execution/values/types"; -import type { FacetFilter, MetricFilter } from "@/components/explorer/types"; +import type { MetricFilter } from "@/components/explorer/types"; +import { applyFacetFilters } from "@/lib/metricFilters"; // Generic processed value type (supports ScalarValue, SeriesValue, or other // value shapes that include `dimensions`). @@ -18,13 +19,12 @@ export type Filter = MetricFilter; /** * Generic hook props to allow processing of either ScalarValue, SeriesValue, - * or any object that has a `dimensions` map. + * or any object that has an `id` and a `dimensions` map. */ interface UseValuesProcessorProps< - T extends { dimensions: { [key: string]: unknown } }, + T extends { id: number; dimensions: { [key: string]: unknown } }, > { initialValues: T[]; - loading: boolean; initialFilters?: MetricFilter[]; // now supports facet/isolate/exclude filters initialExcludedRowIds?: string[]; onFiltersChange?: (filters: MetricFilter[]) => void; @@ -32,16 +32,15 @@ interface UseValuesProcessorProps< /** * useValuesProcessor - * - Generic hook that can process arrays of values that include a `dimensions` field. - * - Returns processed values with a generated `rowId`, filtering by facets. + * - Generic hook that can process arrays of values that include `id` and `dimensions` fields. + * - Returns the values matching the facet filters, each with a `rowId` from its `id`. * - Note: isolate/exclude filtering is now intended to be handled by the backend * via query parameters; this hook only applies facet (dimension) filters client-side. */ export function useValuesProcessor< - T extends { dimensions: { [key: string]: unknown } }, + T extends { id: number; dimensions: { [key: string]: unknown } }, >({ initialValues, - loading, initialFilters, initialExcludedRowIds, onFiltersChange, @@ -76,53 +75,20 @@ export function useValuesProcessor< setExcludedRowIds(excluded); }; - const processedValues = useMemo((): ProcessedValue[] => { - if (!initialValues) return []; - - // Use database ID when available so rowId is stable across fetches. - // Fall back to crypto.randomUUID() for backward compatibility. - return initialValues.map((v) => ({ - ...v, - rowId: - (v as any).id !== undefined - ? String((v as any).id) - : crypto.randomUUID(), - })) as ProcessedValue[]; - }, [initialValues]); - - const facetFilteredValues = useMemo(() => { - if (loading && processedValues.length === 0) return []; - - const itemsToFilter = processedValues; - // Apply only facet (dimension) filters client-side. Isolate/exclude are handled server-side. - const activeFacetFilters = (filters ?? []).filter( - (f): f is FacetFilter => (f as any).type === "facet", - ); - if (activeFacetFilters.length === 0) { - return itemsToFilter; - } - return itemsToFilter.filter((value) => { - return activeFacetFilters.every((filter) => { - const dimensionValue = value.dimensions[filter.facetKey]; - if (dimensionValue === undefined || dimensionValue === null) - return false; - - // Normalize filter values to strings and trim whitespace - const filterValues = filter.values.map((v) => String(v).trim()); - return filterValues.includes(String(dimensionValue).trim()); - }); - }); - }, [processedValues, filters, loading]); - - const finalDisplayedValues = useMemo(() => { - // Backend handles isolate/exclude by id. Keep only facet filtering here. - return facetFilteredValues; - }, [facetFilteredValues]); + // The database id keeps a row's id stable across fetches + const displayedValues = useMemo( + () => + applyFacetFilters(initialValues, filters).map((v) => ({ + ...v, + rowId: String(v.id), + })), + [initialValues, filters], + ); return { filters, setFilters, - finalDisplayedValues, + displayedValues, excludedRowIds, setExcludedRowIds: handleExcludeRowIds, rowSelection, diff --git a/frontend/src/lib/metricFilters.test.ts b/frontend/src/lib/metricFilters.test.ts index 4b97ceed..06ec0e1d 100644 --- a/frontend/src/lib/metricFilters.test.ts +++ b/frontend/src/lib/metricFilters.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseCommaList } from "./metricFilters"; +import type { FacetFilter } from "@/components/explorer/types"; +import { applyFacetFilters, parseCommaList } from "./metricFilters"; describe("parseCommaList", () => { it("splits on commas and trims each entry", () => { @@ -22,3 +23,75 @@ describe("parseCommaList", () => { expect(parseCommaList(" ")).toEqual([]); }); }); + +describe("applyFacetFilters", () => { + const value = (id: number, dimensions: Record) => ({ + id, + dimensions, + }); + const values = [ + value(1, { source_id: "CESM2", experiment_id: "historical" }), + value(2, { source_id: "GFDL", experiment_id: "historical" }), + value(3, { source_id: "CESM2", experiment_id: "ssp585" }), + value(4, { experiment_id: "historical" }), + ]; + const facet = (facetKey: string, ...facetValues: string[]): FacetFilter => ({ + type: "facet", + id: facetKey, + facetKey, + values: facetValues, + }); + const idsOf = (filtered: { id: number }[]) => filtered.map((v) => v.id); + + it("returns the values unchanged without facet filters", () => { + expect(applyFacetFilters(values, [])).toBe(values); + expect( + applyFacetFilters(values, [ + { type: "isolate", id: "i", ids: new Set(["1"]) }, + { type: "exclude", id: "e", ids: new Set(["2"]) }, + ]), + ).toBe(values); + }); + + it("keeps values whose dimension is any of a filter's values", () => { + expect( + idsOf(applyFacetFilters(values, [facet("source_id", "CESM2", "GFDL")])), + ).toEqual([1, 2, 3]); + }); + + it("keeps only values matching every filter", () => { + expect( + idsOf( + applyFacetFilters(values, [ + facet("source_id", "CESM2"), + facet("experiment_id", "historical"), + ]), + ), + ).toEqual([1]); + }); + + it("drops values without the filtered dimension", () => { + expect( + idsOf(applyFacetFilters(values, [facet("source_id", "CESM2", "GFDL")])), + ).not.toContain(4); + expect( + applyFacetFilters([value(5, { source_id: null })], [facet("source_id")]), + ).toEqual([]); + }); + + it("ignores whitespace around values and compares as strings", () => { + expect( + idsOf( + applyFacetFilters( + [value(6, { source_id: " CESM2 " }), value(7, { member: 1 })], + [facet("source_id", "CESM2 ")], + ), + ), + ).toEqual([6]); + expect( + idsOf( + applyFacetFilters([value(7, { member: 1 })], [facet("member", "1")]), + ), + ).toEqual([7]); + }); +}); diff --git a/frontend/src/lib/metricFilters.ts b/frontend/src/lib/metricFilters.ts index a5900906..33ecab4e 100644 --- a/frontend/src/lib/metricFilters.ts +++ b/frontend/src/lib/metricFilters.ts @@ -1,3 +1,5 @@ +import type { FacetFilter, MetricFilter } from "@/components/explorer/types"; + /** * Split a comma separated list, trimming each entry and dropping empty ones. */ @@ -7,3 +9,31 @@ export function parseCommaList(text: string): string[] { .map((entry) => entry.trim()) .filter(Boolean); } + +/** + * The values matching every facet filter in `filters` + * + * A value matches a facet filter when its dimension is one of the filter's values, ignoring surrounding whitespace. + * Isolate and exclude filters are left to the API. + */ +export function applyFacetFilters< + T extends { dimensions: Record }, +>(values: T[], filters: MetricFilter[]): T[] { + const facetFilters = filters.filter( + (filter): filter is FacetFilter => filter.type === "facet", + ); + if (facetFilters.length === 0) { + return values; + } + return values.filter((value) => + facetFilters.every((filter) => { + const dimension = value.dimensions[filter.facetKey]; + if (dimension === undefined || dimension === null) { + return false; + } + return filter.values + .map((v) => String(v).trim()) + .includes(String(dimension).trim()); + }), + ); +} From 98d8723188e471588ed55d0fc524515d2c126514 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:30:37 +0200 Subject: [PATCH 30/61] cleanup: share id filter merging and filter badges in FilterControls, drop dead excluded rows badge --- .../execution/values/filterControls.test.tsx | 51 +++ .../execution/values/filterControls.tsx | 328 +++++------------- .../components/execution/values/values.tsx | 4 - frontend/src/hooks/useValuesProcessor.ts | 14 - frontend/src/lib/metricFilters.test.ts | 57 ++- frontend/src/lib/metricFilters.ts | 29 +- 6 files changed, 223 insertions(+), 260 deletions(-) create mode 100644 frontend/src/components/execution/values/filterControls.test.tsx diff --git a/frontend/src/components/execution/values/filterControls.test.tsx b/frontend/src/components/execution/values/filterControls.test.tsx new file mode 100644 index 00000000..54e29b17 --- /dev/null +++ b/frontend/src/components/execution/values/filterControls.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { SetStateAction } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { MetricFilter } from "@/components/explorer/types"; +import { FilterControls } from "./filterControls"; + +const filters: MetricFilter[] = [ + { type: "facet", id: "f", facetKey: "source_id", values: ["A", "B"] }, + { type: "isolate", id: "i", ids: new Set(["1", "2"]) }, + { type: "exclude", id: "e", ids: new Set(["3"]) }, +]; + +/** Render the controls, returning the filters each setFilters call would leave. */ +function renderControls(rowSelection: Record = {}) { + const results: MetricFilter[][] = []; + const setFilters = vi.fn((next: SetStateAction) => { + results.push(typeof next === "function" ? next(filters) : next); + }); + render( + , + ); + return results; +} + +describe("FilterControls", () => { + it("shows a badge per filter", () => { + renderControls(); + expect(screen.getByText("source_id: A, B")).toBeInTheDocument(); + expect(screen.getByText("Isolating 2 rows")).toBeInTheDocument(); + expect(screen.getByText("Excluding 1 rows")).toBeInTheDocument(); + }); + + it("removes a filter from its badge", () => { + const results = renderControls(); + fireEvent.click(screen.getByLabelText("Remove isolate filter")); + expect(results).toEqual([[filters[0], filters[2]]]); + }); + + it("adds the selected rows to the exclude filter", () => { + const results = renderControls({ "4": true }); + fireEvent.click(screen.getByText("Exclude Selected")); + expect(results).toEqual([ + [filters[0], filters[1], { ...filters[2], ids: new Set(["3", "4"]) }], + ]); + }); +}); diff --git a/frontend/src/components/execution/values/filterControls.tsx b/frontend/src/components/execution/values/filterControls.tsx index e89ad41c..089c55f4 100644 --- a/frontend/src/components/execution/values/filterControls.tsx +++ b/frontend/src/components/execution/values/filterControls.tsx @@ -1,18 +1,18 @@ import type { RowSelectionState } from "@tanstack/react-table"; import { PlusCircle, X } from "lucide-react"; -import type { Dispatch, SetStateAction } from "react"; +import type { ComponentProps, Dispatch, SetStateAction } from "react"; import { FilterAddPopover } from "@/components/execution/values/filterAddPopover.tsx"; import type { Facet } from "@/components/execution/values/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { Filter } from "@/hooks/useValuesProcessor"; +import { mergeIdFilter } from "@/lib/metricFilters"; +import { cn } from "@/lib/utils"; interface FilterControlsProps { facets: Facet[]; filters: Filter[]; setFilters: Dispatch>; - excludedRowIds: Set; - setExcludedRowIds: Dispatch>>; rowSelection: RowSelectionState; } @@ -21,273 +21,65 @@ export function FilterControls({ filters, setFilters, rowSelection, - excludedRowIds, - setExcludedRowIds, }: FilterControlsProps) { const availableFacets = facets .filter( (facet) => !filters.some( - (filter) => - (filter as any).type === "facet" && - (filter as any).facetKey === facet.key, + (filter) => filter.type === "facet" && filter.facetKey === facet.key, ), ) .sort((a, b) => a.key.localeCompare(b.key)); // If there are active isolate/exclude filters, prefill the ID popover with the first occurrence. - const firstIsolate = filters.find((f) => (f as any).type === "isolate") as - | any - | undefined; - const firstExclude = filters.find((f) => (f as any).type === "exclude") as - | any - | undefined; - const initialIdMode = firstIsolate - ? "isolate" - : firstExclude - ? "exclude" - : undefined; - const initialIds = ( - firstIsolate - ? firstIsolate.ids - : firstExclude - ? firstExclude.ids - : undefined - ) as Set | undefined; + const idFilter = + filters.find((filter) => filter.type === "isolate") ?? + filters.find((filter) => filter.type === "exclude"); + + const addSelected = (mode: "isolate" | "exclude") => + setFilters((existing) => + mergeIdFilter(existing, mode, Object.keys(rowSelection)), + ); return (
- {filters.map((filter) => { - if ((filter as any).type === "facet") { - const f = filter as any; - return ( - - - {facets.find((ff) => ff.key === f.facetKey)?.key || - f.facetKey} - : {f.values.length > 1 ? f.values.join(", ") : f.values[0]} - - - - ); - } - - if ((filter as any).type === "isolate") { - const f = filter as any; - return ( - - Isolating {f.ids ? f.ids.size : 0} rows - - - ); - } - - if ((filter as any).type === "exclude") { - const f = filter as any; - return ( - - Excluding {f.ids ? f.ids.size : 0} rows - - - ); - } - - return null; - })} - - {/* Legacy excludedRowIds badge (kept for compatibility, but server now handles excludes) */} - {excludedRowIds.size > 0 ? ( - - Filtered Rows: {excludedRowIds.size} - - - ) : null} + {filters.map((filter) => ( + + setFilters((existing) => + existing.filter((x) => x.id !== filter.id), + ) + } + /> + ))} { setFilters((existing) => [ ...existing, - { - type: "facet", - id: crypto.randomUUID(), - facetKey, - values, - } as any, + { type: "facet", id: crypto.randomUUID(), facetKey, values }, ]); }} - onAddIds={(mode, ids) => { - setFilters((existing) => { - const idx = existing.findIndex((f) => (f as any).type === mode); - if (idx >= 0) { - const existingFilter = existing[idx] as any; - const existingIds: string[] = existingFilter.ids - ? Array.from(existingFilter.ids) - : []; - const mergedIds = new Set([ - ...existingIds, - ...Array.from(ids), - ]); - const updatedFilter = { - ...existingFilter, - ids: mergedIds, - } as any; - const newFilters = [...existing]; - newFilters[idx] = updatedFilter; - return newFilters; - } - return [ - ...existing, - { - type: mode, - id: crypto.randomUUID(), - ids, - } as any, - ]; - }); - }} + onAddIds={(mode, ids) => + setFilters((existing) => mergeIdFilter(existing, mode, ids)) + } disabled={availableFacets.length === 0 && facets.length > 0} /> {Object.keys(rowSelection).length ? ( <> - - @@ -297,3 +89,61 @@ export function FilterControls({
); } + +interface FilterBadgeLook { + label: string; + variant: ComponentProps["variant"]; + className?: string; + removeLabel: string; +} + +function filterBadgeLook(filter: Filter): FilterBadgeLook { + switch (filter.type) { + case "facet": + return { + label: `${filter.facetKey}: ${filter.values.join(", ")}`, + variant: "secondary", + removeLabel: "Remove filter", + }; + case "isolate": + return { + label: `Isolating ${filter.ids.size} rows`, + variant: "outline", + className: "border-emerald-400 text-emerald-600", + removeLabel: "Remove isolate filter", + }; + case "exclude": + return { + label: `Excluding ${filter.ids.size} rows`, + variant: "destructive", + removeLabel: "Remove exclude filter", + }; + } +} + +function FilterBadge({ + filter, + onRemove, +}: { + filter: Filter; + onRemove: () => void; +}) { + const { label, variant, className, removeLabel } = filterBadgeLook(filter); + return ( + + {label} + + + ); +} diff --git a/frontend/src/components/execution/values/values.tsx b/frontend/src/components/execution/values/values.tsx index eb5dd837..adf51d05 100644 --- a/frontend/src/components/execution/values/values.tsx +++ b/frontend/src/components/execution/values/values.tsx @@ -67,8 +67,6 @@ export function Values({ valueType, ...props }: ValuesProps) { displayedValues, setRowSelection, rowSelection, - excludedRowIds, - setExcludedRowIds, } = useValuesProcessor({ initialValues: filteredValues, initialFilters: props.initialFilters, @@ -138,8 +136,6 @@ export function Values({ valueType, ...props }: ValuesProps) { filters={filters} setFilters={setFilters} rowSelection={rowSelection} - excludedRowIds={excludedRowIds} - setExcludedRowIds={setExcludedRowIds} />
From d26820dd3a4cc06b2c2468dd2fb71d377d0e0771 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:37:04 +0200 Subject: [PATCH 33/61] cleanup: share a CheckboxCommandList between the selector and value filter popovers --- .../app/checkboxCommandList.test.tsx | 40 ++++++++++++ .../components/app/checkboxCommandList.tsx | 65 +++++++++++++++++++ .../diagnostics/selectorFilterPanel.test.ts | 31 +++++++++ .../diagnostics/selectorFilterPanel.tsx | 59 ++++------------- .../execution/values/filterAddPopover.tsx | 54 +++------------ 5 files changed, 156 insertions(+), 93 deletions(-) create mode 100644 frontend/src/components/app/checkboxCommandList.test.tsx create mode 100644 frontend/src/components/app/checkboxCommandList.tsx create mode 100644 frontend/src/components/diagnostics/selectorFilterPanel.test.ts diff --git a/frontend/src/components/app/checkboxCommandList.test.tsx b/frontend/src/components/app/checkboxCommandList.test.tsx new file mode 100644 index 00000000..a11dd2d8 --- /dev/null +++ b/frontend/src/components/app/checkboxCommandList.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { CheckboxCommandList } from "./checkboxCommandList"; + +beforeAll(() => { + // cmdk measures and scrolls its items, which jsdom does not implement + globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + Element.prototype.scrollIntoView ??= () => {}; +}); + +function renderList(selected: string[]) { + const onSelectedChange = vi.fn(); + render( + , + ); + return onSelectedChange; +} + +describe("CheckboxCommandList", () => { + it("adds a value that is not selected", () => { + const onSelectedChange = renderList(["A"]); + fireEvent.click(screen.getByText("C")); + expect(onSelectedChange).toHaveBeenCalledWith(["A", "C"]); + }); + + it("removes a value that is selected", () => { + const onSelectedChange = renderList(["A", "B"]); + fireEvent.click(screen.getByText("A")); + expect(onSelectedChange).toHaveBeenCalledWith(["B"]); + }); +}); diff --git a/frontend/src/components/app/checkboxCommandList.tsx b/frontend/src/components/app/checkboxCommandList.tsx new file mode 100644 index 00000000..8741c3cd --- /dev/null +++ b/frontend/src/components/app/checkboxCommandList.tsx @@ -0,0 +1,65 @@ +import { Check } from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command.tsx"; +import { cn } from "@/lib/utils"; + +interface CheckboxCommandListProps { + values: string[]; + selected: string[]; + /** Called with `selected` after a value is ticked or unticked */ + onSelectedChange: (selected: string[]) => void; + placeholder: string; +} + +/** A searchable list of values with a checkbox each, for picking several. */ +export function CheckboxCommandList({ + values, + selected, + onSelectedChange, + placeholder, +}: CheckboxCommandListProps) { + return ( + + + + No values found. + + {values.map((value) => { + const isSelected = selected.includes(value); + return ( + + onSelectedChange( + isSelected + ? selected.filter((v) => v !== value) + : [...selected, value], + ) + } + > +
+ +
+ {value} +
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/diagnostics/selectorFilterPanel.test.ts b/frontend/src/components/diagnostics/selectorFilterPanel.test.ts new file mode 100644 index 00000000..d8fcada0 --- /dev/null +++ b/frontend/src/components/diagnostics/selectorFilterPanel.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import type { ExecutionGroup } from "@/client"; +import { extractSelectorFacets } from "./selectorFilterPanel"; + +const group = (selectors: ExecutionGroup["selectors"]) => + ({ selectors }) as ExecutionGroup; + +describe("extractSelectorFacets", () => { + it("returns no facets without groups or selectors", () => { + expect(extractSelectorFacets([])).toEqual([]); + expect(extractSelectorFacets([group({})])).toEqual([]); + }); + + it("collects each key's distinct values across groups and source types", () => { + const groups = [ + group({ + cmip6: [ + ["source_id", "GFDL"], + ["experiment_id", "historical"], + ], + obs4mips: [["source_id", "CERES"]], + }), + group({ cmip6: [["source_id", "GFDL"]] }), + group({ cmip6: [["source_id", "ACCESS"]] }), + ]; + expect(extractSelectorFacets(groups)).toEqual([ + { key: "experiment_id", values: ["historical"] }, + { key: "source_id", values: ["ACCESS", "CERES", "GFDL"] }, + ]); + }); +}); diff --git a/frontend/src/components/diagnostics/selectorFilterPanel.tsx b/frontend/src/components/diagnostics/selectorFilterPanel.tsx index ac2939c9..4f5b00ad 100644 --- a/frontend/src/components/diagnostics/selectorFilterPanel.tsx +++ b/frontend/src/components/diagnostics/selectorFilterPanel.tsx @@ -1,22 +1,14 @@ -import { Check, X } from "lucide-react"; +import { X } from "lucide-react"; import { useMemo } from "react"; import type { ExecutionGroup } from "@/client"; +import { CheckboxCommandList } from "@/components/app/checkboxCommandList"; import { Badge } from "@/components/ui/badge.tsx"; import { Button } from "@/components/ui/button.tsx"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command.tsx"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover.tsx"; -import { cn } from "@/lib/utils"; interface SelectorFacet { key: string; @@ -29,7 +21,10 @@ interface SelectorFilterPanelProps { onFiltersChange: (filters: Record) => void; } -function extractSelectorFacets(groups: ExecutionGroup[]): SelectorFacet[] { +/** The selector keys of `groups`, each with its distinct values, both sorted. */ +export function extractSelectorFacets( + groups: ExecutionGroup[], +): SelectorFacet[] { const facetMap = new Map>(); for (const group of groups) { for (const pairs of Object.values(group.selectors)) { @@ -87,42 +82,12 @@ function MultiSelectFacet({ - - - - No values found. - - {facet.values.map((value) => { - const isSelected = selected.includes(value); - return ( - { - onSelectionChange( - isSelected - ? selected.filter((v) => v !== value) - : [...selected, value], - ); - }} - > -
- -
- {value} -
- ); - })} -
-
-
+
); diff --git a/frontend/src/components/execution/values/filterAddPopover.tsx b/frontend/src/components/execution/values/filterAddPopover.tsx index 31b2732c..c293464d 100644 --- a/frontend/src/components/execution/values/filterAddPopover.tsx +++ b/frontend/src/components/execution/values/filterAddPopover.tsx @@ -1,15 +1,8 @@ -import { Check, PlusCircle } from "lucide-react"; +import { PlusCircle } from "lucide-react"; import { type ComponentProps, useEffect, useState } from "react"; +import { CheckboxCommandList } from "@/components/app/checkboxCommandList"; import type { Facet } from "@/components/execution/values/types"; import { Button } from "@/components/ui/button.tsx"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command.tsx"; import { Label } from "@/components/ui/label.tsx"; import { Popover, @@ -24,7 +17,6 @@ import { SelectValue, } from "@/components/ui/select.tsx"; import { parseCommaList } from "@/lib/metricFilters"; -import { cn } from "@/lib/utils"; interface FilterAddPopoverProps extends ComponentProps { facets: Facet[]; @@ -163,42 +155,12 @@ export function FilterAddPopover({ - - - - No values found. - - {facetValues.map((value) => { - const isSelected = selectedValues.includes(value); - return ( - { - setSelectedValues((prev) => - isSelected - ? prev.filter((v) => v !== value) - : [...prev, value], - ); - }} - > -
- -
- {value} -
- ); - })} -
-
-
+
From ee8000deb5c12bcd545d0ea289c0bb75231e5ad8 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:38:31 +0200 Subject: [PATCH 34/61] cleanup: remove the values barrel file the panels no longer go through --- frontend/src/components/execution/values/index.ts | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 frontend/src/components/execution/values/index.ts diff --git a/frontend/src/components/execution/values/index.ts b/frontend/src/components/execution/values/index.ts deleted file mode 100644 index 8b9d58ee..00000000 --- a/frontend/src/components/execution/values/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Values } from "@/components/execution/values/values.tsx"; - -export { Values }; From fd774c7207b9de45b99f4d8ea66c90ca2ecfeaf6 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:40:35 +0200 Subject: [PATCH 35/61] fix: accept value tab facet params the router parses as numbers or booleans --- frontend/src/lib/valuesSearch.test.ts | 6 ++++++ frontend/src/lib/valuesSearch.ts | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/valuesSearch.test.ts b/frontend/src/lib/valuesSearch.test.ts index cf765f9e..468f0655 100644 --- a/frontend/src/lib/valuesSearch.test.ts +++ b/frontend/src/lib/valuesSearch.test.ts @@ -54,6 +54,12 @@ describe.each([ source_id: "CESM2,GFDL", }); }); + + it("keeps facet params the router parsed as numbers or booleans as strings", () => { + expect( + schema.parse({ member: 1, isolate_ids: 42, flag: true }), + ).toMatchObject({ member: "1", isolate_ids: "42", flag: "true" }); + }); }); describe("diagnosticValuesSearchSchema", () => { diff --git a/frontend/src/lib/valuesSearch.ts b/frontend/src/lib/valuesSearch.ts index 09064a83..faa7728e 100644 --- a/frontend/src/lib/valuesSearch.ts +++ b/frontend/src/lib/valuesSearch.ts @@ -5,6 +5,17 @@ import { mipEraSearchFields } from "./mipEras"; export const DEFAULT_PAGE_SIZE = 50; +/** + * A facet filter param, as a string + * + * The router parses search values that look like numbers or booleans, such as `?member=1`, + * so those are turned back into the string the facet value is. + */ +const facetParam = z + .union([z.string(), z.number(), z.boolean()]) + .transform(String) + .optional(); + // Outlier and pagination params every values tab shares const valuesSearchFields = { detect_outliers: z.enum(["off", "iqr"]).default("iqr"), @@ -19,7 +30,7 @@ const valuesSearchFields = { /** Search params of an execution group's values tabs. Any other param is a facet filter. */ export const executionValuesSearchSchema = z .object(valuesSearchFields) - .catchall(z.string().optional()); + .catchall(facetParam); /** Search params of a diagnostic's values tabs, which also pick the series grouping and MIP era. */ export const diagnosticValuesSearchSchema = z @@ -30,7 +41,7 @@ export const diagnosticValuesSearchSchema = z ...valuesSearchFields, ...mipEraSearchFields, }) - .catchall(z.string().optional()); + .catchall(facetParam); export type ExecutionValuesSearch = z.output< typeof executionValuesSearchSchema From 04540ce905bebeb8b49038c9422cc13659c07896 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:26:20 +0200 Subject: [PATCH 36/61] cleanup: derive the diagnostics catalog filters from the URL with a controlled filter and pure filter helpers --- .../diagnostics/diagnosticsFilter.test.tsx | 109 ++-- .../diagnostics/diagnosticsFilter.tsx | 604 ++++++------------ frontend/src/content/about-diagnostics.mdx | 26 + frontend/src/lib/diagnosticFilters.test.ts | 215 +++++++ frontend/src/lib/diagnosticFilters.ts | 141 ++++ .../src/routes/_app/diagnostics.index.tsx | 238 +++---- 6 files changed, 703 insertions(+), 630 deletions(-) create mode 100644 frontend/src/content/about-diagnostics.mdx create mode 100644 frontend/src/lib/diagnosticFilters.test.ts create mode 100644 frontend/src/lib/diagnosticFilters.ts diff --git a/frontend/src/components/diagnostics/diagnosticsFilter.test.tsx b/frontend/src/components/diagnostics/diagnosticsFilter.test.tsx index 0d8094aa..2a8e17fd 100644 --- a/frontend/src/components/diagnostics/diagnosticsFilter.test.tsx +++ b/frontend/src/components/diagnostics/diagnosticsFilter.test.tsx @@ -1,77 +1,56 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { CatalogDiagnostic } from "@/lib/diagnosticCatalog"; +import { + type DiagnosticFilterState, + NO_DIAGNOSTIC_FILTERS, +} from "@/lib/diagnosticFilters"; import { DiagnosticsFilter } from "./diagnosticsFilter"; -function diagnostic( - name: string, - hasValues: boolean | null, -): CatalogDiagnostic { - return { - id: name.length, - name, - slug: name, - description: "", - provider: { slug: "pmp", name: "PMP" }, - execution_groups: [], - has_metric_values: hasValues, - has_scalar_values: hasValues, - has_series_values: hasValues === null ? null : false, - execution_count: 1, - successful_execution_count: 1, - execution_group_count: 1, - successful_execution_group_count: 1, - promoted_version: 1, - group_by: [], - aft_link: null, - }; -} +const diagnostics = [ + { name: "enso", provider: { slug: "pmp", name: "PMP" }, aft_link: null }, + { name: "gpp", provider: { slug: "ilamb", name: "ILAMB" }, aft_link: null }, +] as unknown as CatalogDiagnostic[]; -const names = (call: CatalogDiagnostic[]) => call.map((d) => d.name); +function renderFilter(changes: Partial) { + const onChange = vi.fn(); + const value = { ...NO_DIAGNOSTIC_FILTERS, ...changes }; + render( + , + ); + return { onChange, value }; +} describe("DiagnosticsFilter", () => { - it("keeps the search applied when the value flags arrive", () => { - const onFilterChange = vi.fn(); - const pending = [diagnostic("alpha", null), diagnostic("beta", null)]; - const { rerender } = render( - , - ); - expect(names(onFilterChange.mock.lastCall![0])).toEqual(["alpha"]); - - const loaded = [diagnostic("alpha", true), diagnostic("beta", false)]; - rerender( - , - ); - expect(onFilterChange.mock.lastCall![0]).toEqual([loaded[0]]); + it("adds a checked provider to the filters", () => { + const { onChange, value } = renderFilter({ providers: ["pmp"] }); + fireEvent.click(screen.getByRole("button", { name: /Providers/ })); + fireEvent.click(screen.getByLabelText("ilamb")); + expect(onChange).toHaveBeenCalledWith({ + ...value, + providers: ["pmp", "ilamb"], + }); }); - it("keeps diagnostics with unknown flags until the flags load", () => { - const onFilterChange = vi.fn(); - const pending = [diagnostic("alpha", null), diagnostic("beta", null)]; - const { rerender } = render( - , - ); - expect(names(onFilterChange.mock.lastCall![0])).toEqual(["alpha", "beta"]); + it("checks one metric values option at a time", () => { + const { onChange, value } = renderFilter({ metricValues: true }); + fireEvent.click(screen.getByRole("button", { name: /Metric Values/ })); + fireEvent.click(screen.getByLabelText("No metric values")); + expect(onChange).toHaveBeenLastCalledWith({ + ...value, + metricValues: false, + }); + fireEvent.click(screen.getByLabelText("Has metric values")); + expect(onChange).toHaveBeenLastCalledWith({ ...value, metricValues: null }); + }); - rerender( - , - ); - expect(names(onFilterChange.mock.lastCall![0])).toEqual(["alpha"]); + it("clears every filter", () => { + const { onChange } = renderFilter({ search: "enso", themes: ["Ocean"] }); + fireEvent.click(screen.getByRole("button", { name: "Clear Filters" })); + expect(onChange).toHaveBeenCalledWith(NO_DIAGNOSTIC_FILTERS); }); }); diff --git a/frontend/src/components/diagnostics/diagnosticsFilter.tsx b/frontend/src/components/diagnostics/diagnosticsFilter.tsx index bf11cb02..9ef80844 100644 --- a/frontend/src/components/diagnostics/diagnosticsFilter.tsx +++ b/frontend/src/components/diagnostics/diagnosticsFilter.tsx @@ -1,9 +1,9 @@ import { Filter, Search, X } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { type ReactNode, useMemo } from "react"; +import { DebouncedInput } from "@/components/app/debouncedInput"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, @@ -11,472 +11,236 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import type { CatalogDiagnostic } from "@/lib/diagnosticCatalog"; +import { + type DiagnosticFilterState, + diagnosticFilterOptions, + hasActiveFilters, + hasSelections, + NO_DIAGNOSTIC_FILTERS, +} from "@/lib/diagnosticFilters"; + +interface CheckboxFilterPopoverProps { + label: string; + title: string; + /** Prefix of the checkbox ids, which must be unique on the page */ + idPrefix: string; + options: { value: string; label: string }[]; + selected: string[]; + onToggle: (value: string) => void; + className?: string; +} + +/** A button opening a list of checkboxes, showing how many are checked. */ +function CheckboxFilterPopover({ + label, + title, + idPrefix, + options, + selected, + onToggle, + className = "w-80", +}: CheckboxFilterPopoverProps) { + return ( + + + + + +
+

{title}

+
+ {options.map((option) => ( +
+ onToggle(option.value)} + /> + +
+ ))} +
+
+
+
+ ); +} + +function ActiveFilterBadge({ + children, + onRemove, +}: { + children: ReactNode; + onRemove: () => void; +}) { + return ( + + {children} + + + ); +} + +const toggled = (values: string[], value: string) => + values.includes(value) + ? values.filter((v) => v !== value) + : [...values, value]; + +const METRIC_VALUE_OPTIONS = [ + { value: "true", label: "Has metric values" }, + { value: "false", label: "No metric values" }, +]; interface DiagnosticsFilterProps { + /** The diagnostics to offer filter options from */ diagnostics: CatalogDiagnostic[]; - onFilterChange: (filteredDiagnostics: CatalogDiagnostic[]) => void; - onFilterParamsChange?: ( - search: string, - providers: string[], - aftIds: string[], - themes: string[], - metricValues: boolean | null, - ) => void; - initialSearch?: string; - initialProviders?: string[]; - initialAftIds?: string[]; - initialThemes?: string[]; - initialMetricValues?: boolean | null; + value: DiagnosticFilterState; + onChange: (value: DiagnosticFilterState) => void; } export function DiagnosticsFilter({ diagnostics, - onFilterChange, - onFilterParamsChange, - initialSearch = "", - initialProviders = [], - initialAftIds = [], - initialThemes = [], - initialMetricValues = null, + value, + onChange, }: DiagnosticsFilterProps) { - const [searchTerm, setSearchTerm] = useState(initialSearch); - const [selectedProviders, setSelectedProviders] = - useState(initialProviders); - const [selectedAftIds, setSelectedAftIds] = useState(initialAftIds); - const [selectedThemes, setSelectedThemes] = useState(initialThemes); - const [showWithMetricValues, setShowWithMetricValues] = useState< - boolean | null - >(initialMetricValues); - - // Extract unique providers from diagnostics - const allProviders = Array.from( - new Set(diagnostics.map((d) => d.provider.slug)), - ).sort(); - - // Extract unique AFT diagnostics from diagnostics - const allAftIds = Array.from( - new Map( - diagnostics - .filter((d) => d.aft_link) - .map((d) => [ - d.aft_link!.id, - { id: d.aft_link!.id, name: d.aft_link!.name }, - ]), - ).values(), - ).sort((a, b) => a.id.localeCompare(b.id)); - - // Extract unique themes from diagnostics - const allThemes = Array.from( - new Set( - diagnostics - .filter((d) => d.aft_link?.theme) - .map((d) => d.aft_link!.theme!), - ), - ).sort(); - - // Apply filters (only updates the filtered diagnostics list) - const applyFilters = useCallback( - ( - search: string, - providers: string[], - aftIds: string[], - themes: string[], - metricValuesFilter: boolean | null, - updateUrl = false, - ) => { - let filtered = diagnostics; - - // Search filter - if (search.trim()) { - const searchLower = search.toLowerCase(); - filtered = filtered.filter( - (diagnostic) => - diagnostic.name.toLowerCase().includes(searchLower) || - diagnostic.description?.toLowerCase().includes(searchLower) || - diagnostic.provider.name.toLowerCase().includes(searchLower) || - diagnostic.provider.slug.toLowerCase().includes(searchLower), - ); - } - - // Provider filter - if (providers.length > 0) { - filtered = filtered.filter((diagnostic) => - providers.includes(diagnostic.provider.slug), - ); - } - - // AFT ID filter - if (aftIds.length > 0) { - filtered = filtered.filter( - (diagnostic) => - diagnostic.aft_link && aftIds.includes(diagnostic.aft_link.id), - ); - } - - // Theme filter - if (themes.length > 0) { - filtered = filtered.filter( - (diagnostic) => - diagnostic.aft_link?.theme && - themes.includes(diagnostic.aft_link.theme), - ); - } - - // Metric values filter, keeping diagnostics whose flags have not loaded yet - if (metricValuesFilter !== null) { - filtered = filtered.filter( - (diagnostic) => - diagnostic.has_metric_values === null || - diagnostic.has_metric_values === metricValuesFilter, - ); - } - - onFilterChange(filtered); - - // Update URL only when explicitly requested (user interaction) - if (updateUrl && onFilterParamsChange) { - onFilterParamsChange( - search, - providers, - aftIds, - themes, - metricValuesFilter, - ); - } - }, - [diagnostics, onFilterChange, onFilterParamsChange], + const options = useMemo( + () => diagnosticFilterOptions(diagnostics), + [diagnostics], ); - - // Reapply the current filters whenever the diagnostics change, including when late value flags arrive. - // biome-ignore lint/correctness/useExhaustiveDependencies: filter changes already apply themselves - useEffect(() => { - applyFilters( - searchTerm, - selectedProviders, - selectedAftIds, - selectedThemes, - showWithMetricValues, - false, - ); - }, [diagnostics]); - - const handleSearchChange = (value: string) => { - setSearchTerm(value); - applyFilters( - value, - selectedProviders, - selectedAftIds, - selectedThemes, - showWithMetricValues, - true, // Update URL on user interaction - ); - }; - - const handleProviderToggle = (provider: string) => { - const newProviders = selectedProviders.includes(provider) - ? selectedProviders.filter((p) => p !== provider) - : [...selectedProviders, provider]; - setSelectedProviders(newProviders); - applyFilters( - searchTerm, - newProviders, - selectedAftIds, - selectedThemes, - showWithMetricValues, - true, // Update URL on user interaction - ); - }; - - const handleAftIdToggle = (aftId: string) => { - const newAftIds = selectedAftIds.includes(aftId) - ? selectedAftIds.filter((id) => id !== aftId) - : [...selectedAftIds, aftId]; - setSelectedAftIds(newAftIds); - applyFilters( - searchTerm, - selectedProviders, - newAftIds, - selectedThemes, - showWithMetricValues, - true, // Update URL on user interaction - ); - }; - - const handleThemeToggle = (theme: string) => { - const newThemes = selectedThemes.includes(theme) - ? selectedThemes.filter((t) => t !== theme) - : [...selectedThemes, theme]; - setSelectedThemes(newThemes); - applyFilters( - searchTerm, - selectedProviders, - selectedAftIds, - newThemes, - showWithMetricValues, - true, // Update URL on user interaction - ); - }; - - const handleMetricValuesFilter = (value: boolean | null) => { - setShowWithMetricValues(value); - applyFilters( - searchTerm, - selectedProviders, - selectedAftIds, - selectedThemes, - value, - true, // Update URL on user interaction - ); - }; - - const clearAllFilters = () => { - setSearchTerm(""); - setSelectedProviders([]); - setSelectedAftIds([]); - setSelectedThemes([]); - setShowWithMetricValues(null); - applyFilters("", [], [], [], null, true); - }; - - const hasActiveFilters = - searchTerm || - selectedProviders.length > 0 || - selectedAftIds.length > 0 || - selectedThemes.length > 0 || - showWithMetricValues !== null; + const update = (changes: Partial) => + onChange({ ...value, ...changes }); + const toggleProvider = (provider: string) => + update({ providers: toggled(value.providers, provider) }); + const toggleAftId = (aftId: string) => + update({ aftIds: toggled(value.aftIds, aftId) }); + const toggleTheme = (theme: string) => + update({ themes: toggled(value.themes, theme) }); return (
- {/* Search Bar */}
- handleSearchChange(e.target.value)} + value={value.search} + onValueChange={(search) => update({ search })} className="pl-10" />
- {/* Filter Controls */}
- - - - - -
-

Filter by Providers

-
- {allProviders.map((provider) => ( -
- handleProviderToggle(provider)} - /> - -
- ))} -
-
-
-
+ ({ value: p, label: p }))} + selected={value.providers} + onToggle={toggleProvider} + /> - {allAftIds.length > 0 && ( - - - - - -
-

Filter by AFT Diagnostics

-
- {allAftIds.map((aft) => ( -
- handleAftIdToggle(aft.id)} - /> - -
- ))} -
-
-
-
+ {options.aftLinks.length > 0 && ( + ({ + value: aft.id, + label: `${aft.id} - ${aft.name}`, + }))} + selected={value.aftIds} + onToggle={toggleAftId} + className="w-120" + /> )} - {allThemes.length > 0 && ( - - - - - -
-

Filter by Themes

-
- {allThemes.map((theme) => ( -
- handleThemeToggle(theme)} - /> - -
- ))} -
-
-
-
+ {options.themes.length > 0 && ( + ({ value: t, label: t }))} + selected={value.themes} + onToggle={toggleTheme} + /> )} - - - - - -
-

Filter by Metric Values

-
-
- - handleMetricValuesFilter( - showWithMetricValues === true ? null : true, - ) - } - /> - -
-
- - handleMetricValuesFilter( - showWithMetricValues === false ? null : false, - ) - } - /> - -
-
-
-
-
+ + update({ + metricValues: + `${value.metricValues}` === checked ? null : checked === "true", + }) + } + className="w-60" + /> - {hasActiveFilters && ( - )}
- {/* Active Filters Display */} - {(selectedProviders.length > 0 || - selectedAftIds.length > 0 || - selectedThemes.length > 0 || - showWithMetricValues !== null) && ( + {hasSelections(value) && (
- {selectedProviders.map((provider) => ( - ( + toggleProvider(provider)} > Provider: {provider} - handleProviderToggle(provider)} - /> - + ))} - {selectedAftIds.map((aftId) => { - const aft = allAftIds.find((a) => a.id === aftId); + {value.aftIds.map((aftId) => { + const aft = options.aftLinks.find((a) => a.id === aftId); return ( - toggleAftId(aftId)} > CMIP7 AFT: {aftId} {aft ? ` - ${aft.name}` : ""} - handleAftIdToggle(aftId)} - /> - + ); })} - {selectedThemes.map((theme) => ( - ( + toggleTheme(theme)} > Theme: {theme} - handleThemeToggle(theme)} - /> - + ))} - {showWithMetricValues !== null && ( - - {showWithMetricValues ? "Has values" : "No values"} - handleMetricValuesFilter(null)} - /> - + {value.metricValues !== null && ( + update({ metricValues: null })}> + {value.metricValues ? "Has values" : "No values"} + )}
)} diff --git a/frontend/src/content/about-diagnostics.mdx b/frontend/src/content/about-diagnostics.mdx new file mode 100644 index 00000000..24d55897 --- /dev/null +++ b/frontend/src/content/about-diagnostics.mdx @@ -0,0 +1,26 @@ +### About Diagnostics + +This catalog provides detailed information about the diagnostic tools used to evaluate climate model performance against observational data and established benchmarks. +Each diagnostic implements specific methodologies for assessing model fidelity across different Earth system components. + +### Diagnostic Providers + +- **PMP (PCMDI Metrics Package)** +- **ESMValTool** +- **ILAMB/IOMB** + +### What You'll Find + +Each diagnostic provides information about the methodology, reference datasets used, and the data requirements. +The REF performs multiple executions for each diagnostic across different models and datasets, with each execution producing useful outputs: + +- **Metric Values:** Scalar statistics or time series quantifying model performance +- **Figures:** Pre-generated visualizations (spatial plots, time series, comparisons) +- **Data Files:** Downloadable data for custom analysis and offline visualization + +Use the filters below to explore diagnostics by provider, variable, realm, or search for specific analyses. +Click on any diagnostic to view detailed results and documentation. + +--- + +Found an error, issue with a diagnostic or have an idea? [Report it on GitHub →](https://github.com/Climate-REF/ref-app/issues) diff --git a/frontend/src/lib/diagnosticFilters.test.ts b/frontend/src/lib/diagnosticFilters.test.ts new file mode 100644 index 00000000..4f79dae9 --- /dev/null +++ b/frontend/src/lib/diagnosticFilters.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import type { AftDiagnosticDetail } from "@/client"; +import type { CatalogDiagnostic } from "./diagnosticCatalog"; +import { + type DiagnosticFilterState, + diagnosticFilterOptions, + filterDiagnostics, + hasActiveFilters, + hasSelections, + NO_DIAGNOSTIC_FILTERS, + parseDiagnosticFilters, + serialiseDiagnosticFilters, +} from "./diagnosticFilters"; + +function diagnostic( + name: string, + { + provider = "pmp", + description = "", + hasValues = null, + aft = null, + }: { + provider?: string; + description?: string | null; + hasValues?: boolean | null; + aft?: { id: string; name?: string; theme?: string | null } | null; + } = {}, +): CatalogDiagnostic { + return { + name, + slug: name, + description, + provider: { slug: provider, name: provider.toUpperCase() }, + has_metric_values: hasValues, + aft_link: + aft && ({ name: aft.id, theme: null, ...aft } as AftDiagnosticDetail), + } as CatalogDiagnostic; +} + +const filters = ( + changes: Partial, +): DiagnosticFilterState => ({ ...NO_DIAGNOSTIC_FILTERS, ...changes }); + +const names = (diagnostics: CatalogDiagnostic[]) => + diagnostics.map((d) => d.name); + +describe("filterDiagnostics", () => { + const all = [ + diagnostic("sea-ice", { + provider: "esmvaltool", + aft: { id: "AFT-1", theme: "Ocean" }, + }), + diagnostic("enso", { + description: "El Nino Southern Oscillation", + aft: { id: "AFT-2", theme: "Atmosphere" }, + }), + diagnostic("gpp", { provider: "ilamb", description: null }), + ]; + + it("keeps everything without filters", () => { + expect(filterDiagnostics(all, NO_DIAGNOSTIC_FILTERS)).toEqual(all); + }); + + it.each([ + ["the name", "SEA", ["sea-ice"]], + ["the description", "southern", ["enso"]], + ["the provider name", "ILAMB", ["gpp"]], + ["the provider slug", "esmval", ["sea-ice"]], + ])("searches %s ignoring case", (_, search, expected) => { + expect(names(filterDiagnostics(all, filters({ search })))).toEqual( + expected, + ); + }); + + it("ignores a blank search", () => { + expect(filterDiagnostics(all, filters({ search: " " }))).toEqual(all); + }); + + it("keeps any of the selections within a filter", () => { + expect( + names(filterDiagnostics(all, filters({ providers: ["pmp", "ilamb"] }))), + ).toEqual(["enso", "gpp"]); + }); + + it("leaves out diagnostics without an AFT link when filtering by AFT id or theme", () => { + expect( + names(filterDiagnostics(all, filters({ aftIds: ["AFT-1", "AFT-2"] }))), + ).toEqual(["sea-ice", "enso"]); + expect( + names(filterDiagnostics(all, filters({ themes: ["Ocean"] }))), + ).toEqual(["sea-ice"]); + }); + + it("keeps only the diagnostics matching every filter", () => { + expect( + names( + filterDiagnostics( + all, + filters({ providers: ["pmp"], themes: ["Ocean", "Atmosphere"] }), + ), + ), + ).toEqual(["enso"]); + expect( + filterDiagnostics( + all, + filters({ providers: ["pmp"], themes: ["Ocean"] }), + ), + ).toEqual([]); + }); + + it("filters by metric values, keeping diagnostics whose flags have not loaded yet", () => { + const flagged = [ + diagnostic("with", { hasValues: true }), + diagnostic("without", { hasValues: false }), + diagnostic("pending", { hasValues: null }), + ]; + expect( + names(filterDiagnostics(flagged, filters({ metricValues: true }))), + ).toEqual(["with", "pending"]); + expect( + names(filterDiagnostics(flagged, filters({ metricValues: false }))), + ).toEqual(["without", "pending"]); + }); +}); + +describe("diagnosticFilterOptions", () => { + it("lists each provider, AFT diagnostic and theme once, sorted", () => { + const options = diagnosticFilterOptions([ + diagnostic("a", { + provider: "pmp", + aft: { id: "AFT-2", name: "Two", theme: "Ocean" }, + }), + diagnostic("b", { + provider: "esmvaltool", + aft: { id: "AFT-1", name: "One", theme: "Land" }, + }), + diagnostic("c", { + provider: "pmp", + aft: { id: "AFT-2", name: "Two", theme: "Ocean" }, + }), + diagnostic("d", { + provider: "ilamb", + aft: { id: "AFT-3", name: "Three" }, + }), + diagnostic("e", { provider: "ilamb" }), + ]); + expect(options).toEqual({ + providers: ["esmvaltool", "ilamb", "pmp"], + aftLinks: [ + { id: "AFT-1", name: "One" }, + { id: "AFT-2", name: "Two" }, + { id: "AFT-3", name: "Three" }, + ], + themes: ["Land", "Ocean"], + }); + }); +}); + +describe("hasActiveFilters", () => { + it("counts the search as active but not as a selection", () => { + const searched = filters({ search: "enso" }); + expect(hasActiveFilters(searched)).toBe(true); + expect(hasSelections(searched)).toBe(false); + }); + + it("counts a metric values filter of false as a selection", () => { + expect(hasSelections(filters({ metricValues: false }))).toBe(true); + expect(hasActiveFilters(NO_DIAGNOSTIC_FILTERS)).toBe(false); + }); +}); + +describe("URL search params", () => { + it.each([ + filters({}), + filters({ search: "sea ice" }), + filters({ + providers: ["pmp", "esmvaltool"], + aftIds: ["AFT-1"], + themes: ["Ocean", "Land"], + metricValues: false, + }), + filters({ metricValues: true }), + ])("round trips %j", (state) => { + expect(parseDiagnosticFilters(serialiseDiagnosticFilters(state))).toEqual( + state, + ); + }); + + it("joins lists with commas and writes the metric values flag as text", () => { + expect( + serialiseDiagnosticFilters( + filters({ providers: ["pmp", "ilamb"], metricValues: true }), + ), + ).toEqual({ + search: undefined, + providers: "pmp,ilamb", + aftIds: undefined, + themes: undefined, + metricValues: "true", + }); + }); + + it("leaves out empty filters so they drop from the URL", () => { + expect( + Object.values(serialiseDiagnosticFilters(NO_DIAGNOSTIC_FILTERS)), + ).toEqual([undefined, undefined, undefined, undefined, undefined]); + }); + + it("reads missing or empty params as no filter", () => { + expect(parseDiagnosticFilters({})).toEqual(NO_DIAGNOSTIC_FILTERS); + expect(parseDiagnosticFilters({ providers: "", themes: "Ocean," })).toEqual( + filters({ themes: ["Ocean"] }), + ); + }); +}); diff --git a/frontend/src/lib/diagnosticFilters.ts b/frontend/src/lib/diagnosticFilters.ts new file mode 100644 index 00000000..d6450a29 --- /dev/null +++ b/frontend/src/lib/diagnosticFilters.ts @@ -0,0 +1,141 @@ +import type { CatalogDiagnostic } from "./diagnosticCatalog"; + +/** What the diagnostics catalog is narrowed to */ +export interface DiagnosticFilterState { + /** Text to find in the name, description or provider */ + search: string; + /** Provider slugs */ + providers: string[]; + /** CMIP7 AFT diagnostic ids */ + aftIds: string[]; + /** CMIP7 AFT themes */ + themes: string[]; + /** Keep only diagnostics with (true) or without (false) metric values, or null for all */ + metricValues: boolean | null; +} + +export const NO_DIAGNOSTIC_FILTERS: DiagnosticFilterState = { + search: "", + providers: [], + aftIds: [], + themes: [], + metricValues: null, +}; + +export function hasActiveFilters(filters: DiagnosticFilterState): boolean { + return filters.search !== "" || hasSelections(filters); +} + +/** Whether anything other than the search is filtered on */ +export function hasSelections(filters: DiagnosticFilterState): boolean { + return ( + filters.providers.length > 0 || + filters.aftIds.length > 0 || + filters.themes.length > 0 || + filters.metricValues !== null + ); +} + +function matchesSearch(diagnostic: CatalogDiagnostic, search: string) { + const needle = search.toLowerCase(); + return [ + diagnostic.name, + diagnostic.description, + diagnostic.provider.name, + diagnostic.provider.slug, + ].some((field) => field?.toLowerCase().includes(needle)); +} + +/** Whether `value` is one of the selections, where selecting nothing keeps everything */ +const isSelected = (selected: string[], value: string | null | undefined) => + selected.length === 0 || (value != null && selected.includes(value)); + +/** + * Keep the diagnostics matching every filter, and any of the selections within each filter + * + * Diagnostics whose metric value flags have not loaded yet are kept by the metric values filter. + */ +export function filterDiagnostics( + diagnostics: CatalogDiagnostic[], + { search, providers, aftIds, themes, metricValues }: DiagnosticFilterState, +): CatalogDiagnostic[] { + return diagnostics.filter( + (diagnostic) => + (!search.trim() || matchesSearch(diagnostic, search)) && + isSelected(providers, diagnostic.provider.slug) && + isSelected(aftIds, diagnostic.aft_link?.id) && + isSelected(themes, diagnostic.aft_link?.theme) && + (metricValues === null || + diagnostic.has_metric_values === null || + diagnostic.has_metric_values === metricValues), + ); +} + +/** The values each diagnostics filter can select, sorted */ +export interface DiagnosticFilterOptions { + providers: string[]; + aftLinks: { id: string; name: string }[]; + themes: string[]; +} + +export function diagnosticFilterOptions( + diagnostics: CatalogDiagnostic[], +): DiagnosticFilterOptions { + const aftLinks = new Map(); + const themes = new Set(); + for (const { aft_link } of diagnostics) { + if (aft_link) { + aftLinks.set(aft_link.id, { id: aft_link.id, name: aft_link.name }); + if (aft_link.theme) { + themes.add(aft_link.theme); + } + } + } + return { + providers: [...new Set(diagnostics.map((d) => d.provider.slug))].sort(), + aftLinks: [...aftLinks.values()].sort((a, b) => a.id.localeCompare(b.id)), + themes: [...themes].sort(), + }; +} + +/** The filters as the catalog's URL search params, with lists joined by commas */ +export interface DiagnosticFilterSearch { + search?: string; + providers?: string; + aftIds?: string; + themes?: string; + metricValues?: "true" | "false"; +} + +const splitList = (value: string | undefined) => + value?.split(",").filter(Boolean) ?? []; + +const joinList = (values: string[]) => + values.length > 0 ? values.join(",") : undefined; + +export function parseDiagnosticFilters( + search: DiagnosticFilterSearch, +): DiagnosticFilterState { + return { + search: search.search ?? "", + providers: splitList(search.providers), + aftIds: splitList(search.aftIds), + themes: splitList(search.themes), + metricValues: + search.metricValues === undefined ? null : search.metricValues === "true", + }; +} + +/** Turn the filters into URL search params, leaving out the ones not filtered on. */ +export function serialiseDiagnosticFilters( + filters: DiagnosticFilterState, +): DiagnosticFilterSearch { + return { + search: filters.search || undefined, + providers: joinList(filters.providers), + aftIds: joinList(filters.aftIds), + themes: joinList(filters.themes), + metricValues: + filters.metricValues === null ? undefined : `${filters.metricValues}`, + }; +} diff --git a/frontend/src/routes/_app/diagnostics.index.tsx b/frontend/src/routes/_app/diagnostics.index.tsx index 34904d7b..29beae1f 100644 --- a/frontend/src/routes/_app/diagnostics.index.tsx +++ b/frontend/src/routes/_app/diagnostics.index.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Loader2 } from "lucide-react"; -import { useMemo, useState } from "react"; +import { type ComponentProps, useMemo } from "react"; import { z } from "zod"; import { diagnosticsCatalogOptions, @@ -17,11 +17,18 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { LinkExternal } from "@/components/ui/link"; +import AboutDiagnostics from "@/content/about-diagnostics.mdx"; import { useMipEra } from "@/hooks/useMipEra"; import { type CatalogDiagnostic, withValueFlags, } from "@/lib/diagnosticCatalog"; +import { + type DiagnosticFilterState, + filterDiagnostics, + parseDiagnosticFilters, + serialiseDiagnosticFilters, +} from "@/lib/diagnosticFilters"; import { mipEraSearchFields } from "@/lib/mipEras"; const diagnosticNotes: { slug: string; note: string; noteUrl?: string }[] = [ @@ -73,6 +80,25 @@ const CATALOG_HEADER = { ), }; +/** Styles for the "About Diagnostics" prose, which sits in a card rather than a prose block */ +const ABOUT_COMPONENTS = { + h3: (props: ComponentProps<"h3">) => ( +

+ ), + ul: (props: ComponentProps<"ul">) => ( +

) : ( - <> - {searchParams.view === "cards" ? ( -
- {filteredDiagnostics.map((diagnostic) => { - const note = diagnosticNotes.find( - (n) => n.slug === diagnostic.slug, - ); - return ( - - ); - })} -
- ) : ( - - )} - - {filteredDiagnostics.length === 0 && ( -
-

- No diagnostics found matching your filters. -

-
- )} - + )}
); From 0a452417205c6087dc76fa445df43290a744df7f Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:33:48 +0200 Subject: [PATCH 37/61] cleanup: move the ensemble chart's data and axis computations into a tested module and extract its tooltip --- .../diagnostics/ensembleChart.test.ts | 99 --- .../components/diagnostics/ensembleChart.tsx | 634 ++++++------------ .../diagnostics/ensembleChartData.test.ts | 303 +++++++++ .../diagnostics/ensembleChartData.ts | 273 ++++++++ 4 files changed, 780 insertions(+), 529 deletions(-) delete mode 100644 frontend/src/components/diagnostics/ensembleChart.test.ts create mode 100644 frontend/src/components/diagnostics/ensembleChartData.test.ts create mode 100644 frontend/src/components/diagnostics/ensembleChartData.ts diff --git a/frontend/src/components/diagnostics/ensembleChart.test.ts b/frontend/src/components/diagnostics/ensembleChart.test.ts deleted file mode 100644 index d82d5ad5..00000000 --- a/frontend/src/components/diagnostics/ensembleChart.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - formatCategoryLabel, - KNOWN_CATEGORY_ORDERS, - sortCategories, -} from "./ensembleChart"; - -type NamedItem = { name: string }; - -function items(...names: string[]): NamedItem[] { - return names.map((name) => ({ name })); -} - -function names(sorted: NamedItem[]): string[] { - return sorted.map((item) => item.name); -} - -describe("KNOWN_CATEGORY_ORDERS", () => { - it("includes Annual variants before seasons", () => { - const season = KNOWN_CATEGORY_ORDERS.season; - expect(season.indexOf("Annual")).toBeLessThan(season.indexOf("DJF")); - expect(season.indexOf("DJF")).toBeLessThan(season.indexOf("MAM")); - expect(season.indexOf("MAM")).toBeLessThan(season.indexOf("JJA")); - expect(season.indexOf("JJA")).toBeLessThan(season.indexOf("SON")); - }); -}); - -describe("sortCategories", () => { - it("sorts seasons chronologically via auto-detection", () => { - const input = items("SON", "DJF", "JJA", "MAM"); - const result = names(sortCategories(input)); - expect(result).toEqual(["DJF", "MAM", "JJA", "SON"]); - }); - - it("sorts Annual before seasons via auto-detection", () => { - const input = items("SON", "Annual", "DJF", "JJA", "MAM"); - const result = names(sortCategories(input)); - expect(result).toEqual(["Annual", "DJF", "MAM", "JJA", "SON"]); - }); - - it("handles lowercase annual variant", () => { - const input = items("SON", "annual", "DJF", "MAM", "JJA"); - const result = names(sortCategories(input)); - expect(result).toEqual(["annual", "DJF", "MAM", "JJA", "SON"]); - }); - - it("handles ANN variant", () => { - const input = items("SON", "ANN", "DJF", "MAM", "JJA"); - const result = names(sortCategories(input)); - expect(result).toEqual(["ANN", "DJF", "MAM", "JJA", "SON"]); - }); - - it("handles lowercase PMP season values", () => { - const input = items("ann", "djf", "jja", "mam", "son"); - const result = names(sortCategories(input)); - expect(result).toEqual(["ann", "djf", "mam", "jja", "son"]); - }); - - it("preserves original order for non-season data", () => { - const input = items("region_c", "region_a", "region_b"); - const result = names(sortCategories(input)); - expect(result).toEqual(["region_c", "region_a", "region_b"]); - }); - - it("uses explicit category order when provided", () => { - const input = items("c", "a", "b"); - const result = names(sortCategories(input, ["a", "b", "c"])); - expect(result).toEqual(["a", "b", "c"]); - }); - - it("puts items not in explicit order at the end", () => { - const input = items("c", "unknown", "a", "b"); - const result = names(sortCategories(input, ["a", "b", "c"])); - expect(result).toEqual(["a", "b", "c", "unknown"]); - }); - - it("returns empty array for empty input", () => { - expect(sortCategories([])).toEqual([]); - }); - - it("does not auto-detect when categories don't fully match a known order", () => { - // "Temperature" is not in any known order, so no sorting applied - const input = items("Temperature", "DJF", "MAM"); - const result = names(sortCategories(input)); - expect(result).toEqual(["Temperature", "DJF", "MAM"]); - }); -}); - -describe("formatCategoryLabel", () => { - it("expands ann to annual", () => { - expect(formatCategoryLabel("ann")).toBe("annual"); - expect(formatCategoryLabel("ANN")).toBe("annual"); - }); - - it("leaves other values unchanged", () => { - expect(formatCategoryLabel("djf")).toBe("djf"); - expect(formatCategoryLabel("historical")).toBe("historical"); - }); -}); diff --git a/frontend/src/components/diagnostics/ensembleChart.tsx b/frontend/src/components/diagnostics/ensembleChart.tsx index 964ecff4..b47faac5 100644 --- a/frontend/src/components/diagnostics/ensembleChart.tsx +++ b/frontend/src/components/diagnostics/ensembleChart.tsx @@ -1,4 +1,3 @@ -import * as d3 from "d3-array"; import { scaleLinear } from "d3-scale"; import { useMemo, useRef, useState } from "react"; import { @@ -13,10 +12,6 @@ import { YAxis, } from "recharts"; import type { ScalarValue } from "@/client/types.gen"; -import { - EXPERIMENT_ORDER, - getFixedDimensionColor, -} from "@/components/charts/experimentColors"; import { BoxWhiskerShape } from "@/components/execution/values/boxWhiskerShape.tsx"; import type { ChartGroupingConfig } from "@/components/explorer/grouping"; import { @@ -24,86 +19,16 @@ import { initializeGroupingConfig, } from "@/components/explorer/grouping"; import { createScaledTickFormatter } from "../execution/values/series/utils"; - -// Well-known category orderings for common climate dimensions (matched case-insensitively) -export const KNOWN_CATEGORY_ORDERS: Record = { - // Meteorological seasons (Annual first, then chronological) - season: ["Annual", "ANN", "DJF", "MAM", "JJA", "SON"], - experiment_id: EXPERIMENT_ORDER, -}; - -// Display labels for abbreviated category values (keyed by lowercase value) -const CATEGORY_LABELS: Record = { - ann: "annual", -}; - -export function formatCategoryLabel(name: string): string { - return CATEGORY_LABELS[name.toLowerCase()] ?? name; -} - -function indexOfIgnoreCase(order: string[], name: string): number { - const lower = name.toLowerCase(); - return order.findIndex((item) => item.toLowerCase() === lower); -} - -/** - * Sort chart categories using a known ordering if one exists, - * otherwise preserve the original order. - */ -export function sortCategories( - items: T[], - categoryOrder?: string[], -): T[] { - const order = categoryOrder; - if (!order) { - // Auto-detect: check if all category names match a known ordering - for (const knownOrder of Object.values(KNOWN_CATEGORY_ORDERS)) { - const ranked = items.map((item) => ({ - item, - rank: indexOfIgnoreCase(knownOrder, item.name), - })); - if (ranked.every(({ rank }) => rank !== -1)) { - return ranked.sort((a, b) => a.rank - b.rank).map(({ item }) => item); - } - } - return items; - } - - return [...items].sort((a, b) => { - const aIdx = indexOfIgnoreCase(order, a.name); - const bIdx = indexOfIgnoreCase(order, b.name); - // Items not in the order go to the end, preserving relative order - if (aIdx === -1 && bIdx === -1) return 0; - if (aIdx === -1) return 1; - if (bIdx === -1) return -1; - return aIdx - bIdx; - }); -} - -// Color palette for different groups -const COLORS = [ - "#8884d8", - "#82ca9d", - "#ffc658", - "#ff7300", - "#00ff00", - "#0088fe", - "#00c49f", - "#ffbb28", - "#ff8042", - "#8dd1e1", -]; - -// Reserved colours win, so an experiment looks the same on every chart. -function groupColor( - dimension: string | undefined, - value: string, - index: number, -): string { - return ( - getFixedDimensionColor(dimension, value) ?? COLORS[index % COLORS.length] - ); -} +import { + buildEnsembleChartData, + categoryAxisLayout, + computeYDomain, + ENSEMBLE_GROUP, + type EnsembleCategory, + formatCategoryLabel, + groupColor, + hueGroupNames, +} from "./ensembleChartData"; interface EnsembleChartProps { data: ScalarValue[]; @@ -124,14 +49,11 @@ interface EnsembleChartProps { categoryOrder?: string[]; } -interface GroupStatistics { - min: number; - lowerQuartile: number; - median: number; - upperQuartile: number; - max: number; - values: number[]; - points: ScalarValue[]; +/** The data point nearest the pointer, and the box it belongs to */ +interface HighlightedPoint { + categoryName: string; + groupName: string; + point: ScalarValue; } export const EmptyEnsembleChart = () => { @@ -153,6 +75,134 @@ const chartHeight = 500; const marginTop = 24; const marginBottom = 10; +/** + * The data point whose marker is nearest to a position in chart coordinates + * + * Markers are compared by the chart coordinates they carry rather than by measuring them, + * because their client rects were offset in Firefox. + */ +function findNearestPoint( + container: HTMLElement, + x: number, + y: number, + categoriesByName: Map, +): HighlightedPoint | null { + let nearest: HighlightedPoint | null = null; + let distance = Number.POSITIVE_INFINITY; + for (const marker of container.querySelectorAll( + "[data-box-point]", + )) { + const dx = x - Number(marker.dataset.x); + const dy = y - Number(marker.dataset.y); + const nextDistance = dx * dx + dy * dy; + if (nextDistance >= distance) continue; + const categoryName = marker.dataset.category!; + const groupName = marker.dataset.group!; + const point = + categoriesByName.get(categoryName)?.groups[groupName]?.points[ + Number(marker.dataset.boxPoint) + ]; + if (!point) continue; + distance = nextDistance; + nearest = { categoryName, groupName, point }; + } + return nearest; +} + +interface EnsembleTooltipProps { + active?: boolean; + coordinate?: { x?: number }; + chartWidth?: number; + highlightedPoint: HighlightedPoint | null; + /** The category of the highlighted point */ + category: EnsembleCategory | undefined; + /** Whether the chart has several boxes per category, so the box's group is named */ + hasHueGroups: boolean; + format: (v: number) => string; + metricUnits: string; +} + +/** The statistics of the highlighted point's box, and the point itself */ +function EnsembleTooltip({ + active, + coordinate, + chartWidth = 0, + highlightedPoint, + category, + hasHueGroups, + format, + metricUnits, +}: EnsembleTooltipProps) { + if (!active || !highlightedPoint || coordinate === undefined) return null; + const { categoryName, groupName, point } = highlightedPoint; + const stats = category?.groups[groupName]; + // The highlighted point may be from data the chart no longer shows + if (!stats?.points.includes(point)) return null; + const clipped = category?.__outliers[groupName] ?? 0; + // If cursor is on the right half of the chart, show tooltip on the left + const onLeft = + coordinate.x !== undefined && + chartWidth > 0 && + coordinate.x > chartWidth / 2; + + return ( +
+
+ {formatCategoryLabel(categoryName)} + {groupName !== ENSEMBLE_GROUP && hasHueGroups && ( + ({groupName}) + )} +
+ +
+
Statistics
+
+ {renderKV("Min", format(stats.min))} + {renderKV("Q1", format(stats.lowerQuartile))} + {renderKV("Median", format(stats.median))} + {renderKV("Q3", format(stats.upperQuartile))} + {renderKV("Max", format(stats.max))} + {renderKV("Count", String(stats.values.length + clipped))} + {clipped > 0 && ( +
+ ({clipped} outliers clipped) +
+ )} +
+
+ +
+
Closest Data Point
+
+
+ {renderKV("Value", format(Number(point.value)))} + {renderKV("Units", metricUnits)} +
+
+
Dimensions:
+
+ {Object.entries(point.dimensions).map(([key, value]) => ( +
+ {key}: + + {value} + +
+ ))} +
+
+
+
+
+ ); +} + export const EnsembleChart = ({ data, metricName, @@ -168,11 +218,8 @@ export const EnsembleChart = ({ categoryOrder, }: EnsembleChartProps) => { const chartRef = useRef(null); - const [highlightedPoint, setHighlightedPoint] = useState<{ - categoryName: string; - groupName: string; - point: ScalarValue; - } | null>(null); + const [highlightedPoint, setHighlightedPoint] = + useState(null); // Extract available dimensions and initialize grouping config const availableDimensions = useMemo( @@ -180,243 +227,66 @@ export const EnsembleChart = ({ [data], ); - const finalGroupingConfig = useMemo(() => { + const { groupBy, hue } = useMemo(() => { return initializeGroupingConfig(availableDimensions, groupingConfig); }, [availableDimensions, groupingConfig]); - // Group data by the specified groupBy dimension - const groupByDimension = finalGroupingConfig.groupBy || "metric"; - const hueDimension = finalGroupingConfig.hue; - - // When groupBy and hue are the same, don't create subgroups - // This prevents odd spacing where only one bar has data at each x-axis position - const isSelfHued = hueDimension === groupByDimension; - - // Group by the main dimension (x-axis), then by hue within each group, - // and compute box-whisker statistics for each subgroup. - const sortedChartData = useMemo(() => { - const primaryGroupedData = Object.groupBy( - data, - (d: ScalarValue) => d.dimensions[groupByDimension] ?? metricName, - ); - - const chartData = Object.entries(primaryGroupedData).map( - ( - [groupName, values]: [string, ScalarValue[] | undefined], - categoryIndex, - ) => { - if (!values || values.length === 0) { - return { - name: groupName, - groups: {}, - __outliers: {}, - __categoryColor: isSelfHued - ? groupColor(groupByDimension, groupName, categoryIndex) - : undefined, - }; - } - - // If hue dimension is specified (and different from groupBy), create sub-groups - let subGroups: { [key: string]: ScalarValue[] }; - if (!isSelfHued && hueDimension && hueDimension !== "none") { - // Normal hue: create sub-groups based on hue dimension - subGroups = Object.groupBy( - values, - (d: ScalarValue) => d.dimensions[hueDimension] ?? "Unknown", - ) as { [key: string]: ScalarValue[] }; - } else { - // Single group if no hue dimension or hue === groupBy - subGroups = { ensemble: values }; - } - - const groups: { [key: string]: GroupStatistics | null } = {}; - const outliers: { [key: string]: number } = {}; - - Object.entries(subGroups).forEach(([subGroupName, subGroupValues]) => { - const allValues: number[] = - subGroupValues - ?.map((d: ScalarValue) => Number(d.value)) - ?.filter((v: number) => Number.isFinite(v)) ?? []; - - const points = subGroupValues - .filter((d) => Number.isFinite(Number(d.value))) - .filter( - (d) => - (clipMin === undefined || Number(d.value) >= clipMin) && - (clipMax === undefined || Number(d.value) <= clipMax), - ) - .sort((a, b) => Number(a.value) - Number(b.value)); - const filteredValues = points.map((point) => Number(point.value)); - - if (filteredValues.length === 0) { - groups[subGroupName] = null; - outliers[subGroupName] = 0; - } else { - const min = d3.min(filteredValues)!; - const max = d3.max(filteredValues)!; - const q1 = d3.quantile(filteredValues, 0.25)!; - const median = d3.median(filteredValues)!; - const q3 = d3.quantile(filteredValues, 0.75)!; - - groups[subGroupName] = { - min, - lowerQuartile: q1, - median, - upperQuartile: q3, - max, - values: filteredValues, - points, - }; - outliers[subGroupName] = allValues.length - filteredValues.length; - } - }); - - return { - name: groupName, - groups, - __outliers: outliers, - __categoryColor: isSelfHued - ? groupColor(groupByDimension, groupName, categoryIndex) - : undefined, - }; - }, - ); - - // Sort categories using explicit order or well-known orderings (e.g., seasons) - return sortCategories(chartData, categoryOrder); - }, [ - data, - groupByDimension, - hueDimension, - isSelfHued, - metricName, - clipMin, - clipMax, - categoryOrder, - ]); + const chartData = useMemo( + () => + buildEnsembleChartData(data, { + groupBy, + hue, + fallbackCategory: metricName, + clipMin, + clipMax, + categoryOrder, + }), + [data, groupBy, hue, metricName, clipMin, clipMax, categoryOrder], + ); const categoriesByName = useMemo( - () => new Map(sortedChartData.map((datum) => [datum.name, datum])), - [sortedChartData], + () => new Map(chartData.map((datum) => [datum.name, datum])), + [chartData], ); - // Get all unique group names for rendering multiple bars - const allGroupNames = useMemo(() => { - const names = new Set(); - sortedChartData.forEach((d) => { - Object.keys(d.groups).forEach((groupName) => { - names.add(groupName); - }); - }); - const sorted = Array.from(names) - .sort() - .map((name) => ({ name })); - return sortCategories( - sorted, - hueDimension ? KNOWN_CATEGORY_ORDERS[hueDimension] : undefined, - ).map((item) => item.name); - }, [sortedChartData, hueDimension]); - - const scale = useMemo(() => { - const allFiniteValues = sortedChartData - .flatMap((d) => - Object.values(d.groups) - .filter((group) => group !== null) - .flatMap((group) => [group.min, group.max]), - ) - .filter((v) => Number.isFinite(v)) as number[]; - - // Find the global min and max - let minVal: number; - let maxVal: number; - - if (allFiniteValues.length === 0) { - minVal = 0; - maxVal = 1; - } else if (symmetricalAxes) { - // Use symmetrical axes centered on zero - const maxAbs = Math.max(...allFiniteValues.map(Math.abs)); - minVal = -maxAbs; - maxVal = maxAbs; - } else { - minVal = Math.min(...allFiniteValues); - maxVal = Math.max(...allFiniteValues); - } - - // If all values are identical, add some padding to ensure a range - if (minVal === maxVal) { - const pad = Math.max(1, Math.abs(minVal) * 0.1); - minVal -= pad; - maxVal += pad; - } - // Add some padding to the domain - const range = maxVal - minVal; - const padding = range * 0.05; + const allGroupNames = useMemo( + () => hueGroupNames(chartData, hue), + [chartData, hue], + ); - return scaleLinear() - .domain([ - yMin !== undefined ? yMin : minVal - padding, - yMax !== undefined ? yMax : maxVal + padding, - ]) - .nice(); - }, [sortedChartData, symmetricalAxes, yMin, yMax]); - const yDomain = scale.domain() as [number, number]; + const yDomain = useMemo( + () => computeYDomain(chartData, { symmetricalAxes, yMin, yMax }), + [chartData, symmetricalAxes, yMin, yMax], + ); + const scale = useMemo(() => scaleLinear().domain(yDomain), [yDomain]); const fmt = valueFormatter ?? createScaledTickFormatter(yDomain); - // Rotate x-axis labels when there are many items to keep them visible - const shouldRotateXAxis = sortedChartData.length > 6; - - // Adjust spacing based on number of items - // make them closer together when there are many - const barCategoryGap = - sortedChartData.length > 20 - ? "2%" - : sortedChartData.length > 10 - ? "5%" - : "20%"; + const { barCategoryGap, ...xAxisLayout } = categoryAxisLayout( + chartData.length, + ); return (
setHighlightedPoint(null)} - onMouseMove={(state) => { - const { chartX, chartY } = state; - if ( - !state.isTooltipActive || - chartX === undefined || - chartY === undefined || - !chartRef.current - ) { - setHighlightedPoint(null); - return; - } - let nearest: typeof highlightedPoint = null; - let distance = Number.POSITIVE_INFINITY; - // Compare in chart coordinates rather than measuring the rendered markers, - // whose client rects were offset in Firefox. - for (const marker of chartRef.current.querySelectorAll( - "[data-box-point]", - )) { - const dx = chartX - Number(marker.dataset.x); - const dy = chartY - Number(marker.dataset.y); - const nextDistance = dx * dx + dy * dy; - if (nextDistance >= distance) continue; - const categoryName = marker.dataset.category!; - const groupName = marker.dataset.group!; - const datum = categoriesByName.get(categoryName); - const point = - datum?.groups[groupName]?.points[ - Number(marker.dataset.boxPoint) - ]; - if (!point) continue; - distance = nextDistance; - nearest = { categoryName, groupName, point }; - } + onMouseMove={({ isTooltipActive, chartX, chartY }) => { + const nearest = + isTooltipActive && + chartX !== undefined && + chartY !== undefined && + chartRef.current + ? findNearestPoint( + chartRef.current, + chartX, + chartY, + categoriesByName, + ) + : null; setHighlightedPoint((previous) => previous?.point === nearest?.point && previous?.categoryName === nearest?.categoryName && @@ -433,14 +303,7 @@ export const EnsembleChart = ({ tickLine={false} axisLine={{ stroke: "#E5E7EB" }} tickMargin={10} - angle={shouldRotateXAxis ? -45 : 0} - textAnchor={shouldRotateXAxis ? "end" : "middle"} - height={shouldRotateXAxis ? 100 : 30} - interval={ - sortedChartData.length > 20 - ? Math.floor(sortedChartData.length / 20) - : 0 - } + {...xAxisLayout} /> { - if (!active || !highlightedPoint) return null; - const datum = categoriesByName.get(highlightedPoint.categoryName); - const statsKey = highlightedPoint.groupName; - const groupStats = datum?.groups[statsKey]; - if (!groupStats?.points.includes(highlightedPoint.point)) - return null; - const outliers = datum?.__outliers; - const closestDataPoint = highlightedPoint.point; - const label = formatCategoryLabel(highlightedPoint.categoryName); - if (coordinate === undefined) { - return null; - } - - let side = "right"; - const chartWidth = viewBox?.width ?? 0; - if ( - coordinate?.x !== undefined && - chartWidth > 0 && - coordinate.x > chartWidth / 2 - ) { - // If cursor is on the right half of the chart, show tooltip on the left - side = "left"; - } - - return ( -
-
- {label} - {statsKey !== "ensemble" && allGroupNames.length > 1 && ( - - ({statsKey}) - - )} -
- - {/* Ensemble Statistics */} -
-
Statistics
-
- {renderKV("Min", fmt(Number(groupStats.min)))} - {renderKV("Q1", fmt(Number(groupStats.lowerQuartile)))} - {renderKV("Median", fmt(Number(groupStats.median)))} - {renderKV("Q3", fmt(Number(groupStats.upperQuartile)))} - {renderKV("Max", fmt(Number(groupStats.max)))} - {renderKV( - "Count", - String( - groupStats.values.length + - (outliers?.[statsKey] ?? 0), - ), - )} - {outliers?.[statsKey] ? ( -
- ({outliers[statsKey]} outliers clipped) -
- ) : null} -
-
- - {/* Closest Data Point */} -
-
Closest Data Point
-
-
- {renderKV("Value", fmt(Number(closestDataPoint.value)))} - {renderKV("Units", metricUnits)} -
-
-
Dimensions:
-
- {Object.entries(closestDataPoint.dimensions).map( - ([key, value]) => ( -
- - {key}: - - - {value} - -
- ), - )} -
-
-
-
-
- ); - }} + content={({ active, coordinate, viewBox }) => ( + 1} + format={fmt} + metricUnits={metricUnits} + /> + )} /> {/* Show legend if there are multiple groups */} {allGroupNames.length > 1 && } @@ -581,7 +355,7 @@ export const EnsembleChart = ({ key={groupName} dataKey={(d) => d?.groups?.[groupName]?.median} name={formatCategoryLabel(groupName)} - fill={groupColor(hueDimension, groupName, index)} + fill={groupColor(hue, groupName, index)} isAnimationActive={false} shape={ ({ name })); +} + +function names(sorted: NamedItem[]): string[] { + return sorted.map((item) => item.name); +} + +describe("KNOWN_CATEGORY_ORDERS", () => { + it("includes Annual variants before seasons", () => { + const season = KNOWN_CATEGORY_ORDERS.season; + expect(season.indexOf("Annual")).toBeLessThan(season.indexOf("DJF")); + expect(season.indexOf("DJF")).toBeLessThan(season.indexOf("MAM")); + expect(season.indexOf("MAM")).toBeLessThan(season.indexOf("JJA")); + expect(season.indexOf("JJA")).toBeLessThan(season.indexOf("SON")); + }); +}); + +describe("sortCategories", () => { + it("sorts seasons chronologically via auto-detection", () => { + const input = items("SON", "DJF", "JJA", "MAM"); + const result = names(sortCategories(input)); + expect(result).toEqual(["DJF", "MAM", "JJA", "SON"]); + }); + + it("sorts Annual before seasons via auto-detection", () => { + const input = items("SON", "Annual", "DJF", "JJA", "MAM"); + const result = names(sortCategories(input)); + expect(result).toEqual(["Annual", "DJF", "MAM", "JJA", "SON"]); + }); + + it("handles lowercase annual variant", () => { + const input = items("SON", "annual", "DJF", "MAM", "JJA"); + const result = names(sortCategories(input)); + expect(result).toEqual(["annual", "DJF", "MAM", "JJA", "SON"]); + }); + + it("handles ANN variant", () => { + const input = items("SON", "ANN", "DJF", "MAM", "JJA"); + const result = names(sortCategories(input)); + expect(result).toEqual(["ANN", "DJF", "MAM", "JJA", "SON"]); + }); + + it("handles lowercase PMP season values", () => { + const input = items("ann", "djf", "jja", "mam", "son"); + const result = names(sortCategories(input)); + expect(result).toEqual(["ann", "djf", "mam", "jja", "son"]); + }); + + it("preserves original order for non-season data", () => { + const input = items("region_c", "region_a", "region_b"); + const result = names(sortCategories(input)); + expect(result).toEqual(["region_c", "region_a", "region_b"]); + }); + + it("uses explicit category order when provided", () => { + const input = items("c", "a", "b"); + const result = names(sortCategories(input, ["a", "b", "c"])); + expect(result).toEqual(["a", "b", "c"]); + }); + + it("puts items not in explicit order at the end", () => { + const input = items("c", "unknown", "a", "b"); + const result = names(sortCategories(input, ["a", "b", "c"])); + expect(result).toEqual(["a", "b", "c", "unknown"]); + }); + + it("returns empty array for empty input", () => { + expect(sortCategories([])).toEqual([]); + }); + + it("does not auto-detect when categories don't fully match a known order", () => { + // "Temperature" is not in any known order, so no sorting applied + const input = items("Temperature", "DJF", "MAM"); + const result = names(sortCategories(input)); + expect(result).toEqual(["Temperature", "DJF", "MAM"]); + }); +}); + +describe("formatCategoryLabel", () => { + it("expands ann to annual", () => { + expect(formatCategoryLabel("ann")).toBe("annual"); + expect(formatCategoryLabel("ANN")).toBe("annual"); + }); + + it("leaves other values unchanged", () => { + expect(formatCategoryLabel("djf")).toBe("djf"); + expect(formatCategoryLabel("historical")).toBe("historical"); + }); +}); + +let nextId = 0; +const value = (value: number, dimensions: Record = {}) => + ({ + id: nextId++, + execution_group_id: 1, + execution_id: 1, + value, + dimensions, + }) as ScalarValue; + +const build = (data: ScalarValue[], options: Partial) => + buildEnsembleChartData(data, { fallbackCategory: "tas", ...options }); + +describe("buildEnsembleChartData", () => { + it("makes a category per groupBy value with a box per hue value", () => { + const chart = build( + [ + value(1, { season: "JJA", model: "B" }), + value(2, { season: "DJF", model: "A" }), + value(3, { season: "DJF", model: "B" }), + ], + { groupBy: "season", hue: "model" }, + ); + expect(chart.map((c) => c.name)).toEqual(["DJF", "JJA"]); + expect(Object.keys(chart[0].groups)).toEqual(["A", "B"]); + expect(Object.keys(chart[1].groups)).toEqual(["B"]); + expect(chart[0].__categoryColor).toBeUndefined(); + }); + + it("draws one box per category, coloured by category, when the hue is the groupBy dimension", () => { + const chart = build( + [ + value(1, { experiment_id: "piControl" }), + value(2, { experiment_id: "historical" }), + ], + { groupBy: "experiment_id", hue: "experiment_id" }, + ); + expect(chart.map((c) => Object.keys(c.groups))).toEqual([ + [ENSEMBLE_GROUP], + [ENSEMBLE_GROUP], + ]); + expect(chart.map((c) => [c.name, c.__categoryColor])).toEqual([ + ["piControl", "#8884d8"], + // historical has a reserved colour + ["historical", "#4e79a7"], + ]); + }); + + it("draws one uncoloured box per category without a hue", () => { + const chart = build([value(1, { model: "A" }), value(2, { model: "B" })], { + groupBy: "model", + hue: "none", + }); + expect(chart.map((c) => Object.keys(c.groups))).toEqual([ + [ENSEMBLE_GROUP], + [ENSEMBLE_GROUP], + ]); + expect(chart.map((c) => c.__categoryColor)).toEqual([undefined, undefined]); + }); + + it("puts values missing a dimension in the fallback category and an Unknown box", () => { + const chart = build([value(1, { model: "A" }), value(2)], { + groupBy: "region", + hue: "model", + }); + expect(chart.map((c) => c.name)).toEqual(["tas"]); + expect(Object.keys(chart[0].groups)).toEqual(["A", "Unknown"]); + }); + + it("drops non-finite values and counts the clipped ones", () => { + const [category] = build( + [ + value(-5), + value(5), + value(15), + value(Number.NaN), + value(Number.POSITIVE_INFINITY), + ], + { clipMin: 0, clipMax: 10 }, + ); + expect(category.groups[ENSEMBLE_GROUP]?.values).toEqual([5]); + expect(category.__outliers[ENSEMBLE_GROUP]).toBe(2); + }); + + it("has no box when every value is clipped", () => { + const [category] = build([value(15)], { clipMax: 10 }); + expect(category.groups[ENSEMBLE_GROUP]).toBeNull(); + expect(category.__outliers[ENSEMBLE_GROUP]).toBe(1); + }); + + it("computes the quartiles with the points sorted by value", () => { + const data = [4, 1, 3, 2, 5].map((v) => value(v)); + const box = build(data, {})[0].groups[ENSEMBLE_GROUP]; + expect(box).toMatchObject({ + min: 1, + lowerQuartile: 2, + median: 3, + upperQuartile: 4, + max: 5, + values: [1, 2, 3, 4, 5], + }); + expect(box?.points.map((p) => p.value)).toEqual([1, 2, 3, 4, 5]); + }); + + it("orders categories by the given order", () => { + const chart = build( + [value(1, { region: "b" }), value(2, { region: "a" })], + { groupBy: "region", categoryOrder: ["a", "b"] }, + ); + expect(chart.map((c) => c.name)).toEqual(["a", "b"]); + }); +}); + +describe("hueGroupNames", () => { + const chart = build( + [ + value(1, { model: "m", experiment_id: "piControl" }), + value(2, { model: "m", experiment_id: "esm-hist" }), + value(3, { model: "n", experiment_id: "historical" }), + ], + { groupBy: "model", hue: "experiment_id" }, + ); + + it("lists each box name once in the known order for the hue", () => { + expect(hueGroupNames(chart, "experiment_id")).toEqual([ + "historical", + "esm-hist", + "piControl", + ]); + }); + + it("sorts names alphabetically without a known order", () => { + expect(hueGroupNames(chart, "other")).toEqual([ + "esm-hist", + "historical", + "piControl", + ]); + }); +}); + +describe("computeYDomain", () => { + const chart = (...values: number[]) => + build( + values.map((v) => value(v)), + {}, + ); + + it("pads the range of the values and rounds it", () => { + expect(computeYDomain(chart(0, 4, 10))).toEqual([-1, 11]); + }); + + it("covers 0 to 1 without values", () => { + expect(computeYDomain([])).toEqual([-0.1, 1.1]); + }); + + it("centres symmetrical axes on zero", () => { + expect(computeYDomain(chart(-2, 8), { symmetricalAxes: true })).toEqual([ + -10, 10, + ]); + }); + + it("widens the range when all values are equal", () => { + expect(computeYDomain(chart(5, 5))).toEqual([3.8, 6.2]); + }); + + it("uses the given bounds instead of the data", () => { + expect(computeYDomain(chart(0, 10), { yMin: 0, yMax: 100 })).toEqual([ + 0, 100, + ]); + expect(computeYDomain(chart(0, 10), { yMax: 5 })).toEqual([-0.5, 5]); + }); +}); + +describe("categoryAxisLayout", () => { + it("keeps the labels of a few categories level and spaces them widely", () => { + expect(categoryAxisLayout(6)).toEqual({ + angle: 0, + textAnchor: "middle", + height: 30, + interval: 0, + barCategoryGap: "20%", + }); + }); + + it("rotates the labels of more than 6 categories", () => { + expect(categoryAxisLayout(7)).toMatchObject({ angle: -45, height: 100 }); + expect(categoryAxisLayout(11).barCategoryGap).toBe("5%"); + }); + + it("labels only some of more than 20 categories", () => { + expect(categoryAxisLayout(45)).toMatchObject({ + interval: 2, + barCategoryGap: "2%", + }); + }); +}); diff --git a/frontend/src/components/diagnostics/ensembleChartData.ts b/frontend/src/components/diagnostics/ensembleChartData.ts new file mode 100644 index 00000000..51e189c6 --- /dev/null +++ b/frontend/src/components/diagnostics/ensembleChartData.ts @@ -0,0 +1,273 @@ +import * as d3 from "d3-array"; +import { scaleLinear } from "d3-scale"; +import type { ScalarValue } from "@/client/types.gen"; +import { + EXPERIMENT_ORDER, + getFixedDimensionColor, +} from "@/components/charts/experimentColors"; +import type { BoxPlot } from "@/components/execution/values/types"; + +// Well-known category orderings for common climate dimensions (matched case-insensitively) +export const KNOWN_CATEGORY_ORDERS: Record = { + // Meteorological seasons (Annual first, then chronological) + season: ["Annual", "ANN", "DJF", "MAM", "JJA", "SON"], + experiment_id: EXPERIMENT_ORDER, +}; + +// Display labels for abbreviated category values (keyed by lowercase value) +const CATEGORY_LABELS: Record = { + ann: "annual", +}; + +export function formatCategoryLabel(name: string): string { + return CATEGORY_LABELS[name.toLowerCase()] ?? name; +} + +function indexOfIgnoreCase(order: string[], name: string): number { + const lower = name.toLowerCase(); + return order.findIndex((item) => item.toLowerCase() === lower); +} + +/** + * Sort chart categories using a known ordering if one exists, + * otherwise preserve the original order. + */ +export function sortCategories( + items: T[], + categoryOrder?: string[], +): T[] { + const order = categoryOrder; + if (!order) { + // Auto-detect: check if all category names match a known ordering + for (const knownOrder of Object.values(KNOWN_CATEGORY_ORDERS)) { + const ranked = items.map((item) => ({ + item, + rank: indexOfIgnoreCase(knownOrder, item.name), + })); + if (ranked.every(({ rank }) => rank !== -1)) { + return ranked.sort((a, b) => a.rank - b.rank).map(({ item }) => item); + } + } + return items; + } + + return [...items].sort((a, b) => { + const aIdx = indexOfIgnoreCase(order, a.name); + const bIdx = indexOfIgnoreCase(order, b.name); + // Items not in the order go to the end, preserving relative order + if (aIdx === -1 && bIdx === -1) return 0; + if (aIdx === -1) return 1; + if (bIdx === -1) return -1; + return aIdx - bIdx; + }); +} + +// Color palette for different groups +const COLORS = [ + "#8884d8", + "#82ca9d", + "#ffc658", + "#ff7300", + "#00ff00", + "#0088fe", + "#00c49f", + "#ffbb28", + "#ff8042", + "#8dd1e1", +]; + +// Reserved colours win, so an experiment looks the same on every chart. +export function groupColor( + dimension: string | undefined, + value: string, + index: number, +): string { + return ( + getFixedDimensionColor(dimension, value) ?? COLORS[index % COLORS.length] + ); +} + +/** The name of the single box drawn per category when the chart has no hue groups */ +export const ENSEMBLE_GROUP = "ensemble"; + +/** One x-axis category of the ensemble chart, with a box per hue group */ +export interface EnsembleCategory { + name: string; + /** Box statistics by hue group, or null when clipping left the group no values */ + groups: Record; + /** How many of each group's values fell outside the clip range */ + __outliers: Record; + /** The colour of every box in the category, when the hue is the category itself */ + __categoryColor?: string; +} + +export interface EnsembleChartOptions { + /** The dimension the categories come from, "metric" if not given */ + groupBy?: string; + /** The dimension the boxes within a category are coloured by, or "none" */ + hue?: string; + /** The category of values without the groupBy dimension */ + fallbackCategory: string; + clipMin?: number; + clipMax?: number; + categoryOrder?: string[]; +} + +/** Box plot statistics of points sorted by value */ +function boxStats(points: ScalarValue[]): BoxPlot { + const values = points.map((point) => Number(point.value)); + return { + min: values[0], + lowerQuartile: d3.quantile(values, 0.25)!, + median: d3.median(values)!, + upperQuartile: d3.quantile(values, 0.75)!, + max: values[values.length - 1], + values, + points, + }; +} + +/** `Object.groupBy` as entries, which never holds an empty group */ +function groupEntries( + values: ScalarValue[], + key: (value: ScalarValue) => string, +): [string, ScalarValue[]][] { + return Object.entries(Object.groupBy(values, key)) as [ + string, + ScalarValue[], + ][]; +} + +/** + * Group the values into categories by the groupBy dimension and into boxes by the hue dimension + * + * Non-finite values are dropped, and values outside the clip range are counted but not drawn. + * When the hue is the groupBy dimension or "none", each category has one box, named `ENSEMBLE_GROUP`, + * which is coloured by its category when the hue is the groupBy dimension. + * Categories are sorted by `categoryOrder` or a known ordering, such as seasons. + */ +export function buildEnsembleChartData( + data: ScalarValue[], + { + groupBy, + hue, + fallbackCategory, + clipMin, + clipMax, + categoryOrder, + }: EnsembleChartOptions, +): EnsembleCategory[] { + const categoryDimension = groupBy || "metric"; + const isSelfHued = hue === categoryDimension; + // When the hue is the category itself, splitting would leave one box per category off centre + const hueDimension = !isSelfHued && hue && hue !== "none" ? hue : undefined; + const inClipRange = (value: number) => + (clipMin === undefined || value >= clipMin) && + (clipMax === undefined || value <= clipMax); + + const categories = groupEntries( + data, + (d) => d.dimensions[categoryDimension] ?? fallbackCategory, + ).map(([name, values], index): EnsembleCategory => { + const subGroups = hueDimension + ? groupEntries(values, (d) => d.dimensions[hueDimension] ?? "Unknown") + : [[ENSEMBLE_GROUP, values] as const]; + const groups: EnsembleCategory["groups"] = {}; + const outliers: EnsembleCategory["__outliers"] = {}; + for (const [groupName, groupValues] of subGroups) { + const finite = groupValues.filter((d) => + Number.isFinite(Number(d.value)), + ); + const points = finite + .filter((d) => inClipRange(Number(d.value))) + .sort((a, b) => Number(a.value) - Number(b.value)); + groups[groupName] = points.length > 0 ? boxStats(points) : null; + outliers[groupName] = finite.length - points.length; + } + return { + name, + groups, + __outliers: outliers, + __categoryColor: isSelfHued + ? groupColor(categoryDimension, name, index) + : undefined, + }; + }); + + return sortCategories(categories, categoryOrder); +} + +/** The names of the boxes across all categories, in a known order for the hue dimension or alphabetically */ +export function hueGroupNames( + categories: EnsembleCategory[], + hue: string | undefined, +): string[] { + const names = [ + ...new Set(categories.flatMap((category) => Object.keys(category.groups))), + ] + .sort() + .map((name) => ({ name })); + return sortCategories( + names, + hue ? KNOWN_CATEGORY_ORDERS[hue] : undefined, + ).map((item) => item.name); +} + +export interface YDomainOptions { + /** Centre the axis on zero */ + symmetricalAxes?: boolean; + /** Fixed lower bound, replacing the one from the data */ + yMin?: number; + /** Fixed upper bound, replacing the one from the data */ + yMax?: number; +} + +/** + * The y-axis range covering every box, padded by 5% and rounded to nice values + * + * Without values it covers 0 to 1, and when all values are equal it is widened around them. + */ +export function computeYDomain( + categories: EnsembleCategory[], + { symmetricalAxes, yMin, yMax }: YDomainOptions = {}, +): [number, number] { + const extremes = categories.flatMap((category) => + Object.values(category.groups).flatMap((group) => + group ? [group.min, group.max] : [], + ), + ); + + let [minVal, maxVal] = [0, 1]; + if (extremes.length > 0 && symmetricalAxes) { + const maxAbs = Math.max(...extremes.map(Math.abs)); + [minVal, maxVal] = [-maxAbs, maxAbs]; + } else if (extremes.length > 0) { + [minVal, maxVal] = [Math.min(...extremes), Math.max(...extremes)]; + } + + // If all values are identical, add some padding to ensure a range + if (minVal === maxVal) { + const pad = Math.max(1, Math.abs(minVal) * 0.1); + minVal -= pad; + maxVal += pad; + } + const padding = (maxVal - minVal) * 0.05; + + return scaleLinear() + .domain([yMin ?? minVal - padding, yMax ?? maxVal + padding]) + .nice() + .domain() as [number, number]; +} + +/** How the x-axis fits `count` categories, rotating the labels and narrowing the gaps when there are many */ +export function categoryAxisLayout(count: number) { + const rotate = count > 6; + return { + angle: rotate ? -45 : 0, + textAnchor: rotate ? "end" : "middle", + height: rotate ? 100 : 30, + // Label every nth category, so about 20 are labelled at most + interval: count > 20 ? Math.floor(count / 20) : 0, + barCategoryGap: count > 20 ? "2%" : count > 10 ? "5%" : "20%", + } as const; +} From 6462b12f5a879ee25588305d874c7cecbdbde4d7 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:38:14 +0200 Subject: [PATCH 38/61] cleanup: split the figure gallery into paginated and virtual grids with tested figure filtering --- .../diagnostics/figureGallery.test.tsx | 86 ++++ .../components/diagnostics/figureGallery.tsx | 375 ++++++++---------- .../diagnostics/figureGalleryData.test.ts | 102 +++++ .../diagnostics/figureGalleryData.ts | 54 +++ .../diagnostics/figureGalleryModal.tsx | 49 ++- 5 files changed, 435 insertions(+), 231 deletions(-) create mode 100644 frontend/src/components/diagnostics/figureGallery.test.tsx create mode 100644 frontend/src/components/diagnostics/figureGalleryData.test.ts create mode 100644 frontend/src/components/diagnostics/figureGalleryData.ts diff --git a/frontend/src/components/diagnostics/figureGallery.test.tsx b/frontend/src/components/diagnostics/figureGallery.test.tsx new file mode 100644 index 00000000..d29809a4 --- /dev/null +++ b/frontend/src/components/diagnostics/figureGallery.test.tsx @@ -0,0 +1,86 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { Execution, ExecutionGroup, ExecutionOutput } from "@/client"; +import { diagnosticsListExecutionGroupsOptions } from "@/client/@tanstack/react-query.gen.ts"; +import { FigureGallery } from "./figureGallery"; + +const figure = (id: number, filename: string) => + ({ + id, + filename, + description: filename, + long_name: filename, + url: `/${filename}`, + output_type: "plot", + }) as ExecutionOutput; + +const groupOf = (figures: ExecutionOutput[]) => + ({ + id: 1, + key: "group", + selectors: {}, + executions: [{ outputs: figures } as Execution], + }) as ExecutionGroup; + +function renderGallery(filenames: string[], pageSize?: number) { + const queryClient = new QueryClient(); + const path = { provider_slug: "pmp", diagnostic_slug: "enso" }; + queryClient.setQueryData( + diagnosticsListExecutionGroupsOptions({ path, query: {} }).queryKey, + { + count: 1, + data: [groupOf(filenames.map((name, i) => figure(i + 1, name)))], + }, + ); + return render( + + + , + ); +} + +const filterInput = () => screen.getByLabelText("Filter by Figure Name"); + +describe("FigureGallery", () => { + it("goes back to the first page when the filter changes", () => { + renderGallery(["a1.png", "a2.png", "a3.png", "b.png"], 2); + fireEvent.click(screen.getByRole("button", { name: /Next/ })); + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + + fireEvent.change(filterInput(), { target: { value: "^a" } }); + expect(screen.getByText(/Showing 1–2 of 3/)).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + }); + + it("opens the clicked figure and steps through the filtered figures", () => { + renderGallery(["a.png", "b.png", "c.png"], 2); + fireEvent.click(screen.getAllByRole("img", { name: "b.png" })[0]); + expect(screen.getByRole("dialog")).toHaveTextContent("2 of 3"); + + fireEvent.keyDown(window, { key: "ArrowRight" }); + expect(screen.getByRole("dialog")).toHaveTextContent("3 of 3"); + fireEvent.keyDown(window, { key: "ArrowRight" }); + expect(screen.getByRole("dialog")).toHaveTextContent("1 of 3"); + }); + + it("scrolls through every figure without a page size", () => { + renderGallery(["a.png", "b.png", "c.png", "d.png"]); + expect(screen.queryByText(/Page/)).not.toBeInTheDocument(); + expect( + screen.getAllByRole("img").map((img) => img.getAttribute("alt")), + ).toEqual(["a.png", "b.png", "c.png", "d.png"]); + }); + + it("says when no figure matches the filters", () => { + renderGallery(["a.png"], 2); + fireEvent.change(filterInput(), { target: { value: "zzz" } }); + expect( + screen.getByText("No figures found matching your filters."), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/diagnostics/figureGallery.tsx b/frontend/src/components/diagnostics/figureGallery.tsx index 1d014dda..c48b90c2 100644 --- a/frontend/src/components/diagnostics/figureGallery.tsx +++ b/frontend/src/components/diagnostics/figureGallery.tsx @@ -8,15 +8,7 @@ import { ExternalLink, MoreHorizontal, } from "lucide-react"; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import type { ExecutionGroup, ExecutionOutput } from "@/client"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { diagnosticsListExecutionGroupsOptions } from "@/client/@tanstack/react-query.gen.ts"; import { MipEraEmptyState } from "@/components/charts/mipEraBar"; import { useSelectedMipEra } from "@/components/charts/mipEraContext"; @@ -31,12 +23,14 @@ import { } from "@/components/ui/dropdown-menu.tsx"; import { Input } from "@/components/ui/input.tsx"; import { Label } from "@/components/ui/label.tsx"; +import { + collectLatestPlotFigures, + type FigureWithGroup, + filterFigures, +} from "./figureGalleryData"; import { FigureGalleryModal } from "./figureGalleryModal.tsx"; import { FigureGallerySkeleton } from "./figureGallerySkeleton.tsx"; -import { - matchesSelectorFilters, - SelectorFilterPanel, -} from "./selectorFilterPanel.tsx"; +import { SelectorFilterPanel } from "./selectorFilterPanel.tsx"; interface DiagnosticFigureGalleryProps { providerSlug: string; @@ -47,11 +41,6 @@ interface DiagnosticFigureGalleryProps { initialFilter?: string; } -interface FigureWithGroup { - figure: ExecutionOutput; - executionGroup: ExecutionGroup; -} - const FigureDropDown = ({ figure, executionGroup }: FigureWithGroup) => { return ( @@ -148,6 +137,137 @@ function PaginationControls({ ); } +/** Grid columns at a window width, matching the grid's md and lg breakpoints */ +function columnsAt(width: number): number { + if (width < 768) return 1; + if (width < 1024) return 2; + return 3; +} + +/** How many columns the figure grid has at the current window width */ +function useGridColumns(): number { + const [columns, setColumns] = useState(() => columnsAt(window.innerWidth)); + useEffect(() => { + const handleResize = () => setColumns(columnsAt(window.innerWidth)); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + return columns; +} + +interface FigureGridProps { + figures: FigureWithGroup[]; + /** Open the figure at an index of `figures` */ + onOpen: (index: number) => void; +} + +/** One page of figures at a time, with controls to change page */ +function PaginatedFigureGrid({ + figures, + onOpen, + pageSize, + page, + onPageChange, +}: FigureGridProps & { + pageSize: number; + page: number; + onPageChange: (page: number) => void; +}) { + const totalPages = Math.ceil(figures.length / pageSize); + const start = page * pageSize; + const pageFigures = figures.slice(start, start + pageSize); + return ( + <> +
+ Showing {start + 1}–{start + pageFigures.length} of {figures.length}{" "} + figures +
+
+ {pageFigures.map(({ figure, executionGroup }, index) => ( + onOpen(start + index)} + /> + ))} +
+ {totalPages > 1 && ( + + )} + + ); +} + +/** All the figures in rows, rendering only the rows near the window's scroll position */ +function VirtualFigureGrid({ + figures, + onOpen, + columns, +}: FigureGridProps & { columns: number }) { + const listRef = useRef(null); + const [scrollMargin, setScrollMargin] = useState(0); + + // biome-ignore lint/correctness/useExhaustiveDependencies: figures.length triggers re-measurement when filter changes affect layout position + useLayoutEffect(() => { + if (listRef.current) { + const rect = listRef.current.getBoundingClientRect(); + setScrollMargin(rect.top + window.scrollY); + } + }, [figures.length]); + + const rowVirtualizer = useWindowVirtualizer({ + count: Math.ceil(figures.length / columns), + estimateSize: () => 400, + overscan: 9, + scrollMargin, + }); + const rows = rowVirtualizer.getVirtualItems(); + + return ( +
+
+ {rows.map((virtualRow) => { + const start = virtualRow.index * columns; + return ( +
+
+ {figures + .slice(start, start + columns) + .map(({ figure, executionGroup }, colIndex) => ( + onOpen(start + colIndex)} + /> + ))} +
+
+ ); + })} +
+
+ ); +} + export function FigureGallery({ providerSlug, diagnosticSlug, @@ -161,17 +281,8 @@ export function FigureGallery({ const [selectedFigureIndex, setSelectedFigureIndex] = useState( null, ); - const [isModalOpen, setIsModalOpen] = useState(false); const [currentPage, setCurrentPage] = useState(0); - - const getColumns = useCallback(() => { - const width = window.innerWidth; - if (width < 768) return 1; - if (width < 1024) return 2; - return 3; - }, []); - - const [columns, setColumns] = useState(getColumns()); + const columns = useGridColumns(); const selectedMipEra = useSelectedMipEra(); const { data: executionGroups, isLoading } = useQuery( @@ -180,117 +291,28 @@ export function FigureGallery({ query: { mip_era: selectedMipEra ?? undefined }, }), ); - - useEffect(() => { - const handleResize = () => { - setColumns(getColumns()); - }; - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, [getColumns]); - const groups = executionGroups?.data ?? []; - const allFigures = useMemo( - () => - groups.flatMap((group) => { - // Only use the latest execution to avoid showing duplicate/outdated figures - const latestExecution = group.executions.at(-1); - if (!latestExecution) return []; - return (latestExecution.outputs ?? []) - .filter((output) => output.output_type === "plot") - .map((figure) => ({ figure, executionGroup: group })); - }), - [groups], + const allFigures = useMemo(() => collectLatestPlotFigures(groups), [groups]); + const filteredFigures = useMemo( + () => filterFigures(allFigures, filter, selectorFilters), + [allFigures, filter, selectorFilters], ); - const filteredFigures = useMemo(() => { - const filterRegex = filter - ? (() => { - try { - return new RegExp(filter, "i"); - } catch { - return null; - } - })() - : null; - - return allFigures.filter(({ figure, executionGroup }) => { - if (!matchesSelectorFilters(executionGroup.selectors, selectorFilters)) { - return false; - } - if (filterRegex) { - return ( - filterRegex.test(figure.description) || - filterRegex.test(figure.filename) - ); - } - return true; - }); - }, [allFigures, selectorFilters, filter]); - - // Reset page when filters change - // biome-ignore lint/correctness/useExhaustiveDependencies: reset page on filter change - useEffect(() => { - setCurrentPage(0); - }, [filter, selectorFilters]); - - const usePagination = pageSize !== undefined && pageSize > 0; - const totalPages = usePagination - ? Math.ceil(filteredFigures.length / pageSize) - : 1; - - const visibleFigures = usePagination - ? filteredFigures.slice( - currentPage * pageSize, - (currentPage + 1) * pageSize, - ) - : filteredFigures; - - const listRef = useRef(null); - const [scrollMargin, setScrollMargin] = useState(0); - - // biome-ignore lint/correctness/useExhaustiveDependencies: filteredFigures.length triggers re-measurement when filter changes affect layout position - useLayoutEffect(() => { - if (!usePagination && listRef.current) { - const rect = listRef.current.getBoundingClientRect(); - setScrollMargin(rect.top + window.scrollY); - } - }, [filteredFigures.length, usePagination]); - - const totalRows = Math.ceil(filteredFigures.length / columns); - const rowVirtualizer = useWindowVirtualizer({ - count: usePagination ? 0 : totalRows, - estimateSize: () => 400, - overscan: 9, - scrollMargin, - enabled: !usePagination, - }); - - const goToPrevious = useCallback(() => { - setSelectedFigureIndex((prev) => - prev === null || prev === 0 ? filteredFigures.length - 1 : prev - 1, - ); - }, [filteredFigures.length]); - - const goToNext = useCallback(() => { - setSelectedFigureIndex((prev) => - prev === null || prev === filteredFigures.length - 1 ? 0 : prev + 1, - ); - }, [filteredFigures.length]); - if (isLoading) { return ; } - const openFigure = (index: number) => { - setSelectedFigureIndex(index); - setIsModalOpen(true); + // A filter change can leave the current page past the last one + const changeFilter = (text: string) => { + setFilter(text); + setCurrentPage(0); + }; + const changeSelectorFilters = (filters: Record) => { + setSelectorFilters(filters); + setCurrentPage(0); }; - const items = rowVirtualizer.getVirtualItems(); return (
@@ -299,87 +321,17 @@ export function FigureGallery({ id="figure-filter" placeholder="Filter figures by name..." value={filter} - onChange={(e) => setFilter(e.target.value)} + onChange={(e) => changeFilter(e.target.value)} />
- {filteredFigures.length > 0 ? ( - usePagination ? ( - <> -
- Showing {currentPage * pageSize + 1}– - {Math.min((currentPage + 1) * pageSize, filteredFigures.length)}{" "} - of {filteredFigures.length} figures -
-
- {visibleFigures.map(({ figure, executionGroup }, index) => { - const globalIndex = currentPage * pageSize + index; - return ( - openFigure(globalIndex)} - /> - ); - })} -
- {totalPages > 1 && ( - - )} - - ) : ( -
-
- {items.map((virtualRow) => ( -
-
- {Array.from({ length: columns }, (_, colIndex) => { - const globalIndex = virtualRow.index * columns + colIndex; - if (globalIndex >= filteredFigures.length) return null; - const { figure, executionGroup } = - filteredFigures[globalIndex]; - return ( - openFigure(globalIndex)} - /> - ); - })} -
-
- ))} -
-
- ) - ) : ( + {filteredFigures.length === 0 ? (
{groups.length === 0 ? ( @@ -387,15 +339,26 @@ export function FigureGallery({ "No figures found matching your filters." )}
+ ) : pageSize !== undefined && pageSize > 0 ? ( + + ) : ( + )}
); diff --git a/frontend/src/components/diagnostics/figureGalleryData.test.ts b/frontend/src/components/diagnostics/figureGalleryData.test.ts new file mode 100644 index 00000000..bc970b3a --- /dev/null +++ b/frontend/src/components/diagnostics/figureGalleryData.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import type { Execution, ExecutionGroup, ExecutionOutput } from "@/client"; +import { + collectLatestPlotFigures, + type FigureWithGroup, + filterFigures, +} from "./figureGalleryData"; + +let nextId = 1; +const output = ( + filename: string, + { description = "", type = "plot" } = {}, +): ExecutionOutput => + ({ + id: nextId++, + filename, + description, + output_type: type, + }) as ExecutionOutput; + +/** A group whose executions, oldest first, produced these outputs */ +const group = ( + executions: ExecutionOutput[][], + selectors: ExecutionGroup["selectors"] = {}, +): ExecutionGroup => + ({ + id: nextId++, + selectors, + executions: executions.map((outputs) => ({ outputs }) as Execution), + }) as ExecutionGroup; + +const filenames = (figures: FigureWithGroup[]) => + figures.map(({ figure }) => figure.filename); + +describe("collectLatestPlotFigures", () => { + it("takes the plots of each group's latest execution only", () => { + const first = group([[output("old.png")], [output("new.png")]]); + const second = group([[output("other.png")]]); + const figures = collectLatestPlotFigures([first, second]); + expect(filenames(figures)).toEqual(["new.png", "other.png"]); + expect(figures.map((f) => f.executionGroup)).toEqual([first, second]); + }); + + it("leaves out outputs that are not plots", () => { + const figures = collectLatestPlotFigures([ + group([ + [ + output("map.png"), + output("data.nc", { type: "data" }), + output("index.html", { type: "html" }), + ], + ]), + ]); + expect(filenames(figures)).toEqual(["map.png"]); + }); + + it("skips groups without executions", () => { + expect(collectLatestPlotFigures([group([])])).toEqual([]); + }); +}); + +describe("filterFigures", () => { + const cmip6 = (source_id: string): ExecutionGroup["selectors"] => ({ + cmip6: [["source_id", source_id]], + }); + const figures = collectLatestPlotFigures([ + group( + [[output("tas_bias.png", { description: "Near-surface temperature" })]], + cmip6("ACCESS-CM2"), + ), + group( + [[output("pr_bias.png", { description: "Precipitation" })]], + cmip6("MIROC6"), + ), + ]); + + it("keeps everything without filters", () => { + expect(filterFigures(figures, "", {})).toEqual(figures); + }); + + it("matches the description or the filename as a regular expression ignoring case", () => { + expect(filenames(filterFigures(figures, "TEMPERATURE", {}))).toEqual([ + "tas_bias.png", + ]); + expect(filenames(filterFigures(figures, "^pr_", {}))).toEqual([ + "pr_bias.png", + ]); + }); + + it("does not filter on text that is not a valid regular expression", () => { + expect(filterFigures(figures, "tas_(", {})).toEqual(figures); + }); + + it("keeps the figures whose group matches the selector filters", () => { + expect( + filenames(filterFigures(figures, "", { source_id: ["MIROC6"] })), + ).toEqual(["pr_bias.png"]); + expect(filterFigures(figures, "tas", { source_id: ["MIROC6"] })).toEqual( + [], + ); + }); +}); diff --git a/frontend/src/components/diagnostics/figureGalleryData.ts b/frontend/src/components/diagnostics/figureGalleryData.ts new file mode 100644 index 00000000..9ad24499 --- /dev/null +++ b/frontend/src/components/diagnostics/figureGalleryData.ts @@ -0,0 +1,54 @@ +import type { ExecutionGroup, ExecutionOutput } from "@/client"; +import { matchesSelectorFilters } from "./selectorFilterPanel.tsx"; + +/** A figure and the execution group that produced it */ +export interface FigureWithGroup { + figure: ExecutionOutput; + executionGroup: ExecutionGroup; +} + +/** + * The plots of each group's latest execution + * + * Earlier executions are left out, so a group's figures are not shown twice or out of date. + */ +export function collectLatestPlotFigures( + groups: ExecutionGroup[], +): FigureWithGroup[] { + return groups.flatMap((group) => + // Executions are oldest first, so the last one is the latest + (group.executions.at(-1)?.outputs ?? []) + .filter((output) => output.output_type === "plot") + .map((figure) => ({ figure, executionGroup: group })), + ); +} + +/** Compile `text` as a case-insensitive regular expression, or null when it is not a valid one */ +function compilePattern(text: string): RegExp | null { + try { + return new RegExp(text, "i"); + } catch { + return null; + } +} + +/** + * Keep the figures whose group matches the selector filters + * and whose description or filename matches `text` as a regular expression + * + * Text that is empty or not a valid regular expression does not filter. + */ +export function filterFigures( + figures: FigureWithGroup[], + text: string, + selectorFilters: Record, +): FigureWithGroup[] { + const pattern = text ? compilePattern(text) : null; + return figures.filter( + ({ figure, executionGroup }) => + matchesSelectorFilters(executionGroup.selectors, selectorFilters) && + (!pattern || + pattern.test(figure.description) || + pattern.test(figure.filename)), + ); +} diff --git a/frontend/src/components/diagnostics/figureGalleryModal.tsx b/frontend/src/components/diagnostics/figureGalleryModal.tsx index 206776d4..984aca26 100644 --- a/frontend/src/components/diagnostics/figureGalleryModal.tsx +++ b/frontend/src/components/diagnostics/figureGalleryModal.tsx @@ -1,6 +1,5 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { useEffect } from "react"; -import type { ExecutionGroup, ExecutionOutput } from "@/client"; import { Figure } from "@/components/diagnostics/figure"; import { Button } from "@/components/ui/button.tsx"; import { @@ -9,48 +8,48 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog.tsx"; - -interface FigureWithGroup { - figure: ExecutionOutput; - executionGroup: ExecutionGroup; -} +import type { FigureWithGroup } from "./figureGalleryData"; interface FigureGalleryModalProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; figures: FigureWithGroup[]; + /** The index of the figure shown, or null when the modal is closed */ selectedIndex: number | null; - onPrevious: () => void; - onNext: () => void; + /** Show the figure at an index, or close the modal with null */ + onSelect: (index: number | null) => void; } +/** The index `by` figures on from `index`, wrapping around past either end */ +const stepped = (index: number, by: number, count: number) => + (index + by + count) % count; + export function FigureGalleryModal({ - isOpen, - onOpenChange, figures, selectedIndex, - onPrevious, - onNext, + onSelect, }: FigureGalleryModalProps) { + const count = figures.length; useEffect(() => { - if (!isOpen) return; + if (selectedIndex === null) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowLeft") { - onPrevious(); + onSelect(stepped(selectedIndex, -1, count)); } else if (event.key === "ArrowRight") { - onNext(); + onSelect(stepped(selectedIndex, 1, count)); } else if (event.key === "Escape") { - onOpenChange(false); + onSelect(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isOpen, onPrevious, onNext, onOpenChange]); + }, [selectedIndex, count, onSelect]); return ( - + !open && onSelect(null)} + > {selectedIndex !== null && figures[selectedIndex] && ( <> @@ -62,19 +61,19 @@ export function FigureGalleryModal({
- {selectedIndex + 1} of {figures.length} + {selectedIndex + 1} of {count}
- {/* AFT Information */} {diagnostic.aft_link && ( -
- CMIP7 AFT ID: - - {diagnostic.aft_link.id} - -
+ + {diagnostic.aft_link.id} + )} - - {/* Metric Values Status */} -
- Scalar Values: + -
- - {/* Metric Values Status */} -
- Series Values: + + -
- {/* Total Executions */} -
- Executions: - - {diagnostic.successful_execution_group_count}/ - {diagnostic.execution_group_count} - {note && ( - - - - - -

{note}

- {noteURL && ( - - Read the note about this diagnostic - - )} -
-
- )} -
-
+ + + {diagnostic.successful_execution_group_count}/ + {diagnostic.execution_group_count} + {note && ( + + + + + +

{note}

+ {noteURL && ( + + Read the note about this diagnostic + + )} +
+
+ )} +
{/* Reference Datasets */} {diagnostic.reference_datasets && From 4d45ab789b03c5c7d291c3ca62df20964c9c9030 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:28:07 +0200 Subject: [PATCH 40/61] cleanup: share info field, section and optional tab markup in the diagnostic layout --- .../route.tsx | 573 ++++++++---------- 1 file changed, 244 insertions(+), 329 deletions(-) diff --git a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/route.tsx b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/route.tsx index 1cad1544..7f617af8 100644 --- a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/route.tsx +++ b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/route.tsx @@ -6,6 +6,7 @@ import { useNavigate, } from "@tanstack/react-router"; import { HelpCircle } from "lucide-react"; +import type { ReactNode } from "react"; import type { GroupBy, ReferenceDatasetLink } from "@/client"; import { diagnosticsGetOptions } from "@/client/@tanstack/react-query.gen.ts"; import { DiagnosticInfoSkeleton } from "@/components/diagnostics/diagnosticInfoSkeleton"; @@ -26,6 +27,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { formatBytes, formatCoreHours, formatDuration } from "@/lib/format"; +import { cn } from "@/lib/utils"; const GroupByItem = ({ source_type, group_by }: GroupBy) => { return ( @@ -39,11 +41,105 @@ const GroupByItem = ({ source_type, group_by }: GroupBy) => { ); }; +/** A help icon explaining the label next to it. */ +const HelpTooltip = ({ children }: { children: ReactNode }) => ( + + + + + {children} + +); + +/** A labelled value, with an optional help tooltip on the label. */ +const InfoField = ({ + label, + help, + className, + children, +}: { + label: string; + help?: ReactNode; + className?: string; + children: ReactNode; +}) => ( +
+
+

{label}

+ {help && {help}} +
+ {children} +
+); + +/** A titled section of the diagnostic's details, set off from the one above. */ +const InfoSection = ({ + title, + help, + children, +}: { + title: string; + help?: ReactNode; + children: ReactNode; +}) => ( + <> + +
+
+

{title}

+ {help && {help}} +
+ {children} +
+ +); + +/** + * A tab that only some diagnostics have + * + * It navigates on click rather than being a link, as a disabled link could still be followed. + */ +const NavTab = ({ + value, + label, + help, + disabled, +}: { + value: "scalars" | "series" | "explorer"; + label: string; + help: string; + disabled: boolean; +}) => { + const params = Route.useParams(); + const navigate = useNavigate(); + return ( + + navigate({ + to: `/diagnostics/$providerSlug/$diagnosticSlug/${value}`, + params, + }) + } + > + + + {label} + + +

{help}

+
+
+
+ ); +}; + const DiagnosticInfoLayout = () => { const data = Route.useLoaderData(); const { providerSlug, diagnosticSlug } = Route.useParams(); const location = useLocation(); - const navigate = useNavigate(); // Determine the active tab based on the current URL pathname const activeTab = location.pathname.split("/").pop() || "figures"; // Default to 'figures' @@ -66,310 +162,181 @@ const DiagnosticInfoLayout = () => { {data.aft_link && (
-
-
-

- CMIP7 AFT Collection ID -

- - - - - -

- The unique identifier for the Associated Forcing Task - (AFT) in the CMIP7 framework. AFTs define specific - model evaluation tasks that address key climate - science questions. -

-
-
-
+

{data.aft_link.id}

-
+ {data.aft_link.provider_link && ( -
-

- Diagnostic Description -

+ Provider documentation -
+ )}
-
-
-

- Diagnostic Collection Description (CMIP7 AFT) -

- - - - - -

- The description of the scientific objective this - diagnostic addresses as defined by the CMIP Model - Benchmarking Task Team. -

-

- Multiple diagnostics may be associated with a single - collection. -

-
-
-
+ +

+ The description of the scientific objective this + diagnostic addresses as defined by the CMIP Model + Benchmarking Task Team. +

+

+ Multiple diagnostics may be associated with a single + collection. +

+ + } + >

{data.aft_link.short_description}

-
+
)}
-
-
-

Metric Provider

- - - - - -

- The software package or tool that implements and executes - this diagnostic. Each provider may offer multiple - diagnostics. -

-
-
-
+

{data?.provider.name}

-
-
-
-

Slug

- - - - - -

A unique identifier for this diagnostic.

-
-
-
+ +

{data?.slug}

-
-
-
-

Groupings

- - - - - -

- The dataset attributes (such as model name, experiment ID, - or variable name) that are used to organize datasets into - groups before running this diagnostic. Each unique - combination of these attributes creates a separate - execution group. -

-
-
-
+ +
{data.group_by.map((groups: GroupBy) => ( ))}
-
-
-
-

- Execution groups (successful/total) -

- - - - - -

- A collection of diagnostic runs that share the same - combination of dataset grouping values. For example, if - grouped by model and experiment, all runs for "CESM2" and - "historical" form one execution group. The counter shows - how many groups completed successfully versus the total - number of groups. -

-
-
-
+ +

{data.successful_execution_group_count}/ {data.execution_group_count}

-
-
-
-

- Diagnostic version + + + The version of this diagnostic currently in use. +
+ The version is updated when the diagnostic is updated. This + may differ between diagnostics for the same provider.

- - - - - -

- The version of this diagnostic currently in use. -
- The version is updated when the diagnostic is updated. - This may differ between diagnostics for the same provider. -

-
-
-
+ } + >

v{data.promoted_version}

-
+
{data.resource_usage && ( - <> - -
-
-

- Resource Usage -

- - - - - -

- Core hours consumed by the executions of this - diagnostic, alongside how long they took to run. A core - hour is one CPU core busy for an hour, so it measures - the compute an execution consumed rather than its - elapsed time. -

-

- Core hours and peak memory are optional, so they roll up - over only the executions that recorded them. -

-
-
-
-
-
-

- Mean wall time -

-

- {formatDuration(data.resource_usage.wall_seconds_mean)} -

-
-
-

- Longest execution -

-

- {formatDuration(data.resource_usage.wall_seconds_max)} -

-
-
-

- Core hours (sum / mean) -

-

- {formatCoreHours(data.resource_usage.cpu_seconds_total)} /{" "} - {formatCoreHours(data.resource_usage.cpu_seconds_mean)} -

-
-
-

- Peak memory (min / max) -

-

- {formatBytes(data.resource_usage.peak_memory_bytes_min)} /{" "} - {formatBytes(data.resource_usage.peak_memory_bytes_max)} -

-
-
+ +

+ Core hours consumed by the executions of this diagnostic, + alongside how long they took to run. A core hour is one CPU + core busy for an hour, so it measures the compute an + execution consumed rather than its elapsed time. +

+

+ Core hours and peak memory are optional, so they roll up + over only the executions that recorded them. +

+ + } + > +
+ +

+ {formatDuration(data.resource_usage.wall_seconds_mean)} +

+
+ +

+ {formatDuration(data.resource_usage.wall_seconds_max)} +

+
+ +

+ {formatCoreHours(data.resource_usage.cpu_seconds_total)} /{" "} + {formatCoreHours(data.resource_usage.cpu_seconds_mean)} +

+
+ +

+ {formatBytes(data.resource_usage.peak_memory_bytes_min)} /{" "} + {formatBytes(data.resource_usage.peak_memory_bytes_max)} +

+
- +
)} - {/* Reference Datasets Section */} {data.reference_datasets && data.reference_datasets.length > 0 && ( - <> - -
-
-

- Reference Datasets -

- - - - - -

- Observational or reference datasets that this diagnostic - uses for comparison. Primary references are essential - for the diagnostic to run, while secondary references - provide additional context. -

-
-
-
-
- {data.reference_datasets.map((ref: ReferenceDatasetLink) => ( - -
- - {ref.type} - -
- - {ref.slug} - - {ref.description && ( -

- {ref.description} -

- )} -
+ +
+ {data.reference_datasets.map((ref: ReferenceDatasetLink) => ( + +
+ + {ref.type} + +
+ {ref.slug} + {ref.description && ( +

+ {ref.description} +

+ )}
- - ))} -
+
+ + ))}
- + )} - {/* Tags Section */} {data.tags && data.tags.length > 0 && ( - <> - -
-

- Tags -

-
- {data.tags.map((tag: string) => ( - - {tag} - - ))} -
+ +
+ {data.tags.map((tag: string) => ( + + {tag} + + ))}
- +
)} @@ -383,53 +350,18 @@ const DiagnosticInfoLayout = () => { Execution Groups - - navigate({ - to: "/diagnostics/$providerSlug/$diagnosticSlug/scalars", - params: { providerSlug, diagnosticSlug }, - }) - } - > - - - Scalar Values - - -

- Single numeric values calculated from the data, such as global - means, root mean square errors, or correlation coefficients. -

-
-
-
- + - navigate({ - to: "/diagnostics/$providerSlug/$diagnosticSlug/series", - params: { providerSlug, diagnosticSlug }, - }) - } - > - - - Series Values - - -

- Time series or multi-dimensional data values, such as monthly - means over multiple years or values across different - latitudes. -

-
-
-
+ help="Time series or multi-dimensional data values, such as monthly means over multiple years or values across different latitudes." + /> { Figures - - navigate({ - to: "/diagnostics/$providerSlug/$diagnosticSlug/explorer", - params: { providerSlug, diagnosticSlug }, - }) - } - > - - - Explorer - - -

- Interactive visualizations from the CMIP7 AFT collection that - feature this diagnostic. -

-
-
-
+ help="Interactive visualizations from the CMIP7 AFT collection that feature this diagnostic." + /> From 20d41f42e1be00e076979b7ffcd460128ade549d Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:29:23 +0200 Subject: [PATCH 41/61] cleanup: drop dead execution id code and casts from the execution group routes --- .../_app/executions.$groupId/datasets.tsx | 3 +- .../routes/_app/executions.$groupId/files.tsx | 2 - .../routes/_app/executions.$groupId/logs.tsx | 3 +- .../routes/_app/executions.$groupId/route.tsx | 82 ++++++++++--------- 4 files changed, 44 insertions(+), 46 deletions(-) diff --git a/frontend/src/routes/_app/executions.$groupId/datasets.tsx b/frontend/src/routes/_app/executions.$groupId/datasets.tsx index 3765ead4..85b25322 100644 --- a/frontend/src/routes/_app/executions.$groupId/datasets.tsx +++ b/frontend/src/routes/_app/executions.$groupId/datasets.tsx @@ -10,7 +10,6 @@ import { const DatasetsTab = () => { const { groupId } = Route.useParams(); - // const { executionId } = Route.useSearch(); return ( @@ -22,7 +21,7 @@ const DatasetsTab = () => { - + ); diff --git a/frontend/src/routes/_app/executions.$groupId/files.tsx b/frontend/src/routes/_app/executions.$groupId/files.tsx index b8d4ab12..8eace470 100644 --- a/frontend/src/routes/_app/executions.$groupId/files.tsx +++ b/frontend/src/routes/_app/executions.$groupId/files.tsx @@ -5,12 +5,10 @@ import { ExecutionFilesContainer } from "@/components/execution/executionFiles"; const FilesTab = () => { const { groupId } = Route.useParams(); - // const { executionId } = Route.useSearch(); const { data: execution } = useSuspenseQuery( executionsExecutionOptions({ path: { group_id: groupId }, - // query: { execution_id: executionId }, }), ); diff --git a/frontend/src/routes/_app/executions.$groupId/logs.tsx b/frontend/src/routes/_app/executions.$groupId/logs.tsx index 0d281dfe..4609b016 100644 --- a/frontend/src/routes/_app/executions.$groupId/logs.tsx +++ b/frontend/src/routes/_app/executions.$groupId/logs.tsx @@ -10,7 +10,6 @@ import { const LogsTab = () => { const { groupId } = Route.useParams(); - // const { executionId } = Route.useSearch(); return ( @@ -22,7 +21,7 @@ const LogsTab = () => { - + ); diff --git a/frontend/src/routes/_app/executions.$groupId/route.tsx b/frontend/src/routes/_app/executions.$groupId/route.tsx index ad4000a3..4b463b54 100644 --- a/frontend/src/routes/_app/executions.$groupId/route.tsx +++ b/frontend/src/routes/_app/executions.$groupId/route.tsx @@ -20,11 +20,39 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { executionStatus } from "@/lib/executionStatus"; import { formatBytes, formatDuration } from "@/lib/format"; +/** + * A tab that only some groups have + * + * It navigates on click rather than being a link, as a disabled link could still be followed. + */ +const NavTab = ({ + value, + label, + disabled, +}: { + value: "scalar" | "series"; + label: string; + disabled: boolean; +}) => { + const { groupId } = Route.useParams(); + const navigate = useNavigate(); + return ( + + navigate({ to: `/executions/$groupId/${value}`, params: { groupId } }) + } + > + {label} + + ); +}; + const ExecutionInfoLayout = () => { const { groupId } = Route.useParams(); - // const { executionId } = Route.useSearch(); const location = useLocation(); - const navigate = useNavigate(); const { data } = useSuspenseQuery( executionsGetOptions({ @@ -35,7 +63,6 @@ const ExecutionInfoLayout = () => { const { data: execution } = useSuspenseQuery( executionsExecutionOptions({ path: { group_id: groupId }, - // query: { execution_id: executionId }, }), ); @@ -48,17 +75,12 @@ const ExecutionInfoLayout = () => { }), ); - const executionId = undefined; - - const hasScalar = diagnostic.has_scalar_values; - const hasSeries = diagnostic.has_series_values; - // Determine the active tab based on the current URL pathname const activeTab = location.pathname.split("/").pop() || "datasets"; return ( <> - {`${diagnostic.name} - ${data?.key} - Climate-REF`} + {`${diagnostic.name} - ${data.key} - Climate-REF`}
{ action={ } items={[ { label: "Date", value: format( - new Date(execution.updated_at as string), + new Date(execution.updated_at), "yyyy-MM-dd HH:mm", ), }, @@ -100,7 +118,7 @@ const ExecutionInfoLayout = () => { label: "Execution Group Key", value: ( - {data?.key} + {data.key} ), className: "col-span-2", @@ -164,32 +182,16 @@ const ExecutionInfoLayout = () => { Files - - navigate({ - to: "/executions/$groupId/scalar", - params: { groupId }, - }) - } - > - Scalar Values - - + - navigate({ - to: "/executions/$groupId/series", - params: { groupId }, - }) - } - > - Series Values - + label="Series Values" + disabled={!diagnostic.has_series_values} + /> Logs From d768d93ea4c59e33fc6d21da40e1d2a07d805212 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:31:38 +0200 Subject: [PATCH 42/61] fix: open execution groups that have never run instead of failing on the missing execution --- .../components/execution/executionNotRun.tsx | 8 ++++ frontend/src/hooks/useLatestExecution.test.ts | 33 +++++++++++++++++ frontend/src/hooks/useLatestExecution.ts | 15 ++++++++ .../_app/executions.$groupId/datasets.tsx | 9 ++++- .../routes/_app/executions.$groupId/files.tsx | 24 ++++++++---- .../routes/_app/executions.$groupId/logs.tsx | 9 ++++- .../routes/_app/executions.$groupId/route.tsx | 37 +++++++++---------- 7 files changed, 105 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/execution/executionNotRun.tsx create mode 100644 frontend/src/hooks/useLatestExecution.test.ts create mode 100644 frontend/src/hooks/useLatestExecution.ts diff --git a/frontend/src/components/execution/executionNotRun.tsx b/frontend/src/components/execution/executionNotRun.tsx new file mode 100644 index 00000000..f1783b1a --- /dev/null +++ b/frontend/src/components/execution/executionNotRun.tsx @@ -0,0 +1,8 @@ +/** Stands in for what an execution would show, for a group that has never run. */ +export function ExecutionNotRun({ what }: { what: string }) { + return ( +

+ This execution group has not run yet, so there are no {what} to show. +

+ ); +} diff --git a/frontend/src/hooks/useLatestExecution.test.ts b/frontend/src/hooks/useLatestExecution.test.ts new file mode 100644 index 00000000..5dbb6b82 --- /dev/null +++ b/frontend/src/hooks/useLatestExecution.test.ts @@ -0,0 +1,33 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { describe, expect, it } from "vitest"; +import type { Execution, ExecutionGroup } from "@/client"; +import { executionsGetOptions } from "@/client/@tanstack/react-query.gen"; +import { useLatestExecution } from "./useLatestExecution"; + +const execution = (id: number) => ({ id, successful: true }) as Execution; + +/** Render the hook with the group already in the cache, so it does not suspend. */ +function renderWithGroup(executions: Execution[]) { + const queryClient = new QueryClient(); + queryClient.setQueryData( + executionsGetOptions({ path: { group_id: "1" } }).queryKey, + { id: 1, executions } as ExecutionGroup, + ); + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); + return renderHook(() => useLatestExecution("1"), { wrapper }); +} + +describe("useLatestExecution", () => { + it("is the last of the group's executions, which are oldest first", () => { + const { result } = renderWithGroup([execution(3), execution(7)]); + expect(result.current?.id).toBe(7); + }); + + it("is undefined for a group that has never run", () => { + const { result } = renderWithGroup([]); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/frontend/src/hooks/useLatestExecution.ts b/frontend/src/hooks/useLatestExecution.ts new file mode 100644 index 00000000..d915bc47 --- /dev/null +++ b/frontend/src/hooks/useLatestExecution.ts @@ -0,0 +1,15 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { executionsGetOptions } from "@/client/@tanstack/react-query.gen"; + +/** + * The latest execution of a group, or `undefined` for a group that has never run + * + * It comes from the group, whose executions are oldest first, + * rather than from the group's execution endpoint, which fails for a group without one. + */ +export function useLatestExecution(groupId: string) { + const { data } = useSuspenseQuery( + executionsGetOptions({ path: { group_id: groupId } }), + ); + return data.executions.at(-1); +} diff --git a/frontend/src/routes/_app/executions.$groupId/datasets.tsx b/frontend/src/routes/_app/executions.$groupId/datasets.tsx index 85b25322..0925807f 100644 --- a/frontend/src/routes/_app/executions.$groupId/datasets.tsx +++ b/frontend/src/routes/_app/executions.$groupId/datasets.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { ExecutionDatasetTable } from "@/components/execution/executionDatasetTable"; +import { ExecutionNotRun } from "@/components/execution/executionNotRun"; import { Card, CardContent, @@ -7,9 +8,11 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { useLatestExecution } from "@/hooks/useLatestExecution"; const DatasetsTab = () => { const { groupId } = Route.useParams(); + const hasRun = useLatestExecution(groupId) !== undefined; return ( @@ -21,7 +24,11 @@ const DatasetsTab = () => { - + {hasRun ? ( + + ) : ( + + )} ); diff --git a/frontend/src/routes/_app/executions.$groupId/files.tsx b/frontend/src/routes/_app/executions.$groupId/files.tsx index 8eace470..4757b4b4 100644 --- a/frontend/src/routes/_app/executions.$groupId/files.tsx +++ b/frontend/src/routes/_app/executions.$groupId/files.tsx @@ -1,17 +1,25 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { executionsExecutionOptions } from "@/client/@tanstack/react-query.gen"; import { ExecutionFilesContainer } from "@/components/execution/executionFiles"; +import { ExecutionNotRun } from "@/components/execution/executionNotRun"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useLatestExecution } from "@/hooks/useLatestExecution"; const FilesTab = () => { const { groupId } = Route.useParams(); + const execution = useLatestExecution(groupId); - const { data: execution } = useSuspenseQuery( - executionsExecutionOptions({ - path: { group_id: groupId }, - }), - ); - + if (!execution) { + return ( + + + Files + + + + + + ); + } return ; }; diff --git a/frontend/src/routes/_app/executions.$groupId/logs.tsx b/frontend/src/routes/_app/executions.$groupId/logs.tsx index 4609b016..2bb60d4a 100644 --- a/frontend/src/routes/_app/executions.$groupId/logs.tsx +++ b/frontend/src/routes/_app/executions.$groupId/logs.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { ExecutionLogContainer } from "@/components/execution/executionLogs"; +import { ExecutionNotRun } from "@/components/execution/executionNotRun"; import { Card, CardContent, @@ -7,9 +8,11 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { useLatestExecution } from "@/hooks/useLatestExecution"; const LogsTab = () => { const { groupId } = Route.useParams(); + const hasRun = useLatestExecution(groupId) !== undefined; return ( @@ -21,7 +24,11 @@ const LogsTab = () => { - + {hasRun ? ( + + ) : ( + + )} ); diff --git a/frontend/src/routes/_app/executions.$groupId/route.tsx b/frontend/src/routes/_app/executions.$groupId/route.tsx index 4b463b54..8a32cbfc 100644 --- a/frontend/src/routes/_app/executions.$groupId/route.tsx +++ b/frontend/src/routes/_app/executions.$groupId/route.tsx @@ -9,7 +9,6 @@ import { import { format } from "date-fns"; import { diagnosticsGetOptions, - executionsExecutionOptions, executionsGetOptions, } from "@/client/@tanstack/react-query.gen"; import { DownloadOutputs } from "@/components/execution/downloadOutputs.tsx"; @@ -60,11 +59,8 @@ const ExecutionInfoLayout = () => { }), ); - const { data: execution } = useSuspenseQuery( - executionsExecutionOptions({ - path: { group_id: groupId }, - }), - ); + // Executions are oldest first, and a group that has never run has none + const execution = data.executions.at(-1); const { data: diagnostic } = useSuspenseQuery( diagnosticsGetOptions({ @@ -101,18 +97,19 @@ const ExecutionInfoLayout = () => { } action={ - + execution && ( + + ) } items={[ { label: "Date", - value: format( - new Date(execution.updated_at), - "yyyy-MM-dd HH:mm", - ), + value: execution + ? format(new Date(execution.updated_at), "yyyy-MM-dd HH:mm") + : "—", }, { label: "Execution Group Key", @@ -136,7 +133,7 @@ const ExecutionInfoLayout = () => { label: "Wall time", value: ( - {formatDuration(execution.wall_seconds)} + {formatDuration(execution?.wall_seconds)} ), }, @@ -144,7 +141,7 @@ const ExecutionInfoLayout = () => { label: "CPU time", value: ( - {formatDuration(execution.cpu_seconds)} + {formatDuration(execution?.cpu_seconds)} ), }, @@ -152,13 +149,13 @@ const ExecutionInfoLayout = () => { label: "Peak memory", value: ( - {formatBytes(execution.peak_memory_bytes)} + {formatBytes(execution?.peak_memory_bytes)} ), }, { label: "Number of outputs", - value: execution.outputs?.length ?? 0, + value: execution?.outputs?.length ?? 0, }, ]} /> @@ -185,12 +182,12 @@ const ExecutionInfoLayout = () => { From d401a95e41954268dd56067e01c77bb7533884ff Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:33:50 +0200 Subject: [PATCH 43/61] cleanup: share StatCard between the dashboard statistics and the model page --- frontend/src/components/app/statCard.test.tsx | 19 +++ frontend/src/components/app/statCard.tsx | 29 ++++ .../dashboard/executionStats.test.tsx | 82 ++++++++++++ .../components/dashboard/executionStats.tsx | 126 ++++++------------ frontend/src/routes/_app/models.$sourceId.tsx | 32 +---- 5 files changed, 173 insertions(+), 115 deletions(-) create mode 100644 frontend/src/components/app/statCard.test.tsx create mode 100644 frontend/src/components/app/statCard.tsx create mode 100644 frontend/src/components/dashboard/executionStats.test.tsx diff --git a/frontend/src/components/app/statCard.test.tsx b/frontend/src/components/app/statCard.test.tsx new file mode 100644 index 00000000..193418bf --- /dev/null +++ b/frontend/src/components/app/statCard.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StatCard } from "./statCard"; + +describe("StatCard", () => { + it("shows the title, value and hint", () => { + render(); + expect(screen.getByText("Datasets")).toBeInTheDocument(); + expect(screen.getByText("1,234")).toBeInTheDocument(); + expect(screen.getByText("56 files tracked")).toBeInTheDocument(); + }); + + it("shows a placeholder in place of the value while loading", () => { + render(); + expect(screen.getByText("...")).toBeInTheDocument(); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + expect(screen.getByText("Counted")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/app/statCard.tsx b/frontend/src/components/app/statCard.tsx new file mode 100644 index 00000000..e2cae969 --- /dev/null +++ b/frontend/src/components/app/statCard.tsx @@ -0,0 +1,29 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +/** A headline number in a card, with an optional line explaining it. */ +export function StatCard({ + title, + value, + hint, + loading = false, +}: { + title: string; + value: string; + hint?: string; + /** Show a placeholder in place of the value while it loads */ + loading?: boolean; +}) { + return ( + + + + {title} + + + +
{loading ? "..." : value}
+ {hint ?

{hint}

: null} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/executionStats.test.tsx b/frontend/src/components/dashboard/executionStats.test.tsx new file mode 100644 index 00000000..bba76b10 --- /dev/null +++ b/frontend/src/components/dashboard/executionStats.test.tsx @@ -0,0 +1,82 @@ +import { useQuery } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ExecutionStats as Stats } from "@/client"; +import { ExecutionStats } from "./executionStats"; + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { ...actual, useQuery: vi.fn() }; +}); + +const mockedUseQuery = vi.mocked(useQuery); + +const STATS = { + total_execution_groups: 1200, + successful_execution_groups: 1000, + failed_execution_groups: 150, + running_execution_groups: 30, + not_started_execution_groups: 20, + success_rate_percentage: 83.3, + execution_groups_by_mip_era: { CMIP6: 900, CMIP7: 300 }, + scalar_value_count: 5000, + series_value_count: 250, + total_datasets: 42, + total_files: 4321, + resource_usage: null, +} satisfies Stats; + +const mockQuery = (result: { + data?: Stats; + isLoading?: boolean; + error?: Error; +}) => + mockedUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + ...result, + } as ReturnType); + +describe("ExecutionStats", () => { + beforeEach(() => { + mockedUseQuery.mockReset(); + }); + + it("shows the statistics with counts formatted", () => { + mockQuery({ data: STATS }); + render(); + + expect(screen.getByText("1,200")).toBeInTheDocument(); + expect( + screen.getByText("900 ran against CMIP6, 300 against CMIP7"), + ).toBeInTheDocument(); + expect(screen.getByText("83.3%")).toBeInTheDocument(); + expect( + screen.getByText("1,000 successful, 150 failed"), + ).toBeInTheDocument(); + expect(screen.getByText("5,250")).toBeInTheDocument(); + expect(screen.getByText("5,000 scalars, 250 series")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByText("4,321 files tracked")).toBeInTheDocument(); + }); + + it("shows placeholders while loading", () => { + mockQuery({ isLoading: true }); + render(); + + expect(screen.getByText("Total Execution Groups")).toBeInTheDocument(); + expect(screen.getAllByText("...")).toHaveLength(7); + expect(screen.getByText("... files tracked")).toBeInTheDocument(); + }); + + it("says when the statistics failed to load", () => { + mockQuery({ error: new Error("boom") }); + render(); + + expect( + screen.getByText("Failed to load execution statistics"), + ).toBeInTheDocument(); + expect(screen.queryByText("Success Rate")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/dashboard/executionStats.tsx b/frontend/src/components/dashboard/executionStats.tsx index 389a7c09..287e7b6c 100644 --- a/frontend/src/components/dashboard/executionStats.tsx +++ b/frontend/src/components/dashboard/executionStats.tsx @@ -1,33 +1,19 @@ import { useQuery } from "@tanstack/react-query"; import { executionsGetExecutionStatisticsOptions } from "@/client/@tanstack/react-query.gen.ts"; +import { StatCard } from "@/components/app/statCard"; import { Card, - CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { formatCount } from "@/lib/format"; -const maybeShowLoading = (isLoading: boolean, content: React.ReactNode) => - isLoading ? "..." : content?.toLocaleString(); - export function ExecutionStats() { const { data, isLoading, error } = useQuery( executionsGetExecutionStatisticsOptions(), ); - const totalCount = data?.total_execution_groups ?? 0; - const successCount = data?.successful_execution_groups ?? 0; - const failedCount = data?.failed_execution_groups ?? 0; - const successRate = data?.success_rate_percentage ?? 0; - const scalarCount = data?.scalar_value_count ?? 0; - const seriesCount = data?.series_value_count ?? 0; - const metricValueCount = scalarCount + seriesCount; - const perEra = data?.execution_groups_by_mip_era; - const totalDatasets = data?.total_datasets ?? 0; - const totalFiles = data?.total_files ?? 0; - if (error) { return ( @@ -39,79 +25,47 @@ export function ExecutionStats() { ); } + const perEra = data?.execution_groups_by_mip_era; return ( <> - {/* Total Execution Groups */} - - - - Total Execution Groups - - - -
- {maybeShowLoading(isLoading, totalCount)} -
-

- {perEra - ? `${formatCount(perEra.CMIP6 ?? 0)} ran against CMIP6, ${formatCount(perEra.CMIP7 ?? 0)} against CMIP7` - : "..."} -

-
-
- - {/* Success Rate */} - - - - Success Rate - - - -
- {maybeShowLoading(isLoading, `${successRate}%`)} -
-

- {formatCount(successCount)} successful, {formatCount(failedCount)}{" "} - failed -

-
-
- - {/* Metric Values */} - - - - Number of unique Metric Values - - - -
- {maybeShowLoading(isLoading, metricValueCount)} -
-

- {formatCount(scalarCount)} scalars, {formatCount(seriesCount)}{" "} - series -

-
-
- - {/* Datasets & Files */} - - - - Ingested Datasets - - - -
- {maybeShowLoading(isLoading, totalDatasets)} -
-

- {maybeShowLoading(isLoading, totalFiles)} files tracked -

-
-
+ + + + ); } diff --git a/frontend/src/routes/_app/models.$sourceId.tsx b/frontend/src/routes/_app/models.$sourceId.tsx index bfd95613..fddc3639 100644 --- a/frontend/src/routes/_app/models.$sourceId.tsx +++ b/frontend/src/routes/_app/models.$sourceId.tsx @@ -6,6 +6,7 @@ import { modelsGetOptions, } from "@/client/@tanstack/react-query.gen"; import { PageHeader } from "@/components/app/pageHeader"; +import { StatCard } from "@/components/app/statCard"; import { MipEraScope } from "@/components/charts/mipEraBar"; import { DiagnosticRunsTable } from "@/components/models/diagnosticRunsTable"; import { EnsembleComparisonTable } from "@/components/models/ensembleComparisonTable"; @@ -39,30 +40,6 @@ export const Route = createFileRoute("/_app/models/$sourceId")({ }, }); -function StatCard({ - title, - value, - hint, -}: { - title: string; - value: string; - hint?: string; -}) { - return ( - - - - {title} - - - -
{value}
- {hint ?

{hint}

: null} -
-
- ); -} - function ModelDetailPage() { const { sourceId } = Route.useParams(); const search = Route.useSearch(); @@ -144,11 +121,8 @@ function ModelDetailPage() { />
From 302bf0b392e25f5c33f58cd47b264b5b79696605 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:35:13 +0200 Subject: [PATCH 44/61] cleanup: reuse the dataset table and columns for an execution's datasets --- .../datasets/datasetColumns.test.tsx | 26 ++++++++++ .../components/datasets/datasetColumns.tsx | 12 +++-- .../src/components/datasets/datasetTable.tsx | 3 +- .../execution/executionDatasetTable.tsx | 50 ++----------------- 4 files changed, 41 insertions(+), 50 deletions(-) create mode 100644 frontend/src/components/datasets/datasetColumns.test.tsx diff --git a/frontend/src/components/datasets/datasetColumns.test.tsx b/frontend/src/components/datasets/datasetColumns.test.tsx new file mode 100644 index 00000000..0af15891 --- /dev/null +++ b/frontend/src/components/datasets/datasetColumns.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { datasetColumns } from "./datasetColumns"; + +const ids = (sourceType?: string) => + datasetColumns(sourceType).map( + (column) => column.id ?? (column as { accessorKey?: string }).accessorKey, + ); + +describe("datasetColumns", () => { + it("adds the CMIP facets for a CMIP source type", () => { + expect(ids("cmip6")).toEqual([ + "slug", + "dataset_type", + "experiment_id", + "source_id", + "variable_id", + "esgf_link", + ]); + }); + + it("has only the common columns for another source type or a mix of them", () => { + const common = ["slug", "dataset_type", "esgf_link"]; + expect(ids("obs4mips")).toEqual(common); + expect(ids()).toEqual(common); + }); +}); diff --git a/frontend/src/components/datasets/datasetColumns.tsx b/frontend/src/components/datasets/datasetColumns.tsx index 12a32312..6aea61dd 100644 --- a/frontend/src/components/datasets/datasetColumns.tsx +++ b/frontend/src/components/datasets/datasetColumns.tsx @@ -74,7 +74,13 @@ const cmipColumns = [ ]; const otherColumns = [...LEADING_COLUMNS, ...TRAILING_COLUMNS]; -/** The columns for a table of `sourceType` datasets, as a reference stable across renders. */ -export function datasetColumns(sourceType: string): ColumnDef[] { - return isCmipSourceType(sourceType) ? cmipColumns : otherColumns; +/** + * The columns for a table of `sourceType` datasets, as a reference stable across renders + * + * Without a source type, for datasets of several types, only the columns every dataset has. + */ +export function datasetColumns(sourceType?: string): ColumnDef[] { + return sourceType && isCmipSourceType(sourceType) + ? cmipColumns + : otherColumns; } diff --git a/frontend/src/components/datasets/datasetTable.tsx b/frontend/src/components/datasets/datasetTable.tsx index 1f4d1758..81355e81 100644 --- a/frontend/src/components/datasets/datasetTable.tsx +++ b/frontend/src/components/datasets/datasetTable.tsx @@ -4,7 +4,8 @@ import { datasetColumns } from "./datasetColumns.tsx"; interface DatasetTableProps { data: Dataset[]; - sourceType: string; + /** Left out for datasets of several source types */ + sourceType?: string; loading?: boolean; } diff --git a/frontend/src/components/execution/executionDatasetTable.tsx b/frontend/src/components/execution/executionDatasetTable.tsx index ad06b77f..affb337d 100644 --- a/frontend/src/components/execution/executionDatasetTable.tsx +++ b/frontend/src/components/execution/executionDatasetTable.tsx @@ -1,49 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { type ColumnDef, createColumnHelper } from "@tanstack/react-table"; -import { SquareArrowOutUpRight } from "lucide-react"; -import type { Dataset } from "@/client"; - import { executionsExecutionDatasetsOptions } from "@/client/@tanstack/react-query.gen.ts"; -import { DataTable } from "@/components/dataTable/dataTable.tsx"; - -const columnHelper = createColumnHelper(); - -const columns: ColumnDef[] = [ - columnHelper.accessor("slug", { - header: "Slug", - cell: (cellContext) => { - return ( - - {cellContext.getValue()} - - ); - }, - }) as ColumnDef, - columnHelper.accessor("dataset_type", { - header: "Dataset Type", - }) as ColumnDef, - columnHelper.display({ - id: "esgf_link", - cell: (cellContext) => { - if (cellContext.row.original.more_info_url) { - return ( - - - - ); - } - }, - }), -]; +import DatasetTable from "@/components/datasets/datasetTable.tsx"; interface ExecutionDatasetTableProps { groupId: string; @@ -54,11 +11,12 @@ export function ExecutionDatasetTable({ groupId, executionId, }: ExecutionDatasetTableProps) { - const { data } = useQuery( + const { data, isLoading } = useQuery( executionsExecutionDatasetsOptions({ path: { group_id: groupId }, query: { execution_id: executionId }, }), ); - return ; + // Without a source type, as an execution can use datasets of several + return ; } From b35cbb8704ae354b241d6890f53e6dc9370e2c51 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:37:08 +0200 Subject: [PATCH 45/61] cleanup: move execution log parsing and filtering to src/lib and memoise the parse --- .../executionLogs/executionLogContainer.tsx | 72 ++--------- .../executionLogs/executionLogView.tsx | 76 ++++------- frontend/src/lib/executionLog.test.ts | 118 ++++++++++++++++++ frontend/src/lib/executionLog.ts | 66 ++++++++++ 4 files changed, 216 insertions(+), 116 deletions(-) create mode 100644 frontend/src/lib/executionLog.test.ts create mode 100644 frontend/src/lib/executionLog.ts diff --git a/frontend/src/components/execution/executionLogs/executionLogContainer.tsx b/frontend/src/components/execution/executionLogs/executionLogContainer.tsx index b27227a4..4acceaf6 100644 --- a/frontend/src/components/execution/executionLogs/executionLogContainer.tsx +++ b/frontend/src/components/execution/executionLogs/executionLogContainer.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Download } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { executionsExecutionLogsOptions } from "@/client/@tanstack/react-query.gen.ts"; import { Button } from "@/components/ui/button"; import { @@ -11,70 +11,16 @@ import { CardTitle, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton.tsx"; +import { apiErrorMessage } from "@/lib/apiError"; import { downloadTextFile } from "@/lib/downloadUtils.ts"; -import { ExecutionLogView, type LogMessage } from "./executionLogView.tsx"; - -function isValidationError( - error: unknown, -): error is { detail: Array<{ msg: string }> } { - if (typeof error !== "object" || error === null || !("detail" in error)) { - return false; - } - const { detail } = error as { detail: unknown }; - return ( - Array.isArray(detail) && - detail.length > 0 && - typeof detail[0]?.msg === "string" - ); -} - -function formatValidationError(error: { - detail: Array<{ msg: string }>; -}): string { - return error.detail.map((d) => d.msg).join("; "); -} +import { parseLogOutput } from "@/lib/executionLog"; +import { ExecutionLogView } from "./executionLogView.tsx"; interface ExecutionLogContainerProps { groupId: string; executionId?: string; } -function parseLogOutput(logOutput: string) { - const splitRegex = /(?=\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})/g; - const parseRegex = - /(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) \| (\w+) +\|(.*)/s; // 's' flag for DOTALL in TypeScript - - const logMessages = logOutput.split(splitRegex); - const parsedLogs: LogMessage[] = []; - - // Remove the first empty string if the log starts with a timestamp - if (logMessages[0] === "") { - logMessages.shift(); - } - - for (const logMessage of logMessages) { - const trimmedMessage = logMessage.trim(); - if (!trimmedMessage) { - continue; // Skip empty strings - } - - const match = trimmedMessage.match(parseRegex); - if (match) { - const timestamp = match[1]; - const level = match[2].trim(); - const message = match[3].trim(); - parsedLogs.push({ timestamp, level, message }); - } else { - parsedLogs.push({ - timestamp: null, - level: null, - message: trimmedMessage, - }); // Mark as unparsed but include message - } - } - return parsedLogs; -} - export function ExecutionLogContainer({ groupId, executionId, @@ -137,18 +83,15 @@ export function ExecutionLogContainer({ } }, [data]); + const parsedLogs = useMemo(() => parseLogOutput(logContent), [logContent]); + if (error) { return ( Error Loading Logs - An error occurred while loading the logs:{" "} - {error instanceof Error - ? error.message - : isValidationError(error) - ? formatValidationError(error) - : JSON.stringify(error)} + An error occurred while loading the logs: {apiErrorMessage(error)} @@ -156,7 +99,6 @@ export function ExecutionLogContainer({ } const isBusy = isLoading || isStreaming; - const parsedLogs = parseLogOutput(logContent); return ( diff --git a/frontend/src/components/execution/executionLogs/executionLogView.tsx b/frontend/src/components/execution/executionLogs/executionLogView.tsx index 82a448b8..be066f9d 100644 --- a/frontend/src/components/execution/executionLogs/executionLogView.tsx +++ b/frontend/src/components/execution/executionLogs/executionLogView.tsx @@ -7,12 +7,19 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { + filterLogs, + type LogLevelFilter, + type LogMessage, + levelClass, +} from "@/lib/executionLog"; -export interface LogMessage { - timestamp: string | null; - level: string | null; - message: string; -} +const LEVEL_BUTTONS: { value: LogLevelFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "info", label: "Info" }, + { value: "warning", label: "Warning" }, + { value: "error", label: "Error" }, +]; interface ExecutionLogViewProps { logs: LogMessage[]; @@ -20,16 +27,9 @@ interface ExecutionLogViewProps { export function ExecutionLogView({ logs }: ExecutionLogViewProps) { const [searchTerm, setSearchTerm] = useState(""); - const [logLevel, setLogLevel] = useState< - "all" | "debug" | "info" | "warning" | "error" - >("all"); + const [logLevel, setLogLevel] = useState("all"); - const filteredLogs = logs.filter( - (log) => - (logLevel === "all" || log.level?.toLowerCase() === logLevel) && - (log.message.toLowerCase().includes(searchTerm.toLowerCase()) || - log.timestamp?.includes(searchTerm)), - ); + const filteredLogs = filterLogs(logs, logLevel, searchTerm); return (
@@ -44,34 +44,16 @@ export function ExecutionLogView({ logs }: ExecutionLogViewProps) { />
- - - - + {LEVEL_BUTTONS.map(({ value, label }) => ( + + ))}
@@ -86,15 +68,7 @@ export function ExecutionLogView({ logs }: ExecutionLogViewProps) { {log.level?.toUpperCase()} diff --git a/frontend/src/lib/executionLog.test.ts b/frontend/src/lib/executionLog.test.ts new file mode 100644 index 00000000..3c2f569d --- /dev/null +++ b/frontend/src/lib/executionLog.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + filterLogs, + type LogMessage, + levelClass, + parseLogOutput, +} from "./executionLog"; + +const record = (timestamp: string, level: string, message: string) => + `${timestamp} | ${level.padEnd(8)} | climate_ref_core.providers:run:773 - ${message}`; + +describe("parseLogOutput", () => { + it("splits the log into records with their timestamp and level", () => { + const log = [ + record("2026-10-12 07:19:49.775", "INFO", "Starting"), + record("2026-10-12 07:19:50.001", "WARNING", "Slow"), + ].join("\n"); + + expect(parseLogOutput(log)).toEqual([ + { + timestamp: "2026-10-12 07:19:49.775", + level: "INFO", + message: "climate_ref_core.providers:run:773 - Starting", + }, + { + timestamp: "2026-10-12 07:19:50.001", + level: "WARNING", + message: "climate_ref_core.providers:run:773 - Slow", + }, + ]); + }); + + it("keeps the lines continuing a message with its record", () => { + const log = [ + record("2026-10-12 07:19:49.775", "ERROR", "Execution failed"), + "Traceback (most recent call last):", + "ValueError: bad input", + record("2026-10-12 07:19:50.001", "INFO", "Done"), + ].join("\n"); + + const parsed = parseLogOutput(log); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toEqual({ + timestamp: "2026-10-12 07:19:49.775", + level: "ERROR", + message: + "climate_ref_core.providers:run:773 - Execution failed\nTraceback (most recent call last):\nValueError: bad input", + }); + }); + + it("keeps text before the first record as a message without a level", () => { + const log = [ + "Preparing environment", + record("2026-10-12 07:19:49.775", "INFO", "Starting"), + ].join("\n"); + + expect(parseLogOutput(log)[0]).toEqual({ + timestamp: null, + level: null, + message: "Preparing environment", + }); + expect(parseLogOutput(log)[1].level).toBe("INFO"); + }); + + it("keeps a timestamped line in another format as an unparsed message", () => { + expect(parseLogOutput("2026-10-12 07:19:49.775 something else\n")).toEqual([ + { + timestamp: null, + level: null, + message: "2026-10-12 07:19:49.775 something else", + }, + ]); + }); + + it("is empty for an empty or blank log", () => { + expect(parseLogOutput("")).toEqual([]); + expect(parseLogOutput("\n \n")).toEqual([]); + }); +}); + +describe("filterLogs", () => { + const logs: LogMessage[] = [ + { timestamp: "2026-10-12 07:19:49.775", level: "INFO", message: "Start" }, + { + timestamp: "2026-10-12 07:19:50.001", + level: "ERROR", + message: "Disk Full", + }, + { timestamp: null, level: null, message: "plain text" }, + ]; + + it("keeps everything for all levels and no search term", () => { + expect(filterLogs(logs, "all", "")).toEqual(logs); + }); + + it("keeps only the messages at the level", () => { + expect(filterLogs(logs, "error", "")).toEqual([logs[1]]); + expect(filterLogs(logs, "debug", "")).toEqual([]); + }); + + it("matches the message ignoring case, or the timestamp", () => { + expect(filterLogs(logs, "all", "disk")).toEqual([logs[1]]); + expect(filterLogs(logs, "all", "07:19:49")).toEqual([logs[0]]); + }); + + it("combines the level and the search term", () => { + expect(filterLogs(logs, "info", "disk")).toEqual([]); + }); +}); + +describe("levelClass", () => { + it("colours each level", () => { + expect(levelClass("DEBUG")).toContain("green"); + expect(levelClass("INFO")).toContain("blue"); + expect(levelClass("WARNING")).toContain("yellow"); + expect(levelClass("ERROR")).toContain("red"); + }); +}); diff --git a/frontend/src/lib/executionLog.ts b/frontend/src/lib/executionLog.ts new file mode 100644 index 00000000..e308dfd4 --- /dev/null +++ b/frontend/src/lib/executionLog.ts @@ -0,0 +1,66 @@ +/** One record of an execution log, or a stretch of text outside the log format with no timestamp or level. */ +export interface LogMessage { + timestamp: string | null; + level: string | null; + message: string; +} + +/** Where a loguru record starts, e.g. "2026-09-04 15:02:18.111" */ +const RECORD_START = /(?=\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})/g; +/** A loguru record, e.g. "2026-09-04 15:02:18.111 | ERROR | module:function:772 - message", whose message may span lines */ +const RECORD = /(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) \| (\w+) +\|(.*)/s; + +/** + * Split an execution log into its records + * + * A record runs until the next timestamp, so lines continuing a message, such as a traceback, stay with it. + * Text that does not parse as a record is kept as a message without a timestamp or level. + */ +export function parseLogOutput(logOutput: string): LogMessage[] { + const parsed: LogMessage[] = []; + for (const chunk of logOutput.split(RECORD_START)) { + const trimmed = chunk.trim(); + if (!trimmed) { + continue; + } + const match = trimmed.match(RECORD); + parsed.push( + match + ? { timestamp: match[1], level: match[2], message: match[3].trim() } + : { timestamp: null, level: null, message: trimmed }, + ); + } + return parsed; +} + +/** A level to narrow the log to, with `all` for every message */ +export type LogLevelFilter = "all" | "debug" | "info" | "warning" | "error"; + +/** The messages at `level` whose message or timestamp contains `term`, ignoring case in the message. */ +export function filterLogs( + logs: LogMessage[], + level: LogLevelFilter, + term: string, +): LogMessage[] { + const lowerTerm = term.toLowerCase(); + return logs.filter( + (log) => + (level === "all" || log.level?.toLowerCase() === level) && + (log.message.toLowerCase().includes(lowerTerm) || + log.timestamp?.includes(term)), + ); +} + +/** The badge colours for a log level. */ +export function levelClass(level: string | null): string { + switch (level) { + case "DEBUG": + return "bg-green-500/10 text-green-500"; + case "INFO": + return "bg-blue-500/10 text-blue-500"; + case "WARNING": + return "bg-yellow-500/10 text-yellow-500"; + default: + return "bg-red-500/10 text-red-500"; + } +} From a7360a67d558046b194016e21af4b5eed3815d1f Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:38:00 +0200 Subject: [PATCH 46/61] fix: log view shows no badge for unparsed lines, adds the Debug filter and drops a stray class --- .../executionLogs/executionLogView.test.tsx | 28 ++++++++++++++ .../executionLogs/executionLogView.tsx | 37 ++++++++++--------- frontend/src/lib/executionLog.ts | 2 +- 3 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/execution/executionLogs/executionLogView.test.tsx diff --git a/frontend/src/components/execution/executionLogs/executionLogView.test.tsx b/frontend/src/components/execution/executionLogs/executionLogView.test.tsx new file mode 100644 index 00000000..24828388 --- /dev/null +++ b/frontend/src/components/execution/executionLogs/executionLogView.test.tsx @@ -0,0 +1,28 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { LogMessage } from "@/lib/executionLog"; +import { ExecutionLogView } from "./executionLogView"; + +const LOGS: LogMessage[] = [ + { timestamp: null, level: null, message: "Preparing environment" }, + { timestamp: "2026-10-12 07:19:49.775", level: "DEBUG", message: "Config" }, + { timestamp: "2026-10-12 07:19:50.001", level: "INFO", message: "Started" }, +]; + +describe("ExecutionLogView", () => { + it("shows a level badge only for parsed messages", () => { + render(); + const unparsed = screen.getByText("Preparing environment").parentElement; + expect(unparsed?.querySelector("[data-slot='badge']")).toBeNull(); + expect(screen.getByText("DEBUG")).toBeInTheDocument(); + expect(screen.getByText("INFO")).toBeInTheDocument(); + }); + + it("narrows the log to debug messages", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Debug" })); + expect(screen.getByText("Config")).toBeInTheDocument(); + expect(screen.queryByText("Started")).not.toBeInTheDocument(); + expect(screen.getByText("Showing 1 of 3 log entries")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/execution/executionLogs/executionLogView.tsx b/frontend/src/components/execution/executionLogs/executionLogView.tsx index be066f9d..942acd7c 100644 --- a/frontend/src/components/execution/executionLogs/executionLogView.tsx +++ b/frontend/src/components/execution/executionLogs/executionLogView.tsx @@ -16,6 +16,7 @@ import { const LEVEL_BUTTONS: { value: LogLevelFilter; label: string }[] = [ { value: "all", label: "All" }, + { value: "debug", label: "Debug" }, { value: "info", label: "Info" }, { value: "warning", label: "Warning" }, { value: "error", label: "Error" }, @@ -32,7 +33,7 @@ export function ExecutionLogView({ logs }: ExecutionLogViewProps) { const filteredLogs = filterLogs(logs, logLevel, searchTerm); return ( -
+
@@ -57,28 +58,28 @@ export function ExecutionLogView({ logs }: ExecutionLogViewProps) {
-
- -
-            {filteredLogs.map((log, index) => (
-              // biome-ignore lint/suspicious/noArrayIndexKey: The message might not have a unique identifier,
-              
- - {log.timestamp} - + +
+          {filteredLogs.map((log, index) => (
+            // biome-ignore lint/suspicious/noArrayIndexKey: The message might not have a unique identifier,
+            
+ + {log.timestamp} + + {log.level && ( - {log.level?.toUpperCase()} + {log.level.toUpperCase()} - {log.message} -
- ))} -
- -
-
+ )} + {log.message} +
+ ))} + + +
Showing {filteredLogs.length} of {logs.length} log entries diff --git a/frontend/src/lib/executionLog.ts b/frontend/src/lib/executionLog.ts index e308dfd4..f6b1ba28 100644 --- a/frontend/src/lib/executionLog.ts +++ b/frontend/src/lib/executionLog.ts @@ -52,7 +52,7 @@ export function filterLogs( } /** The badge colours for a log level. */ -export function levelClass(level: string | null): string { +export function levelClass(level: string): string { switch (level) { case "DEBUG": return "bg-green-500/10 text-green-500"; From 8085b51220ab54aedae256962e1c605339fa07bd Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:39:03 +0200 Subject: [PATCH 47/61] cleanup: compute the document title from the route matches instead of state and an effect --- frontend/src/hooks/useDocumentTitle.ts | 34 ++++---------------------- frontend/src/lib/documentTitle.test.ts | 29 ++++++++++++++++++++++ frontend/src/lib/documentTitle.ts | 16 ++++++++++++ 3 files changed, 50 insertions(+), 29 deletions(-) create mode 100644 frontend/src/lib/documentTitle.test.ts create mode 100644 frontend/src/lib/documentTitle.ts diff --git a/frontend/src/hooks/useDocumentTitle.ts b/frontend/src/hooks/useDocumentTitle.ts index 753c5b63..9c00b8fd 100644 --- a/frontend/src/hooks/useDocumentTitle.ts +++ b/frontend/src/hooks/useDocumentTitle.ts @@ -1,36 +1,12 @@ import { useMatches } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; - -const BASE_TITLE = "Climate-REF"; +import { titleFromMatches } from "@/lib/documentTitle"; /** - * Hook that updates the document title based on the current route's staticData. - * Uses route matches to find the most specific title in the route hierarchy. - * Returns the current title for use with React's component. + * The document title for the current route, from the most specific route's `staticData.title` * - * For dynamic titles, you can still use <title> which will override this hook. + * It is returned for use with React's <title> component. + * For dynamic titles, a page can still render its own <title>, which overrides this one. */ export function useDocumentTitle() { - const matches = useMatches(); - const [title, setTitle] = useState(BASE_TITLE); - - useEffect(() => { - // Find the most specific route with a title in staticData - // Iterate from the end (most specific) to start (least specific) - for (let i = matches.length - 1; i >= 0; i--) { - const match = matches[i]; - const routeTitle = match?.staticData?.title; - - if (routeTitle && typeof routeTitle === "string") { - const fullTitle = `${routeTitle} - ${BASE_TITLE}`; - setTitle(fullTitle); - return; - } - } - - // If no title found, use base title - setTitle(BASE_TITLE); - }, [matches]); - - return title; + return titleFromMatches(useMatches()); } diff --git a/frontend/src/lib/documentTitle.test.ts b/frontend/src/lib/documentTitle.test.ts new file mode 100644 index 00000000..6749c23f --- /dev/null +++ b/frontend/src/lib/documentTitle.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { titleFromMatches } from "./documentTitle"; + +const match = (title?: string) => ({ staticData: { title } }); + +describe("titleFromMatches", () => { + it("uses the most specific route's title", () => { + expect( + titleFromMatches([match(), match("Execution Details"), match("Logs")]), + ).toBe("Logs - Climate-REF"); + }); + + it("falls back to a layout's title when the route has none", () => { + expect( + titleFromMatches([match(), match("Diagnostic Details"), match()]), + ).toBe("Diagnostic Details - Climate-REF"); + }); + + it("skips an empty title", () => { + expect(titleFromMatches([match("Datasets"), match("")])).toBe( + "Datasets - Climate-REF", + ); + }); + + it("is the site name without any title", () => { + expect(titleFromMatches([])).toBe("Climate-REF"); + expect(titleFromMatches([{}, match()])).toBe("Climate-REF"); + }); +}); diff --git a/frontend/src/lib/documentTitle.ts b/frontend/src/lib/documentTitle.ts new file mode 100644 index 00000000..05187b44 --- /dev/null +++ b/frontend/src/lib/documentTitle.ts @@ -0,0 +1,16 @@ +const BASE_TITLE = "Climate-REF"; + +/** + * The document title for the matched routes, from the most specific route that sets `staticData.title` + * + * Matches run from the root to the current route, so a nested route's title wins over its layout's. + * Without a title anywhere it is the site name alone. + */ +export function titleFromMatches( + matches: readonly { staticData?: { title?: unknown } }[], +): string { + const title = matches + .map((match) => match.staticData?.title) + .findLast((title) => typeof title === "string" && title !== ""); + return title ? `${title} - ${BASE_TITLE}` : BASE_TITLE; +} From 96aed8e6c5e3adbf7bb866fae47efdad98c45257 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber <fuchsi.huber@climate-resource.com> Date: Mon, 19 Oct 2026 19:40:09 +0200 Subject: [PATCH 48/61] cleanup: move dropping empty search values and collecting dataset facets to src/lib --- frontend/src/lib/datasetFacets.test.ts | 42 +++++++++++++++++++ frontend/src/lib/datasetFacets.ts | 18 ++++++++ frontend/src/lib/search.test.ts | 29 +++++++++++++ frontend/src/lib/search.ts | 8 ++++ frontend/src/routes/_app/datasets.index.tsx | 23 +--------- frontend/src/routes/_app/executions.index.tsx | 11 +---- 6 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 frontend/src/lib/datasetFacets.test.ts create mode 100644 frontend/src/lib/datasetFacets.ts create mode 100644 frontend/src/lib/search.test.ts create mode 100644 frontend/src/lib/search.ts diff --git a/frontend/src/lib/datasetFacets.test.ts b/frontend/src/lib/datasetFacets.test.ts new file mode 100644 index 00000000..6ffb7408 --- /dev/null +++ b/frontend/src/lib/datasetFacets.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import type { CmipDatasetMetadata } from "@/client"; +import { collectFacets } from "./datasetFacets"; + +const metadata = ( + source_id: string, + experiment_id: string, +): CmipDatasetMetadata => ({ + source_id, + experiment_id, + variable_id: "tas", + variant_label: "r1i1p1f1", +}); + +describe("collectFacets", () => { + it("lists each facet's values once, in the order they first appear", () => { + expect( + collectFacets([ + { metadata: metadata("CESM2", "historical") }, + { metadata: metadata("ACCESS-ESM1-5", "historical") }, + { metadata: metadata("CESM2", "ssp585") }, + ]), + ).toEqual({ + source_id: ["CESM2", "ACCESS-ESM1-5"], + experiment_id: ["historical", "ssp585"], + variable_id: ["tas"], + variant_label: ["r1i1p1f1"], + }); + }); + + it("skips datasets without metadata", () => { + expect(collectFacets([{ metadata: null }])).toEqual({}); + expect( + collectFacets([{ metadata: null }, { metadata: metadata("A", "B") }]) + .source_id, + ).toEqual(["A"]); + }); + + it("is empty without datasets", () => { + expect(collectFacets([])).toEqual({}); + }); +}); diff --git a/frontend/src/lib/datasetFacets.ts b/frontend/src/lib/datasetFacets.ts new file mode 100644 index 00000000..719c45bb --- /dev/null +++ b/frontend/src/lib/datasetFacets.ts @@ -0,0 +1,18 @@ +import type { Dataset } from "@/client"; + +/** The values each metadata facet takes across `datasets`, each value once, in the order they first appear. */ +export function collectFacets( + datasets: Pick<Dataset, "metadata">[], +): Record<string, string[]> { + const facets = new Map<string, Set<string>>(); + for (const { metadata } of datasets) { + for (const [key, value] of Object.entries(metadata ?? {})) { + const values = facets.get(key) ?? new Set<string>(); + values.add(value); + facets.set(key, values); + } + } + return Object.fromEntries( + [...facets].map(([key, values]) => [key, [...values]]), + ); +} diff --git a/frontend/src/lib/search.test.ts b/frontend/src/lib/search.test.ts new file mode 100644 index 00000000..d7caeeba --- /dev/null +++ b/frontend/src/lib/search.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { dropEmpty } from "./search"; + +describe("dropEmpty", () => { + it("drops undefined and empty string values", () => { + expect( + dropEmpty({ + diagnostic_name_contains: "", + provider_name_contains: undefined, + dirty: "true", + mip_era: "CMIP6", + }), + ).toEqual({ dirty: "true", mip_era: "CMIP6" }); + }); + + it("keeps other falsy values", () => { + expect(dropEmpty({ offset: 0, flag: false, value: null })).toEqual({ + offset: 0, + flag: false, + value: null, + }); + }); + + it("leaves the search it was given unchanged", () => { + const search = { a: "", b: "x" }; + dropEmpty(search); + expect(search).toEqual({ a: "", b: "x" }); + }); +}); diff --git a/frontend/src/lib/search.ts b/frontend/src/lib/search.ts new file mode 100644 index 00000000..f89a8a04 --- /dev/null +++ b/frontend/src/lib/search.ts @@ -0,0 +1,8 @@ +/** A copy of `search` without its undefined or empty string values, which would only clutter the URL. */ +export function dropEmpty<T extends object>(search: T): Partial<T> { + return Object.fromEntries( + Object.entries(search).filter( + ([, value]) => value !== undefined && value !== "", + ), + ) as Partial<T>; +} diff --git a/frontend/src/routes/_app/datasets.index.tsx b/frontend/src/routes/_app/datasets.index.tsx index db0a1755..e7fdf3f4 100644 --- a/frontend/src/routes/_app/datasets.index.tsx +++ b/frontend/src/routes/_app/datasets.index.tsx @@ -7,6 +7,7 @@ import DatasetTable from "@/components/datasets/datasetTable"; import { FilterPanel } from "@/components/datasets/filterPanel"; import { Button } from "@/components/ui/button.tsx"; import { Card, CardContent } from "@/components/ui/card"; +import { collectFacets } from "@/lib/datasetFacets"; import { nextOffset } from "@/lib/pagination"; /** Facet filters by name, also read from the JSON string older links carry. Anything malformed means no facet filters. */ @@ -74,27 +75,7 @@ function SourcesIndexPage() { } = useInfiniteQuery(datasetsQueryOptions(search)); const datasets = data?.pages.flatMap((page) => page.data) || []; - const allFacets = datasets.reduce( - (acc, ds) => { - if (ds.metadata) { - for (const [key, value] of Object.entries(ds.metadata)) { - if (!acc[key]) { - acc[key] = new Set(); - } - acc[key].add(value); - } - } - return acc; - }, - {} as Record<string, Set<string>>, - ); - - const facetsForFilter = Object.fromEntries( - Object.entries(allFacets).map(([key, valueSet]) => [ - key, - Array.from(valueSet), - ]), - ); + const facetsForFilter = collectFacets(datasets); const handleFilterChange = (newFilters: Partial<DatasetsSearch>) => { const next = { ...search, ...newFilters }; diff --git a/frontend/src/routes/_app/executions.index.tsx b/frontend/src/routes/_app/executions.index.tsx index 5a59f72e..b4d33fc5 100644 --- a/frontend/src/routes/_app/executions.index.tsx +++ b/frontend/src/routes/_app/executions.index.tsx @@ -11,6 +11,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { useMipEra } from "@/hooks/useMipEra"; import { mipEraSearchFields } from "@/lib/mipEras"; import { nextOffset } from "@/lib/pagination"; +import { dropEmpty } from "@/lib/search"; const ExecutionsSearchSchema = z.object({ diagnostic_name_contains: z.string().optional(), @@ -70,15 +71,7 @@ function ExecutionsListPage() { const executionGroups = data?.pages.flatMap((page) => page.data) ?? []; const updateSearch = (patch: Partial<typeof search>) => { - // Remove empty values to keep URL clean and keep types aligned with Route.validateSearch - const next: Partial<typeof search> = { ...search, ...patch }; - (Object.keys(next) as (keyof typeof next)[]).forEach((k) => { - const v = next[k]; - if (v === undefined || v === "") { - delete next[k]; - } - }); - navigate({ search: next as typeof search }); + navigate({ search: dropEmpty({ ...search, ...patch }) as typeof search }); }; const handleFilterChange = (newFilters: Record<string, any>) => { From 81a4bcde7e078235f54dd133cf7de9ba4c0dbbb0 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber <fuchsi.huber@climate-resource.com> Date: Mon, 19 Oct 2026 19:41:03 +0200 Subject: [PATCH 49/61] cleanup: correct the download file name comment, log a failed download once and drop the dataset page cast --- .../components/execution/downloadOutputs.tsx | 5 ++--- frontend/src/routes/_app/datasets.$slug.tsx | 21 ++++++++----------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/execution/downloadOutputs.tsx b/frontend/src/components/execution/downloadOutputs.tsx index 925d2dc0..56b845a3 100644 --- a/frontend/src/components/execution/downloadOutputs.tsx +++ b/frontend/src/components/execution/downloadOutputs.tsx @@ -34,11 +34,10 @@ export function DownloadOutputs({ }); if (error) { - console.error(`Error downloading ${type}:`, error); - throw new Error(`Failed to download ${type}`); + throw new Error(`Failed to download ${type}`, { cause: error }); } - // Get filename from Content-Disposition header + // Named here, as the bundle comes without a file name and the archive's names only the execution const filename = `execution-${executionGroup}-${executionId}-${type}.${type === "bundle" ? "json" : "tar.gz"}`; // @ts-expect-error Can't type correctly diff --git a/frontend/src/routes/_app/datasets.$slug.tsx b/frontend/src/routes/_app/datasets.$slug.tsx index cdd23e1a..513994b7 100644 --- a/frontend/src/routes/_app/datasets.$slug.tsx +++ b/frontend/src/routes/_app/datasets.$slug.tsx @@ -1,5 +1,4 @@ import { createFileRoute } from "@tanstack/react-router"; -import type { ExecutionGroup } from "@/client"; import { datasetsExecutionsOptions, datasetsGetOptions, @@ -42,20 +41,18 @@ export const Route = createFileRoute("/_app/datasets/$slug")({ function DatasetPage() { const { dataset, executions } = Route.useLoaderData(); - const ds = dataset; - const exs = (executions?.data as unknown as ExecutionGroup[]) ?? []; return ( <div className="container mx-auto p-4"> - <title>{`${ds.slug} - Climate-REF`} + {`${dataset.slug} - Climate-REF`} @@ -65,9 +62,9 @@ function DatasetPage() { ) } items={[ - { label: "Slug", value: ds.slug }, - ...(ds.metadata - ? Object.entries(ds.metadata).map(([key, value]) => ({ + { label: "Slug", value: dataset.slug }, + ...(dataset.metadata + ? Object.entries(dataset.metadata).map(([key, value]) => ({ label: key, value: value, })) @@ -80,7 +77,7 @@ function DatasetPage() { Executions - +
From a1de67ce9c6a0805c2de98bfdf2e2e833def1481 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 19:42:53 +0200 Subject: [PATCH 50/61] cleanup: share the card for execution group tabs that need an execution --- .../components/execution/executionNotRun.tsx | 8 ---- .../components/execution/executionTabCard.tsx | 47 +++++++++++++++++++ .../_app/executions.$groupId/datasets.tsx | 36 ++++---------- .../routes/_app/executions.$groupId/files.tsx | 2 +- .../routes/_app/executions.$groupId/logs.tsx | 36 ++++---------- 5 files changed, 66 insertions(+), 63 deletions(-) delete mode 100644 frontend/src/components/execution/executionNotRun.tsx create mode 100644 frontend/src/components/execution/executionTabCard.tsx diff --git a/frontend/src/components/execution/executionNotRun.tsx b/frontend/src/components/execution/executionNotRun.tsx deleted file mode 100644 index f1783b1a..00000000 --- a/frontend/src/components/execution/executionNotRun.tsx +++ /dev/null @@ -1,8 +0,0 @@ -/** Stands in for what an execution would show, for a group that has never run. */ -export function ExecutionNotRun({ what }: { what: string }) { - return ( -

- This execution group has not run yet, so there are no {what} to show. -

- ); -} diff --git a/frontend/src/components/execution/executionTabCard.tsx b/frontend/src/components/execution/executionTabCard.tsx new file mode 100644 index 00000000..924c4ec5 --- /dev/null +++ b/frontend/src/components/execution/executionTabCard.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useLatestExecution } from "@/hooks/useLatestExecution"; + +/** Stands in for what an execution would show, for a group that has never run. */ +export function ExecutionNotRun({ what }: { what: string }) { + return ( +

+ This execution group has not run yet, so there are no {what} to show. +

+ ); +} + +/** A tab of the execution group page whose content needs an execution, which says so for a group that has never run. */ +export function ExecutionTabCard({ + groupId, + title, + description, + what, + children, +}: { + groupId: string; + title: string; + description: ReactNode; + /** What the tab shows, for the message when there is none */ + what: string; + children: ReactNode; +}) { + const hasRun = useLatestExecution(groupId) !== undefined; + return ( + + + {title} + {description} + + + {hasRun ? children : } + + + ); +} diff --git a/frontend/src/routes/_app/executions.$groupId/datasets.tsx b/frontend/src/routes/_app/executions.$groupId/datasets.tsx index 0925807f..7d748fd0 100644 --- a/frontend/src/routes/_app/executions.$groupId/datasets.tsx +++ b/frontend/src/routes/_app/executions.$groupId/datasets.tsx @@ -1,36 +1,18 @@ import { createFileRoute } from "@tanstack/react-router"; import { ExecutionDatasetTable } from "@/components/execution/executionDatasetTable"; -import { ExecutionNotRun } from "@/components/execution/executionNotRun"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { useLatestExecution } from "@/hooks/useLatestExecution"; +import { ExecutionTabCard } from "@/components/execution/executionTabCard"; const DatasetsTab = () => { const { groupId } = Route.useParams(); - const hasRun = useLatestExecution(groupId) !== undefined; - return ( - - - Datasets - - All datasets used in this execution. Click a dataset to view metadata - and provenance. - - - - {hasRun ? ( - - ) : ( - - )} - - + + + ); }; diff --git a/frontend/src/routes/_app/executions.$groupId/files.tsx b/frontend/src/routes/_app/executions.$groupId/files.tsx index 4757b4b4..bd25d48f 100644 --- a/frontend/src/routes/_app/executions.$groupId/files.tsx +++ b/frontend/src/routes/_app/executions.$groupId/files.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { ExecutionFilesContainer } from "@/components/execution/executionFiles"; -import { ExecutionNotRun } from "@/components/execution/executionNotRun"; +import { ExecutionNotRun } from "@/components/execution/executionTabCard"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useLatestExecution } from "@/hooks/useLatestExecution"; diff --git a/frontend/src/routes/_app/executions.$groupId/logs.tsx b/frontend/src/routes/_app/executions.$groupId/logs.tsx index 2bb60d4a..712456d9 100644 --- a/frontend/src/routes/_app/executions.$groupId/logs.tsx +++ b/frontend/src/routes/_app/executions.$groupId/logs.tsx @@ -1,36 +1,18 @@ import { createFileRoute } from "@tanstack/react-router"; import { ExecutionLogContainer } from "@/components/execution/executionLogs"; -import { ExecutionNotRun } from "@/components/execution/executionNotRun"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { useLatestExecution } from "@/hooks/useLatestExecution"; +import { ExecutionTabCard } from "@/components/execution/executionTabCard"; const LogsTab = () => { const { groupId } = Route.useParams(); - const hasRun = useLatestExecution(groupId) !== undefined; - return ( - - - Logs - - Runtime logs from the selected execution. Use your browser search - (Ctrl/Cmd+F) to find warnings or errors. - - - - {hasRun ? ( - - ) : ( - - )} - - + + + ); }; From 3491c1a1610d70fdff128d1bb1eaf073006b3505 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 20:33:11 +0200 Subject: [PATCH 51/61] cleanup: key diagnostic explorer cards by content and update the deprecated biome config --- frontend/biome.json | 4 ++-- .../explorer.tsx | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/biome.json b/frontend/biome.json index 775503fc..b52f0c08 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.11/schema.json", + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "vcs": { "enabled": false, "clientKind": "git", @@ -32,7 +32,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "correctness": { "useUniqueElementIds": "off" }, diff --git a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx index 1c92dc95..cf3a8ca8 100644 --- a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx +++ b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx @@ -9,10 +9,22 @@ import { ExplorerCardContentSkeleton, } from "@/components/explorer/explorerCardContent"; import { filterExplorerContentForDiagnostic } from "@/components/explorer/mapping"; +import type { ExplorerCardContent as ExplorerCardContentItem } from "@/components/explorer/types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useMipEra } from "@/hooks/useMipEra"; import { mipEraSearchSchema } from "@/lib/mipEras"; +/** A key per content item, numbering an item that more than one card shows */ +function contentKeys(items: ExplorerCardContentItem[]): string[] { + const seen = new Map(); + return items.map((item) => { + const key = `${item.type}:${item.provider}:${item.diagnostic}:${item.title}`; + const count = (seen.get(key) ?? 0) + 1; + seen.set(key, count); + return count === 1 ? key : `${key}:${count}`; + }); +} + const parentRoute = getRouteApi( "/_app/diagnostics/$providerSlug/$diagnosticSlug", ); @@ -46,13 +58,11 @@ function ExplorerPanels({ ); } + const keys = contentKeys(matchingContentItems); return (
{matchingContentItems.map((contentItem, index) => ( - } - > + }> }> From 640284d02442a6012c37c954f5c0f10a3bab6bc9 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 20:34:08 +0200 Subject: [PATCH 52/61] fix: show a never-run execution group's value tabs as not run instead of failing --- .../execution/values/valuesPanel.test.tsx | 27 ++++++++++ .../execution/values/valuesPanel.tsx | 50 +++++++++---------- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/execution/values/valuesPanel.test.tsx b/frontend/src/components/execution/values/valuesPanel.test.tsx index 12ff827d..d8f518b9 100644 --- a/frontend/src/components/execution/values/valuesPanel.test.tsx +++ b/frontend/src/components/execution/values/valuesPanel.test.tsx @@ -3,8 +3,13 @@ import { describe, expect, it, vi } from "vitest"; import { executionValuesSearchSchema } from "@/lib/valuesSearch"; import { ExecutionValuesPanel } from "./valuesPanel"; +/** The executions of the group the panel shows, which the group query returns */ +let groupExecutions: { id: number; successful: boolean | null }[] = []; +const valuesQuery = vi.fn(); + vi.mock("@tanstack/react-query", async () => ({ ...(await vi.importActual("@tanstack/react-query")), + useSuspenseQuery: () => ({ data: { executions: groupExecutions } }), useQuery: () => ({ data: { data: [], @@ -14,11 +19,13 @@ vi.mock("@tanstack/react-query", async () => ({ types: ["scalar"], }, isLoading: false, + ...valuesQuery(), }), })); describe("ExecutionValuesPanel", () => { it("keeps its filters in the URL", () => { + groupExecutions = [{ id: 3, successful: true }]; const navigate = vi.fn(); render( { replace: true, }); }); + + it("says a group that has never run has no values, without querying them", () => { + groupExecutions = []; + valuesQuery.mockClear(); + render( + , + ); + + expect( + screen.getByText( + "This execution group has not run yet, so there are no series values to show.", + ), + ).toBeInTheDocument(); + expect(valuesQuery).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/execution/values/valuesPanel.tsx b/frontend/src/components/execution/values/valuesPanel.tsx index 5d648f94..e9d6233b 100644 --- a/frontend/src/components/execution/values/valuesPanel.tsx +++ b/frontend/src/components/execution/values/valuesPanel.tsx @@ -1,12 +1,6 @@ import type { useNavigate } from "@tanstack/react-router"; import { MipEraScope } from "@/components/charts/mipEraBar"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { ExecutionTabCard } from "@/components/execution/executionTabCard"; import { useDiagnosticMetricValues } from "@/hooks/useDiagnosticMetricValues"; import { useExecutionMetricValues } from "@/hooks/useExecutionMetricValues"; import type { useMetricValues } from "@/hooks/useMetricValues"; @@ -91,37 +85,39 @@ export function DiagnosticValuesPanel({ ); } -/** An execution group's scalar or series values. */ -export function ExecutionValuesPanel({ +type ExecutionValuesPanelProps = ValuesPanelProps & { + groupId: string; +}; + +/** An execution group's values, queried only once the group has run. */ +function ExecutionValuesTable({ groupId, search, navigate, valueType, -}: ValuesPanelProps & { groupId: string }) { +}: ExecutionValuesPanelProps) { const result = useExecutionMetricValues({ groupId, search, valueType, navigate, }); - const kind = valueType === "scalar" ? "Scalar" : "Series"; + return ( + + ); +} +/** An execution group's scalar or series values. */ +export function ExecutionValuesPanel(props: ExecutionValuesPanelProps) { + const kind = props.valueType === "scalar" ? "Scalar" : "Series"; return ( - - - {kind} Values - - {kind} metrics from the current execution. Use facets to filter and - Export to download as CSV. - - - - - - + + + ); } From ed9e2118bada874e52b8b0341e1a6e26a93918c7 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 20:36:30 +0200 Subject: [PATCH 53/61] fix: recover from errors without a page reload and reset page errors on navigation --- .../src/components/app/errorBoundary.test.tsx | 120 ++++++++++++++++++ frontend/src/components/app/errorBoundary.tsx | 115 ++++++++--------- frontend/src/components/app/index.ts | 1 - .../components/explorer/explorerCardGroup.tsx | 3 +- frontend/src/routes/__root.tsx | 31 +++-- .../explorer.tsx | 4 +- frontend/src/routes/_app/route.tsx | 11 +- 7 files changed, 209 insertions(+), 76 deletions(-) create mode 100644 frontend/src/components/app/errorBoundary.test.tsx diff --git a/frontend/src/components/app/errorBoundary.test.tsx b/frontend/src/components/app/errorBoundary.test.tsx new file mode 100644 index 00000000..2f969562 --- /dev/null +++ b/frontend/src/components/app/errorBoundary.test.tsx @@ -0,0 +1,120 @@ +import { + QueryClient, + QueryClientProvider, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Suspense } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ErrorBoundary, type FallbackProps } from "./errorBoundary"; + +/** Throws while `broken.current` is true */ +function Flaky({ broken }: { broken: { current: boolean } }) { + if (broken.current) { + throw new Error("Broken"); + } + return

Working

; +} + +const fallback = ({ error, reset }: FallbackProps) => ( + +); + +function renderBoundary(ui: React.ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrap = (node: React.ReactNode) => ( + {node} + ); + const result = render(wrap(ui)); + return { + ...result, + rerender: (next: React.ReactNode) => result.rerender(wrap(next)), + }; +} + +describe("ErrorBoundary", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // React logs every caught error; keep the test output readable + const silenceErrors = () => + vi.spyOn(console, "error").mockImplementation(() => {}); + + it("renders the children again when the fallback resets", () => { + silenceErrors(); + const broken = { current: true }; + renderBoundary( + + + , + ); + expect(screen.getByText("Try again after Broken")).toBeInTheDocument(); + + broken.current = false; + fireEvent.click(screen.getByRole("button")); + expect(screen.getByText("Working")).toBeInTheDocument(); + }); + + it("clears the error when a reset key changes", () => { + silenceErrors(); + const broken = { current: true }; + const { rerender } = renderBoundary( + + + , + ); + expect(screen.getByRole("button")).toBeInTheDocument(); + + broken.current = false; + rerender( + + + , + ); + expect(screen.getByText("Working")).toBeInTheDocument(); + }); + + it("keeps showing the fallback while the reset keys stay the same", () => { + silenceErrors(); + const broken = { current: true }; + const { rerender } = renderBoundary( + + + , + ); + broken.current = false; + rerender( + + + , + ); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("fetches a failed query again when the fallback resets", async () => { + silenceErrors(); + const queryFn = vi + .fn() + .mockRejectedValueOnce(new Error("Offline")) + .mockResolvedValue("Loaded"); + function Data() { + const { data } = useSuspenseQuery({ queryKey: ["data"], queryFn }); + return

{data}

; + } + renderBoundary( + + Loading

}> + +
+
, + ); + fireEvent.click(await screen.findByText("Try again after Offline")); + expect(await screen.findByText("Loaded")).toBeInTheDocument(); + expect(queryFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/components/app/errorBoundary.tsx b/frontend/src/components/app/errorBoundary.tsx index 7057ba6a..ae40c7cb 100644 --- a/frontend/src/components/app/errorBoundary.tsx +++ b/frontend/src/components/app/errorBoundary.tsx @@ -1,24 +1,41 @@ +import { useQueryErrorResetBoundary } from "@tanstack/react-query"; import { Component, type ErrorInfo, type ReactNode } from "react"; import { ErrorFallback } from "./errorFallback"; +/** What a fallback gets to show the error and to try again. */ +export interface FallbackProps { + error?: Error; + /** Render the children again, fetching any failed query anew */ + reset: () => void; +} + interface Props { children: ReactNode; - fallback?: ReactNode; + /** Shown instead of the children after an error; a function gets `reset` to try again */ + fallback?: ReactNode | ((props: FallbackProps) => ReactNode); onError?: (error: Error, errorInfo: ErrorInfo) => void; - resetKeys?: Array; - resetOnPropsChange?: boolean; + /** The error is cleared when any of these change, such as the path when navigating away */ + resetKeys?: ReadonlyArray; +} + +interface InnerProps extends Props { + onReset: () => void; } interface State { hasError: boolean; error?: Error; - errorInfo?: ErrorInfo; } -export class ErrorBoundary extends Component { - private resetTimeoutId: number | null = null; +const keysChanged = ( + previous: ReadonlyArray = [], + next: ReadonlyArray = [], +) => + previous.length !== next.length || + previous.some((key, index) => !Object.is(key, next[index])); - constructor(props: Props) { +class ErrorBoundaryInner extends Component { + constructor(props: InnerProps) { super(props); this.state = { hasError: false }; } @@ -34,67 +51,47 @@ export class ErrorBoundary extends Component { console.error("ErrorBoundary caught an error:", error, errorInfo); } - // Call the optional onError callback this.props.onError?.(error, errorInfo); - - // Update state with error info - this.setState({ errorInfo }); } - componentDidUpdate(prevProps: Props) { - const { resetKeys, resetOnPropsChange } = this.props; - const { hasError } = this.state; - - // Reset error boundary when resetKeys change - if (hasError && resetKeys && prevProps.resetKeys) { - const hasResetKeyChanged = resetKeys.some( - (key, index) => prevProps.resetKeys?.[index] !== key, - ); - - if (hasResetKeyChanged) { - this.resetErrorBoundary(); - } - } - - // Reset error boundary when any props change (if enabled) - if (hasError && resetOnPropsChange && prevProps !== this.props) { - this.resetErrorBoundary(); + componentDidUpdate(prevProps: InnerProps) { + if ( + this.state.hasError && + keysChanged(prevProps.resetKeys, this.props.resetKeys) + ) { + this.reset(); } } - componentWillUnmount() { - if (this.resetTimeoutId) { - clearTimeout(this.resetTimeoutId); - } - } - - resetErrorBoundary = () => { - if (this.resetTimeoutId) { - clearTimeout(this.resetTimeoutId); - } - - this.resetTimeoutId = window.setTimeout(() => { - this.setState({ - hasError: false, - error: undefined, - errorInfo: undefined, - }); - }, 0); + reset = () => { + // Without this, a query that failed would throw its cached error again + this.props.onReset(); + this.setState({ hasError: false, error: undefined }); }; render() { - if (this.state.hasError) { - // Render custom fallback UI if provided, otherwise render default - return ( - this.props.fallback || ( - - ) - ); + if (!this.state.hasError) { + return this.props.children; } - - return this.props.children; + const { fallback } = this.props; + if (typeof fallback === "function") { + return fallback({ error: this.state.error, reset: this.reset }); + } + return ( + fallback ?? ( + + ) + ); } } + +/** + * Shows a fallback instead of its children when they throw + * + * Trying again from the fallback renders the children anew and fetches the queries that failed, + * so recovering does not need a page reload. + */ +export function ErrorBoundary(props: Props) { + const { reset } = useQueryErrorResetBoundary(); + return ; +} diff --git a/frontend/src/components/app/index.ts b/frontend/src/components/app/index.ts index 615e9e7e..79d568ae 100644 --- a/frontend/src/components/app/index.ts +++ b/frontend/src/components/app/index.ts @@ -1,2 +1 @@ export { ErrorBoundary } from "./errorBoundary"; -export { ErrorFallback } from "./errorFallback"; diff --git a/frontend/src/components/explorer/explorerCardGroup.tsx b/frontend/src/components/explorer/explorerCardGroup.tsx index 53068b6c..8e6649cf 100644 --- a/frontend/src/components/explorer/explorerCardGroup.tsx +++ b/frontend/src/components/explorer/explorerCardGroup.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import { ErrorBoundary, ErrorFallback } from "../app"; +import { ErrorBoundary } from "../app"; import { ExplorerCardContent, ExplorerCardContentSkeleton, @@ -16,7 +16,6 @@ export function ExplorerCardGroup({ card }: ExplorerCardGroupProps) { {card.content.map((contentItem) => ( } > }> diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 0239afe6..3205bcf0 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -2,7 +2,11 @@ import { init } from "@plausible-analytics/tracker"; import * as Sentry from "@sentry/react"; import type { QueryClient } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; +import { + createRootRouteWithContext, + Outlet, + useLocation, +} from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { ApiEndpointWarning } from "@/components/app/apiEndpointWarning"; import { ErrorBoundary } from "@/components/app/errorBoundary"; @@ -27,23 +31,22 @@ init({ function AppLayout() { const { isUsingOverride } = useApiEndpoint(); const title = useDocumentTitle(); + // A page that failed is shown again once the visitor navigates elsewhere + const pathname = useLocation({ select: (location) => location.pathname }); return ( (
- } + )} onError={(error, errorInfo) => { - // Log to console in development - if (import.meta.env.DEV) { - console.error("Root ErrorBoundary caught error:", error, errorInfo); - } - Sentry.captureException(error, { contexts: { errorInfo: { @@ -57,7 +60,15 @@ function AppLayout() { } + resetKeys={[pathname]} + fallback={({ error, reset }) => ( + + )} >
{isUsingOverride && ( diff --git a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx index cf3a8ca8..5b74580e 100644 --- a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx +++ b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/explorer.tsx @@ -2,7 +2,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute, getRouteApi, Navigate } from "@tanstack/react-router"; import { Suspense } from "react"; import { explorerGetCollectionOptions } from "@/client/@tanstack/react-query.gen"; -import { ErrorBoundary, ErrorFallback } from "@/components/app"; +import { ErrorBoundary } from "@/components/app"; import { MipEraScope } from "@/components/charts/mipEraBar"; import { ExplorerCardContent, @@ -62,7 +62,7 @@ function ExplorerPanels({ return (
{matchingContentItems.map((contentItem, index) => ( - }> + }> diff --git a/frontend/src/routes/_app/route.tsx b/frontend/src/routes/_app/route.tsx index 48d281ed..2f2d2f4f 100644 --- a/frontend/src/routes/_app/route.tsx +++ b/frontend/src/routes/_app/route.tsx @@ -1,11 +1,18 @@ -import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { createFileRoute, Outlet, useLocation } from "@tanstack/react-router"; import { ErrorBoundary } from "@/components/app/errorBoundary"; import { ComponentErrorFallback } from "@/components/app/errorFallback"; function Content() { + // A page that failed is shown again once the visitor navigates elsewhere + const pathname = useLocation({ select: (location) => location.pathname }); return (
- }> + ( + + )} + >
From 85c06daf5173e888bf70fea8d7b5712cfe908705 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 20:37:32 +0200 Subject: [PATCH 54/61] cleanup: split the series canvas drawing into line, crosshair and axes helpers and redraw through a ref --- .../execution/values/series/seriesCanvas.tsx | 241 +++++++++++------- .../values/series/seriesVisualization.tsx | 22 +- 2 files changed, 162 insertions(+), 101 deletions(-) diff --git a/frontend/src/components/execution/values/series/seriesCanvas.tsx b/frontend/src/components/execution/values/series/seriesCanvas.tsx index 367e39d1..419d18ec 100644 --- a/frontend/src/components/execution/values/series/seriesCanvas.tsx +++ b/frontend/src/components/execution/values/series/seriesCanvas.tsx @@ -1,5 +1,12 @@ import type * as d3 from "d3"; -import { memo, useCallback, useEffect, useRef } from "react"; +import { + memo, + type Ref, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from "react"; import type { SeriesMetadata } from "../types"; import type { ChartMargins, XScaleType } from "./useChartScales"; import { @@ -67,7 +74,13 @@ export interface CrosshairPosition { nearestColor: string; } +/** Lets the chart redraw the canvas for hover changes without re-rendering it */ +export interface SeriesCanvasHandle { + redraw: () => void; +} + interface SeriesCanvasProps { + ref?: Ref; chartData: ChartDataPoint[]; seriesMetadata: SeriesMetadata[]; indexName: string; @@ -92,6 +105,7 @@ interface SeriesCanvasProps { } export const SeriesCanvas = memo(function SeriesCanvas({ + ref, chartData, seriesMetadata, indexName, @@ -143,80 +157,25 @@ export const SeriesCanvas = memo(function SeriesCanvas({ // Draw grid drawGrid(ctx, xScale, yScale, innerWidth, innerHeight, isDark); - const hoveredLabel = hoveredLabelRef.current; - for (const meta of orderSeriesForDraw( + drawSeriesLines(ctx, { + chartData, seriesMetadata, + indexName, hiddenLabels, - hoveredLabel, - )) { - const stroke = seriesStroke( - meta, - { - hidden: hiddenLabels.has(meta.label), - hovered: meta.label === hoveredLabel, - }, - isDark, - ); - ctx.strokeStyle = stroke.color; - ctx.lineWidth = stroke.width; - ctx.globalAlpha = stroke.alpha; - - ctx.beginPath(); - let started = false; - for (const row of chartData) { - const value = plottablePoint(row, indexName, meta.seriesIndex); - if (!value) { - started = false; - continue; - } - const px = xScale(value.x); - const py = yScale(value.y); - if (!started) { - ctx.moveTo(px, py); - started = true; - } else { - ctx.lineTo(px, py); - } - } - ctx.stroke(); - } + hoveredLabel: hoveredLabelRef.current, + xScale, + yScale, + isDark, + }); - ctx.globalAlpha = 1; - - // Draw crosshair - const crosshair = crosshairRef.current; - if (crosshair) { - // Vertical line at snapped X position - ctx.setLineDash([4, 4]); - ctx.strokeStyle = "#94A3B8"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(crosshair.dataPixelX, 0); - ctx.lineTo(crosshair.dataPixelX, innerHeight); - ctx.stroke(); - ctx.setLineDash([]); - - // Dot on nearest point - ctx.beginPath(); - ctx.arc( - crosshair.nearestPixelX, - crosshair.nearestPixelY, - 5, - 0, - Math.PI * 2, - ); - ctx.fillStyle = crosshair.nearestColor; - ctx.fill(); - ctx.strokeStyle = "#FFFFFF"; - ctx.lineWidth = 2; - ctx.stroke(); + if (crosshairRef.current) { + drawCrosshair(ctx, crosshairRef.current, innerHeight); } ctx.restore(); // Draw axes outside clip region - drawAxes( - ctx, + drawAxes(ctx, { xScale, yScale, margins, @@ -228,7 +187,7 @@ export const SeriesCanvas = memo(function SeriesCanvas({ metricName, units, indexUnits, - ); + }); }, [ chartData, seriesMetadata, @@ -257,16 +216,17 @@ export const SeriesCanvas = memo(function SeriesCanvas({ return () => cancelAnimationFrame(rafRef.current); }, [draw]); - // Expose redraw for hover updates (called from parent via ref) - useEffect(() => { - const el = canvasRef.current; - if (el) { - (el as HTMLCanvasElement & { redraw: () => void }).redraw = () => { + // Hover changes live in refs, so the chart asks for a redraw instead of re-rendering + useImperativeHandle( + ref, + () => ({ + redraw: () => { cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(draw); - }; - } - }, [draw]); + }, + }), + [draw], + ); return (
@@ -281,6 +241,96 @@ export const SeriesCanvas = memo(function SeriesCanvas({ ); }); +/** What the series lines are drawn from */ +interface SeriesLinesOptions { + chartData: ChartDataPoint[]; + seriesMetadata: SeriesMetadata[]; + indexName: string; + hiddenLabels: Set; + hoveredLabel: string | null; + xScale: XScaleType; + yScale: d3.ScaleLinear; + isDark: boolean; +} + +/** Draw a line per series, breaking it where a value is missing */ +function drawSeriesLines( + ctx: CanvasRenderingContext2D, + { + chartData, + seriesMetadata, + indexName, + hiddenLabels, + hoveredLabel, + xScale, + yScale, + isDark, + }: SeriesLinesOptions, +) { + for (const meta of orderSeriesForDraw( + seriesMetadata, + hiddenLabels, + hoveredLabel, + )) { + const stroke = seriesStroke( + meta, + { + hidden: hiddenLabels.has(meta.label), + hovered: meta.label === hoveredLabel, + }, + isDark, + ); + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.globalAlpha = stroke.alpha; + + ctx.beginPath(); + let started = false; + for (const row of chartData) { + const value = plottablePoint(row, indexName, meta.seriesIndex); + if (!value) { + started = false; + continue; + } + const px = xScale(value.x); + const py = yScale(value.y); + if (!started) { + ctx.moveTo(px, py); + started = true; + } else { + ctx.lineTo(px, py); + } + } + ctx.stroke(); + } + + ctx.globalAlpha = 1; +} + +/** Draw the hover crosshair: a vertical line at the data position and a dot on the nearest point */ +function drawCrosshair( + ctx: CanvasRenderingContext2D, + crosshair: CrosshairPosition, + innerHeight: number, +) { + ctx.setLineDash([4, 4]); + ctx.strokeStyle = "#94A3B8"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(crosshair.dataPixelX, 0); + ctx.lineTo(crosshair.dataPixelX, innerHeight); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.beginPath(); + ctx.arc(crosshair.nearestPixelX, crosshair.nearestPixelY, 5, 0, Math.PI * 2); + ctx.fillStyle = crosshair.nearestColor; + ctx.fill(); + ctx.strokeStyle = "#FFFFFF"; + ctx.lineWidth = 2; + ctx.stroke(); +} + function drawGrid( ctx: CanvasRenderingContext2D, xScale: XScaleType, @@ -341,19 +391,36 @@ function axisTitle(name: string, units?: string): string { return units ? `${name} (${units})` : name; } +/** What the axes are drawn from */ +interface AxesOptions { + xScale: XScaleType; + yScale: d3.ScaleLinear; + margins: ChartMargins; + innerWidth: number; + innerHeight: number; + indexName: string; + isDark: boolean; + isTimeAxis: boolean; + metricName?: string; + units?: string; + indexUnits?: string; +} + function drawAxes( ctx: CanvasRenderingContext2D, - xScale: XScaleType, - yScale: d3.ScaleLinear, - margins: ChartMargins, - innerWidth: number, - innerHeight: number, - indexName: string, - isDark: boolean, - isTimeAxis: boolean, - metricName?: string, - units?: string, - indexUnits?: string, + { + xScale, + yScale, + margins, + innerWidth, + innerHeight, + indexName, + isDark, + isTimeAxis, + metricName, + units, + indexUnits, + }: AxesOptions, ) { ctx.save(); diff --git a/frontend/src/components/execution/values/series/seriesVisualization.tsx b/frontend/src/components/execution/values/series/seriesVisualization.tsx index ed3c6567..3b07ab3c 100644 --- a/frontend/src/components/execution/values/series/seriesVisualization.tsx +++ b/frontend/src/components/execution/values/series/seriesVisualization.tsx @@ -9,6 +9,7 @@ import { CanvasTooltip } from "./canvasTooltip"; import { type CrosshairPosition, SeriesCanvas, + type SeriesCanvasHandle, seriesLineColor, } from "./seriesCanvas"; import { SeriesLegend } from "./seriesLegend"; @@ -61,6 +62,7 @@ export function SeriesVisualization({ const [groupByDimension, setGroupByDimension] = useState(null); const containerRef = useRef(null); const crosshairRef = useRef(null); + const canvasRef = useRef(null); const [containerWidth, setContainerWidth] = useState(800); // Tooltip state @@ -218,10 +220,7 @@ export function SeriesVisualization({ hoveredLabelRef.current = label; setHoveredLabel(label); // Trigger canvas redraw without React re-render - const canvas = containerRef.current?.querySelector("canvas") as - | (HTMLCanvasElement & { redraw?: () => void }) - | null; - canvas?.redraw?.(); + canvasRef.current?.redraw(); }, []); // Canvas mouse handlers @@ -229,10 +228,7 @@ export function SeriesVisualization({ (e: React.MouseEvent) => { // Capture values synchronously — React pools events, so currentTarget // and coordinates become null inside requestAnimationFrame. - const canvas = e.currentTarget as HTMLCanvasElement & { - redraw?: () => void; - }; - const rect = canvas.getBoundingClientRect(); + const rect = e.currentTarget.getBoundingClientRect(); const mouseX = e.clientX - rect.left - margins.left; const mouseY = e.clientY - rect.top - margins.top; @@ -258,7 +254,7 @@ export function SeriesVisualization({ nearestColor: seriesLineColor(nearest.metadata, isDark), }; - canvas.redraw?.(); + canvasRef.current?.redraw(); setTooltipState({ visible: true, @@ -272,7 +268,7 @@ export function SeriesVisualization({ if (hoveredLabelRef.current !== null) { hoveredLabelRef.current = null; } - canvas.redraw?.(); + canvasRef.current?.redraw(); setTooltipState((prev) => prev.visible ? { visible: false, x: 0, y: 0, nearest: null, allAtX: [] } @@ -289,10 +285,7 @@ export function SeriesVisualization({ hoveredLabelRef.current = null; crosshairRef.current = null; setTooltipState({ visible: false, x: 0, y: 0, nearest: null, allAtX: [] }); - const canvas = containerRef.current?.querySelector("canvas") as - | (HTMLCanvasElement & { redraw?: () => void }) - | null; - canvas?.redraw?.(); + canvasRef.current?.redraw(); }, []); const handleCanvasClick = useCallback( @@ -383,6 +376,7 @@ export function SeriesVisualization({
Date: Mon, 19 Oct 2026 20:39:02 +0200 Subject: [PATCH 55/61] cleanup: split the Taylor diagram drawing into layers over a tested geometry module --- .../content/taylorDiagramContent.test.tsx | 51 ++ .../explorer/content/taylorDiagramContent.tsx | 528 +++++++++--------- .../explorer/content/taylorGeometry.test.ts | 37 ++ .../explorer/content/taylorGeometry.ts | 66 +++ 4 files changed, 404 insertions(+), 278 deletions(-) create mode 100644 frontend/src/components/explorer/content/taylorDiagramContent.test.tsx create mode 100644 frontend/src/components/explorer/content/taylorGeometry.test.ts create mode 100644 frontend/src/components/explorer/content/taylorGeometry.ts diff --git a/frontend/src/components/explorer/content/taylorDiagramContent.test.tsx b/frontend/src/components/explorer/content/taylorDiagramContent.test.tsx new file mode 100644 index 00000000..7bd7f681 --- /dev/null +++ b/frontend/src/components/explorer/content/taylorDiagramContent.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TaylorDiagramContent } from "./taylorDiagramContent"; +import { CORRELATION_TICKS, RMSE_CONTOURS } from "./taylorGeometry"; + +const models = [ + { name: "CanESM5", stddev: 0.9, correlation: 0.8, color: "#4e79a7" }, + { name: "MIROC6", stddev: 1.2, correlation: 0.6 }, +]; + +describe("TaylorDiagramContent", () => { + it("draws a point per model, the RMSE contours and the correlation ticks", () => { + const { container } = render(); + const circles = container.querySelectorAll("svg > circle"); + expect(circles).toHaveLength(models.length); + expect(container.querySelectorAll("g circle")).toHaveLength( + RMSE_CONTOURS.length, + ); + for (const corr of CORRELATION_TICKS) { + expect(screen.getAllByText(String(corr)).length).toBeGreaterThan(0); + } + }); + + it("clips each diagram's contours to its own clip path", () => { + const { container } = render( + <> + + + , + ); + const ids = Array.from(container.querySelectorAll("clipPath"), (c) => c.id); + expect(new Set(ids).size).toBe(2); + for (const group of container.querySelectorAll("g[clip-path]")) { + expect(ids).toContain( + group.getAttribute("clip-path")?.match(/url\(#(.*)\)/)?.[1], + ); + } + }); + + it("names the hovered model in the tooltip", () => { + const { container } = render(); + fireEvent.mouseEnter(container.querySelectorAll("svg > circle")[1]); + expect(screen.getByText("MIROC6")).toBeInTheDocument(); + expect(screen.getByText("0.600")).toBeInTheDocument(); + }); + + it("explains an empty diagram", () => { + render(); + expect(screen.getByText("No model data available")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/explorer/content/taylorDiagramContent.tsx b/frontend/src/components/explorer/content/taylorDiagramContent.tsx index 4d0d947e..4253d6ae 100644 --- a/frontend/src/components/explorer/content/taylorDiagramContent.tsx +++ b/frontend/src/components/explorer/content/taylorDiagramContent.tsx @@ -1,6 +1,15 @@ import { flip, offset, shift, useFloating } from "@floating-ui/react"; import * as d3 from "d3"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; +import { + CORRELATION_TICKS, + RMSE_CONTOURS, + STDDEV_ARCS, + STDDEV_MAX, + STDDEV_TICKS, + type TaylorGeometry, + taylorGeometry, +} from "./taylorGeometry"; /** * Represents a model's performance metrics for the Taylor diagram @@ -23,6 +32,225 @@ interface TaylorDiagramProps { marginRight?: number; // defaults to 50 } +type Svg = d3.Selection; + +/** A quarter circle around the origin at a standard deviation */ +const quarterArc = (geometry: TaylorGeometry, stddev: number) => + d3 + .arc() + .innerRadius(geometry.radius) + .outerRadius(geometry.radius) + .startAngle(0) + .endAngle(Math.PI / 2)(stddev); + +/** The standard deviation arcs, and the reference with its dashed arc */ +function drawStddevGrid( + svg: Svg, + geometry: TaylorGeometry, + referenceStddev: number, +) { + const { center } = geometry; + const transform = `translate(${center.x},${center.y})`; + for (const sd of STDDEV_ARCS) { + svg + .append("path") + .attr("d", quarterArc(geometry, sd)) + .attr("transform", transform) + .attr("fill", "none") + .attr("stroke", "#aaa"); + } + + svg + .append("rect") + .attr("x", center.x + geometry.radius(referenceStddev) - 5) + .attr("y", center.y - 5) + .attr("width", 10) + .attr("height", 10) + .attr("fill", "black"); + + svg + .append("path") + .attr("d", quarterArc(geometry, referenceStddev)) + .attr("transform", transform) + .attr("fill", "none") + .attr("stroke", "#888") + .attr("stroke-dasharray", "10,10") + .attr("stroke-width", 2); +} + +/** The two standard deviation axes with their ticks and title */ +function drawAxes(svg: Svg, geometry: TaylorGeometry) { + const { center, radius } = geometry; + const end = radius(STDDEV_MAX); + const line = (x1: number, y1: number, x2: number, y2: number) => + svg + .append("line") + .attr("x1", x1) + .attr("y1", y1) + .attr("x2", x2) + .attr("y2", y2) + .attr("stroke", "black"); + + line(center.x, center.y, center.x + end, center.y); + line(center.x, center.y, center.x, center.y - end); + + for (const sd of STDDEV_TICKS) { + const x = center.x + radius(sd); + line(x, center.y - 5, x, center.y + 5); + svg + .append("text") + .attr("x", x) + .attr("y", center.y + 18) + .attr("text-anchor", "middle") + .attr("font-size", "12px") + .text(sd); + + const y = center.y - radius(sd); + line(center.x - 5, y, center.x + 5, y); + svg + .append("text") + .attr("x", center.x - 18) + .attr("y", y + 4) + .attr("text-anchor", "end") + .attr("font-size", "12px") + .text(sd); + } + + svg + .append("text") + .attr("x", center.x + end / 2) + .attr("y", center.y + 40) + .attr("text-anchor", "middle") + .attr("font-size", "14px") + .text("Standard deviation (Normalized)"); +} + +/** The RMSE circles around the reference point, clipped to the diagram */ +function drawRmseContours( + svg: Svg, + geometry: TaylorGeometry, + referenceStddev: number, + clipId: string, +) { + const { center, radius } = geometry; + const end = radius(STDDEV_MAX); + // A filled quarter circle: centre, top of the y axis, arc to the end of the x axis + svg + .append("defs") + .append("clipPath") + .attr("id", clipId) + .append("path") + .attr( + "d", + `M ${center.x},${center.y} L ${center.x},${center.y - end} A ${end},${end} 0 0 1 ${center.x + end},${center.y} Z`, + ); + + const ref = { x: center.x + radius(referenceStddev), y: center.y }; + const contours = svg.append("g").attr("clip-path", `url(#${clipId})`); + // Labels sit just outside each circle, a little past the top of it + const labelAngle = Math.PI * 0.62; + for (const rmse of RMSE_CONTOURS) { + contours + .append("circle") + .attr("cx", ref.x) + .attr("cy", ref.y) + .attr("r", radius(rmse)) + .attr("fill", "none") + .attr("stroke", "#ddd") + .attr("stroke-width", 1.5) + .attr("stroke-dasharray", "10,5"); + + const labelRadius = radius(rmse) + 10; + svg + .append("text") + .attr("x", ref.x + labelRadius * Math.cos(labelAngle)) + .attr("y", ref.y - labelRadius * Math.sin(labelAngle)) + .attr("font-size", "12px") + .attr("fill", "#aaa") + .text(rmse); + } +} + +/** The correlation ticks on the outer arc and its title */ +function drawCorrelationTicks(svg: Svg, geometry: TaylorGeometry) { + const { center, radius } = geometry; + const outer = radius(STDDEV_MAX); + const tickEnd = outer + 8; + const at = (distance: number, angle: number) => ({ + x: center.x + distance * Math.cos(angle), + y: center.y - distance * Math.sin(angle), + }); + + for (const corr of CORRELATION_TICKS) { + const angle = Math.acos(corr); + const from = at(outer, angle); + const to = at(tickEnd, angle); + svg + .append("line") + .attr("x1", from.x) + .attr("y1", from.y) + .attr("x2", to.x) + .attr("y2", to.y) + .attr("stroke", "#888") + .attr("stroke-width", 2); + + svg + .append("text") + .attr("x", center.x + (tickEnd + 16) * Math.cos(angle)) + .attr("y", center.y - (tickEnd + 8) * Math.sin(angle)) + .attr("text-anchor", "middle") + .attr("font-size", "13px") + .text(corr); + } + + const titleAngle = Math.PI / 4; + const title = at(outer + 40, titleAngle); + svg + .append("text") + .attr("x", title.x) + .attr("y", title.y) + .attr("text-anchor", "middle") + .attr("font-size", "14px") + .attr("font-weight", "600") + .attr("fill", "#555") + .attr( + "transform", + `rotate(${titleAngle * (180 / Math.PI)}, ${title.x}, ${title.y})`, + ) + .text("Correlation"); +} + +/** A point per model, growing and darkening while hovered */ +function drawModels( + svg: Svg, + geometry: TaylorGeometry, + models: TaylorDiagramModel[], + onHover: (model: TaylorDiagramModel | null) => void, +) { + for (const model of models) { + const { x, y } = geometry.point(model.stddev, model.correlation); + const fill = model.color ?? "orange"; + const hoverFill = d3.color(fill)?.darker(0.6).formatHex() ?? fill; + + svg + .append("circle") + .attr("cx", x) + .attr("cy", y) + .attr("r", 6) + .attr("fill", fill) + .style("cursor", "pointer") + .style("transition", "r 0.2s ease, fill 0.2s ease") + .on("mouseenter", function () { + d3.select(this).attr("r", 9).attr("fill", hoverFill); + onHover(model); + }) + .on("mouseleave", function () { + d3.select(this).attr("r", 6).attr("fill", fill); + onHover(null); + }); + } +} + /** * TaylorDiagramContent component renders a Taylor diagram visualization * showing model performance in terms of correlation and normalized standard deviation. @@ -43,6 +271,8 @@ export function TaylorDiagramContent({ marginRight = 50, }: TaylorDiagramProps) { const svgRef = useRef(null); + // Unique per diagram, so several diagrams on a page keep their own clip path + const clipId = `taylor-clip-${useId().replace(/[^\w-]/g, "")}`; const [hoveredModel, setHoveredModel] = useState( null, ); @@ -77,285 +307,23 @@ export function TaylorDiagramContent({ useEffect(() => { if (!svgRef.current) return; - - // Clear previous content to prevent duplicate elements - d3.select(svgRef.current).selectAll("*").remove(); - - // Track mouse position for tooltip - const handleMouseMove = (event: MouseEvent) => { - setMousePosition({ - x: event.clientX, - y: event.clientY, - }); - }; - - svgRef.current.addEventListener("mousemove", handleMouseMove); - const currentSvg = svgRef.current; - const svg = d3.select(svgRef.current); - const radius = Math.min( - width - marginLeft - marginRight, - height - marginTop - marginBottom, - ); - const center = { x: marginLeft, y: height - marginBottom }; // bottom-left origin - - // Define the maximum standard deviation for the diagram - const stddevMax = 1.6; - - // Scale for converting standard deviation values to pixel radius - const r = d3.scaleLinear().domain([0, stddevMax]).range([0, radius]); - - /** - * Convert correlation coefficient to angle in radians - * Correlation of 1.0 maps to 0° (horizontal), 0.0 maps to 90° (vertical) - */ - const corrToAngle = (c: number) => Math.acos(c); - - // Arc generator for standard deviation circles (quarter circles) - const arc = d3 - .arc() - .innerRadius((d: number) => r(d)) - .outerRadius((d: number) => r(d)) - .startAngle(0) - .endAngle(Math.PI / 2); - - // Draw standard deviation arcs at fixed intervals - [0.4, 0.8, 1.0, 1.2, 1.6].forEach((sd) => { - svg - .append("path") - .attr("d", arc(sd)) - .attr("transform", `translate(${center.x},${center.y})`) - .attr("fill", "none") - .attr("stroke", "#aaa"); - }); - - // Draw reference point (black square) at the reference standard deviation - svg - .append("rect") - .attr("x", center.x + r(referenceStddev) - 5) - .attr("y", center.y - 5) - .attr("width", 10) - .attr("height", 10) - .attr("fill", "black"); - - // Draw dashed circle at reference standard deviation - svg - .append("path") - .attr("d", arc(referenceStddev)) - .attr("transform", `translate(${center.x},${center.y})`) - .attr("fill", "none") - .attr("stroke", "#888") - .attr("stroke-dasharray", "10,10") - .attr("stroke-width", 2); - - // Plot model points based on correlation (angle) and standard deviation (radius) - models.forEach((model) => { - const angle = corrToAngle(model.correlation); - const x = center.x + r(model.stddev) * Math.cos(angle); - const y = center.y - r(model.stddev) * Math.sin(angle); - const fill = model.color ?? "orange"; - const hoverFill = d3.color(fill)?.darker(0.6).formatHex() ?? fill; - - svg - .append("circle") - .attr("cx", x) - .attr("cy", y) - .attr("r", 6) - .attr("fill", fill) - .style("cursor", "pointer") - .style("transition", "r 0.2s ease, fill 0.2s ease") - .on("mouseenter", function () { - d3.select(this).attr("r", 9).attr("fill", hoverFill); - setHoveredModel(model); - }) - .on("mouseleave", function () { - d3.select(this).attr("r", 6).attr("fill", fill); - setHoveredModel(null); - }); - }); - - // Draw x-axis (horizontal standard deviation axis) - svg - .append("line") - .attr("x1", center.x) - .attr("y1", center.y) - .attr("x2", center.x + r(stddevMax)) - .attr("y2", center.y) - .attr("stroke", "black"); - - // Draw y-axis (vertical standard deviation axis) - svg - .append("line") - .attr("x1", center.x) - .attr("y1", center.y) - .attr("x2", center.x) - .attr("y2", center.y - r(stddevMax)) - .attr("stroke", "black"); - - // X-axis ticks and labels - [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.6].forEach((sd) => { - const x = center.x + r(sd); - svg - .append("line") - .attr("x1", x) - .attr("y1", center.y - 5) - .attr("x2", x) - .attr("y2", center.y + 5) - .attr("stroke", "black"); - - svg - .append("text") - .attr("x", x) - .attr("y", center.y + 18) - .attr("text-anchor", "middle") - .attr("font-size", "12px") - .text(sd); - }); - - // Y-axis ticks and labels - [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.6].forEach((sd) => { - const y = center.y - r(sd); - svg - .append("line") - .attr("x1", center.x - 5) - .attr("y1", y) - .attr("x2", center.x + 5) - .attr("y2", y) - .attr("stroke", "black"); - - svg - .append("text") - .attr("x", center.x - 18) - .attr("y", y + 4) - .attr("text-anchor", "end") - .attr("font-size", "12px") - .text(sd); - }); - - // X-axis label - svg - .append("text") - .attr("x", center.x + r(stddevMax) / 2) - .attr("y", center.y + 40) - .attr("text-anchor", "middle") - .attr("font-size", "14px") - .text("Standard deviation (Normalized)"); - - // Create unique clip path ID to avoid conflicts with multiple diagrams - const clipId = `taylor-clip-${Date.now()}`; - - // Create clip path for Taylor diagram region - use a filled quarter circle - // Path creates a filled region: center -> top of Y-axis -> arc to right of X-axis -> close - svg - .append("defs") - .append("clipPath") - .attr("id", clipId) - .append("path") - .attr( - "d", - `M ${center.x},${center.y} L ${center.x},${center.y - r(stddevMax)} A ${r(stddevMax)},${r(stddevMax)} 0 0 1 ${center.x + r(stddevMax)},${center.y} Z`, - ); - - // RMSE contour arcs centered at the reference point - const refX = center.x + r(referenceStddev); - const refY = center.y; - - // Create a group for RMSE arcs with clipping applied - const rmseGroup = svg.append("g").attr("clip-path", `url(#${clipId})`); - - // Draw RMSE arcs as circles (simpler and more reliable than d3.arc for full circles) - [0.4, 0.8, 1.2, 1.6].forEach((rmse) => { - const radiusVal = r(rmse); - const labelOffset = 10; - - // Draw the circle - rmseGroup - .append("circle") - .attr("cx", refX) - .attr("cy", refY) - .attr("r", radiusVal) - .attr("fill", "none") - .attr("stroke", "#ddd") - .attr("stroke-width", 1.5) - .attr("stroke-dasharray", "10,5"); - - // Add label at 45 degrees if it's within the diagram bounds - const labelAngle = Math.PI * 0.62; - const labelX = refX + (radiusVal + labelOffset) * Math.cos(labelAngle); - const labelY = refY - (radiusVal + labelOffset) * Math.sin(labelAngle); - - // Check if label is within the valid diagram region - const dx = labelX - center.x; - const dy = labelY - center.y; - const labelDist = Math.sqrt(dx * dx + dy * dy); - - svg - .append("text") - .attr("x", labelX) - .attr("y", labelY) - .attr("font-size", "12px") - .attr("fill", "#aaa") - .text(rmse); - - if ( - labelDist <= r(stddevMax) && - labelAngle >= 0 && - labelAngle <= Math.PI / 2 - ) { - } - }); - - // Correlation tick marks and labels on the outer arc - [0.99, 0.95, 0.9, 0.8, 0.7, 0.6, 0.4].forEach((corr) => { - const angle = corrToAngle(corr); - const rOuter = r(stddevMax); - const rTick = rOuter + 8; // length of tick - const x1 = center.x + rOuter * Math.cos(angle); - const y1 = center.y - rOuter * Math.sin(angle); - const x2 = center.x + rTick * Math.cos(angle); - const y2 = center.y - rTick * Math.sin(angle); - - svg - .append("line") - .attr("x1", x1) - .attr("y1", y1) - .attr("x2", x2) - .attr("y2", y2) - .attr("stroke", "#888") - .attr("stroke-width", 2); - - svg - .append("text") - .attr("x", center.x + (rTick + 16) * Math.cos(angle)) - .attr("y", center.y - (rTick + 8) * Math.sin(angle)) - .attr("text-anchor", "middle") - .attr("font-size", "13px") - .text(corr); + // Clear previous content to prevent duplicate elements + svg.selectAll("*").remove(); + + const geometry = taylorGeometry({ + width, + height, + marginTop, + marginBottom, + marginLeft, + marginRight, }); - - // Add "Correlation" label along the outer arc - const labelAngle = Math.PI / 4; // 45 degrees - const labelRadius = r(stddevMax) + 40; // Further out than tick labels - svg - .append("text") - .attr("x", center.x + labelRadius * Math.cos(labelAngle)) - .attr("y", center.y - labelRadius * Math.sin(labelAngle)) - .attr("text-anchor", "middle") - .attr("font-size", "14px") - .attr("font-weight", "600") - .attr("fill", "#555") - .attr( - "transform", - `rotate(${labelAngle * (180 / Math.PI)}, ${center.x + labelRadius * Math.cos(labelAngle)}, ${center.y - labelRadius * Math.sin(labelAngle)})`, - ) - .text("Correlation"); - - // Cleanup mouse event listener - return () => { - if (currentSvg) { - currentSvg.removeEventListener("mousemove", handleMouseMove); - } - }; + drawStddevGrid(svg, geometry, referenceStddev); + drawModels(svg, geometry, models, setHoveredModel); + drawAxes(svg, geometry); + drawRmseContours(svg, geometry, referenceStddev, clipId); + drawCorrelationTicks(svg, geometry); }, [ models, width, @@ -365,6 +333,7 @@ export function TaylorDiagramContent({ marginBottom, marginLeft, marginRight, + clipId, ]); // Handle empty state @@ -389,6 +358,9 @@ export function TaylorDiagramContent({ width={width} height={height} viewBox={`0 0 ${width} ${height}`} + onMouseMove={(event) => + setMousePosition({ x: event.clientX, y: event.clientY }) + } /> {hoveredModel && (
{ + it("puts the origin at the bottom left of the plot", () => { + expect(geometry.center).toEqual({ x: 50, y: 350 }); + }); + + it("fits the largest standard deviation into the smaller side of the plot", () => { + expect(geometry.radius(0)).toBe(0); + expect(geometry.radius(STDDEV_MAX)).toBe(300); + }); + + it("puts a perfect correlation on the x axis and none on the y axis", () => { + const onX = geometry.point(1, 1); + expect(onX.x).toBeCloseTo(50 + geometry.radius(1)); + expect(onX.y).toBeCloseTo(350); + + const onY = geometry.point(1, 0); + expect(onY.x).toBeCloseTo(50); + expect(onY.y).toBeCloseTo(350 - geometry.radius(1)); + }); + + it("keeps a point at its standard deviation's distance from the origin", () => { + const { x, y } = geometry.point(0.8, 0.6); + expect(Math.hypot(x - 50, y - 350)).toBeCloseTo(geometry.radius(0.8)); + }); +}); diff --git a/frontend/src/components/explorer/content/taylorGeometry.ts b/frontend/src/components/explorer/content/taylorGeometry.ts new file mode 100644 index 00000000..a1730257 --- /dev/null +++ b/frontend/src/components/explorer/content/taylorGeometry.ts @@ -0,0 +1,66 @@ +import * as d3 from "d3"; + +/** The largest normalised standard deviation the diagram shows */ +export const STDDEV_MAX = 1.6; +/** Normalised standard deviations drawn as quarter circles around the origin */ +export const STDDEV_ARCS = [0.4, 0.8, 1.0, 1.2, 1.6]; +/** Normalised standard deviations ticked on both axes */ +export const STDDEV_TICKS = [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.6]; +/** Root mean square errors drawn as circles around the reference point */ +export const RMSE_CONTOURS = [0.4, 0.8, 1.2, 1.6]; +/** Correlations ticked on the outer arc */ +export const CORRELATION_TICKS = [0.99, 0.95, 0.9, 0.8, 0.7, 0.6, 0.4]; + +export interface TaylorLayout { + width: number; + height: number; + marginTop: number; + marginBottom: number; + marginLeft: number; + marginRight: number; +} + +interface Point { + x: number; + y: number; +} + +/** Where things go on a Taylor diagram, with the origin at the bottom left */ +export interface TaylorGeometry { + center: Point; + /** The pixel radius of a normalised standard deviation */ + radius: (stddev: number) => number; + /** The pixel position of a standard deviation at a correlation, which is the angle from the x axis */ + point: (stddev: number, correlation: number) => Point; +} + +export function taylorGeometry({ + width, + height, + marginTop, + marginBottom, + marginLeft, + marginRight, +}: TaylorLayout): TaylorGeometry { + const plotRadius = Math.min( + width - marginLeft - marginRight, + height - marginTop - marginBottom, + ); + const center = { x: marginLeft, y: height - marginBottom }; + const radius = d3 + .scaleLinear() + .domain([0, STDDEV_MAX]) + .range([0, plotRadius]); + return { + center, + radius, + point: (stddev, correlation) => { + // A correlation of 1 lies on the x axis and 0 on the y axis + const angle = Math.acos(correlation); + return { + x: center.x + radius(stddev) * Math.cos(angle), + y: center.y - radius(stddev) * Math.sin(angle), + }; + }, + }; +} From e4a8501119fdf82c4b99ea5170f9b16edfdf5921 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 21:02:37 +0200 Subject: [PATCH 56/61] fix: make the Taylor diagram, its tooltip and chart empty states readable in dark mode --- .../values/series/seriesVisualization.tsx | 6 ++-- .../explorer/content/chartEmptyState.tsx | 19 ++++++++++ .../explorer/content/seriesChartContent.tsx | 14 +++----- .../explorer/content/taylorDiagramContent.tsx | 35 +++++++++---------- .../content/taylorDiagramContentWrapper.tsx | 16 ++++----- .../components/explorer/explorerTooltip.tsx | 4 +-- 6 files changed, 52 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/explorer/content/chartEmptyState.tsx diff --git a/frontend/src/components/execution/values/series/seriesVisualization.tsx b/frontend/src/components/execution/values/series/seriesVisualization.tsx index 3b07ab3c..14edc7b6 100644 --- a/frontend/src/components/execution/values/series/seriesVisualization.tsx +++ b/frontend/src/components/execution/values/series/seriesVisualization.tsx @@ -316,11 +316,11 @@ export function SeriesVisualization({
Too Many Series to Display
-
+
Found {totalSeries} series values, but only up to {maxSeriesLimit}{" "} can be displayed for performance reasons.
-
+
Please use the filtering options above to reduce the number of series, or download the data as CSV for analysis.
@@ -332,7 +332,7 @@ export function SeriesVisualization({ if (totalSeries === 0) { return ( -
+
No series data available
); diff --git a/frontend/src/components/explorer/content/chartEmptyState.tsx b/frontend/src/components/explorer/content/chartEmptyState.tsx new file mode 100644 index 00000000..6353cd50 --- /dev/null +++ b/frontend/src/components/explorer/content/chartEmptyState.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; + +/** Stands in for a chart with nothing to plot, saying why. */ +export function ChartEmptyState({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+
+

{title}

+

{children}

+
+
+ ); +} diff --git a/frontend/src/components/explorer/content/seriesChartContent.tsx b/frontend/src/components/explorer/content/seriesChartContent.tsx index 52745e73..a0504d33 100644 --- a/frontend/src/components/explorer/content/seriesChartContent.tsx +++ b/frontend/src/components/explorer/content/seriesChartContent.tsx @@ -6,6 +6,7 @@ import { isSeriesValue } from "@/components/execution/values/types"; import { ExplorerCardDropdown } from "../explorerCardDropdown"; import { ExplorerCardFrame } from "../explorerCardFrame"; import type { SeriesChartContent as SeriesChartContentType } from "../types"; +import { ChartEmptyState } from "./chartEmptyState"; import { useExplorerMetricValues } from "./useExplorerMetricValues"; interface SeriesChartContentProps { @@ -35,15 +36,10 @@ export function SeriesChartContent({ contentItem }: SeriesChartContentProps) {
{filterBar} -
-
-

No series data available

-

- This diagnostic may not have series data or filters may be too - restrictive -

-
-
+ + This diagnostic may not have series data or filters may be too + restrictive +
); diff --git a/frontend/src/components/explorer/content/taylorDiagramContent.tsx b/frontend/src/components/explorer/content/taylorDiagramContent.tsx index 4253d6ae..1c6047b0 100644 --- a/frontend/src/components/explorer/content/taylorDiagramContent.tsx +++ b/frontend/src/components/explorer/content/taylorDiagramContent.tsx @@ -1,6 +1,7 @@ import { flip, offset, shift, useFloating } from "@floating-ui/react"; import * as d3 from "d3"; import { useEffect, useId, useRef, useState } from "react"; +import { ChartEmptyState } from "./chartEmptyState"; import { CORRELATION_TICKS, RMSE_CONTOURS, @@ -66,7 +67,7 @@ function drawStddevGrid( .attr("y", center.y - 5) .attr("width", 10) .attr("height", 10) - .attr("fill", "black"); + .attr("fill", "currentColor"); svg .append("path") @@ -89,7 +90,7 @@ function drawAxes(svg: Svg, geometry: TaylorGeometry) { .attr("y1", y1) .attr("x2", x2) .attr("y2", y2) - .attr("stroke", "black"); + .attr("stroke", "currentColor"); line(center.x, center.y, center.x + end, center.y); line(center.x, center.y, center.x, center.y - end); @@ -212,7 +213,7 @@ function drawCorrelationTicks(svg: Svg, geometry: TaylorGeometry) { .attr("text-anchor", "middle") .attr("font-size", "14px") .attr("font-weight", "600") - .attr("fill", "#555") + .style("fill", "var(--muted-foreground)") .attr( "transform", `rotate(${titleAngle * (180 / Math.PI)}, ${title.x}, ${title.y})`, @@ -339,15 +340,10 @@ export function TaylorDiagramContent({ // Handle empty state if (models.length === 0) { return ( -
-
-

No model data available

-

- This diagram requires models with correlation and standard deviation - values -

-
-
+ + This diagram requires models with correlation and standard deviation + values + ); } @@ -358,6 +354,9 @@ export function TaylorDiagramContent({ width={width} height={height} viewBox={`0 0 ${width} ${height}`} + // Text and axes take the theme's text colour + className="text-foreground" + fill="currentColor" onMouseMove={(event) => setMousePosition({ x: event.clientX, y: event.clientY }) } @@ -370,22 +369,22 @@ export function TaylorDiagramContent({ pointerEvents: "none", zIndex: 50, }} - className="bg-white border border-gray-200 rounded-lg shadow-lg p-4 min-w-[200px]" + className="bg-popover text-popover-foreground border rounded-lg shadow-lg p-4 min-w-[200px]" >
-
+
{hoveredModel.name}
- Correlation: - + Correlation: + {hoveredModel.correlation.toFixed(3)}
- Std Dev: - + Std Dev: + {hoveredModel.stddev.toFixed(3)}
diff --git a/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx b/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx index 356dff87..f9ca5994 100644 --- a/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx +++ b/frontend/src/components/explorer/content/taylorDiagramContentWrapper.tsx @@ -3,6 +3,7 @@ import { experimentLegend } from "@/components/charts/experimentColors"; import { MipEraSections } from "@/components/charts/mipEraSections"; import type { ScalarValue } from "@/components/execution/values/types"; import type { ExplorerCardContent } from "../types"; +import { ChartEmptyState } from "./chartEmptyState"; import { TaylorDiagramContent } from "./taylorDiagramContent"; import { transformToTaylorModels } from "./taylorModels"; import { useExplorerMetricValues } from "./useExplorerMetricValues"; @@ -70,16 +71,11 @@ export function TaylorDiagramContentWrapper({ if (allModels.length === 0) { return ( -
-
-

No Taylor diagram data available

-

- This diagnostic requires Spatial Distribution metrics with - Correlation and Normalized Standard Deviation {" "} - statistics -

-
-
+ + This diagnostic requires Spatial Distribution metrics with + Correlation and Normalized Standard Deviation {" "} + statistics + ); } diff --git a/frontend/src/components/explorer/explorerTooltip.tsx b/frontend/src/components/explorer/explorerTooltip.tsx index 37c3e531..6224ac03 100644 --- a/frontend/src/components/explorer/explorerTooltip.tsx +++ b/frontend/src/components/explorer/explorerTooltip.tsx @@ -25,10 +25,10 @@ export const ExplorerTooltip = ({ { "opacity-0 group-hover:opacity-100 transition-opacity": showOnHover, }, - "top-2 right-2 p-1 bg-white/80 hover:bg-white rounded shadow-sm", + "top-2 right-2 p-1 bg-background/80 hover:bg-background rounded shadow-sm", )} > - + From 9f4351e97c98d79f6a9ed24f6109fd17803e2cb9 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 21:05:22 +0200 Subject: [PATCH 57/61] fix: render Markdown links in explorer card and collection descriptions --- frontend/src/components/app/textWithLinks.tsx | 15 +++++++ .../components/explorer/collectionHeader.tsx | 7 ++- .../components/explorer/explorerCardFrame.tsx | 5 ++- frontend/src/components/explorer/types.ts | 5 +-- frontend/src/lib/markdownLinks.test.ts | 44 +++++++++++++++++++ frontend/src/lib/markdownLinks.ts | 30 +++++++++++++ 6 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/app/textWithLinks.tsx create mode 100644 frontend/src/lib/markdownLinks.test.ts create mode 100644 frontend/src/lib/markdownLinks.ts diff --git a/frontend/src/components/app/textWithLinks.tsx b/frontend/src/components/app/textWithLinks.tsx new file mode 100644 index 00000000..a693562d --- /dev/null +++ b/frontend/src/components/app/textWithLinks.tsx @@ -0,0 +1,15 @@ +import { LinkExternal } from "@/components/ui/link"; +import { splitMarkdownLinks } from "@/lib/markdownLinks"; + +/** Text from the API, with the Markdown links it contains shown as links */ +export function TextWithLinks({ text }: { text: string }) { + return splitMarkdownLinks(text).map((part) => + part.href ? ( + + {part.text} + + ) : ( + {part.text} + ), + ); +} diff --git a/frontend/src/components/explorer/collectionHeader.tsx b/frontend/src/components/explorer/collectionHeader.tsx index 120db011..0d212c0d 100644 --- a/frontend/src/components/explorer/collectionHeader.tsx +++ b/frontend/src/components/explorer/collectionHeader.tsx @@ -1,5 +1,6 @@ import { Link } from "@tanstack/react-router"; import type { AftCollectionDetail } from "@/client/types.gen"; +import { TextWithLinks } from "@/components/app/textWithLinks"; import { Badge } from "@/components/ui/badge"; import { collectionText } from "./collectionText"; @@ -13,7 +14,9 @@ function Callout({ title, text }: { title: string; text: string }) { return (

{title}

-

{text}

+

+ +

); } @@ -42,7 +45,7 @@ export function CollectionHeader({ {description && (

- {description} +

)} diff --git a/frontend/src/components/explorer/explorerCardFrame.tsx b/frontend/src/components/explorer/explorerCardFrame.tsx index 16181ae6..14b78e59 100644 --- a/frontend/src/components/explorer/explorerCardFrame.tsx +++ b/frontend/src/components/explorer/explorerCardFrame.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { ReferenceDatasetLink } from "@/client/types.gen"; +import { TextWithLinks } from "@/components/app/textWithLinks"; import { cn } from "@/lib/utils"; import { Badge } from "../ui/badge"; import { @@ -37,7 +38,9 @@ export function ExplorerCardFrame({ {contentItem.title}
{contentItem.description && ( - {contentItem.description} + + + )}
diff --git a/frontend/src/components/explorer/types.ts b/frontend/src/components/explorer/types.ts index f52571c6..ef27af83 100644 --- a/frontend/src/components/explorer/types.ts +++ b/frontend/src/components/explorer/types.ts @@ -1,4 +1,3 @@ -import type { ReactNode } from "react"; import type { ReferenceDatasetLink } from "@/client/types.gen"; import type { ChartGroupingConfig } from "./grouping"; @@ -15,7 +14,7 @@ export type BaseCardContent = { provider: string; diagnostic: string; title: string; - description?: string | ReactNode; + description?: string; interpretation?: string; span?: 1 | 2; referenceDatasets?: ReferenceDatasetLink[]; @@ -69,7 +68,7 @@ export type ExplorerCardContent = export type ExplorerCard = { title: string; - description?: string | ReactNode; + description?: string; content: ExplorerCardContent[]; }; diff --git a/frontend/src/lib/markdownLinks.test.ts b/frontend/src/lib/markdownLinks.test.ts new file mode 100644 index 00000000..5d739021 --- /dev/null +++ b/frontend/src/lib/markdownLinks.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { splitMarkdownLinks } from "./markdownLinks"; + +describe("splitMarkdownLinks", () => { + it("turns Markdown links into links between the surrounding text", () => { + expect( + splitMarkdownLinks( + "Pattern RMSE, see [Lee et al. 2019](https://doi.org/10.1007/s00382-018-4355-4).", + ), + ).toEqual([ + { text: "Pattern RMSE, see ", start: 0 }, + { + text: "Lee et al. 2019", + href: "https://doi.org/10.1007/s00382-018-4355-4", + start: 18, + }, + { text: ".", start: 78 }, + ]); + }); + + it("keeps several links and links at either end", () => { + const parts = splitMarkdownLinks( + "[A](https://a.org) and [B](http://b.org)", + ); + expect(parts.map(({ text, href }) => ({ text, href }))).toEqual([ + { text: "A", href: "https://a.org" }, + { text: " and ", href: undefined }, + { text: "B", href: "http://b.org" }, + ]); + }); + + it("leaves text without links as one piece", () => { + expect(splitMarkdownLinks("No links here")).toEqual([ + { text: "No links here", start: 0 }, + ]); + expect(splitMarkdownLinks("")).toEqual([]); + }); + + it("keeps links to anything but a web address as text", () => { + expect(splitMarkdownLinks("[x](javascript:alert(1)) [y](/local)")).toEqual([ + { text: "[x](javascript:alert(1)) [y](/local)", start: 0 }, + ]); + }); +}); diff --git a/frontend/src/lib/markdownLinks.ts b/frontend/src/lib/markdownLinks.ts new file mode 100644 index 00000000..2c9b8e8c --- /dev/null +++ b/frontend/src/lib/markdownLinks.ts @@ -0,0 +1,30 @@ +/** A piece of text, which is a link when it has an `href`; `start` is where it begins in the original */ +export interface TextPart { + text: string; + href?: string; + start: number; +} + +/** A Markdown link to a web address, e.g. `[Lee et al. 2019](https://doi.org/10.1007/s00382-018-4355-4)` */ +const MARKDOWN_LINK = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g; + +/** + * Split text at the Markdown links it contains + * + * Only links to web addresses become links; anything else, such as a relative or `javascript:` target, stays text. + */ +export function splitMarkdownLinks(text: string): TextPart[] { + const parts: TextPart[] = []; + let end = 0; + for (const match of text.matchAll(MARKDOWN_LINK)) { + if (match.index > end) { + parts.push({ text: text.slice(end, match.index), start: end }); + } + parts.push({ text: match[1], href: match[2], start: match.index }); + end = match.index + match[0].length; + } + if (end < text.length) { + parts.push({ text: text.slice(end), start: end }); + } + return parts; +} From ad48a138665baa91ac8e32f8cf95dd8d4eafbfda Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 21:18:15 +0200 Subject: [PATCH 58/61] cleanup: share the series chart's plot position and hidden tooltip state --- .../values/series/seriesVisualization.tsx | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/execution/values/series/seriesVisualization.tsx b/frontend/src/components/execution/values/series/seriesVisualization.tsx index 14edc7b6..b39dd08c 100644 --- a/frontend/src/components/execution/values/series/seriesVisualization.tsx +++ b/frontend/src/components/execution/values/series/seriesVisualization.tsx @@ -13,7 +13,7 @@ import { seriesLineColor, } from "./seriesCanvas"; import { SeriesLegend } from "./seriesLegend"; -import { useChartScales } from "./useChartScales"; +import { type ChartMargins, useChartScales } from "./useChartScales"; import type { NearestResult } from "./useSpatialIndex"; import { findTooltipPoints, useSpatialIndex } from "./useSpatialIndex"; import { @@ -43,6 +43,34 @@ interface SimpleSeriesVisualizationProps { const CHART_HEIGHT = 700; +interface TooltipState { + visible: boolean; + x: number; + y: number; + nearest: NearestResult | null; + allAtX: NearestResult[]; +} + +const HIDDEN_TOOLTIP: TooltipState = { + visible: false, + x: 0, + y: 0, + nearest: null, + allAtX: [], +}; + +/** Where a mouse event is within the plot area, which starts inside the margins */ +function plotPosition( + e: React.MouseEvent, + margins: ChartMargins, +) { + const rect = e.currentTarget.getBoundingClientRect(); + return { + x: e.clientX - rect.left - margins.left, + y: e.clientY - rect.top - margins.top, + }; +} + export function SeriesVisualization({ seriesValues, referenceSeriesValues = [], @@ -66,13 +94,8 @@ export function SeriesVisualization({ const [containerWidth, setContainerWidth] = useState(800); // Tooltip state - const [tooltipState, setTooltipState] = useState<{ - visible: boolean; - x: number; - y: number; - nearest: NearestResult | null; - allAtX: NearestResult[]; - }>({ visible: false, x: 0, y: 0, nearest: null, allAtX: [] }); + const [tooltipState, setTooltipState] = + useState(HIDDEN_TOOLTIP); const tooltipRafRef = useRef(0); // Observe container width for responsive sizing @@ -228,9 +251,7 @@ export function SeriesVisualization({ (e: React.MouseEvent) => { // Capture values synchronously — React pools events, so currentTarget // and coordinates become null inside requestAnimationFrame. - const rect = e.currentTarget.getBoundingClientRect(); - const mouseX = e.clientX - rect.left - margins.left; - const mouseY = e.clientY - rect.top - margins.top; + const { x: mouseX, y: mouseY } = plotPosition(e, margins); cancelAnimationFrame(tooltipRafRef.current); tooltipRafRef.current = requestAnimationFrame(() => { @@ -269,11 +290,7 @@ export function SeriesVisualization({ hoveredLabelRef.current = null; } canvasRef.current?.redraw(); - setTooltipState((prev) => - prev.visible - ? { visible: false, x: 0, y: 0, nearest: null, allAtX: [] } - : prev, - ); + setTooltipState((prev) => (prev.visible ? HIDDEN_TOOLTIP : prev)); } }); }, @@ -284,15 +301,13 @@ export function SeriesVisualization({ cancelAnimationFrame(tooltipRafRef.current); hoveredLabelRef.current = null; crosshairRef.current = null; - setTooltipState({ visible: false, x: 0, y: 0, nearest: null, allAtX: [] }); + setTooltipState(HIDDEN_TOOLTIP); canvasRef.current?.redraw(); }, []); const handleCanvasClick = useCallback( (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const mouseX = e.clientX - rect.left - margins.left; - const mouseY = e.clientY - rect.top - margins.top; + const { x: mouseX, y: mouseY } = plotPosition(e, margins); const nearest = spatialIndex.findNearest( mouseX, From a234d75d6397042349821a2232e28cd8b732f470 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Mon, 19 Oct 2026 21:18:52 +0200 Subject: [PATCH 59/61] cleanup: update the changelog for the fixes and cleanup in #139 --- changelog/139.fix.md | 10 +++++++++- changelog/139.trivial.md | 7 ++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/changelog/139.fix.md b/changelog/139.fix.md index 8b262e64..f3fca35d 100644 --- a/changelog/139.fix.md +++ b/changelog/139.fix.md @@ -1 +1,9 @@ -* The recent executions on the dashboard now show running executions as running instead of failed. +* Execution groups whose latest execution is still running now show as running, and groups that have never run show as not run instead of failed. Their pages open instead of failing. +* The executions list shows API errors instead of an empty list. Its filters wait until typing pauses, and "Clear All" also empties the inputs. +* The datasets page no longer breaks on a malformed facet filter in the URL, and typing in the diagnostics search no longer adds a browser history entry per key. +* Series charts: the tooltip compares values at the highlighted point, y-axis labels stay readable for large values in a small range, toggling a legend group works while a series is soloed, and reference series are visible in dark mode. +* Explorer cards: "View chart values" and the CSV download use the card's region, MIP era and unverified setting, series cards download series values, and a configured filter default that does not exist falls back to the first option. +* Execution value tabs keep their filters in the URL, and value tab links with numeric facet values no longer fail. +* "Try Again" after an error retries the failed part instead of reloading the page, and navigating away from a page that failed shows the new page. +* The Taylor diagram is readable in dark mode, and links in explorer descriptions are shown as links. +* Log downloads are named after the execution group, the log view has a Debug filter, and the model page shows the actual error when loading fails. diff --git a/changelog/139.trivial.md b/changelog/139.trivial.md index 8b8073bb..aa91bc1a 100644 --- a/changelog/139.trivial.md +++ b/changelog/139.trivial.md @@ -1,4 +1,5 @@ * Added [Fallow](https://fallow.tools) to check the frontend for dead code, duplication and complexity (`make fallow-audit`), skipping generated code and the shadcn/ui components. -* Removed unused frontend code and exports, and an import cycle between the theme explorer and its route. -* Simplified the log summary and the failures page filters, with tests for the latter. -* Moved build-only packages to `devDependencies` and `@tailwindcss/typography`, which the styles load, to `dependencies`. +* Removed dead frontend code: unused exports, components, hook state and props, barrel re-exports, and an import cycle between the theme explorer and its route. +* Merged duplicated code into shared pieces, such as one data table and one panel for the scalar and series value tabs, one execution status badge, and shared stat cards, filter popovers and chart empty states. +* Moved logic out of components into tested functions (log parsing and summary, filters and search params, pagination, chart data and axes, Taylor diagram geometry, explorer mapping), split the largest components and the chart drawing code into smaller parts, and pointed two tests at the real code instead of copies of it. The frontend test suite grew from 384 to 626 tests. +* Moved build-only packages to `devDependencies` and `@tailwindcss/typography`, which the styles load, to `dependencies`, and updated the deprecated Biome config. From d8f95105efe4d0b3beb9981c1b2a7a86b8d65dd0 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Tue, 20 Oct 2026 09:11:43 +0200 Subject: [PATCH 60/61] cleanup: enable more ruff flags (C4, C90) to check for code complexity --- backend/ruff.toml | 7 +++ backend/src/ref_backend/core/filter_utils.py | 47 ++++++------------- backend/src/ref_backend/models/diagnostics.py | 4 +- backend/tests/test_core/test_filter_utils.py | 16 +++++++ 4 files changed, 40 insertions(+), 34 deletions(-) diff --git a/backend/ruff.toml b/backend/ruff.toml index 1301f496..80c4d6cc 100644 --- a/backend/ruff.toml +++ b/backend/ruff.toml @@ -13,6 +13,8 @@ select = [ "RUF", "UP", "S", + "C90", + "C4", ] unfixable = [ "PD002", @@ -26,8 +28,13 @@ ignore = [ "D200", "D400", "UP007", + "C408", # dict(key=value) calls read as well as literals ] +[lint.mccabe] +# Flags functions with more independent paths than a reader can comfortably follow +max-complexity = 10 + [lint.per-file-ignores] "test*.py" = [ "D", # Documentation not needed in tests diff --git a/backend/src/ref_backend/core/filter_utils.py b/backend/src/ref_backend/core/filter_utils.py index 162ae7b4..71511137 100644 --- a/backend/src/ref_backend/core/filter_utils.py +++ b/backend/src/ref_backend/core/filter_utils.py @@ -14,21 +14,16 @@ def _normalize_list_from_value(value: Any) -> list[str] | None: Returns None if the input should be treated as a single value (no list semantics). """ - if value is None: - return None - - # If already a list (e.g. from parsed JSON), coerce & clean if isinstance(value, list): - vals = [str(v).strip() for v in value if v is not None and str(v).strip()] - return vals if vals else None - - # If a comma-separated string, split and clean - if isinstance(value, str) and "," in value: - vals = [v.strip() for v in value.split(",") if v.strip()] - return vals if vals else None + # e.g. from parsed JSON + items = [str(v) for v in value if v is not None] + elif isinstance(value, str) and "," in value: + items = value.split(",") + else: + return None - # Otherwise, treat as a single scalar value - return None + vals = [item.strip() for item in items if item.strip()] + return vals or None def build_filter_clause(column: Any, value: Any) -> BinaryExpression[Any]: @@ -40,27 +35,15 @@ def build_filter_clause(column: Any, value: Any) -> BinaryExpression[Any]: an `IN` clause is returned. - If `value` contains exactly one normalized item, a simple equality (`column == item`) is returned. - - Otherwise, fall back to equality with the original `value`. + - Otherwise, fall back to equality with the original `value`, + including for an empty list. This function is intended to be used in route handlers where query values may be single values, comma-separated strings, or parsed JSON lists. """ - # Handle lists explicitly - if isinstance(value, list): - vals = _normalize_list_from_value(value) - if vals is None: - # Empty list -> fallback equality (keeps prior behavior) - return column == value - if len(vals) == 1: - return column == vals[0] - return column.in_(vals) - - # Handle comma-separated strings or other scalars vals = _normalize_list_from_value(value) - if vals: - if len(vals) == 1: - return column == vals[0] - return column.in_(vals) - - # Default: equality - return column == value + if vals is None: + return column == value + if len(vals) == 1: + return column == vals[0] + return column.in_(vals) diff --git a/backend/src/ref_backend/models/diagnostics.py b/backend/src/ref_backend/models/diagnostics.py index 5b87aa0b..04f473f9 100644 --- a/backend/src/ref_backend/models/diagnostics.py +++ b/backend/src/ref_backend/models/diagnostics.py @@ -123,8 +123,8 @@ def _build_group_by_summary(diagnostic: models.Diagnostic, app_context: "AppCont ) return [] data_requirements = sorted( - list(concrete_diagnostic.data_requirements), - key=lambda dr: dr[0].source_type.value if isinstance(dr, tuple) else dr.source_type.value, # type: ignore + concrete_diagnostic.data_requirements, + key=lambda dr: dr[0].source_type.value if isinstance(dr, tuple) else dr.source_type.value, ) # A diagnostic often declares one data requirement per variable, all grouping the same way, diff --git a/backend/tests/test_core/test_filter_utils.py b/backend/tests/test_core/test_filter_utils.py index 571f6b7a..09d92937 100644 --- a/backend/tests/test_core/test_filter_utils.py +++ b/backend/tests/test_core/test_filter_utils.py @@ -58,6 +58,16 @@ def test_list_with_empty_strings_after_strip_returns_none(self): result = _normalize_list_from_value([" ", " "]) assert result is None + def test_string_of_only_commas_returns_none(self): + """Test that a string of commas and whitespace returns None.""" + result = _normalize_list_from_value(" , ,") + assert result is None + + def test_non_string_scalar_returns_none(self): + """Test that a scalar other than a string returns None.""" + result = _normalize_list_from_value(42) + assert result is None + class TestBuildFilterClause: """Test the build_filter_clause function.""" @@ -115,6 +125,12 @@ def test_numeric_scalar_returns_equality(self): sql = self._compile_clause(clause) assert "test.name = 42" in sql + def test_comma_separated_string_with_one_item_returns_equality(self): + """Test that a comma-separated string with one non-empty item returns equality.""" + clause = build_filter_clause(self.column, "a, ") + sql = self._compile_clause(clause) + assert "test.name = 'a'" in sql + def test_none_value_returns_equality(self): """Test that None value returns equality expression.""" clause = build_filter_clause(self.column, None) From d76b63c0860f106b71f065649010eac79344ba39 Mon Sep 17 00:00:00 2001 From: Fuchsi* Huber Date: Tue, 20 Oct 2026 09:44:14 +0200 Subject: [PATCH 61/61] cleanup: pyscn reported duplications --- .../src/ref_backend/api/routes/diagnostics.py | 62 ++++---- backend/src/ref_backend/core/reader_values.py | 135 +++++++++--------- backend/tests/test_core/test_reader_values.py | 19 ++- 3 files changed, 117 insertions(+), 99 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index 9aa2d833..f516fb1f 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Literal, NamedTuple from fastapi import APIRouter, HTTPException, Query, Request from sqlalchemy import ColumnElement, Integer, func, select @@ -92,6 +92,18 @@ def _group_scope( return group_scope +def _scoped_diagnostics( + app_context: AppContextDep, mip_era: str | None, source_id: str | None +) -> tuple[list[models.Diagnostic], list[ColumnElement[bool]]]: + """ + Get the listed diagnostics, with the scope that counts their groups for `mip_era` and `source_id` + + Without any listed diagnostic the scope matches no group, so the counts come out empty. + """ + diagnostics = _listed_diagnostics(app_context) + return diagnostics, _group_scope([d.id for d in diagnostics], mip_era, source_id) + + def _diagnostics_with_values( app_context: AppContextDep, value_class: type[models.MetricValue], group_scope: list[ColumnElement[bool]] ) -> set[int]: @@ -105,6 +117,20 @@ def _diagnostics_with_values( return {row[0] for row in rows} +class _ValueTypes(NamedTuple): + """The ids of the diagnostics with scalar values and of those with series values""" + + scalar: set[int] + series: set[int] + + +def _value_types(app_context: AppContextDep, group_scope: list[ColumnElement[bool]]) -> _ValueTypes: + return _ValueTypes( + scalar=_diagnostics_with_values(app_context, models.ScalarMetricValue, group_scope), + series=_diagnostics_with_values(app_context, models.SeriesMetricValue, group_scope), + ) + + def _catalog_entries( app_context: AppContextDep, diagnostics: list[models.Diagnostic], group_scope: list[ColumnElement[bool]] ) -> list[DiagnosticCatalogEntry]: @@ -188,19 +214,14 @@ async def _list( Pass `mip_era` to count only the execution groups that ran against that era, and `source_id` to count only those that ran against that model. """ - diagnostics = _listed_diagnostics(app_context) - if not diagnostics: - return Collection(data=[]) - - group_scope = _group_scope([d.id for d in diagnostics], mip_era, source_id) - scalar_ids = _diagnostics_with_values(app_context, models.ScalarMetricValue, group_scope) - series_ids = _diagnostics_with_values(app_context, models.SeriesMetricValue, group_scope) + diagnostics, group_scope = _scoped_diagnostics(app_context, mip_era, source_id) + value_types = _value_types(app_context, group_scope) return Collection( data=[ DiagnosticSummary.from_catalog_entry( entry, - has_scalar_values=entry.id in scalar_ids, - has_series_values=entry.id in series_ids, + has_scalar_values=entry.id in value_types.scalar, + has_series_values=entry.id in value_types.series, ) for entry in _catalog_entries(app_context, diagnostics, group_scope) ] @@ -219,11 +240,7 @@ async def catalog( Pass `mip_era` and `source_id` to count only the execution groups that ran against that era or model. """ - diagnostics = _listed_diagnostics(app_context) - if not diagnostics: - return Collection(data=[]) - - group_scope = _group_scope([d.id for d in diagnostics], mip_era, source_id) + diagnostics, group_scope = _scoped_diagnostics(app_context, mip_era, source_id) return Collection(data=_catalog_entries(app_context, diagnostics, group_scope)) @@ -236,20 +253,15 @@ async def value_flags( These are the flags `/diagnostics/catalog` leaves out. """ - diagnostics = _listed_diagnostics(app_context) - if not diagnostics: - return Collection(data=[]) - - group_scope = _group_scope([d.id for d in diagnostics], mip_era, source_id) - scalar_ids = _diagnostics_with_values(app_context, models.ScalarMetricValue, group_scope) - series_ids = _diagnostics_with_values(app_context, models.SeriesMetricValue, group_scope) + diagnostics, group_scope = _scoped_diagnostics(app_context, mip_era, source_id) + value_types = _value_types(app_context, group_scope) return Collection( data=[ DiagnosticValueFlags( id=d.id, - has_metric_values=d.id in scalar_ids or d.id in series_ids, - has_scalar_values=d.id in scalar_ids, - has_series_values=d.id in series_ids, + has_metric_values=d.id in value_types.scalar or d.id in value_types.series, + has_scalar_values=d.id in value_types.scalar, + has_series_values=d.id in value_types.series, ) for d in diagnostics ] diff --git a/backend/src/ref_backend/core/reader_values.py b/backend/src/ref_backend/core/reader_values.py index 089cb14c..cdab9798 100644 --- a/backend/src/ref_backend/core/reader_values.py +++ b/backend/src/ref_backend/core/reader_values.py @@ -2,8 +2,8 @@ import csv import io -from collections.abc import Generator, Mapping -from typing import TYPE_CHECKING, Literal +from collections.abc import Generator, Iterable, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Literal from fastapi import HTTPException from starlette.responses import StreamingResponse @@ -31,6 +31,46 @@ def parse_dimension_filters(query_params: Mapping[str, str]) -> dict[str, str]: return {key: value for key, value in query_params.items() if key in cv_dimensions} +def _csv_response( + rows: Iterable[Sequence[Any]], filename: str, headers: Mapping[str, str] | None = None +) -> StreamingResponse: + """Stream `rows` as a CSV download, writing each row as it is produced.""" + + def generate_csv() -> Generator[str]: + output = io.StringIO() + writer = csv.writer(output) + for row in rows: + writer.writerow(row) + yield output.getvalue() + output.seek(0) + output.truncate() + + return StreamingResponse( + generate_csv(), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}", **(headers or {})}, + ) + + +def _scalar_csv_rows(collection: ScalarValueCollection, detection_ran: bool) -> Generator[list[Any]]: + items = collection.items + # Dimension columns come from the values, so an empty collection only has the fixed ones + dimensions = sorted(items[0].dimensions.keys()) if items else [] + header = [*dimensions, "value", "type"] + if detection_ran: + header.extend(["is_outlier", "verification_status"]) + yield header + + for item in items: + row = [item.dimensions.get(d) for d in dimensions] + [ + sanitize_float_value(item.value), + "scalar", + ] + if detection_ran: + row.extend([item.is_outlier, item.verification_status]) + yield row + + def generate_csv_response_scalar( collection: ScalarValueCollection, detection_ran: bool, @@ -41,45 +81,35 @@ def generate_csv_response_scalar( Preserves the historical column layout: sorted dimension columns, then ``value`` and ``type``, and (when detection ran) ``is_outlier`` and ``verification_status``. + An empty collection gets a header of the columns that do not depend on the values. """ + headers = {} + if detection_ran: + headers["X-REF-Had-Outliers"] = "true" if collection.had_outliers else "false" + headers["X-REF-Outlier-Count"] = str(collection.outlier_count) + return _csv_response(_scalar_csv_rows(collection, detection_ran), filename, headers) - def generate_csv() -> Generator[str]: - output = io.StringIO() - writer = csv.writer(output) - - items = collection.items - if not items: - yield "" - return - dimensions = sorted(items[0].dimensions.keys()) - header = [*dimensions, "value", "type"] - if detection_ran: - header.extend(["is_outlier", "verification_status"]) - writer.writerow(header) +_SERIES_COLUMNS = ["value", "index", "index_name", "type"] - for item in items: - row = [item.dimensions.get(d) for d in dimensions] + [ - sanitize_float_value(item.value), - "scalar", - ] - if detection_ran: - row.extend([item.is_outlier, item.verification_status]) - writer.writerow(row) - output.seek(0) - yield output.read() +def _series_csv_rows(collection: SeriesValueCollection) -> Generator[list[Any]]: + if not collection.items: + yield list(_SERIES_COLUMNS) + return - headers = {"Content-Disposition": f"attachment; filename={filename}"} - if detection_ran: - headers["X-REF-Had-Outliers"] = "true" if collection.had_outliers else "false" - headers["X-REF-Outlier-Count"] = str(collection.outlier_count) + for sv in collection.items: + dimensions = sorted(sv.dimensions.keys()) + yield [*dimensions, *_SERIES_COLUMNS] - return StreamingResponse( - generate_csv(), - media_type="text/csv", - headers=headers, - ) + for i, value in enumerate(sv.values): + index_value = sv.index[i] if sv.index and i < len(sv.index) else i + yield [sv.dimensions.get(d) for d in dimensions] + [ + sanitize_float_value(value), + index_value, + sv.index_name or "index", + "series", + ] def generate_csv_response_series( @@ -90,41 +120,10 @@ def generate_csv_response_series( Generate a CSV streaming response from a reader series collection. Preserves the historical flattened layout: one header/data block per series, with a - row per index point. + row per index point. An empty collection gets a header of the columns that do not + depend on the values. """ - - def generate_csv() -> Generator[str]: - output = io.StringIO() - writer = csv.writer(output) - - items = collection.items - if not items: - yield "" - return - - for sv in items: - dimensions = sorted(sv.dimensions.keys()) - header = [*dimensions, "value", "index", "index_name", "type"] - writer.writerow(header) - - for i, value in enumerate(sv.values): - index_value = sv.index[i] if sv.index and i < len(sv.index) else i - row = [sv.dimensions.get(d) for d in dimensions] + [ - sanitize_float_value(value), - index_value, - sv.index_name or "index", - "series", - ] - writer.writerow(row) - - output.seek(0) - yield output.read() - - return StreamingResponse( - generate_csv(), - media_type="text/csv", - headers={"Content-Disposition": f"attachment; filename={filename}"}, - ) + return _csv_response(_series_csv_rows(collection), filename) def annotate_mip_eras(app_context: "AppContext", collection: MetricValueCollection) -> MetricValueCollection: diff --git a/backend/tests/test_core/test_reader_values.py b/backend/tests/test_core/test_reader_values.py index caf607d0..3b408864 100644 --- a/backend/tests/test_core/test_reader_values.py +++ b/backend/tests/test_core/test_reader_values.py @@ -129,11 +129,18 @@ def test_header_with_detection(self): assert response.headers["X-REF-Had-Outliers"] == "true" assert response.headers["X-REF-Outlier-Count"] == "3" - def test_empty_collection_yields_empty_body(self): - """An empty collection produces an empty body.""" + def test_empty_collection_yields_fixed_header(self): + """An empty collection produces just the header of the columns that do not depend on the values.""" collection = _scalar_collection([]) response = generate_csv_response_scalar(collection, detection_ran=False, filename="out.csv") - assert _read_body(response) == "" + assert _read_body(response).splitlines() == ["value,type"] + + def test_empty_collection_with_detection_includes_outlier_columns(self): + """An empty collection keeps the outlier columns when detection ran.""" + collection = _scalar_collection([]) + response = generate_csv_response_scalar(collection, detection_ran=True, filename="out.csv") + assert _read_body(response).splitlines() == ["value,type,is_outlier,verification_status"] + assert response.headers["X-REF-Outlier-Count"] == "0" def test_nan_value_is_sanitized_to_empty_cell(self): """A NaN value renders as an empty CSV cell rather than the literal 'nan'.""" @@ -174,8 +181,8 @@ def test_index_falls_back_to_ordinal(self): assert lines[1] == "A,10.0,0,index,series" assert lines[2] == "A,20.0,1,index,series" - def test_empty_collection_yields_empty_body(self): - """An empty collection produces an empty body.""" + def test_empty_collection_yields_fixed_header(self): + """An empty collection produces just the header of the columns that do not depend on the values.""" collection = _series_collection([]) response = generate_csv_response_series(collection, filename="out.csv") - assert _read_body(response) == "" + assert _read_body(response).splitlines() == ["value,index,index_name,type"]